feat(production): la misura torna a cercare l'operatore

Il conto alla rovescia non si ferma piu' a zero. Oltre la scadenza continua
nell'altro senso e dice da quanto la linea e' in ritardo, in rosso, su tutte le
schermate del percorso di misura: lista task, riepilogo, scelta ricetta. Un
ritardo va letto, non dedotto. Allo scadere dell'intervallo la misura si
ripropone da sola: cicalino, un avviso di cinque secondi, e l'operatore torna al
primo task di misura da qualunque schermata si trovi.

Un ciclo copre tutti i task di misura della ricetta, quindi solo l'ultimo lo
chiude e fa ripartire l'intervallo. Farlo ripartire al primo avrebbe fatto
misurare una ricetta con tre task di misura tre volte piu' spesso di come e'
configurata. E' il server a decidere quale task chiude il ciclo, perche' e' lui
a conoscere la sequenza: la lista dei task di misura viaggia con la produzione
(measurement_task_ids), che e' anche cio' che permette a una schermata qualsiasi
di sapere dove riportare l'operatore.

Aggiunta la rimisura: si gira il pezzo e si misura di nuovo dentro lo stesso
ciclo, senza chiudere niente e senza guadagnare tempo sulla scadenza. Le due
letture restano entrambe in statistica, che e' il motivo per cui si prendono.

Chi sta gia' misurando quando l'intervallo scade e' in ritardo, non perso: la
banda diventa rossa e lo si lascia lavorare. Portarlo altrove a meta' ciclo
cancellerebbe quote che ha davanti agli occhi senza guadagnare nulla.

Sistemato anche il pulsante "Conferma ciclo" dell'overlay: alzava una bandierina
locale e basta, quindi il ciclo non veniva mai registrato sul server da quella
strada. Ora passa da confirmCycle come il pulsante della barra.

Migrazione 008: il registro eventi della produzione impara task_measured e
remeasure, e la colonna task_id — un task_measured che non dice quale task non
registra niente di utile. I valori nuovi entrano tutti insieme perche' allargare
una enum MySQL riscrive la tabella, stesso ragionamento dei tipi di task in 007.
Verificata su SQLite (batch mode) e in MySQL con --sql.

La logica dell'orologio — come si legge un ritardo, quando suona, dove sta la
misura — vive in un solo posto (production-clock.js) e la schermata di misura la
usa invece di riscriverla.

Test: +10 (265). Coprono il ciclo che non riparte a meta', la rimisura che non
sposta la scadenza, il rifiuto a linea ferma, la sequenza dei task di misura
esposta dalla produzione, i proxy Flask e la validita' JS della lista task.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-07-28 19:31:51 +00:00
parent 6fbff2fe76
commit bde8fafd77
20 changed files with 1586 additions and 529 deletions
+39 -13
View File
@@ -15,6 +15,7 @@ from src.backend.models.api.production import (
ProductionRunCreate,
ProductionRunResponse,
ProductionRunWithEventsResponse,
RemeasurePayload,
SupervisorAction,
)
from src.backend.models.orm.production import ProductionRun
@@ -23,13 +24,19 @@ from src.backend.services import production_service
router = APIRouter(prefix="/api/production-runs", tags=["production"])
_DERIVED = {
"seconds_to_next_measurement", "overdue", "server_time", "measurement_task_ids",
}
def _as_response(run: ProductionRun) -> ProductionRunResponse:
async def _as_response(db: AsyncSession, run: ProductionRun) -> ProductionRunResponse:
"""The stored run plus everything the client cannot work out on its own."""
return ProductionRunResponse(
**ProductionRunResponse.model_validate(run).model_dump(
exclude={"seconds_to_next_measurement", "overdue", "server_time"}
),
**ProductionRunResponse.model_validate(run).model_dump(exclude=_DERIVED),
**production_service.describe(run),
measurement_task_ids=await production_service.measurement_task_ids(
db, run.version_id,
),
)
@@ -41,7 +48,7 @@ async def open_production_run(
):
"""Start a production at a station."""
run = await production_service.open_run(db, data, user)
return _as_response(run)
return await _as_response(db, run)
@router.get("/current", response_model=ProductionRunResponse | None)
@@ -56,7 +63,7 @@ async def get_current_production_run(
"""
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
return await _as_response(db, run) if run is not None else None
@router.get("/{run_id}", response_model=ProductionRunWithEventsResponse)
@@ -69,7 +76,7 @@ async def get_production_run(
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(),
**(await _as_response(db, run)).model_dump(),
events=[ProductionEventResponse.model_validate(e) for e in events],
)
@@ -81,12 +88,31 @@ async def complete_measurement_cycle(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Record a finished measurement cycle and restart the interval."""
"""Record a finished measurement task; the last one restarts 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,
db, run, user,
task_id=payload.task_id if payload else None,
note=payload.note if payload else None,
)
return _as_response(run)
return await _as_response(db, run)
@router.post("/{run_id}/remeasure", response_model=ProductionRunResponse)
async def remeasure_in_cycle(
run_id: int,
payload: RemeasurePayload | None = None,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Turn the piece over and measure again, without closing the cycle."""
run = await production_service.get_run(db, run_id)
run = await production_service.remeasure(
db, run, user,
task_id=payload.task_id if payload else None,
note=payload.note if payload else None,
)
return await _as_response(db, run)
@router.post("/{run_id}/pause", response_model=ProductionRunResponse)
@@ -102,7 +128,7 @@ async def pause_production_run(
db, action.supervisor_username, action.supervisor_password,
)
run = await production_service.pause_run(db, run, user, supervisor, note=action.note)
return _as_response(run)
return await _as_response(db, run)
@router.post("/{run_id}/resume", response_model=ProductionRunResponse)
@@ -118,7 +144,7 @@ async def resume_production_run(
db, action.supervisor_username, action.supervisor_password,
)
run = await production_service.resume_run(db, run, user, supervisor, note=action.note)
return _as_response(run)
return await _as_response(db, run)
@router.post("/{run_id}/close", response_model=ProductionRunResponse)
@@ -134,4 +160,4 @@ async def close_production_run(
db, action.supervisor_username, action.supervisor_password,
)
run = await production_service.close_run(db, run, user, supervisor, note=action.note)
return _as_response(run)
return await _as_response(db, run)