feat(production): dai una vita propria alla produzione, lato server
Punto 1 del documento modifiche del 28/07, il prerequisito su cui poggiano i punti 3, 4 e 6. Lo stato di una produzione viveva dentro una pagina del browser: timer, conteggio cicli e flag "produzione avviata" erano variabili Alpine di task_execute.html, e la navigazione fra task e' un ricaricamento completo, quindi cambiando task si perdeva tutto. Da qui il loop di misura che non reggeva, il fermo linea che non aveva nulla da fermare e l'assenza di storico. Nuove tabelle production_runs e production_events (migrazione 005), endpoint REST senza stato in memoria di processo - con un'app di stazione installata su ogni PC il database e' l'unico posto condiviso - e il frontend che legge lo stato all'apertura invece di tenerlo in memoria. Tre scelte di modello: - la scadenza e' un timestamp assoluto (next_measurement_at), non un contatore: il countdown si ricalcola da li' a ogni caricamento, e lasciarne andare la differenza sotto zero dara' gratis il contatore del ritardo del punto 3. Al client vanno i secondi gia' calcolati, non il timestamp: un datetime naive verrebbe letto nel fuso del browser e il conto sarebbe sfasato dell'offset UTC; - l'intervallo di misura e' copiato sulla produzione, non referenziato: modificare la ricetta a produzione avviata non deve spostare una scadenza in corso; - active_station_id rispecchia la stazione finche' la produzione e' aperta e va a NULL alla chiusura. Con un vincolo unico sopra, "una stazione = una produzione aperta" e' una garanzia del database e non un controllo soggetto a race; i NULL non collidono, quindi le produzioni chiuse si accumulano senza disturbo. Il fermo linea congela il conto alla rovescia e alla ripresa la scadenza viene traslata della durata del fermo, non ricalcolata: un fermo non regala ne' toglie tempo all'operatore. L'autorizzazione del capoturno passa da authenticate_user e non da un login, che rigenererebbe la sua API key buttando giu' la sessione che ha aperta altrove. La migrazione e' stata eseguita davvero, non solo scritta, su uno SQLite usa e getta: upgrade e downgrade girano e le colonne coincidono con i modelli. La prova ha trovato un difetto - create_unique_constraint dopo create_table e' un ALTER, che SQLite rifiuta - ora il vincolo e' dichiarato dentro create_table. Fuori da questo commit, per stare nei confini del punto 1: l'API espone gia' pause, resume e close, ma i pulsanti fermo linea e fine produzione restano da collegare (punto 6), e il rientro forzato sulla misura allo scadere e' il punto 3. Corretti due difetti trovati strada facendo: env.py non importava ne' Station ne' ProductionRun, quindi l'autogenerate di Alembic era gia' cieco sulle stazioni; e task_execute.html, lo schermo con piu' JavaScript dell'applicazione, non era coperto dal test di sintassi. Aggiungerlo ha richiesto di correggere l'helper, che validava le espressioni Alpine solo come espressione singola e bocciava @click="a = false; b = true", forma che Alpine accetta: ora prova entrambe le letture e fallisce solo se cadono tutte e due. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,137 @@
|
|||||||
|
"""Production runs router - open, read, and drive the life of a production.
|
||||||
|
|
||||||
|
Deliberately stateless: nothing lives in process memory. With one station app
|
||||||
|
installed per PC the database is the only shared place, and any station app must be
|
||||||
|
able to ask "what is going on here?" and get the same answer.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.api.middleware.api_key import get_current_user
|
||||||
|
from src.backend.database import get_db
|
||||||
|
from src.backend.models.api.production import (
|
||||||
|
CycleCompletePayload,
|
||||||
|
ProductionEventResponse,
|
||||||
|
ProductionRunCreate,
|
||||||
|
ProductionRunResponse,
|
||||||
|
ProductionRunWithEventsResponse,
|
||||||
|
SupervisorAction,
|
||||||
|
)
|
||||||
|
from src.backend.models.orm.production import ProductionRun
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
from src.backend.services import production_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/production-runs", tags=["production"])
|
||||||
|
|
||||||
|
|
||||||
|
def _as_response(run: ProductionRun) -> ProductionRunResponse:
|
||||||
|
return ProductionRunResponse(
|
||||||
|
**ProductionRunResponse.model_validate(run).model_dump(
|
||||||
|
exclude={"seconds_to_next_measurement", "overdue", "server_time"}
|
||||||
|
),
|
||||||
|
**production_service.describe(run),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ProductionRunResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def open_production_run(
|
||||||
|
data: ProductionRunCreate,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Start a production at a station."""
|
||||||
|
run = await production_service.open_run(db, data, user)
|
||||||
|
return _as_response(run)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/current", response_model=ProductionRunResponse | None)
|
||||||
|
async def get_current_production_run(
|
||||||
|
station_code: str = Query(..., min_length=1),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""The run open at this station, or null.
|
||||||
|
|
||||||
|
This is what every page asks on load instead of keeping the timer in memory.
|
||||||
|
"""
|
||||||
|
station = await production_service.get_station_by_code(db, station_code)
|
||||||
|
run = await production_service.get_open_run_for_station(db, station.id)
|
||||||
|
return _as_response(run) if run is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{run_id}", response_model=ProductionRunWithEventsResponse)
|
||||||
|
async def get_production_run(
|
||||||
|
run_id: int,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""A run with its full trace - the history a production did not have before."""
|
||||||
|
run = await production_service.get_run(db, run_id)
|
||||||
|
events = await production_service.list_run_events(db, run_id)
|
||||||
|
return ProductionRunWithEventsResponse(
|
||||||
|
**_as_response(run).model_dump(),
|
||||||
|
events=[ProductionEventResponse.model_validate(e) for e in events],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{run_id}/cycle", response_model=ProductionRunResponse)
|
||||||
|
async def complete_measurement_cycle(
|
||||||
|
run_id: int,
|
||||||
|
payload: CycleCompletePayload | None = None,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Record a finished measurement cycle and restart the interval."""
|
||||||
|
run = await production_service.get_run(db, run_id)
|
||||||
|
run = await production_service.complete_cycle(
|
||||||
|
db, run, user, note=payload.note if payload else None,
|
||||||
|
)
|
||||||
|
return _as_response(run)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{run_id}/pause", response_model=ProductionRunResponse)
|
||||||
|
async def pause_production_run(
|
||||||
|
run_id: int,
|
||||||
|
action: SupervisorAction,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Fermo linea - requires a supervisor."""
|
||||||
|
run = await production_service.get_run(db, run_id)
|
||||||
|
supervisor = await production_service.authorise_supervisor(
|
||||||
|
db, action.supervisor_username, action.supervisor_password,
|
||||||
|
)
|
||||||
|
run = await production_service.pause_run(db, run, user, supervisor, note=action.note)
|
||||||
|
return _as_response(run)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{run_id}/resume", response_model=ProductionRunResponse)
|
||||||
|
async def resume_production_run(
|
||||||
|
run_id: int,
|
||||||
|
action: SupervisorAction,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Restart a stopped line - requires a supervisor."""
|
||||||
|
run = await production_service.get_run(db, run_id)
|
||||||
|
supervisor = await production_service.authorise_supervisor(
|
||||||
|
db, action.supervisor_username, action.supervisor_password,
|
||||||
|
)
|
||||||
|
run = await production_service.resume_run(db, run, user, supervisor, note=action.note)
|
||||||
|
return _as_response(run)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{run_id}/close", response_model=ProductionRunResponse)
|
||||||
|
async def close_production_run(
|
||||||
|
run_id: int,
|
||||||
|
action: SupervisorAction,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Fine produzione - requires a supervisor. Stops the timer for good."""
|
||||||
|
run = await production_service.get_run(db, run_id)
|
||||||
|
supervisor = await production_service.authorise_supervisor(
|
||||||
|
db, action.supervisor_username, action.supervisor_password,
|
||||||
|
)
|
||||||
|
run = await production_service.close_run(db, run, user, supervisor, note=action.note)
|
||||||
|
return _as_response(run)
|
||||||
@@ -21,6 +21,7 @@ from src.backend.api.routers.reports import router as reports_router
|
|||||||
from src.backend.api.routers.statistics import router as statistics_router
|
from src.backend.api.routers.statistics import router as statistics_router
|
||||||
from src.backend.api.routers.setup import router as setup_router
|
from src.backend.api.routers.setup import router as setup_router
|
||||||
from src.backend.api.routers.stations import router as stations_router
|
from src.backend.api.routers.stations import router as stations_router
|
||||||
|
from src.backend.api.routers.production import router as production_router
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -73,6 +74,7 @@ app.include_router(statistics_router)
|
|||||||
app.include_router(reports_router)
|
app.include_router(reports_router)
|
||||||
app.include_router(setup_router)
|
app.include_router(setup_router)
|
||||||
app.include_router(stations_router)
|
app.include_router(stations_router)
|
||||||
|
app.include_router(production_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ from src.backend.models.orm.task import RecipeTask, RecipeSubtask # noqa: F401
|
|||||||
from src.backend.models.orm.measurement import Measurement # noqa: F401
|
from src.backend.models.orm.measurement import Measurement # noqa: F401
|
||||||
from src.backend.models.orm.access_log import AccessLog # noqa: F401
|
from src.backend.models.orm.access_log import AccessLog # noqa: F401
|
||||||
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit # noqa: F401
|
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit # noqa: F401
|
||||||
|
from src.backend.models.orm.station import Station, StationRecipeAssignment # noqa: F401
|
||||||
|
from src.backend.models.orm.production import ProductionRun, ProductionEvent # noqa: F401
|
||||||
|
|
||||||
target_metadata = Base.metadata
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""add production_runs and production_events
|
||||||
|
|
||||||
|
Gives a production a life of its own: before this its state lived in the Alpine
|
||||||
|
component of task_execute.html, so changing task - a full page load - lost the timer,
|
||||||
|
the cycle count and the "production started" flag.
|
||||||
|
|
||||||
|
Revision ID: 005_production_runs
|
||||||
|
Revises: 004_input_duration
|
||||||
|
Create Date: 2026-07-28
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '005_production_runs'
|
||||||
|
down_revision: Union[str, None] = '004_input_duration'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'production_runs',
|
||||||
|
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('station_id', sa.Integer, sa.ForeignKey('stations.id'), nullable=False),
|
||||||
|
sa.Column('recipe_id', sa.Integer, sa.ForeignKey('recipes.id'), nullable=False),
|
||||||
|
sa.Column('version_id', sa.Integer, sa.ForeignKey('recipe_versions.id'), nullable=False),
|
||||||
|
sa.Column('operator_id', sa.Integer, sa.ForeignKey('users.id'), nullable=False),
|
||||||
|
sa.Column('lot_number', sa.String(100), nullable=True),
|
||||||
|
sa.Column('serial_number', sa.String(100), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
'status',
|
||||||
|
sa.Enum('running', 'paused', 'closed', name='production_run_status_enum'),
|
||||||
|
nullable=False,
|
||||||
|
server_default='running',
|
||||||
|
),
|
||||||
|
sa.Column('measurement_interval_minutes', sa.SmallInteger, nullable=True),
|
||||||
|
sa.Column('next_measurement_at', sa.DateTime, nullable=True),
|
||||||
|
sa.Column('cycle_count', sa.Integer, nullable=False, server_default='0'),
|
||||||
|
sa.Column('started_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column('paused_at', sa.DateTime, nullable=True),
|
||||||
|
sa.Column('closed_at', sa.DateTime, nullable=True),
|
||||||
|
sa.Column('closed_by', sa.Integer, sa.ForeignKey('users.id'), nullable=True),
|
||||||
|
# Mirrors station_id while open, NULL once closed. The unique constraint makes
|
||||||
|
# "one open run per station" a database guarantee rather than a race; repeated
|
||||||
|
# NULLs do not collide, so closed runs are free to pile up.
|
||||||
|
sa.Column('active_station_id', sa.Integer, nullable=True),
|
||||||
|
# Declared inline rather than added afterwards: adding a constraint is an ALTER,
|
||||||
|
# which SQLite cannot do, and the test databases are SQLite.
|
||||||
|
sa.UniqueConstraint('active_station_id', name='uq_production_runs_active_station'),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
)
|
||||||
|
op.create_index('ix_production_runs_station_id', 'production_runs', ['station_id'])
|
||||||
|
op.create_index('ix_production_runs_recipe_id', 'production_runs', ['recipe_id'])
|
||||||
|
op.create_index('ix_production_runs_version_id', 'production_runs', ['version_id'])
|
||||||
|
op.create_index('ix_production_runs_operator_id', 'production_runs', ['operator_id'])
|
||||||
|
op.create_index('ix_production_runs_lot_number', 'production_runs', ['lot_number'])
|
||||||
|
op.create_index('ix_production_runs_serial_number', 'production_runs', ['serial_number'])
|
||||||
|
op.create_index('ix_production_runs_status', 'production_runs', ['status'])
|
||||||
|
op.create_index(
|
||||||
|
'ix_production_runs_next_measurement_at', 'production_runs', ['next_measurement_at'],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
'ix_production_runs_station_status', 'production_runs', ['station_id', 'status'],
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'production_events',
|
||||||
|
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column(
|
||||||
|
'run_id', sa.Integer,
|
||||||
|
sa.ForeignKey('production_runs.id', ondelete='CASCADE'), nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
'event_type',
|
||||||
|
sa.Enum(
|
||||||
|
'start', 'cycle_completed', 'line_stop', 'resume', 'close',
|
||||||
|
name='production_event_type_enum',
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column('user_id', sa.Integer, sa.ForeignKey('users.id'), nullable=False),
|
||||||
|
sa.Column('supervisor_id', sa.Integer, sa.ForeignKey('users.id'), nullable=True),
|
||||||
|
sa.Column('note', sa.Text, nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
)
|
||||||
|
op.create_index('ix_production_events_run_id', 'production_events', ['run_id'])
|
||||||
|
op.create_index('ix_production_events_event_type', 'production_events', ['event_type'])
|
||||||
|
op.create_index('ix_production_events_user_id', 'production_events', ['user_id'])
|
||||||
|
op.create_index('ix_production_events_created_at', 'production_events', ['created_at'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('production_events')
|
||||||
|
op.drop_table('production_runs')
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Pydantic schemas for production runs and their events."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ProductionRunCreate(BaseModel):
|
||||||
|
station_code: str = Field(..., min_length=1, max_length=100)
|
||||||
|
recipe_id: int = Field(..., gt=0)
|
||||||
|
version_id: Optional[int] = Field(default=None, gt=0)
|
||||||
|
lot_number: Optional[str] = Field(default=None, max_length=100)
|
||||||
|
serial_number: Optional[str] = Field(default=None, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductionEventResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
event_type: str
|
||||||
|
user_id: int
|
||||||
|
supervisor_id: Optional[int]
|
||||||
|
note: Optional[str]
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ProductionRunResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
station_id: int
|
||||||
|
recipe_id: int
|
||||||
|
version_id: int
|
||||||
|
operator_id: int
|
||||||
|
lot_number: Optional[str]
|
||||||
|
serial_number: Optional[str]
|
||||||
|
status: str
|
||||||
|
measurement_interval_minutes: Optional[int]
|
||||||
|
next_measurement_at: Optional[datetime]
|
||||||
|
cycle_count: int
|
||||||
|
started_at: datetime
|
||||||
|
paused_at: Optional[datetime]
|
||||||
|
closed_at: Optional[datetime]
|
||||||
|
closed_by: Optional[int]
|
||||||
|
|
||||||
|
# Derived server-side so every client agrees on the countdown regardless of
|
||||||
|
# clock skew. Negative once the interval has elapsed: how long the run has been
|
||||||
|
# overdue is a fact the operator must see, not deduce.
|
||||||
|
seconds_to_next_measurement: Optional[int] = None
|
||||||
|
overdue: bool = False
|
||||||
|
server_time: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProductionRunWithEventsResponse(ProductionRunResponse):
|
||||||
|
events: list[ProductionEventResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SupervisorAction(BaseModel):
|
||||||
|
"""Credentials of the supervisor authorising a line stop or a close."""
|
||||||
|
|
||||||
|
supervisor_username: str = Field(..., min_length=1)
|
||||||
|
supervisor_password: str = Field(..., min_length=1)
|
||||||
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CycleCompletePayload(BaseModel):
|
||||||
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
ProductionEventType = Literal[
|
||||||
|
"start", "cycle_completed", "line_stop", "resume", "close",
|
||||||
|
]
|
||||||
@@ -6,6 +6,7 @@ from src.backend.models.orm.measurement import Measurement
|
|||||||
from src.backend.models.orm.access_log import AccessLog
|
from src.backend.models.orm.access_log import AccessLog
|
||||||
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
||||||
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||||
|
from src.backend.models.orm.production import ProductionRun, ProductionEvent
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@@ -19,4 +20,6 @@ __all__ = [
|
|||||||
"RecipeVersionAudit",
|
"RecipeVersionAudit",
|
||||||
"Station",
|
"Station",
|
||||||
"StationRecipeAssignment",
|
"StationRecipeAssignment",
|
||||||
|
"ProductionRun",
|
||||||
|
"ProductionEvent",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""ProductionRun and ProductionEvent models.
|
||||||
|
|
||||||
|
A production run is what an operator opens when production starts and a supervisor
|
||||||
|
closes when it ends. It is the thing that outlives a page: before this, the timer,
|
||||||
|
the cycle count and the "production started" flag were Alpine variables in
|
||||||
|
task_execute.html, and navigating between tasks - a full page load - destroyed them.
|
||||||
|
|
||||||
|
The measurement deadline is stored as an absolute timestamp (next_measurement_at)
|
||||||
|
rather than a remaining count. Any client can then derive the countdown from it, get
|
||||||
|
the same answer, survive a reload, and tell how far *past* the interval it is by
|
||||||
|
letting the difference go negative.
|
||||||
|
|
||||||
|
With one station app installed per PC (see the install architecture of 28/07) the
|
||||||
|
database is the only place this state can live: the API keeps nothing in process
|
||||||
|
memory.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
DateTime, Enum, ForeignKey, Index, Integer, SmallInteger, String, Text,
|
||||||
|
UniqueConstraint, func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.backend.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||||
|
from src.backend.models.orm.station import Station
|
||||||
|
|
||||||
|
|
||||||
|
RUN_STATUSES = ("running", "paused", "closed")
|
||||||
|
EVENT_TYPES = ("start", "cycle_completed", "line_stop", "resume", "close")
|
||||||
|
|
||||||
|
|
||||||
|
class ProductionRun(Base):
|
||||||
|
__tablename__ = "production_runs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
|
||||||
|
station_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("stations.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
recipe_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("recipes.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
version_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("recipe_versions.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
operator_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Traceability travels with the run instead of the Flask session, so it stays
|
||||||
|
# attached to every measurement taken during it.
|
||||||
|
lot_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
|
||||||
|
serial_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
|
||||||
|
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
Enum(*RUN_STATUSES, name="production_run_status_enum"),
|
||||||
|
nullable=False,
|
||||||
|
default="running",
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Snapshot of the recipe interval at start: editing the recipe mid-run must not
|
||||||
|
# move the deadline of a production already under way.
|
||||||
|
measurement_interval_minutes: Mapped[Optional[int]] = mapped_column(
|
||||||
|
SmallInteger, nullable=True
|
||||||
|
)
|
||||||
|
# When the next measurement falls due. Null when the recipe has no interval.
|
||||||
|
# Past this instant the run is overdue, and by how much is simply now - this.
|
||||||
|
next_measurement_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
|
DateTime, nullable=True, index=True
|
||||||
|
)
|
||||||
|
cycle_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
started_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
paused_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
closed_by: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mirrors station_id while the run is open and goes NULL when it closes. A unique
|
||||||
|
# index on it lets the database - not a check-then-insert race - guarantee that a
|
||||||
|
# station never has two open runs. Repeated NULLs do not collide in a unique index.
|
||||||
|
active_station_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
|
||||||
|
station: Mapped["Station"] = relationship(lazy="selectin")
|
||||||
|
recipe: Mapped["Recipe"] = relationship(lazy="selectin")
|
||||||
|
version: Mapped["RecipeVersion"] = relationship(lazy="selectin")
|
||||||
|
events: Mapped[list["ProductionEvent"]] = relationship(
|
||||||
|
back_populates="run",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="ProductionEvent.created_at",
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("active_station_id", name="uq_production_runs_active_station"),
|
||||||
|
Index("ix_production_runs_station_status", "station_id", "status"),
|
||||||
|
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_open(self) -> bool:
|
||||||
|
return self.status in ("running", "paused")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<ProductionRun {self.id} station={self.station_id} {self.status}>"
|
||||||
|
|
||||||
|
|
||||||
|
class ProductionEvent(Base):
|
||||||
|
"""Append-only trace of what happened during a run.
|
||||||
|
|
||||||
|
This is the record that did not exist before: without it there is no history of
|
||||||
|
a production, and nothing for the statistics file or the ERP hand-off to stand on.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "production_events"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
run_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("production_runs.id", ondelete="CASCADE"),
|
||||||
|
nullable=False, index=True,
|
||||||
|
)
|
||||||
|
event_type: Mapped[str] = mapped_column(
|
||||||
|
Enum(*EVENT_TYPES, name="production_event_type_enum"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
# Who authorised it, when the action needed a supervisor (fermo linea, chiusura).
|
||||||
|
supervisor_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id"), nullable=True
|
||||||
|
)
|
||||||
|
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=func.now(), index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
run: Mapped["ProductionRun"] = relationship(back_populates="events")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<ProductionEvent run={self.run_id} {self.event_type}>"
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
"""Business logic for production runs.
|
||||||
|
|
||||||
|
Routers must call into these functions rather than manipulating models directly.
|
||||||
|
All functions are async and accept an AsyncSession; they flush but do NOT commit
|
||||||
|
(commit is handled by the FastAPI get_db dependency).
|
||||||
|
|
||||||
|
Clock convention
|
||||||
|
----------------
|
||||||
|
Every timestamp this module writes comes from ``_now()``, so the deadline and the
|
||||||
|
comparison against it are always on the same clock. Clients are handed
|
||||||
|
``seconds_to_next_measurement`` already computed rather than the raw deadline: a
|
||||||
|
naive datetime crossing the wire would be parsed in the browser's own timezone, and
|
||||||
|
the countdown would be off by the UTC offset. The number is the contract; the
|
||||||
|
timestamp is only there for the audit trail.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.models.api.production import ProductionRunCreate
|
||||||
|
from src.backend.models.orm.production import ProductionEvent, ProductionRun
|
||||||
|
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||||
|
from src.backend.models.orm.station import Station
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
from src.backend.services import auth_service
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
"""Single source of time for runs.
|
||||||
|
|
||||||
|
Naive local time, matching what the database writes for ``func.now()`` defaults,
|
||||||
|
so a run's started_at and its deadline are comparable.
|
||||||
|
"""
|
||||||
|
return datetime.now()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Derived view
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def seconds_to_next_measurement(run: ProductionRun, now: Optional[datetime] = None) -> Optional[int]:
|
||||||
|
"""Seconds until the next measurement is due; negative once it is overdue.
|
||||||
|
|
||||||
|
Letting the value go negative is deliberate: the operator has to see how long
|
||||||
|
the line has been past the interval, not merely that it elapsed.
|
||||||
|
While the run is paused the countdown is frozen at what was left when the line
|
||||||
|
stopped - a fermo linea must not eat into the measurement interval.
|
||||||
|
"""
|
||||||
|
if run.next_measurement_at is None:
|
||||||
|
return None
|
||||||
|
reference = run.paused_at if (run.status == "paused" and run.paused_at) else (now or _now())
|
||||||
|
return int(round((run.next_measurement_at - reference).total_seconds()))
|
||||||
|
|
||||||
|
|
||||||
|
def describe(run: ProductionRun) -> dict:
|
||||||
|
"""Fields the API adds on top of the stored columns."""
|
||||||
|
now = _now()
|
||||||
|
remaining = seconds_to_next_measurement(run, now)
|
||||||
|
return {
|
||||||
|
"seconds_to_next_measurement": remaining,
|
||||||
|
"overdue": remaining is not None and remaining < 0,
|
||||||
|
"server_time": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lookups
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def get_run(db: AsyncSession, run_id: int) -> ProductionRun:
|
||||||
|
result = await db.execute(select(ProductionRun).where(ProductionRun.id == run_id))
|
||||||
|
run = result.scalar_one_or_none()
|
||||||
|
if run is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Production run not found",
|
||||||
|
)
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
async def get_open_run_for_station(
|
||||||
|
db: AsyncSession, station_id: int,
|
||||||
|
) -> Optional[ProductionRun]:
|
||||||
|
"""The run currently open at a station, running or paused."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(ProductionRun).where(ProductionRun.active_station_id == station_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_station_by_code(db: AsyncSession, code: str) -> Station:
|
||||||
|
result = await db.execute(select(Station).where(Station.code == code))
|
||||||
|
station = result.scalar_one_or_none()
|
||||||
|
if station is None or not station.active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Station '{code}' not found or inactive",
|
||||||
|
)
|
||||||
|
return station
|
||||||
|
|
||||||
|
|
||||||
|
async def list_run_events(db: AsyncSession, run_id: int) -> list[ProductionEvent]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(ProductionEvent)
|
||||||
|
.where(ProductionEvent.run_id == run_id)
|
||||||
|
.order_by(ProductionEvent.created_at, ProductionEvent.id)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Supervisor authorisation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def authorise_supervisor(db: AsyncSession, username: str, password: str) -> User:
|
||||||
|
"""Check the credentials of the supervisor authorising a stop or a close.
|
||||||
|
|
||||||
|
Uses authenticate_user rather than a full login: logging in would rotate the
|
||||||
|
supervisor's API key and knock out whatever session they have open elsewhere.
|
||||||
|
"""
|
||||||
|
supervisor = await auth_service.authenticate_user(db, username, password)
|
||||||
|
if supervisor is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid supervisor credentials",
|
||||||
|
)
|
||||||
|
if not supervisor.has_role("Supervisor") and not supervisor.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="User is not authorised as supervisor",
|
||||||
|
)
|
||||||
|
return supervisor
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _add_event(
|
||||||
|
db: AsyncSession,
|
||||||
|
run: ProductionRun,
|
||||||
|
event_type: str,
|
||||||
|
user: User,
|
||||||
|
supervisor: Optional[User] = None,
|
||||||
|
note: Optional[str] = None,
|
||||||
|
) -> ProductionEvent:
|
||||||
|
"""Append to the run's trace.
|
||||||
|
|
||||||
|
Added through the session rather than run.events: touching the collection would
|
||||||
|
trigger a lazy load, which raises under async SQLAlchemy.
|
||||||
|
"""
|
||||||
|
event = ProductionEvent(
|
||||||
|
run_id=run.id,
|
||||||
|
event_type=event_type,
|
||||||
|
user_id=user.id,
|
||||||
|
supervisor_id=supervisor.id if supervisor else None,
|
||||||
|
note=note,
|
||||||
|
created_at=_now(),
|
||||||
|
)
|
||||||
|
db.add(event)
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
async def open_run(
|
||||||
|
db: AsyncSession, data: ProductionRunCreate, operator: User,
|
||||||
|
) -> ProductionRun:
|
||||||
|
"""Start a production at a station.
|
||||||
|
|
||||||
|
Refuses if the station already has one open: two runs on the same station would
|
||||||
|
mean two timers and two histories for one physical line.
|
||||||
|
"""
|
||||||
|
station = await get_station_by_code(db, data.station_code)
|
||||||
|
|
||||||
|
existing = await get_open_run_for_station(db, station.id)
|
||||||
|
if existing is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Station '{station.code}' already has an open production run",
|
||||||
|
)
|
||||||
|
|
||||||
|
recipe_row = await db.execute(select(Recipe).where(Recipe.id == data.recipe_id))
|
||||||
|
recipe = recipe_row.scalar_one_or_none()
|
||||||
|
if recipe is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.version_id is not None:
|
||||||
|
version_row = await db.execute(
|
||||||
|
select(RecipeVersion).where(
|
||||||
|
RecipeVersion.id == data.version_id,
|
||||||
|
RecipeVersion.recipe_id == recipe.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
version = version_row.scalar_one_or_none()
|
||||||
|
if version is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Recipe version not found for this recipe",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
version_row = await db.execute(
|
||||||
|
select(RecipeVersion).where(
|
||||||
|
RecipeVersion.recipe_id == recipe.id,
|
||||||
|
RecipeVersion.is_current == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
version = version_row.scalar_one_or_none()
|
||||||
|
if version is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Recipe has no current version",
|
||||||
|
)
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
# The interval is copied, not referenced: editing the recipe mid-production must
|
||||||
|
# not move the deadline of a run already under way.
|
||||||
|
interval = recipe.measurement_interval_minutes
|
||||||
|
run = ProductionRun(
|
||||||
|
station_id=station.id,
|
||||||
|
recipe_id=recipe.id,
|
||||||
|
version_id=version.id,
|
||||||
|
operator_id=operator.id,
|
||||||
|
lot_number=data.lot_number,
|
||||||
|
serial_number=data.serial_number,
|
||||||
|
status="running",
|
||||||
|
measurement_interval_minutes=interval,
|
||||||
|
next_measurement_at=(
|
||||||
|
now + timedelta(minutes=interval) if interval and interval > 0 else None
|
||||||
|
),
|
||||||
|
cycle_count=0,
|
||||||
|
started_at=now,
|
||||||
|
active_station_id=station.id,
|
||||||
|
)
|
||||||
|
db.add(run)
|
||||||
|
await db.flush()
|
||||||
|
_add_event(db, run, "start", operator)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(run)
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def _require_open(run: ProductionRun) -> None:
|
||||||
|
if run.status == "closed":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Production run is already closed",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def complete_cycle(
|
||||||
|
db: AsyncSession, run: ProductionRun, user: User, note: Optional[str] = None,
|
||||||
|
) -> ProductionRun:
|
||||||
|
"""Record a finished measurement cycle and restart the interval."""
|
||||||
|
_require_open(run)
|
||||||
|
if run.status == "paused":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Production run is paused: resume it before measuring",
|
||||||
|
)
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
run.cycle_count += 1
|
||||||
|
if run.measurement_interval_minutes and run.measurement_interval_minutes > 0:
|
||||||
|
# Measured from now, not from the previous deadline: a late measurement must
|
||||||
|
# not compress the following interval.
|
||||||
|
run.next_measurement_at = now + timedelta(
|
||||||
|
minutes=run.measurement_interval_minutes
|
||||||
|
)
|
||||||
|
_add_event(db, run, "cycle_completed", user, note=note)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(run)
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
async def pause_run(
|
||||||
|
db: AsyncSession,
|
||||||
|
run: ProductionRun,
|
||||||
|
user: User,
|
||||||
|
supervisor: User,
|
||||||
|
note: Optional[str] = None,
|
||||||
|
) -> ProductionRun:
|
||||||
|
"""Fermo linea: suspend the run and freeze the countdown."""
|
||||||
|
_require_open(run)
|
||||||
|
if run.status == "paused":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT, detail="Production run is already paused",
|
||||||
|
)
|
||||||
|
run.status = "paused"
|
||||||
|
run.paused_at = _now()
|
||||||
|
_add_event(db, run, "line_stop", user, supervisor=supervisor, note=note)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(run)
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
async def resume_run(
|
||||||
|
db: AsyncSession,
|
||||||
|
run: ProductionRun,
|
||||||
|
user: User,
|
||||||
|
supervisor: User,
|
||||||
|
note: Optional[str] = None,
|
||||||
|
) -> ProductionRun:
|
||||||
|
"""Restart a paused run, pushing the deadline out by the length of the stop."""
|
||||||
|
_require_open(run)
|
||||||
|
if run.status != "paused":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT, detail="Production run is not paused",
|
||||||
|
)
|
||||||
|
now = _now()
|
||||||
|
if run.next_measurement_at is not None and run.paused_at is not None:
|
||||||
|
# Shift rather than recompute: whatever was left when the line stopped is
|
||||||
|
# what the operator gets back, so a stop neither grants nor costs time.
|
||||||
|
run.next_measurement_at = run.next_measurement_at + (now - run.paused_at)
|
||||||
|
run.status = "running"
|
||||||
|
run.paused_at = None
|
||||||
|
_add_event(db, run, "resume", user, supervisor=supervisor, note=note)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(run)
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
async def close_run(
|
||||||
|
db: AsyncSession,
|
||||||
|
run: ProductionRun,
|
||||||
|
user: User,
|
||||||
|
supervisor: User,
|
||||||
|
note: Optional[str] = None,
|
||||||
|
) -> ProductionRun:
|
||||||
|
"""Fine produzione: close the run for good and stop the timer."""
|
||||||
|
_require_open(run)
|
||||||
|
run.status = "closed"
|
||||||
|
run.closed_at = _now()
|
||||||
|
run.closed_by = supervisor.id
|
||||||
|
run.next_measurement_at = None
|
||||||
|
run.paused_at = None
|
||||||
|
# Releasing the slot lets the station open a new run; the unique index on this
|
||||||
|
# column is what keeps exactly one open at a time.
|
||||||
|
run.active_station_id = None
|
||||||
|
_add_event(db, run, "close", user, supervisor=supervisor, note=note)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(run)
|
||||||
|
return run
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
"""Integration tests for /api/production-runs.
|
||||||
|
|
||||||
|
The point of these tables is that a production outlives the page it was started
|
||||||
|
from, so the tests lean on what must survive: the deadline, the cycle count, the
|
||||||
|
trace of what happened, and the rule that a station has one production at a time.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.backend.models.orm.production import ProductionRun
|
||||||
|
from src.backend.models.orm.recipe import Recipe
|
||||||
|
from src.backend.models.orm.station import Station
|
||||||
|
from src.backend.services import auth_service, production_service
|
||||||
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
|
async def _station(db_session, user_id: int, code: str = "ST-PROD") -> Station:
|
||||||
|
station = Station(code=code, name=f"Stazione {code}", active=True, created_by=user_id)
|
||||||
|
db_session.add(station)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(station)
|
||||||
|
return station
|
||||||
|
|
||||||
|
|
||||||
|
async def _recipe_with_interval(db_session, user_id: int, minutes, code="REC-PROD") -> Recipe:
|
||||||
|
recipe = await create_test_recipe(db_session, user_id=user_id, code=code)
|
||||||
|
recipe.measurement_interval_minutes = minutes
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(recipe)
|
||||||
|
return recipe
|
||||||
|
|
||||||
|
|
||||||
|
async def _supervisor(db_session, username="capoturno", password="TurnoPwd1"):
|
||||||
|
user = await auth_service.create_user(
|
||||||
|
db_session,
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
display_name="Capo Turno",
|
||||||
|
roles=["Supervisor"],
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
return user, password
|
||||||
|
|
||||||
|
|
||||||
|
async def _advance_clock(db_session, run_id: int, delta: timedelta) -> None:
|
||||||
|
"""Simulate wall-clock time passing, without sleeping in a test.
|
||||||
|
|
||||||
|
Winding every instant stored on the run back by D is indistinguishable from D
|
||||||
|
having elapsed. Moving only one of them would not be time passing - it would be
|
||||||
|
rewriting history, and would measure the wrong thing.
|
||||||
|
"""
|
||||||
|
run = (await db_session.execute(
|
||||||
|
select(ProductionRun).where(ProductionRun.id == run_id)
|
||||||
|
)).scalar_one()
|
||||||
|
run.started_at = run.started_at - delta
|
||||||
|
if run.next_measurement_at is not None:
|
||||||
|
run.next_measurement_at = run.next_measurement_at - delta
|
||||||
|
if run.paused_at is not None:
|
||||||
|
run.paused_at = run.paused_at - delta
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _open(client, user, station, recipe, **extra):
|
||||||
|
return await client.post(
|
||||||
|
"/api/production-runs",
|
||||||
|
headers=auth_headers(user),
|
||||||
|
json={"station_code": station.code, "recipe_id": recipe.id, **extra},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Opening
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_open_run_requires_auth(client: AsyncClient):
|
||||||
|
resp = await client.post("/api/production-runs", json={"station_code": "X", "recipe_id": 1})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_open_run_starts_the_countdown(
|
||||||
|
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||||
|
):
|
||||||
|
station = await _station(db_session, admin_user.id)
|
||||||
|
recipe = await _recipe_with_interval(db_session, admin_user.id, minutes=30)
|
||||||
|
|
||||||
|
resp = await _open(client, measurement_tec_user, station, recipe)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["status"] == "running"
|
||||||
|
assert body["cycle_count"] == 0
|
||||||
|
assert body["measurement_interval_minutes"] == 30
|
||||||
|
# About half an hour out, allowing for the round trip.
|
||||||
|
assert 29 * 60 <= body["seconds_to_next_measurement"] <= 30 * 60
|
||||||
|
assert body["overdue"] is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_open_run_without_interval_has_no_deadline(
|
||||||
|
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||||
|
):
|
||||||
|
station = await _station(db_session, admin_user.id, code="ST-NOINT")
|
||||||
|
recipe = await _recipe_with_interval(db_session, admin_user.id, None, code="REC-NOINT")
|
||||||
|
|
||||||
|
body = (await _open(client, measurement_tec_user, station, recipe)).json()
|
||||||
|
assert body["next_measurement_at"] is None
|
||||||
|
assert body["seconds_to_next_measurement"] is None
|
||||||
|
assert body["overdue"] is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_open_run_snapshots_the_interval(
|
||||||
|
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||||
|
):
|
||||||
|
"""Editing the recipe mid-production must not move a running deadline."""
|
||||||
|
station = await _station(db_session, admin_user.id, code="ST-SNAP")
|
||||||
|
recipe = await _recipe_with_interval(db_session, admin_user.id, 20, code="REC-SNAP")
|
||||||
|
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||||||
|
|
||||||
|
recipe.measurement_interval_minutes = 90
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
resp = await client.get(
|
||||||
|
f"/api/production-runs/{run_id}", headers=auth_headers(measurement_tec_user),
|
||||||
|
)
|
||||||
|
assert resp.json()["measurement_interval_minutes"] == 20
|
||||||
|
|
||||||
|
|
||||||
|
async def test_station_cannot_have_two_open_runs(
|
||||||
|
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||||
|
):
|
||||||
|
station = await _station(db_session, admin_user.id, code="ST-ONE")
|
||||||
|
recipe = await _recipe_with_interval(db_session, admin_user.id, 15, code="REC-ONE")
|
||||||
|
first = await _open(client, measurement_tec_user, station, recipe)
|
||||||
|
assert first.status_code == 201
|
||||||
|
|
||||||
|
second = await _open(client, measurement_tec_user, station, recipe)
|
||||||
|
assert second.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
async def test_open_run_rejects_unknown_station(
|
||||||
|
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||||
|
):
|
||||||
|
recipe = await _recipe_with_interval(db_session, admin_user.id, 15, code="REC-NOSTAT")
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/production-runs",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={"station_code": "ST-GHOST", "recipe_id": recipe.id},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reading the current run - what replaces the in-page state
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_current_run_is_null_when_nothing_is_running(
|
||||||
|
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||||
|
):
|
||||||
|
station = await _station(db_session, admin_user.id, code="ST-IDLE")
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/production-runs/current",
|
||||||
|
params={"station_code": station.code},
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_current_run_survives_and_keeps_counting_down(
|
||||||
|
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||||
|
):
|
||||||
|
"""The deadline is stored, so a fresh page load rejoins the same countdown."""
|
||||||
|
station = await _station(db_session, admin_user.id, code="ST-KEEP")
|
||||||
|
recipe = await _recipe_with_interval(db_session, admin_user.id, 45, code="REC-KEEP")
|
||||||
|
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
|
||||||
|
|
||||||
|
later = await client.get(
|
||||||
|
"/api/production-runs/current",
|
||||||
|
params={"station_code": station.code},
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
)
|
||||||
|
body = later.json()
|
||||||
|
assert body["id"] == opened["id"]
|
||||||
|
assert body["next_measurement_at"] == opened["next_measurement_at"]
|
||||||
|
assert body["seconds_to_next_measurement"] <= opened["seconds_to_next_measurement"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cycles
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cycle_restarts_the_interval_and_counts(
|
||||||
|
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||||
|
):
|
||||||
|
station = await _station(db_session, admin_user.id, code="ST-CYC")
|
||||||
|
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-CYC")
|
||||||
|
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/production-runs/{run_id}/cycle", headers=auth_headers(measurement_tec_user),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["cycle_count"] == 1
|
||||||
|
assert 9 * 60 <= body["seconds_to_next_measurement"] <= 10 * 60
|
||||||
|
|
||||||
|
|
||||||
|
async def test_overdue_run_reports_negative_seconds(
|
||||||
|
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||||
|
):
|
||||||
|
"""Past the interval the countdown keeps going the other way, so the delay shows."""
|
||||||
|
station = await _station(db_session, admin_user.id, code="ST-LATE")
|
||||||
|
recipe = await _recipe_with_interval(db_session, admin_user.id, 5, code="REC-LATE")
|
||||||
|
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||||||
|
|
||||||
|
await _advance_clock(db_session, run_id, timedelta(minutes=8))
|
||||||
|
|
||||||
|
body = (await client.get(
|
||||||
|
"/api/production-runs/current",
|
||||||
|
params={"station_code": station.code},
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
)).json()
|
||||||
|
assert body["overdue"] is True
|
||||||
|
assert -190 <= body["seconds_to_next_measurement"] <= -170
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fermo linea / ripresa / chiusura
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pause_requires_supervisor_credentials(
|
||||||
|
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||||
|
):
|
||||||
|
station = await _station(db_session, admin_user.id, code="ST-PAUSE1")
|
||||||
|
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-PAUSE1")
|
||||||
|
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/production-runs/{run_id}/pause",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={"supervisor_username": "nobody", "supervisor_password": "wrong"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pause_rejects_non_supervisor(
|
||||||
|
client: AsyncClient, measurement_tec_user, maker_user, admin_user, db_session,
|
||||||
|
):
|
||||||
|
station = await _station(db_session, admin_user.id, code="ST-PAUSE2")
|
||||||
|
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-PAUSE2")
|
||||||
|
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/production-runs/{run_id}/pause",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={
|
||||||
|
"supervisor_username": maker_user.username,
|
||||||
|
"supervisor_password": "testpassword123",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code in (401, 403)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pause_freezes_the_countdown(
|
||||||
|
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||||
|
):
|
||||||
|
"""A fermo linea must not eat the measurement interval."""
|
||||||
|
station = await _station(db_session, admin_user.id, code="ST-FREEZE")
|
||||||
|
recipe = await _recipe_with_interval(db_session, admin_user.id, 30, code="REC-FREEZE")
|
||||||
|
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||||||
|
supervisor, password = await _supervisor(db_session, username="capo-freeze")
|
||||||
|
|
||||||
|
paused = (await client.post(
|
||||||
|
f"/api/production-runs/{run_id}/pause",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={"supervisor_username": supervisor.username, "supervisor_password": password},
|
||||||
|
)).json()
|
||||||
|
assert paused["status"] == "paused"
|
||||||
|
frozen = paused["seconds_to_next_measurement"]
|
||||||
|
|
||||||
|
# An hour goes by with the line stopped: were the countdown still ticking, the
|
||||||
|
# run would now be deeply overdue.
|
||||||
|
await _advance_clock(db_session, run_id, timedelta(hours=1))
|
||||||
|
|
||||||
|
still = (await client.get(
|
||||||
|
"/api/production-runs/current",
|
||||||
|
params={"station_code": station.code},
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
)).json()
|
||||||
|
assert still["status"] == "paused"
|
||||||
|
assert still["seconds_to_next_measurement"] == frozen
|
||||||
|
assert still["overdue"] is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_resume_gives_back_the_time_the_stop_took(
|
||||||
|
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||||
|
):
|
||||||
|
station = await _station(db_session, admin_user.id, code="ST-RESUME")
|
||||||
|
recipe = await _recipe_with_interval(db_session, admin_user.id, 30, code="REC-RESUME")
|
||||||
|
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||||||
|
supervisor, password = await _supervisor(db_session, username="capo-resume")
|
||||||
|
creds = {"supervisor_username": supervisor.username, "supervisor_password": password}
|
||||||
|
|
||||||
|
frozen = (await client.post(
|
||||||
|
f"/api/production-runs/{run_id}/pause",
|
||||||
|
headers=auth_headers(measurement_tec_user), json=creds,
|
||||||
|
)).json()["seconds_to_next_measurement"]
|
||||||
|
|
||||||
|
# The line stays down for ten minutes.
|
||||||
|
await _advance_clock(db_session, run_id, timedelta(minutes=10))
|
||||||
|
|
||||||
|
resumed = (await client.post(
|
||||||
|
f"/api/production-runs/{run_id}/resume",
|
||||||
|
headers=auth_headers(measurement_tec_user), json=creds,
|
||||||
|
)).json()
|
||||||
|
assert resumed["status"] == "running"
|
||||||
|
# The ten minutes of stop were handed back, not charged to the operator.
|
||||||
|
assert abs(resumed["seconds_to_next_measurement"] - frozen) <= 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cycle_refused_while_paused(
|
||||||
|
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||||
|
):
|
||||||
|
station = await _station(db_session, admin_user.id, code="ST-PCYC")
|
||||||
|
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-PCYC")
|
||||||
|
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||||||
|
supervisor, password = await _supervisor(db_session, username="capo-pcyc")
|
||||||
|
|
||||||
|
await client.post(
|
||||||
|
f"/api/production-runs/{run_id}/pause",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={"supervisor_username": supervisor.username, "supervisor_password": password},
|
||||||
|
)
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/production-runs/{run_id}/cycle", headers=auth_headers(measurement_tec_user),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
async def test_close_stops_the_timer_and_frees_the_station(
|
||||||
|
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||||
|
):
|
||||||
|
station = await _station(db_session, admin_user.id, code="ST-CLOSE")
|
||||||
|
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-CLOSE")
|
||||||
|
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||||||
|
supervisor, password = await _supervisor(db_session, username="capo-close")
|
||||||
|
|
||||||
|
closed = (await client.post(
|
||||||
|
f"/api/production-runs/{run_id}/close",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={"supervisor_username": supervisor.username, "supervisor_password": password},
|
||||||
|
)).json()
|
||||||
|
assert closed["status"] == "closed"
|
||||||
|
assert closed["closed_by"] == supervisor.id
|
||||||
|
assert closed["seconds_to_next_measurement"] is None
|
||||||
|
|
||||||
|
# Nothing is open at the station any more...
|
||||||
|
current = await client.get(
|
||||||
|
"/api/production-runs/current",
|
||||||
|
params={"station_code": station.code},
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
)
|
||||||
|
assert current.json() is None
|
||||||
|
|
||||||
|
# ...and a new production can start there.
|
||||||
|
again = await _open(client, measurement_tec_user, station, recipe)
|
||||||
|
assert again.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
async def test_closed_run_refuses_further_actions(
|
||||||
|
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||||
|
):
|
||||||
|
station = await _station(db_session, admin_user.id, code="ST-DEAD")
|
||||||
|
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-DEAD")
|
||||||
|
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||||||
|
supervisor, password = await _supervisor(db_session, username="capo-dead")
|
||||||
|
creds = {"supervisor_username": supervisor.username, "supervisor_password": password}
|
||||||
|
|
||||||
|
await client.post(
|
||||||
|
f"/api/production-runs/{run_id}/close",
|
||||||
|
headers=auth_headers(measurement_tec_user), json=creds,
|
||||||
|
)
|
||||||
|
cycle = await client.post(
|
||||||
|
f"/api/production-runs/{run_id}/cycle", headers=auth_headers(measurement_tec_user),
|
||||||
|
)
|
||||||
|
assert cycle.status_code == 409
|
||||||
|
pause = await client.post(
|
||||||
|
f"/api/production-runs/{run_id}/pause",
|
||||||
|
headers=auth_headers(measurement_tec_user), json=creds,
|
||||||
|
)
|
||||||
|
assert pause.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The trace
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_records_what_happened(
|
||||||
|
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||||
|
):
|
||||||
|
"""The history a production never had: start, cycles, stop, resume, close."""
|
||||||
|
station = await _station(db_session, admin_user.id, code="ST-TRACE")
|
||||||
|
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-TRACE")
|
||||||
|
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||||||
|
supervisor, password = await _supervisor(db_session, username="capo-trace")
|
||||||
|
creds = {"supervisor_username": supervisor.username, "supervisor_password": password}
|
||||||
|
headers = auth_headers(measurement_tec_user)
|
||||||
|
|
||||||
|
await client.post(f"/api/production-runs/{run_id}/cycle", headers=headers)
|
||||||
|
await client.post(f"/api/production-runs/{run_id}/pause", headers=headers, json=creds)
|
||||||
|
await client.post(f"/api/production-runs/{run_id}/resume", headers=headers, json=creds)
|
||||||
|
await client.post(f"/api/production-runs/{run_id}/close", headers=headers, json=creds)
|
||||||
|
|
||||||
|
events = (await client.get(
|
||||||
|
f"/api/production-runs/{run_id}", headers=headers,
|
||||||
|
)).json()["events"]
|
||||||
|
assert [e["event_type"] for e in events] == [
|
||||||
|
"start", "cycle_completed", "line_stop", "resume", "close",
|
||||||
|
]
|
||||||
|
# Who authorised what is on the record.
|
||||||
|
by_type = {e["event_type"]: e for e in events}
|
||||||
|
assert by_type["line_stop"]["supervisor_id"] == supervisor.id
|
||||||
|
assert by_type["close"]["supervisor_id"] == supervisor.id
|
||||||
|
assert by_type["cycle_completed"]["supervisor_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_traceability_travels_with_the_run(
|
||||||
|
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||||
|
):
|
||||||
|
station = await _station(db_session, admin_user.id, code="ST-LOT")
|
||||||
|
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-LOT")
|
||||||
|
body = (await _open(
|
||||||
|
client, measurement_tec_user, station, recipe,
|
||||||
|
lot_number="LOT-42", serial_number="SN-7",
|
||||||
|
)).json()
|
||||||
|
assert body["lot_number"] == "LOT-42"
|
||||||
|
assert body["serial_number"] == "SN-7"
|
||||||
@@ -414,6 +414,67 @@ def validate_supervisor():
|
|||||||
return jsonify({"authorized": True, "supervisor": user.get("display_name", username)}), 200
|
return jsonify({"authorized": True, "supervisor": user.get("display_name", username)}), 200
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Routes: Production run (state that must outlive the page)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@measure_bp.route("/api/production/current", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
@role_required("MeasurementTec")
|
||||||
|
def api_current_production():
|
||||||
|
"""Proxy: the production open at this station, or null.
|
||||||
|
|
||||||
|
Every page asks this on load. Before, the timer and the cycle count lived in the
|
||||||
|
Alpine component and a change of task - a full page load - wiped them.
|
||||||
|
"""
|
||||||
|
station_code, _overridden = _current_station()
|
||||||
|
if not station_code:
|
||||||
|
return jsonify({"error": True, "detail": _("Stazione non configurata")}), 503
|
||||||
|
|
||||||
|
resp = api_client.get(
|
||||||
|
"/api/production-runs/current", params={"station_code": station_code},
|
||||||
|
)
|
||||||
|
if isinstance(resp, dict) and resp.get("error"):
|
||||||
|
return jsonify(resp), resp.get("status_code", 500)
|
||||||
|
return jsonify(resp), 200
|
||||||
|
|
||||||
|
|
||||||
|
@measure_bp.route("/api/production/start", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@role_required("MeasurementTec")
|
||||||
|
def api_start_production():
|
||||||
|
"""Proxy: open a production at this station."""
|
||||||
|
station_code, _overridden = _current_station()
|
||||||
|
if not station_code:
|
||||||
|
return jsonify({"error": True, "detail": _("Stazione non configurata")}), 503
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
payload = {
|
||||||
|
"station_code": station_code,
|
||||||
|
"recipe_id": data.get("recipe_id"),
|
||||||
|
"version_id": data.get("version_id"),
|
||||||
|
"lot_number": data.get("lot_number") or session.get("lot_number") or None,
|
||||||
|
"serial_number": data.get("serial_number") or session.get("serial_number") or None,
|
||||||
|
}
|
||||||
|
resp = api_client.post("/api/production-runs", data=payload)
|
||||||
|
if isinstance(resp, dict) and resp.get("error"):
|
||||||
|
return jsonify(resp), resp.get("status_code", 500)
|
||||||
|
return jsonify(resp), 201
|
||||||
|
|
||||||
|
|
||||||
|
@measure_bp.route("/api/production/<int:run_id>/cycle", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@role_required("MeasurementTec")
|
||||||
|
def api_complete_cycle(run_id: int):
|
||||||
|
"""Proxy: record a finished measurement cycle and restart the interval."""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
resp = api_client.post(
|
||||||
|
f"/api/production-runs/{run_id}/cycle", data={"note": data.get("note")},
|
||||||
|
)
|
||||||
|
if isinstance(resp, dict) and resp.get("error"):
|
||||||
|
return jsonify(resp), resp.get("status_code", 500)
|
||||||
|
return jsonify(resp), 200
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Route: File proxy (browser can't send X-API-Key directly)
|
# Route: File proxy (browser can't send X-API-Key directly)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -428,6 +428,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# ================================================================
|
||||||
|
PRODUCTION ERROR — the server refused to record the production state.
|
||||||
|
Shown because the fallback keeps the operator working locally, and a timer
|
||||||
|
that is not backed by the server must never look like one that is.
|
||||||
|
================================================================ #}
|
||||||
|
<div x-show="productionError"
|
||||||
|
x-transition
|
||||||
|
x-cloak
|
||||||
|
class="shrink-0 bg-red-50 dark:bg-red-900/20 border-t border-red-300 dark:border-red-700 px-4 py-2">
|
||||||
|
<div class="flex items-center justify-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-red-600 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm text-red-800 dark:text-red-200">
|
||||||
|
{{ _('Produzione non registrata sul server') }}:
|
||||||
|
<span x-text="productionError"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{# ================================================================
|
{# ================================================================
|
||||||
AVVIO PRODUZIONE — visible after first cycle, before production started
|
AVVIO PRODUZIONE — visible after first cycle, before production started
|
||||||
================================================================ #}
|
================================================================ #}
|
||||||
@@ -709,6 +729,8 @@ function taskExecute() {
|
|||||||
showCompletionOverlay: false,
|
showCompletionOverlay: false,
|
||||||
|
|
||||||
// ---- Measurement timer ----
|
// ---- Measurement timer ----
|
||||||
|
// The interval is only used for display: the deadline itself lives on the server,
|
||||||
|
// so it survives the page. See loadProductionRun().
|
||||||
measurementIntervalMinutes: {{ measurement_interval_minutes|tojson if measurement_interval_minutes else 'null' }},
|
measurementIntervalMinutes: {{ measurement_interval_minutes|tojson if measurement_interval_minutes else 'null' }},
|
||||||
timerActive: false,
|
timerActive: false,
|
||||||
timerRemaining: 0,
|
timerRemaining: 0,
|
||||||
@@ -716,6 +738,12 @@ function taskExecute() {
|
|||||||
cycleCount: 0,
|
cycleCount: 0,
|
||||||
productionStarted: false,
|
productionStarted: false,
|
||||||
|
|
||||||
|
// ---- Production run (server-side state) ----
|
||||||
|
// Navigating between tasks is a full page load, so anything kept only here dies.
|
||||||
|
// This is read back from the server on every load instead.
|
||||||
|
productionRun: null,
|
||||||
|
productionError: '',
|
||||||
|
|
||||||
// ---- Cycle & workflow state ----
|
// ---- Cycle & workflow state ----
|
||||||
cycleConfirmed: false,
|
cycleConfirmed: false,
|
||||||
showSupervisorModal: false,
|
showSupervisorModal: false,
|
||||||
@@ -812,6 +840,39 @@ function taskExecute() {
|
|||||||
init() {
|
init() {
|
||||||
this.subtasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
|
this.subtasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
|
||||||
this.inputStartedAt = Date.now();
|
this.inputStartedAt = Date.now();
|
||||||
|
// Rejoin whatever production is already open at this station.
|
||||||
|
this.loadProductionRun();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Production run: read the state back from the server ----
|
||||||
|
|
||||||
|
async loadProductionRun() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('{{ url_for("measure.api_current_production") }}');
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const run = await resp.json();
|
||||||
|
if (run && run.id) this.adoptProductionRun(run);
|
||||||
|
} catch (e) {
|
||||||
|
// Offline or server down: leave the page usable, just without the timer.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Take the server's word for the state of the production. Called on load and
|
||||||
|
after every action that changes it, so the page never drifts from the truth. */
|
||||||
|
adoptProductionRun(run) {
|
||||||
|
this.productionRun = run;
|
||||||
|
this.productionStarted = run.status !== 'closed';
|
||||||
|
this.cycleCount = run.cycle_count;
|
||||||
|
|
||||||
|
const seconds = run.seconds_to_next_measurement;
|
||||||
|
if (seconds === null || seconds === undefined || run.status !== 'running') {
|
||||||
|
this.stopMeasurementTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The server hands over seconds already computed rather than a timestamp: a
|
||||||
|
// naive datetime would be read in the browser's timezone and the countdown
|
||||||
|
// would be off by the UTC offset.
|
||||||
|
this.startCountdownFrom(seconds);
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---- Check if a subtask has been measured ----
|
// ---- Check if a subtask has been measured ----
|
||||||
@@ -953,21 +1014,57 @@ function taskExecute() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ---- Confirm measurement cycle (Fine ciclo misura) ----
|
// ---- Confirm measurement cycle (Fine ciclo misura) ----
|
||||||
confirmCycle() {
|
async confirmCycle() {
|
||||||
this.cycleConfirmed = true;
|
this.cycleConfirmed = true;
|
||||||
this.showCompletionOverlay = false;
|
this.showCompletionOverlay = false;
|
||||||
this.cycleCount++;
|
|
||||||
|
|
||||||
// Start measurement timer if recipe has an interval
|
// Recorded server-side so the count and the next deadline outlive this page.
|
||||||
|
if (this.productionRun) {
|
||||||
|
const run = await this.postProduction(
|
||||||
|
'{{ url_for("measure.api_complete_cycle", run_id=0) }}'.replace('/0/', '/' + this.productionRun.id + '/'),
|
||||||
|
);
|
||||||
|
if (run) {
|
||||||
|
this.adoptProductionRun(run);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No production open (recipe run outside a production): keep the old local
|
||||||
|
// behaviour rather than leaving the operator without a timer.
|
||||||
|
this.cycleCount++;
|
||||||
if (this.measurementIntervalMinutes && this.measurementIntervalMinutes > 0) {
|
if (this.measurementIntervalMinutes && this.measurementIntervalMinutes > 0) {
|
||||||
this.startMeasurementTimer();
|
this.startCountdownFrom(this.measurementIntervalMinutes * 60);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/* POST to a production endpoint, returning the updated run or null on failure. */
|
||||||
|
async postProduction(url, body) {
|
||||||
|
this.productionError = '';
|
||||||
|
try {
|
||||||
|
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content || '';
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
|
||||||
|
body: JSON.stringify(body || {}),
|
||||||
|
});
|
||||||
|
const data = await resp.json().catch(() => null);
|
||||||
|
if (!resp.ok) {
|
||||||
|
this.productionError = (data && data.detail) || '{{ _("Errore di comunicazione con il server") }}';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
this.productionError = '{{ _("Errore di connessione") }}';
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---- Measurement timer ----
|
// ---- Measurement timer ----
|
||||||
startMeasurementTimer() {
|
/* Ticks locally for a smooth display, but the number it starts from always comes
|
||||||
|
from the server, and every page load resynchronises it. */
|
||||||
|
startCountdownFrom(seconds) {
|
||||||
this.stopMeasurementTimer();
|
this.stopMeasurementTimer();
|
||||||
this.timerRemaining = this.measurementIntervalMinutes * 60;
|
this.timerRemaining = seconds;
|
||||||
this.timerActive = true;
|
this.timerActive = true;
|
||||||
var self = this;
|
var self = this;
|
||||||
this._timerInterval = setInterval(function () {
|
this._timerInterval = setInterval(function () {
|
||||||
@@ -1014,11 +1111,26 @@ function taskExecute() {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---- Avvio Produzione (GAIA placeholder) ----
|
// ---- Avvio Produzione ----
|
||||||
startProduction() {
|
/* Opens a production run on the server. That row is what makes the timer, the
|
||||||
this.productionStarted = true;
|
cycle count and the history survive a change of task.
|
||||||
// TODO: integrazione GAIA — inviare segnale per avviare timer linea
|
The hand-off to the ERP (GAIA) plugs in on top of this, once the protocol is
|
||||||
// await fetch('/measure/api/gaia/start-production', { method: 'POST', ... });
|
agreed: everything before it works without waiting for that. */
|
||||||
|
async startProduction() {
|
||||||
|
const run = await this.postProduction(
|
||||||
|
'{{ url_for("measure.api_start_production") }}',
|
||||||
|
{
|
||||||
|
recipe_id: this.task.recipe_id,
|
||||||
|
version_id: this.task.version_id,
|
||||||
|
lot_number: this.lotNumber || null,
|
||||||
|
serial_number: this.serialNumber || null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (run) {
|
||||||
|
this.adoptProductionRun(run);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Is the current task the last one in the recipe sequence?
|
// Is the current task the last one in the recipe sequence?
|
||||||
@@ -1030,14 +1142,22 @@ function taskExecute() {
|
|||||||
|
|
||||||
// Start production from the completion overlay (last START task) and begin
|
// Start production from the completion overlay (last START task) and begin
|
||||||
// the measurement-interval cycle if the recipe defines one.
|
// the measurement-interval cycle if the recipe defines one.
|
||||||
startProductionFromOverlay() {
|
async startProductionFromOverlay() {
|
||||||
this.showCompletionOverlay = false;
|
this.showCompletionOverlay = false;
|
||||||
this.startProduction();
|
const opened = await this.startProduction();
|
||||||
|
if (!opened) {
|
||||||
|
// Server refused: keep the operator working rather than stranding them
|
||||||
|
// mid-shift, but productionError is on screen so it is not silent.
|
||||||
this.cycleConfirmed = true;
|
this.cycleConfirmed = true;
|
||||||
this.cycleCount++;
|
this.cycleCount++;
|
||||||
if (this.measurementIntervalMinutes && this.measurementIntervalMinutes > 0) {
|
if (this.measurementIntervalMinutes && this.measurementIntervalMinutes > 0) {
|
||||||
this.startMeasurementTimer();
|
this.startCountdownFrom(this.measurementIntervalMinutes * 60);
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The START-phase measurements have just been taken: record them as the first
|
||||||
|
// cycle, which is also what starts the interval running.
|
||||||
|
await this.confirmCycle();
|
||||||
},
|
},
|
||||||
|
|
||||||
get timerDisplay() {
|
get timerDisplay() {
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""Tests for the production-run proxy routes.
|
||||||
|
|
||||||
|
These are what let task_execute.html read its state back from the server instead of
|
||||||
|
keeping the timer in an Alpine variable that a change of task destroys.
|
||||||
|
"""
|
||||||
|
import importlib
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _restore_measure_module():
|
||||||
|
"""Reload config/measure after each test so env tweaks don't leak."""
|
||||||
|
yield
|
||||||
|
import config
|
||||||
|
importlib.reload(config)
|
||||||
|
import blueprints.measure
|
||||||
|
importlib.reload(blueprints.measure)
|
||||||
|
|
||||||
|
|
||||||
|
def _with_station(monkeypatch, station_code="ST-PROD"):
|
||||||
|
if station_code is None:
|
||||||
|
monkeypatch.delenv("STATION_CODE", raising=False)
|
||||||
|
else:
|
||||||
|
monkeypatch.setenv("STATION_CODE", station_code)
|
||||||
|
monkeypatch.setenv("STATION_SWITCH_ENABLED", "0")
|
||||||
|
import config
|
||||||
|
importlib.reload(config)
|
||||||
|
import blueprints.measure
|
||||||
|
importlib.reload(blueprints.measure)
|
||||||
|
from blueprints import measure as measure_mod
|
||||||
|
return measure_mod
|
||||||
|
|
||||||
|
|
||||||
|
RUN = {
|
||||||
|
"id": 7,
|
||||||
|
"station_id": 1,
|
||||||
|
"recipe_id": 3,
|
||||||
|
"version_id": 5,
|
||||||
|
"operator_id": 2,
|
||||||
|
"lot_number": "LOT-1",
|
||||||
|
"serial_number": None,
|
||||||
|
"status": "running",
|
||||||
|
"measurement_interval_minutes": 30,
|
||||||
|
"next_measurement_at": "2026-07-28T17:00:00",
|
||||||
|
"cycle_count": 2,
|
||||||
|
"started_at": "2026-07-28T16:00:00",
|
||||||
|
"paused_at": None,
|
||||||
|
"closed_at": None,
|
||||||
|
"closed_by": None,
|
||||||
|
"seconds_to_next_measurement": 1500,
|
||||||
|
"overdue": False,
|
||||||
|
"server_time": "2026-07-28T16:35:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_production_passes_the_station(logged_in_client, monkeypatch):
|
||||||
|
measure_mod = _with_station(monkeypatch)
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
mock_api.get.return_value = RUN
|
||||||
|
resp = logged_in_client.get("/measure/api/production/current")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["id"] == 7
|
||||||
|
mock_api.get.assert_called_once_with(
|
||||||
|
"/api/production-runs/current", params={"station_code": "ST-PROD"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_production_returns_null_when_idle(logged_in_client, monkeypatch):
|
||||||
|
"""No production open is a normal answer, not an error."""
|
||||||
|
measure_mod = _with_station(monkeypatch)
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
mock_api.get.return_value = None
|
||||||
|
resp = logged_in_client.get("/measure/api/production/current")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_production_without_station_is_503(logged_in_client, monkeypatch):
|
||||||
|
_with_station(monkeypatch, station_code=None)
|
||||||
|
resp = logged_in_client.get("/measure/api/production/current")
|
||||||
|
assert resp.status_code == 503
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_production_sends_station_and_recipe(logged_in_client, monkeypatch):
|
||||||
|
measure_mod = _with_station(monkeypatch)
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
mock_api.post.return_value = RUN
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/measure/api/production/start",
|
||||||
|
json={"recipe_id": 3, "version_id": 5, "lot_number": "LOT-1"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
endpoint, kwargs = mock_api.post.call_args
|
||||||
|
assert endpoint[0] == "/api/production-runs"
|
||||||
|
sent = kwargs["data"]
|
||||||
|
assert sent["station_code"] == "ST-PROD"
|
||||||
|
assert sent["recipe_id"] == 3
|
||||||
|
assert sent["version_id"] == 5
|
||||||
|
assert sent["lot_number"] == "LOT-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_production_falls_back_to_session_traceability(logged_in_client, monkeypatch):
|
||||||
|
"""Lot and serial already captured in the session must reach the run."""
|
||||||
|
measure_mod = _with_station(monkeypatch)
|
||||||
|
with logged_in_client.session_transaction() as sess:
|
||||||
|
sess["lot_number"] = "LOT-SESSION"
|
||||||
|
sess["serial_number"] = "SN-SESSION"
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
mock_api.post.return_value = RUN
|
||||||
|
logged_in_client.post("/measure/api/production/start", json={"recipe_id": 3})
|
||||||
|
sent = mock_api.post.call_args[1]["data"]
|
||||||
|
assert sent["lot_number"] == "LOT-SESSION"
|
||||||
|
assert sent["serial_number"] == "SN-SESSION"
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_production_propagates_conflict(logged_in_client, monkeypatch):
|
||||||
|
"""A station already running a production must not silently open a second."""
|
||||||
|
measure_mod = _with_station(monkeypatch)
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
mock_api.post.return_value = {
|
||||||
|
"error": True, "status_code": 409,
|
||||||
|
"detail": "Station 'ST-PROD' already has an open production run",
|
||||||
|
}
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/measure/api/production/start", json={"recipe_id": 3},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_cycle_hits_the_run(logged_in_client, monkeypatch):
|
||||||
|
measure_mod = _with_station(monkeypatch)
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
mock_api.post.return_value = {**RUN, "cycle_count": 3}
|
||||||
|
resp = logged_in_client.post("/measure/api/production/7/cycle", json={})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["cycle_count"] == 3
|
||||||
|
endpoint, kwargs = mock_api.post.call_args
|
||||||
|
assert endpoint[0] == "/api/production-runs/7/cycle"
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_cycle_propagates_paused_conflict(logged_in_client, monkeypatch):
|
||||||
|
measure_mod = _with_station(monkeypatch)
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
mock_api.post.return_value = {
|
||||||
|
"error": True, "status_code": 409,
|
||||||
|
"detail": "Production run is paused: resume it before measuring",
|
||||||
|
}
|
||||||
|
resp = logged_in_client.post("/measure/api/production/7/cycle", json={})
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_routes_require_login(client, monkeypatch):
|
||||||
|
_with_station(monkeypatch)
|
||||||
|
for method, url in (
|
||||||
|
("get", "/measure/api/production/current"),
|
||||||
|
("post", "/measure/api/production/start"),
|
||||||
|
("post", "/measure/api/production/7/cycle"),
|
||||||
|
):
|
||||||
|
resp = getattr(client, method)(url)
|
||||||
|
assert resp.status_code in (302, 401), f"{method} {url} -> {resp.status_code}"
|
||||||
@@ -79,10 +79,8 @@ def _alpine_attribute_expressions(html: str):
|
|||||||
yield name, value
|
yield name, value
|
||||||
|
|
||||||
|
|
||||||
def _node_check(source: str, label: str) -> None:
|
def _node_syntax_error(source: str) -> str | None:
|
||||||
"""Fail the test if `node --check` rejects `source`."""
|
"""Return node's complaint about `source`, or None if it parses."""
|
||||||
if not source.strip():
|
|
||||||
return
|
|
||||||
node = shutil.which("node")
|
node = shutil.which("node")
|
||||||
if node is None:
|
if node is None:
|
||||||
pytest.skip("node binary not found on PATH; cannot validate JS syntax")
|
pytest.skip("node binary not found on PATH; cannot validate JS syntax")
|
||||||
@@ -100,31 +98,56 @@ def _node_check(source: str, label: str) -> None:
|
|||||||
finally:
|
finally:
|
||||||
os.unlink(path)
|
os.unlink(path)
|
||||||
|
|
||||||
if result.returncode != 0:
|
return None if result.returncode == 0 else result.stderr.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _fail(label: str, source: str, stderr: str) -> None:
|
||||||
snippet = source.strip()
|
snippet = source.strip()
|
||||||
if len(snippet) > 600:
|
if len(snippet) > 600:
|
||||||
snippet = snippet[:600] + "\n…(truncated)…"
|
snippet = snippet[:600] + "\n…(truncated)…"
|
||||||
pytest.fail(
|
pytest.fail(
|
||||||
f"{label}: node rejected this code as invalid JS.\n"
|
f"{label}: node rejected this code as invalid JS.\n"
|
||||||
f"--- node stderr ---\n{result.stderr.strip()}\n"
|
f"--- node stderr ---\n{stderr}\n"
|
||||||
f"--- source (first 600 chars) ---\n{snippet}"
|
f"--- source (first 600 chars) ---\n{snippet}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _node_check(source: str, label: str) -> None:
|
||||||
|
"""Fail the test if `node --check` rejects `source`."""
|
||||||
|
if not source.strip():
|
||||||
|
return
|
||||||
|
stderr = _node_syntax_error(source)
|
||||||
|
if stderr is not None:
|
||||||
|
_fail(label, source, stderr)
|
||||||
|
|
||||||
|
|
||||||
def _check_alpine_attributes(html: str, page_label: str) -> None:
|
def _check_alpine_attributes(html: str, page_label: str) -> None:
|
||||||
"""Validate every Alpine expression attribute on the page.
|
"""Validate every Alpine expression attribute on the page.
|
||||||
|
|
||||||
Wraps each value in `void (…)` so node parses it as an expression rather
|
Alpine accepts an attribute either as a single expression or as a sequence of
|
||||||
than a statement. Function-body forms like `async () => { … }` parse fine
|
statements, so the check accepts a value that parses as either and only fails
|
||||||
inside that wrapper too.
|
when both readings are rejected. Neither form alone is enough:
|
||||||
|
|
||||||
|
:class="{ 'dark': $store.theme.dark }" only as an expression
|
||||||
|
@click="showOverlay = false; confirmed = true" only as statements
|
||||||
|
|
||||||
|
An unterminated string literal - the bug this file exists for - is a syntax
|
||||||
|
error under both, so the guard is unchanged.
|
||||||
"""
|
"""
|
||||||
for name, value in _alpine_attribute_expressions(html):
|
for name, value in _alpine_attribute_expressions(html):
|
||||||
# Some Alpine attrs accept a function call shorthand (e.g.
|
as_expression = f"void (\n{value}\n);\n"
|
||||||
# x-data="myComponent(window.__x)"); those parse fine as expressions.
|
as_statements = f"(() => {{\n{value}\n}});\n"
|
||||||
wrapper = f"void ({value});\n"
|
|
||||||
_node_check(
|
expression_error = _node_syntax_error(as_expression)
|
||||||
wrapper,
|
if expression_error is None:
|
||||||
f"{page_label} attribute {name}=\"…\" did not parse as JS",
|
continue
|
||||||
|
if _node_syntax_error(as_statements) is None:
|
||||||
|
continue
|
||||||
|
_fail(
|
||||||
|
f"{page_label} attribute {name}=\"…\" parsed neither as an expression "
|
||||||
|
"nor as statements",
|
||||||
|
as_expression,
|
||||||
|
expression_error,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -192,3 +215,52 @@ def test_admin_users_inline_js_is_valid(logged_in_client, mock_admin_api):
|
|||||||
_node_check(body, f"/admin/users script[{i}]")
|
_node_check(body, f"/admin/users script[{i}]")
|
||||||
|
|
||||||
_check_alpine_attributes(html, "/admin/users")
|
_check_alpine_attributes(html, "/admin/users")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_measure_api():
|
||||||
|
"""Patch api_client used inside the measure blueprint."""
|
||||||
|
mock = MagicMock()
|
||||||
|
with patch("blueprints.measure.api_client", mock):
|
||||||
|
yield mock
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_execute_inline_js_is_valid(logged_in_client, mock_measure_api):
|
||||||
|
"""The measurement screen carries the most inline JS in the app.
|
||||||
|
|
||||||
|
It drives the numpad, the caliper burst detection, the supervisor modal and -
|
||||||
|
since the production run moved server-side - the countdown. A broken literal
|
||||||
|
here kills every binding on the screen the operator actually works in.
|
||||||
|
"""
|
||||||
|
_force_italian(logged_in_client)
|
||||||
|
task = {
|
||||||
|
"id": 11,
|
||||||
|
"recipe_id": 3,
|
||||||
|
"version_id": 5,
|
||||||
|
"title": "Quota d'ingresso",
|
||||||
|
"description": "Misura l'altezza",
|
||||||
|
"file_path": None,
|
||||||
|
"file_type": None,
|
||||||
|
"annotations_json": None,
|
||||||
|
"subtasks": [{
|
||||||
|
"id": 21, "marker_number": 1, "name": "Altezza", "order_index": 0,
|
||||||
|
"nominal": 10.0, "utl": 10.5, "uwl": 10.2, "lwl": 9.8, "ltl": 9.5,
|
||||||
|
"unit": "mm",
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
mock_measure_api.get.side_effect = [
|
||||||
|
task, # /api/tasks/11
|
||||||
|
[{"id": 11, "order_index": 0}], # /api/recipes/3/tasks
|
||||||
|
{"id": 3, "measurement_interval_minutes": 30}, # /api/recipes/3
|
||||||
|
]
|
||||||
|
|
||||||
|
resp = logged_in_client.get("/measure/execute/11")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
html = resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
scripts = _INLINE_SCRIPT_RX.findall(html)
|
||||||
|
assert scripts, "expected at least one inline <script> on /measure/execute"
|
||||||
|
for i, body in enumerate(scripts):
|
||||||
|
_node_check(body, f"/measure/execute script[{i}]")
|
||||||
|
|
||||||
|
_check_alpine_attributes(html, "/measure/execute")
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: TieMeasureFlow 1.0\n"
|
"Project-Id-Version: TieMeasureFlow 1.0\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
"POT-Creation-Date: 2026-07-28 15:24+0000\n"
|
"POT-Creation-Date: 2026-07-28 16:15+0000\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language: en\n"
|
"Language: en\n"
|
||||||
@@ -169,6 +169,12 @@ msgstr "Username and password required"
|
|||||||
msgid "Utente non autorizzato (richiesto capoturno)"
|
msgid "Utente non autorizzato (richiesto capoturno)"
|
||||||
msgstr "User not authorized (shift supervisor required)"
|
msgstr "User not authorized (shift supervisor required)"
|
||||||
|
|
||||||
|
#: blueprints/measure.py:431 blueprints/measure.py:448
|
||||||
|
#: templates/errors/station_not_configured.html:2
|
||||||
|
#: templates/errors/station_not_configured.html:16
|
||||||
|
msgid "Stazione non configurata"
|
||||||
|
msgstr "Station not configured"
|
||||||
|
|
||||||
#: templates/base.html:173
|
#: templates/base.html:173
|
||||||
msgid "Sessione in scadenza"
|
msgid "Sessione in scadenza"
|
||||||
msgstr "Session expiring"
|
msgstr "Session expiring"
|
||||||
@@ -370,7 +376,7 @@ msgstr "Optional notes"
|
|||||||
#: templates/maker/task_editor.html:747 templates/maker/task_editor.html:866
|
#: templates/maker/task_editor.html:747 templates/maker/task_editor.html:866
|
||||||
#: templates/maker/task_editor.html:931 templates/maker/task_editor.html:1036
|
#: templates/maker/task_editor.html:931 templates/maker/task_editor.html:1036
|
||||||
#: templates/measure/select_recipe.html:367
|
#: templates/measure/select_recipe.html:367
|
||||||
#: templates/measure/task_execute.html:664
|
#: templates/measure/task_execute.html:684
|
||||||
msgid "Annulla"
|
msgid "Annulla"
|
||||||
msgstr "Cancel"
|
msgstr "Cancel"
|
||||||
|
|
||||||
@@ -515,7 +521,7 @@ msgstr "New User"
|
|||||||
#: templates/admin/users.html:48 templates/admin/users.html:173
|
#: templates/admin/users.html:48 templates/admin/users.html:173
|
||||||
#: templates/admin/users.html:179 templates/auth/login.html:35
|
#: templates/admin/users.html:179 templates/auth/login.html:35
|
||||||
#: templates/auth/login.html:49 templates/auth/profile.html:36
|
#: templates/auth/login.html:49 templates/auth/profile.html:36
|
||||||
#: templates/measure/task_execute.html:649
|
#: templates/measure/task_execute.html:669
|
||||||
msgid "Username"
|
msgid "Username"
|
||||||
msgstr "Username"
|
msgstr "Username"
|
||||||
|
|
||||||
@@ -567,7 +573,7 @@ msgstr "Username cannot be changed"
|
|||||||
|
|
||||||
#: templates/admin/users.html:206 templates/admin/users.html:214
|
#: templates/admin/users.html:206 templates/admin/users.html:214
|
||||||
#: templates/auth/login.html:57 templates/auth/login.html:71
|
#: templates/auth/login.html:57 templates/auth/login.html:71
|
||||||
#: templates/measure/task_execute.html:654
|
#: templates/measure/task_execute.html:674
|
||||||
msgid "Password"
|
msgid "Password"
|
||||||
msgstr "Password"
|
msgstr "Password"
|
||||||
|
|
||||||
@@ -817,11 +823,6 @@ msgstr "Logout blocked during measurements"
|
|||||||
msgid "Prossima misura"
|
msgid "Prossima misura"
|
||||||
msgstr "Next measurement"
|
msgstr "Next measurement"
|
||||||
|
|
||||||
#: templates/errors/station_not_configured.html:2
|
|
||||||
#: templates/errors/station_not_configured.html:16
|
|
||||||
msgid "Stazione non configurata"
|
|
||||||
msgstr "Station not configured"
|
|
||||||
|
|
||||||
#: templates/errors/station_not_configured.html:20
|
#: templates/errors/station_not_configured.html:20
|
||||||
msgid "Questo client non ha impostato la variabile di ambiente STATION_CODE."
|
msgid "Questo client non ha impostato la variabile di ambiente STATION_CODE."
|
||||||
msgstr "This client has not set the STATION_CODE environment variable."
|
msgstr "This client has not set the STATION_CODE environment variable."
|
||||||
@@ -863,7 +864,7 @@ msgstr "Preview"
|
|||||||
#: templates/maker/recipe_editor.html:128 templates/maker/recipe_list.html:246
|
#: templates/maker/recipe_editor.html:128 templates/maker/recipe_list.html:246
|
||||||
#: templates/maker/task_drawing.html:106 templates/maker/task_editor.html:130
|
#: templates/maker/task_drawing.html:106 templates/maker/task_editor.html:130
|
||||||
#: templates/measure/task_complete.html:168
|
#: templates/measure/task_complete.html:168
|
||||||
#: templates/measure/task_execute.html:477 templates/measure/task_list.html:2
|
#: templates/measure/task_execute.html:497 templates/measure/task_list.html:2
|
||||||
#: templates/measure/task_list.html:156
|
#: templates/measure/task_list.html:156
|
||||||
msgid "Task"
|
msgid "Task"
|
||||||
msgstr "Task"
|
msgstr "Task"
|
||||||
@@ -1012,7 +1013,8 @@ msgstr "Error during deletion"
|
|||||||
|
|
||||||
# Recipe Selection Additional
|
# Recipe Selection Additional
|
||||||
#: templates/maker/recipe_list.html:55 templates/measure/select_recipe.html:55
|
#: templates/maker/recipe_list.html:55 templates/measure/select_recipe.html:55
|
||||||
#: templates/measure/task_execute.html:1121
|
#: templates/measure/task_execute.html:1057
|
||||||
|
#: templates/measure/task_execute.html:1241
|
||||||
msgid "Errore di connessione"
|
msgid "Errore di connessione"
|
||||||
msgstr "Connection Error"
|
msgstr "Connection Error"
|
||||||
|
|
||||||
@@ -1610,7 +1612,7 @@ msgid "Misurazione aggiunta"
|
|||||||
msgstr "Measurement added"
|
msgstr "Measurement added"
|
||||||
|
|
||||||
#: templates/maker/task_editor.html:1645
|
#: templates/maker/task_editor.html:1645
|
||||||
#: templates/measure/task_execute.html:870
|
#: templates/measure/task_execute.html:931
|
||||||
msgid "Errore nel salvataggio della misurazione"
|
msgid "Errore nel salvataggio della misurazione"
|
||||||
msgstr "Error saving measurement"
|
msgstr "Error saving measurement"
|
||||||
|
|
||||||
@@ -1768,13 +1770,13 @@ msgstr "Search"
|
|||||||
#: templates/measure/task_complete.html:3
|
#: templates/measure/task_complete.html:3
|
||||||
#: templates/measure/task_complete.html:36
|
#: templates/measure/task_complete.html:36
|
||||||
#: templates/measure/task_execute.html:112
|
#: templates/measure/task_execute.html:112
|
||||||
#: templates/measure/task_execute.html:585
|
#: templates/measure/task_execute.html:605
|
||||||
#: templates/statistics/dashboard.html:139
|
#: templates/statistics/dashboard.html:139
|
||||||
msgid "Riepilogo"
|
msgid "Riepilogo"
|
||||||
msgstr "Summary"
|
msgstr "Summary"
|
||||||
|
|
||||||
#: templates/measure/task_complete.html:44
|
#: templates/measure/task_complete.html:44
|
||||||
#: templates/measure/task_execute.html:562
|
#: templates/measure/task_execute.html:582
|
||||||
msgid "Misurazioni Complete"
|
msgid "Misurazioni Complete"
|
||||||
msgstr "Measurements Complete"
|
msgstr "Measurements Complete"
|
||||||
|
|
||||||
@@ -1795,12 +1797,12 @@ msgid "Totale"
|
|||||||
msgstr "Total"
|
msgstr "Total"
|
||||||
|
|
||||||
#: templates/measure/task_complete.html:103
|
#: templates/measure/task_complete.html:103
|
||||||
#: templates/measure/task_execute.html:570
|
#: templates/measure/task_execute.html:590
|
||||||
msgid "Conformi"
|
msgid "Conformi"
|
||||||
msgstr "Pass"
|
msgstr "Pass"
|
||||||
|
|
||||||
#: templates/measure/task_complete.html:120
|
#: templates/measure/task_complete.html:120
|
||||||
#: templates/measure/task_execute.html:574
|
#: templates/measure/task_execute.html:594
|
||||||
msgid "Attenzione"
|
msgid "Attenzione"
|
||||||
msgstr "Warning"
|
msgstr "Warning"
|
||||||
|
|
||||||
@@ -2000,77 +2002,85 @@ msgstr "Next measurement in"
|
|||||||
msgid "Ciclo"
|
msgid "Ciclo"
|
||||||
msgstr "Cycle"
|
msgstr "Cycle"
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:446
|
#: templates/measure/task_execute.html:445
|
||||||
#: templates/measure/task_execute.html:612
|
msgid "Produzione non registrata sul server"
|
||||||
|
msgstr "Production not recorded on the server"
|
||||||
|
|
||||||
|
#: templates/measure/task_execute.html:466
|
||||||
|
#: templates/measure/task_execute.html:632
|
||||||
msgid "Avvio Produzione"
|
msgid "Avvio Produzione"
|
||||||
msgstr "Production Start"
|
msgstr "Production Start"
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:450
|
#: templates/measure/task_execute.html:470
|
||||||
msgid "Invia segnale al gestionale per avviare il timer della linea"
|
msgid "Invia segnale al gestionale per avviare il timer della linea"
|
||||||
msgstr "Send a signal to the ERP system to start the line timer"
|
msgstr "Send a signal to the ERP system to start the line timer"
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:461
|
#: templates/measure/task_execute.html:481
|
||||||
msgid "Produzione avviata"
|
msgid "Produzione avviata"
|
||||||
msgstr "Production started"
|
msgstr "Production started"
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:506
|
#: templates/measure/task_execute.html:526
|
||||||
msgid "Fine ciclo misura"
|
msgid "Fine ciclo misura"
|
||||||
msgstr "Measurement cycle complete"
|
msgstr "Measurement cycle complete"
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:516
|
#: templates/measure/task_execute.html:536
|
||||||
#: templates/measure/task_execute.html:530
|
#: templates/measure/task_execute.html:550
|
||||||
msgid "Completato"
|
msgid "Completato"
|
||||||
msgstr "Completed"
|
msgstr "Completed"
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:564
|
#: templates/measure/task_execute.html:584
|
||||||
msgid "Tutte le"
|
msgid "Tutte le"
|
||||||
msgstr "All"
|
msgstr "All"
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:564
|
#: templates/measure/task_execute.html:584
|
||||||
msgid "misurazioni sono state registrate."
|
msgid "misurazioni sono state registrate."
|
||||||
msgstr "measurements have been recorded."
|
msgstr "measurements have been recorded."
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:578
|
#: templates/measure/task_execute.html:598
|
||||||
msgid "Non Conf."
|
msgid "Non Conf."
|
||||||
msgstr "Fail"
|
msgstr "Fail"
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:592
|
#: templates/measure/task_execute.html:612
|
||||||
msgid "Conferma ciclo"
|
msgid "Conferma ciclo"
|
||||||
msgstr "Confirm cycle"
|
msgstr "Confirm cycle"
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:602
|
#: templates/measure/task_execute.html:622
|
||||||
msgid "Task successivo"
|
msgid "Task successivo"
|
||||||
msgstr "Next task"
|
msgstr "Next task"
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:642
|
#: templates/measure/task_execute.html:662
|
||||||
msgid "Autorizzazione capoturno"
|
msgid "Autorizzazione capoturno"
|
||||||
msgstr "Shift supervisor authorization"
|
msgstr "Shift supervisor authorization"
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:651
|
#: templates/measure/task_execute.html:671
|
||||||
msgid "Username capoturno"
|
msgid "Username capoturno"
|
||||||
msgstr "Supervisor username"
|
msgstr "Supervisor username"
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:673
|
#: templates/measure/task_execute.html:693
|
||||||
msgid "Autorizza"
|
msgid "Autorizza"
|
||||||
msgstr "Authorize"
|
msgstr "Authorize"
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:914
|
#: templates/measure/task_execute.html:975
|
||||||
msgid "Errore di rete. Riprovare."
|
msgid "Errore di rete. Riprovare."
|
||||||
msgstr "Network error. Please retry."
|
msgstr "Network error. Please retry."
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:1078
|
#: templates/measure/task_execute.html:1052
|
||||||
|
msgid "Errore di comunicazione con il server"
|
||||||
|
msgstr "Error communicating with the server"
|
||||||
|
|
||||||
|
#: templates/measure/task_execute.html:1198
|
||||||
msgid "Misurazione fuori tolleranza"
|
msgid "Misurazione fuori tolleranza"
|
||||||
msgstr "Measurement out of tolerance"
|
msgstr "Measurement out of tolerance"
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:1079
|
#: templates/measure/task_execute.html:1199
|
||||||
msgid "Fermo linea richiesto"
|
msgid "Fermo linea richiesto"
|
||||||
msgstr "Line stop requested"
|
msgstr "Line stop requested"
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:1080
|
#: templates/measure/task_execute.html:1200
|
||||||
msgid "Fine produzione richiesta"
|
msgid "Fine produzione richiesta"
|
||||||
msgstr "End of production requested"
|
msgstr "End of production requested"
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:1099
|
#: templates/measure/task_execute.html:1219
|
||||||
msgid "Credenziali non valide o utente non autorizzato"
|
msgid "Credenziali non valide o utente non autorizzato"
|
||||||
msgstr "Invalid credentials or unauthorized user"
|
msgstr "Invalid credentials or unauthorized user"
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: TieMeasureFlow 1.0\n"
|
"Project-Id-Version: TieMeasureFlow 1.0\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
"POT-Creation-Date: 2026-07-28 15:24+0000\n"
|
"POT-Creation-Date: 2026-07-28 16:15+0000\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language: it\n"
|
"Language: it\n"
|
||||||
@@ -169,6 +169,12 @@ msgstr "Username e password richiesti"
|
|||||||
msgid "Utente non autorizzato (richiesto capoturno)"
|
msgid "Utente non autorizzato (richiesto capoturno)"
|
||||||
msgstr "Utente non autorizzato (richiesto capoturno)"
|
msgstr "Utente non autorizzato (richiesto capoturno)"
|
||||||
|
|
||||||
|
#: blueprints/measure.py:431 blueprints/measure.py:448
|
||||||
|
#: templates/errors/station_not_configured.html:2
|
||||||
|
#: templates/errors/station_not_configured.html:16
|
||||||
|
msgid "Stazione non configurata"
|
||||||
|
msgstr "Stazione non configurata"
|
||||||
|
|
||||||
#: templates/base.html:173
|
#: templates/base.html:173
|
||||||
msgid "Sessione in scadenza"
|
msgid "Sessione in scadenza"
|
||||||
msgstr "Sessione in scadenza"
|
msgstr "Sessione in scadenza"
|
||||||
@@ -372,7 +378,7 @@ msgstr "Note opzionali"
|
|||||||
#: templates/maker/task_editor.html:747 templates/maker/task_editor.html:866
|
#: templates/maker/task_editor.html:747 templates/maker/task_editor.html:866
|
||||||
#: templates/maker/task_editor.html:931 templates/maker/task_editor.html:1036
|
#: templates/maker/task_editor.html:931 templates/maker/task_editor.html:1036
|
||||||
#: templates/measure/select_recipe.html:367
|
#: templates/measure/select_recipe.html:367
|
||||||
#: templates/measure/task_execute.html:664
|
#: templates/measure/task_execute.html:684
|
||||||
msgid "Annulla"
|
msgid "Annulla"
|
||||||
msgstr "Annulla"
|
msgstr "Annulla"
|
||||||
|
|
||||||
@@ -517,7 +523,7 @@ msgstr "Nuovo Utente"
|
|||||||
#: templates/admin/users.html:48 templates/admin/users.html:173
|
#: templates/admin/users.html:48 templates/admin/users.html:173
|
||||||
#: templates/admin/users.html:179 templates/auth/login.html:35
|
#: templates/admin/users.html:179 templates/auth/login.html:35
|
||||||
#: templates/auth/login.html:49 templates/auth/profile.html:36
|
#: templates/auth/login.html:49 templates/auth/profile.html:36
|
||||||
#: templates/measure/task_execute.html:649
|
#: templates/measure/task_execute.html:669
|
||||||
msgid "Username"
|
msgid "Username"
|
||||||
msgstr "Username"
|
msgstr "Username"
|
||||||
|
|
||||||
@@ -569,7 +575,7 @@ msgstr "Il nome utente non può essere modificato"
|
|||||||
|
|
||||||
#: templates/admin/users.html:206 templates/admin/users.html:214
|
#: templates/admin/users.html:206 templates/admin/users.html:214
|
||||||
#: templates/auth/login.html:57 templates/auth/login.html:71
|
#: templates/auth/login.html:57 templates/auth/login.html:71
|
||||||
#: templates/measure/task_execute.html:654
|
#: templates/measure/task_execute.html:674
|
||||||
msgid "Password"
|
msgid "Password"
|
||||||
msgstr "Password"
|
msgstr "Password"
|
||||||
|
|
||||||
@@ -819,11 +825,6 @@ msgstr "Logout bloccato durante le misurazioni"
|
|||||||
msgid "Prossima misura"
|
msgid "Prossima misura"
|
||||||
msgstr "Prossima misura"
|
msgstr "Prossima misura"
|
||||||
|
|
||||||
#: templates/errors/station_not_configured.html:2
|
|
||||||
#: templates/errors/station_not_configured.html:16
|
|
||||||
msgid "Stazione non configurata"
|
|
||||||
msgstr "Stazione non configurata"
|
|
||||||
|
|
||||||
#: templates/errors/station_not_configured.html:20
|
#: templates/errors/station_not_configured.html:20
|
||||||
msgid "Questo client non ha impostato la variabile di ambiente STATION_CODE."
|
msgid "Questo client non ha impostato la variabile di ambiente STATION_CODE."
|
||||||
msgstr "Questo client non ha impostato la variabile di ambiente STATION_CODE."
|
msgstr "Questo client non ha impostato la variabile di ambiente STATION_CODE."
|
||||||
@@ -865,7 +866,7 @@ msgstr "Anteprima"
|
|||||||
#: templates/maker/recipe_editor.html:128 templates/maker/recipe_list.html:246
|
#: templates/maker/recipe_editor.html:128 templates/maker/recipe_list.html:246
|
||||||
#: templates/maker/task_drawing.html:106 templates/maker/task_editor.html:130
|
#: templates/maker/task_drawing.html:106 templates/maker/task_editor.html:130
|
||||||
#: templates/measure/task_complete.html:168
|
#: templates/measure/task_complete.html:168
|
||||||
#: templates/measure/task_execute.html:477 templates/measure/task_list.html:2
|
#: templates/measure/task_execute.html:497 templates/measure/task_list.html:2
|
||||||
#: templates/measure/task_list.html:156
|
#: templates/measure/task_list.html:156
|
||||||
msgid "Task"
|
msgid "Task"
|
||||||
msgstr "Task"
|
msgstr "Task"
|
||||||
@@ -1014,7 +1015,8 @@ msgstr "Errore durante eliminazione"
|
|||||||
|
|
||||||
# Recipe Selection Additional
|
# Recipe Selection Additional
|
||||||
#: templates/maker/recipe_list.html:55 templates/measure/select_recipe.html:55
|
#: templates/maker/recipe_list.html:55 templates/measure/select_recipe.html:55
|
||||||
#: templates/measure/task_execute.html:1121
|
#: templates/measure/task_execute.html:1057
|
||||||
|
#: templates/measure/task_execute.html:1241
|
||||||
msgid "Errore di connessione"
|
msgid "Errore di connessione"
|
||||||
msgstr "Errore di connessione"
|
msgstr "Errore di connessione"
|
||||||
|
|
||||||
@@ -1612,7 +1614,7 @@ msgid "Misurazione aggiunta"
|
|||||||
msgstr "Misurazione aggiunta"
|
msgstr "Misurazione aggiunta"
|
||||||
|
|
||||||
#: templates/maker/task_editor.html:1645
|
#: templates/maker/task_editor.html:1645
|
||||||
#: templates/measure/task_execute.html:870
|
#: templates/measure/task_execute.html:931
|
||||||
msgid "Errore nel salvataggio della misurazione"
|
msgid "Errore nel salvataggio della misurazione"
|
||||||
msgstr "Errore nel salvataggio della misurazione"
|
msgstr "Errore nel salvataggio della misurazione"
|
||||||
|
|
||||||
@@ -1772,13 +1774,13 @@ msgstr "Cerca"
|
|||||||
#: templates/measure/task_complete.html:3
|
#: templates/measure/task_complete.html:3
|
||||||
#: templates/measure/task_complete.html:36
|
#: templates/measure/task_complete.html:36
|
||||||
#: templates/measure/task_execute.html:112
|
#: templates/measure/task_execute.html:112
|
||||||
#: templates/measure/task_execute.html:585
|
#: templates/measure/task_execute.html:605
|
||||||
#: templates/statistics/dashboard.html:139
|
#: templates/statistics/dashboard.html:139
|
||||||
msgid "Riepilogo"
|
msgid "Riepilogo"
|
||||||
msgstr "Riepilogo"
|
msgstr "Riepilogo"
|
||||||
|
|
||||||
#: templates/measure/task_complete.html:44
|
#: templates/measure/task_complete.html:44
|
||||||
#: templates/measure/task_execute.html:562
|
#: templates/measure/task_execute.html:582
|
||||||
msgid "Misurazioni Complete"
|
msgid "Misurazioni Complete"
|
||||||
msgstr "Misurazioni Complete"
|
msgstr "Misurazioni Complete"
|
||||||
|
|
||||||
@@ -1799,12 +1801,12 @@ msgid "Totale"
|
|||||||
msgstr "Totale"
|
msgstr "Totale"
|
||||||
|
|
||||||
#: templates/measure/task_complete.html:103
|
#: templates/measure/task_complete.html:103
|
||||||
#: templates/measure/task_execute.html:570
|
#: templates/measure/task_execute.html:590
|
||||||
msgid "Conformi"
|
msgid "Conformi"
|
||||||
msgstr "Conformi"
|
msgstr "Conformi"
|
||||||
|
|
||||||
#: templates/measure/task_complete.html:120
|
#: templates/measure/task_complete.html:120
|
||||||
#: templates/measure/task_execute.html:574
|
#: templates/measure/task_execute.html:594
|
||||||
msgid "Attenzione"
|
msgid "Attenzione"
|
||||||
msgstr "Attenzione"
|
msgstr "Attenzione"
|
||||||
|
|
||||||
@@ -2004,77 +2006,85 @@ msgstr "Prossima misurazione tra"
|
|||||||
msgid "Ciclo"
|
msgid "Ciclo"
|
||||||
msgstr "Ciclo"
|
msgstr "Ciclo"
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:446
|
#: templates/measure/task_execute.html:445
|
||||||
#: templates/measure/task_execute.html:612
|
msgid "Produzione non registrata sul server"
|
||||||
|
msgstr "Produzione non registrata sul server"
|
||||||
|
|
||||||
|
#: templates/measure/task_execute.html:466
|
||||||
|
#: templates/measure/task_execute.html:632
|
||||||
msgid "Avvio Produzione"
|
msgid "Avvio Produzione"
|
||||||
msgstr "Avvio Produzione"
|
msgstr "Avvio Produzione"
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:450
|
#: templates/measure/task_execute.html:470
|
||||||
msgid "Invia segnale al gestionale per avviare il timer della linea"
|
msgid "Invia segnale al gestionale per avviare il timer della linea"
|
||||||
msgstr "Invia segnale al gestionale per avviare il timer della linea"
|
msgstr "Invia segnale al gestionale per avviare il timer della linea"
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:461
|
#: templates/measure/task_execute.html:481
|
||||||
msgid "Produzione avviata"
|
msgid "Produzione avviata"
|
||||||
msgstr "Produzione avviata"
|
msgstr "Produzione avviata"
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:506
|
#: templates/measure/task_execute.html:526
|
||||||
msgid "Fine ciclo misura"
|
msgid "Fine ciclo misura"
|
||||||
msgstr "Fine ciclo misura"
|
msgstr "Fine ciclo misura"
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:516
|
#: templates/measure/task_execute.html:536
|
||||||
#: templates/measure/task_execute.html:530
|
#: templates/measure/task_execute.html:550
|
||||||
msgid "Completato"
|
msgid "Completato"
|
||||||
msgstr "Completato"
|
msgstr "Completato"
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:564
|
#: templates/measure/task_execute.html:584
|
||||||
msgid "Tutte le"
|
msgid "Tutte le"
|
||||||
msgstr "Tutte le"
|
msgstr "Tutte le"
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:564
|
#: templates/measure/task_execute.html:584
|
||||||
msgid "misurazioni sono state registrate."
|
msgid "misurazioni sono state registrate."
|
||||||
msgstr "misurazioni sono state registrate."
|
msgstr "misurazioni sono state registrate."
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:578
|
#: templates/measure/task_execute.html:598
|
||||||
msgid "Non Conf."
|
msgid "Non Conf."
|
||||||
msgstr "Non Conf."
|
msgstr "Non Conf."
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:592
|
#: templates/measure/task_execute.html:612
|
||||||
msgid "Conferma ciclo"
|
msgid "Conferma ciclo"
|
||||||
msgstr "Conferma ciclo"
|
msgstr "Conferma ciclo"
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:602
|
#: templates/measure/task_execute.html:622
|
||||||
msgid "Task successivo"
|
msgid "Task successivo"
|
||||||
msgstr "Task successivo"
|
msgstr "Task successivo"
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:642
|
#: templates/measure/task_execute.html:662
|
||||||
msgid "Autorizzazione capoturno"
|
msgid "Autorizzazione capoturno"
|
||||||
msgstr "Autorizzazione capoturno"
|
msgstr "Autorizzazione capoturno"
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:651
|
#: templates/measure/task_execute.html:671
|
||||||
msgid "Username capoturno"
|
msgid "Username capoturno"
|
||||||
msgstr "Username capoturno"
|
msgstr "Username capoturno"
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:673
|
#: templates/measure/task_execute.html:693
|
||||||
msgid "Autorizza"
|
msgid "Autorizza"
|
||||||
msgstr "Autorizza"
|
msgstr "Autorizza"
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:914
|
#: templates/measure/task_execute.html:975
|
||||||
msgid "Errore di rete. Riprovare."
|
msgid "Errore di rete. Riprovare."
|
||||||
msgstr "Errore di rete. Riprovare."
|
msgstr "Errore di rete. Riprovare."
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:1078
|
#: templates/measure/task_execute.html:1052
|
||||||
|
msgid "Errore di comunicazione con il server"
|
||||||
|
msgstr "Errore di comunicazione con il server"
|
||||||
|
|
||||||
|
#: templates/measure/task_execute.html:1198
|
||||||
msgid "Misurazione fuori tolleranza"
|
msgid "Misurazione fuori tolleranza"
|
||||||
msgstr "Misurazione fuori tolleranza"
|
msgstr "Misurazione fuori tolleranza"
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:1079
|
#: templates/measure/task_execute.html:1199
|
||||||
msgid "Fermo linea richiesto"
|
msgid "Fermo linea richiesto"
|
||||||
msgstr "Fermo linea richiesto"
|
msgstr "Fermo linea richiesto"
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:1080
|
#: templates/measure/task_execute.html:1200
|
||||||
msgid "Fine produzione richiesta"
|
msgid "Fine produzione richiesta"
|
||||||
msgstr "Fine produzione richiesta"
|
msgstr "Fine produzione richiesta"
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:1099
|
#: templates/measure/task_execute.html:1219
|
||||||
msgid "Credenziali non valide o utente non autorizzato"
|
msgid "Credenziali non valide o utente non autorizzato"
|
||||||
msgstr "Credenziali non valide o utente non autorizzato"
|
msgstr "Credenziali non valide o utente non autorizzato"
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PROJECT VERSION\n"
|
"Project-Id-Version: PROJECT VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
"POT-Creation-Date: 2026-07-28 15:24+0000\n"
|
"POT-Creation-Date: 2026-07-28 16:15+0000\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -166,6 +166,12 @@ msgstr ""
|
|||||||
msgid "Utente non autorizzato (richiesto capoturno)"
|
msgid "Utente non autorizzato (richiesto capoturno)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: blueprints/measure.py:431 blueprints/measure.py:448
|
||||||
|
#: templates/errors/station_not_configured.html:2
|
||||||
|
#: templates/errors/station_not_configured.html:16
|
||||||
|
msgid "Stazione non configurata"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: templates/base.html:173
|
#: templates/base.html:173
|
||||||
msgid "Sessione in scadenza"
|
msgid "Sessione in scadenza"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -363,7 +369,7 @@ msgstr ""
|
|||||||
#: templates/maker/task_editor.html:747 templates/maker/task_editor.html:866
|
#: templates/maker/task_editor.html:747 templates/maker/task_editor.html:866
|
||||||
#: templates/maker/task_editor.html:931 templates/maker/task_editor.html:1036
|
#: templates/maker/task_editor.html:931 templates/maker/task_editor.html:1036
|
||||||
#: templates/measure/select_recipe.html:367
|
#: templates/measure/select_recipe.html:367
|
||||||
#: templates/measure/task_execute.html:664
|
#: templates/measure/task_execute.html:684
|
||||||
msgid "Annulla"
|
msgid "Annulla"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -507,7 +513,7 @@ msgstr ""
|
|||||||
#: templates/admin/users.html:48 templates/admin/users.html:173
|
#: templates/admin/users.html:48 templates/admin/users.html:173
|
||||||
#: templates/admin/users.html:179 templates/auth/login.html:35
|
#: templates/admin/users.html:179 templates/auth/login.html:35
|
||||||
#: templates/auth/login.html:49 templates/auth/profile.html:36
|
#: templates/auth/login.html:49 templates/auth/profile.html:36
|
||||||
#: templates/measure/task_execute.html:649
|
#: templates/measure/task_execute.html:669
|
||||||
msgid "Username"
|
msgid "Username"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -559,7 +565,7 @@ msgstr ""
|
|||||||
|
|
||||||
#: templates/admin/users.html:206 templates/admin/users.html:214
|
#: templates/admin/users.html:206 templates/admin/users.html:214
|
||||||
#: templates/auth/login.html:57 templates/auth/login.html:71
|
#: templates/auth/login.html:57 templates/auth/login.html:71
|
||||||
#: templates/measure/task_execute.html:654
|
#: templates/measure/task_execute.html:674
|
||||||
msgid "Password"
|
msgid "Password"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -800,11 +806,6 @@ msgstr ""
|
|||||||
msgid "Prossima misura"
|
msgid "Prossima misura"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: templates/errors/station_not_configured.html:2
|
|
||||||
#: templates/errors/station_not_configured.html:16
|
|
||||||
msgid "Stazione non configurata"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: templates/errors/station_not_configured.html:20
|
#: templates/errors/station_not_configured.html:20
|
||||||
msgid "Questo client non ha impostato la variabile di ambiente STATION_CODE."
|
msgid "Questo client non ha impostato la variabile di ambiente STATION_CODE."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -842,7 +843,7 @@ msgstr ""
|
|||||||
#: templates/maker/recipe_editor.html:128 templates/maker/recipe_list.html:246
|
#: templates/maker/recipe_editor.html:128 templates/maker/recipe_list.html:246
|
||||||
#: templates/maker/task_drawing.html:106 templates/maker/task_editor.html:130
|
#: templates/maker/task_drawing.html:106 templates/maker/task_editor.html:130
|
||||||
#: templates/measure/task_complete.html:168
|
#: templates/measure/task_complete.html:168
|
||||||
#: templates/measure/task_execute.html:477 templates/measure/task_list.html:2
|
#: templates/measure/task_execute.html:497 templates/measure/task_list.html:2
|
||||||
#: templates/measure/task_list.html:156
|
#: templates/measure/task_list.html:156
|
||||||
msgid "Task"
|
msgid "Task"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -989,7 +990,8 @@ msgid "Errore durante eliminazione"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: templates/maker/recipe_list.html:55 templates/measure/select_recipe.html:55
|
#: templates/maker/recipe_list.html:55 templates/measure/select_recipe.html:55
|
||||||
#: templates/measure/task_execute.html:1121
|
#: templates/measure/task_execute.html:1057
|
||||||
|
#: templates/measure/task_execute.html:1241
|
||||||
msgid "Errore di connessione"
|
msgid "Errore di connessione"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -1580,7 +1582,7 @@ msgid "Misurazione aggiunta"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: templates/maker/task_editor.html:1645
|
#: templates/maker/task_editor.html:1645
|
||||||
#: templates/measure/task_execute.html:870
|
#: templates/measure/task_execute.html:931
|
||||||
msgid "Errore nel salvataggio della misurazione"
|
msgid "Errore nel salvataggio della misurazione"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -1734,13 +1736,13 @@ msgstr ""
|
|||||||
#: templates/measure/task_complete.html:3
|
#: templates/measure/task_complete.html:3
|
||||||
#: templates/measure/task_complete.html:36
|
#: templates/measure/task_complete.html:36
|
||||||
#: templates/measure/task_execute.html:112
|
#: templates/measure/task_execute.html:112
|
||||||
#: templates/measure/task_execute.html:585
|
#: templates/measure/task_execute.html:605
|
||||||
#: templates/statistics/dashboard.html:139
|
#: templates/statistics/dashboard.html:139
|
||||||
msgid "Riepilogo"
|
msgid "Riepilogo"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: templates/measure/task_complete.html:44
|
#: templates/measure/task_complete.html:44
|
||||||
#: templates/measure/task_execute.html:562
|
#: templates/measure/task_execute.html:582
|
||||||
msgid "Misurazioni Complete"
|
msgid "Misurazioni Complete"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -1760,12 +1762,12 @@ msgid "Totale"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: templates/measure/task_complete.html:103
|
#: templates/measure/task_complete.html:103
|
||||||
#: templates/measure/task_execute.html:570
|
#: templates/measure/task_execute.html:590
|
||||||
msgid "Conformi"
|
msgid "Conformi"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: templates/measure/task_complete.html:120
|
#: templates/measure/task_complete.html:120
|
||||||
#: templates/measure/task_execute.html:574
|
#: templates/measure/task_execute.html:594
|
||||||
msgid "Attenzione"
|
msgid "Attenzione"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -1963,77 +1965,85 @@ msgstr ""
|
|||||||
msgid "Ciclo"
|
msgid "Ciclo"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:446
|
#: templates/measure/task_execute.html:445
|
||||||
#: templates/measure/task_execute.html:612
|
msgid "Produzione non registrata sul server"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: templates/measure/task_execute.html:466
|
||||||
|
#: templates/measure/task_execute.html:632
|
||||||
msgid "Avvio Produzione"
|
msgid "Avvio Produzione"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:450
|
#: templates/measure/task_execute.html:470
|
||||||
msgid "Invia segnale al gestionale per avviare il timer della linea"
|
msgid "Invia segnale al gestionale per avviare il timer della linea"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:461
|
#: templates/measure/task_execute.html:481
|
||||||
msgid "Produzione avviata"
|
msgid "Produzione avviata"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:506
|
#: templates/measure/task_execute.html:526
|
||||||
msgid "Fine ciclo misura"
|
msgid "Fine ciclo misura"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:516
|
#: templates/measure/task_execute.html:536
|
||||||
#: templates/measure/task_execute.html:530
|
#: templates/measure/task_execute.html:550
|
||||||
msgid "Completato"
|
msgid "Completato"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:564
|
#: templates/measure/task_execute.html:584
|
||||||
msgid "Tutte le"
|
msgid "Tutte le"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:564
|
#: templates/measure/task_execute.html:584
|
||||||
msgid "misurazioni sono state registrate."
|
msgid "misurazioni sono state registrate."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:578
|
#: templates/measure/task_execute.html:598
|
||||||
msgid "Non Conf."
|
msgid "Non Conf."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:592
|
#: templates/measure/task_execute.html:612
|
||||||
msgid "Conferma ciclo"
|
msgid "Conferma ciclo"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:602
|
#: templates/measure/task_execute.html:622
|
||||||
msgid "Task successivo"
|
msgid "Task successivo"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:642
|
#: templates/measure/task_execute.html:662
|
||||||
msgid "Autorizzazione capoturno"
|
msgid "Autorizzazione capoturno"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:651
|
#: templates/measure/task_execute.html:671
|
||||||
msgid "Username capoturno"
|
msgid "Username capoturno"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:673
|
#: templates/measure/task_execute.html:693
|
||||||
msgid "Autorizza"
|
msgid "Autorizza"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:914
|
#: templates/measure/task_execute.html:975
|
||||||
msgid "Errore di rete. Riprovare."
|
msgid "Errore di rete. Riprovare."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:1078
|
#: templates/measure/task_execute.html:1052
|
||||||
|
msgid "Errore di comunicazione con il server"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: templates/measure/task_execute.html:1198
|
||||||
msgid "Misurazione fuori tolleranza"
|
msgid "Misurazione fuori tolleranza"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:1079
|
#: templates/measure/task_execute.html:1199
|
||||||
msgid "Fermo linea richiesto"
|
msgid "Fermo linea richiesto"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:1080
|
#: templates/measure/task_execute.html:1200
|
||||||
msgid "Fine produzione richiesta"
|
msgid "Fine produzione richiesta"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: templates/measure/task_execute.html:1099
|
#: templates/measure/task_execute.html:1219
|
||||||
msgid "Credenziali non valide o utente non autorizzato"
|
msgid "Credenziali non valide o utente non autorizzato"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user