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
+156
View File
@@ -13,6 +13,7 @@ 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.models.orm.task import RecipeTask
from src.backend.services import auth_service, production_service
from src.backend.tests.conftest import auth_headers, create_test_recipe
@@ -63,6 +64,29 @@ async def _advance_clock(db_session, run_id: int, delta: timedelta) -> None:
await db_session.commit()
async def _only_task(db_session, version_id: int):
"""The single task the fixture recipe comes with - a measurement one."""
row = await db_session.execute(
select(RecipeTask)
.where(RecipeTask.version_id == version_id)
.order_by(RecipeTask.order_index)
)
return row.scalars().first()
async def _add_task(
db_session, version_id: int, order_index: int, title: str, task_type: str = "measure",
) -> RecipeTask:
"""A further task in the sequence, of a declared type."""
task = RecipeTask(
version_id=version_id, order_index=order_index, title=title, task_type=task_type,
)
db_session.add(task)
await db_session.commit()
await db_session.refresh(task)
return task
async def _open(client, user, station, recipe, **extra):
return await client.post(
"/api/production-runs",
@@ -209,6 +233,138 @@ async def test_cycle_restarts_the_interval_and_counts(
assert 9 * 60 <= body["seconds_to_next_measurement"] <= 10 * 60
async def test_measurement_tasks_travel_with_the_run(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
"""Where the measurement is cannot be guessed by the client: the run says so.
This is what lets a screen bring the operator back to the measurement when the
interval expires, whatever page they wandered off to.
"""
station = await _station(db_session, admin_user.id, code="ST-SEQ")
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-SEQ")
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
first = await _only_task(db_session, opened["version_id"])
# A drawing between the two measurements, to prove it is left out.
await _add_task(db_session, opened["version_id"], 1, "Disegno", "drawing")
second = await _add_task(db_session, opened["version_id"], 2, "Seconda misura")
body = (await client.get(
"/api/production-runs/current",
params={"station_code": station.code},
headers=auth_headers(measurement_tec_user),
)).json()
assert body["measurement_task_ids"] == [first.id, second.id]
async def test_cycle_closes_only_on_the_last_measurement_task(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
"""Restarting at the first would measure three times as often as configured."""
station = await _station(db_session, admin_user.id, code="ST-MULTI")
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-MULTI")
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
run_id = opened["id"]
first = await _only_task(db_session, opened["version_id"])
second = await _add_task(db_session, opened["version_id"], 1, "Seconda misura")
headers = auth_headers(measurement_tec_user)
# Four minutes of the interval have gone by when the first task is finished.
await _advance_clock(db_session, run_id, timedelta(minutes=4))
mid = (await client.post(
f"/api/production-runs/{run_id}/cycle", headers=headers, json={"task_id": first.id},
)).json()
assert mid["cycle_count"] == 0, "il ciclo non e' ancora finito"
assert 5 * 60 <= mid["seconds_to_next_measurement"] <= 6 * 60, (
"l'intervallo non deve ripartire a meta' ciclo"
)
end = (await client.post(
f"/api/production-runs/{run_id}/cycle", headers=headers, json={"task_id": second.id},
)).json()
assert end["cycle_count"] == 1
assert 9 * 60 <= end["seconds_to_next_measurement"] <= 10 * 60
events = (await client.get(
f"/api/production-runs/{run_id}", headers=headers,
)).json()["events"]
by_type = {e["event_type"]: e for e in events}
assert by_type["task_measured"]["task_id"] == first.id
assert by_type["cycle_completed"]["task_id"] == second.id
async def test_cycle_without_a_task_closes_as_before(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
"""A caller that knows nothing of the sequence keeps the old behaviour."""
station = await _station(db_session, admin_user.id, code="ST-PLAIN")
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-PLAIN")
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
await _add_task(db_session, opened["version_id"], 1, "Seconda misura")
body = (await client.post(
f"/api/production-runs/{opened['id']}/cycle",
headers=auth_headers(measurement_tec_user),
)).json()
assert body["cycle_count"] == 1
# ---------------------------------------------------------------------------
# Rimisura - turning the piece over
# ---------------------------------------------------------------------------
async def test_remeasure_does_not_buy_another_interval(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
"""A second reading of the same part is not a new cycle."""
station = await _station(db_session, admin_user.id, code="ST-AGAIN")
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-AGAIN")
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
run_id = opened["id"]
task = await _only_task(db_session, opened["version_id"])
headers = auth_headers(measurement_tec_user)
await _advance_clock(db_session, run_id, timedelta(minutes=7))
body = (await client.post(
f"/api/production-runs/{run_id}/remeasure", headers=headers,
json={"task_id": task.id},
)).json()
assert body["cycle_count"] == 0
assert 2 * 60 <= body["seconds_to_next_measurement"] <= 3 * 60, (
"rigirare il pezzo non deve spostare la scadenza"
)
events = (await client.get(
f"/api/production-runs/{run_id}", headers=headers,
)).json()["events"]
remeasured = [e for e in events if e["event_type"] == "remeasure"]
assert len(remeasured) == 1
assert remeasured[0]["task_id"] == task.id
async def test_remeasure_refused_while_the_line_is_stopped(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
station = await _station(db_session, admin_user.id, code="ST-AGPAUSE")
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-AGPAUSE")
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
supervisor, password = await _supervisor(db_session, username="capo-again")
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}/remeasure",
headers=auth_headers(measurement_tec_user),
)
assert resp.status_code == 409
async def test_overdue_run_reports_negative_seconds(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):