From 98b1c5ae856cc13f1b68d9d223bcb50d4bd85a95 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Tue, 28 Jul 2026 21:31:42 +0000 Subject: [PATCH] feat(measure): l'operatore entra nella sequenza, non in un elenco MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Punto 10. Sceglere una ricetta apriva la lista completa dei task e chiedeva all'operatore di decidere da dove cominciare prima ancora di aver fatto qualcosa. Ora AVVIA IN SEQUENZA porta dentro il primo task; la lista resta, un livello sotto, per quando serve vederla tutta o tornare indietro. - nuova rotta /measure/start/: memorizza lotto e seriale (prima lo faceva la lista, che ora si salta), verifica la tracciabilità obbligatoria e apre il primo task - GET /api/measurements/task-progress: quante quote ha già preso ogni task di misura, contate per quota e non per tentativo, delimitate dalla produzione aperta o, fuori produzione, dall'operatore - la lista distingue «Incompiuto 1/3» da «Completato 3/3»: un task lasciato a metà non somigliava più a uno mai aperto - «Visualizza singolo TASK» non è più riservato al Maker — dire «incompiuto» senza dare la strada per tornarci sarebbe una lamentela, non una funzione - nel task «Riepilogo» diventa «Completato» e si apre solo quando il ciclo è chiuso: altrimenti sarebbe la scorciatoia che rende facoltative le quote - «Fine ciclo misura» si vede da subito, spento, e dice quante quote mancano; prima compariva a task già finito, quando non serviva più saperlo Il pulsante verde del footer diventa «Task successivo»: due bottoni con la stessa parola addosso sono uno di troppo. Test: 334 (erano 309). Il side effect dei mock del client Flask ora risponde per endpoint invece che per turno — una lista posizionale si rompe appena una pagina fa una domanda in più al server, che è come crescono tutte. Co-Authored-By: Claude Opus 5 (1M context) --- src/backend/api/routers/measurements.py | 19 + src/backend/models/api/measurement.py | 16 + src/backend/services/measurement_service.py | 69 ++- src/backend/tests/test_task_progress.py | 246 ++++++++++ src/frontend/flask_app/blueprints/measure.py | 135 +++++- .../templates/measure/select_recipe.html | 22 +- .../templates/measure/task_execute.html | 81 +++- .../templates/measure/task_list.html | 53 ++- src/frontend/flask_app/tests/conftest.py | 21 + src/frontend/flask_app/tests/test_measure.py | 123 ++++- .../flask_app/tests/test_operator_flow_ui.py | 192 ++++++++ .../tests/test_template_js_syntax.py | 17 +- .../translations/en/LC_MESSAGES/messages.po | 450 ++++++++++-------- .../translations/it/LC_MESSAGES/messages.po | 450 ++++++++++-------- .../flask_app/translations/messages.pot | 421 ++++++++-------- 15 files changed, 1656 insertions(+), 659 deletions(-) create mode 100644 src/backend/tests/test_task_progress.py create mode 100644 src/frontend/flask_app/tests/test_operator_flow_ui.py diff --git a/src/backend/api/routers/measurements.py b/src/backend/api/routers/measurements.py index 8e1225a..15f9fdf 100644 --- a/src/backend/api/routers/measurements.py +++ b/src/backend/api/routers/measurements.py @@ -24,6 +24,7 @@ from src.backend.models.api.measurement import ( MeasurementCreate, MeasurementListResponse, MeasurementResponse, + TaskProgressListResponse, ) from src.backend.services import auth_service, measurement_service from src.backend.services.measurement_service import save_measurement @@ -77,6 +78,24 @@ async def get_pending_authorisation( return MeasurementResponse.model_validate(blocking) if blocking else None +@router.get("/task-progress", response_model=TaskProgressListResponse) +async def get_task_progress( + version_id: int = Query(..., gt=0), + production_run_id: int | None = Query(None, gt=0), + user: User = Depends(require_measurement_tec), + db: AsyncSession = Depends(get_db), +): + """How many quotes of each measurement task are already taken. + + The task list is where an operator picks up work they left behind, and until + now it showed a task begun and a task never touched identically. + """ + tasks = await measurement_service.task_progress( + db, version_id, user.id, production_run_id, + ) + return TaskProgressListResponse(tasks=tasks) + + @router.post("/{measurement_id}/authorise", response_model=MeasurementResponse) async def authorise_measurement( measurement_id: int, diff --git a/src/backend/models/api/measurement.py b/src/backend/models/api/measurement.py index ed8a26d..4b5980e 100644 --- a/src/backend/models/api/measurement.py +++ b/src/backend/models/api/measurement.py @@ -63,6 +63,22 @@ class MeasurementAuthorisation(BaseModel): supervisor_password: str = Field(..., min_length=1) +class TaskProgressResponse(BaseModel): + """How far one measurement task has got: quotes taken out of quotes expected.""" + + task_id: int + quotes: int + measured: int + # empty (declared as measurement, no quotes yet) | none | partial | complete + state: str + + +class TaskProgressListResponse(BaseModel): + """Progress of every measurement task of a version.""" + + tasks: list[TaskProgressResponse] + + class MeasurementQuery(BaseModel): """Schema for measurement query filters.""" recipe_id: Optional[int] = None diff --git a/src/backend/services/measurement_service.py b/src/backend/services/measurement_service.py index 337717a..cacdb9a 100644 --- a/src/backend/services/measurement_service.py +++ b/src/backend/services/measurement_service.py @@ -8,7 +8,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.backend.models.orm.measurement import Measurement from src.backend.models.orm.recipe import Recipe, RecipeVersion -from src.backend.models.orm.task import RecipeSubtask +from src.backend.models.orm.task import ( + MEASURING_TASK_TYPES, RecipeSubtask, RecipeTask, +) from src.backend.models.orm.user import User @@ -135,6 +137,71 @@ async def pending_authorisation( return result.scalar_one_or_none() +async def task_progress( + db: AsyncSession, + version_id: int, + measured_by: int, + production_run_id: int | None = None, +) -> list[dict]: + """How far each measurement task of a version has got. + + A task left half done is the thing the operator cannot see today: they walk + away from it, come back to the list, and it looks exactly like one never + started. Counting quotes against measurements is what tells them apart. + + Documental tasks are not in here. A note cannot be half read, and marking one + incomplete for ever would be noise on the only screen that has to stay legible. + Which tasks count is read from the declared type, not from whether quotes happen + to have been entered yet - the same rule point 2 settled everywhere else. + """ + rows = (await db.execute( + select(RecipeTask.id, RecipeSubtask.id) + .join(RecipeSubtask, RecipeSubtask.task_id == RecipeTask.id, isouter=True) + .where( + RecipeTask.version_id == version_id, + RecipeTask.task_type.in_(MEASURING_TASK_TYPES), + ) + )).all() + if not rows: + return [] + + by_task: dict[int, set[int]] = {} + for task_id, subtask_id in rows: + quotes = by_task.setdefault(task_id, set()) + if subtask_id is not None: + quotes.add(subtask_id) + + measured = set((await db.execute( + _scope( + select(Measurement.subtask_id).distinct(), + version_id, measured_by, production_run_id, + ) + )).scalars().all()) + + progress = [] + for task_id, subtask_ids in by_task.items(): + total = len(subtask_ids) + done = len(subtask_ids & measured) + if total == 0: + # Declared as a measurement but with no quotes on it yet: nothing to + # take, so nothing to report as missing either. + state = "empty" + elif done == total: + state = "complete" + elif done: + state = "partial" + else: + state = "none" + progress.append({ + "task_id": task_id, + "quotes": total, + "measured": done, + "state": state, + }) + progress.sort(key=lambda row: row["task_id"]) + return progress + + async def authorise_measurement( db: AsyncSession, measurement: Measurement, supervisor: User, ) -> Measurement: diff --git a/src/backend/tests/test_task_progress.py b/src/backend/tests/test_task_progress.py new file mode 100644 index 0000000..47df290 --- /dev/null +++ b/src/backend/tests/test_task_progress.py @@ -0,0 +1,246 @@ +"""Point 10: a task left half done has to look different from one never opened. + +The task list showed both the same way, so an operator coming back to a recipe +had to remember where they had got to - or measure something twice. These tests +are about the count the list now puts on each card. +""" +from httpx import AsyncClient +from sqlalchemy import select + +from src.backend.models.orm.recipe import RecipeVersion +from src.backend.models.orm.station import Station +from src.backend.models.orm.task import RecipeSubtask, RecipeTask +from src.backend.tests.conftest import auth_headers, create_test_recipe + + +async def _version_and_task(db_session, recipe_id: int): + version = (await db_session.execute( + select(RecipeVersion).where( + RecipeVersion.recipe_id == recipe_id, + RecipeVersion.is_current == True, # noqa: E712 + ) + )).scalar_one() + task = (await db_session.execute( + select(RecipeTask).where(RecipeTask.version_id == version.id) + )).scalars().first() + return version, task + + +async def _add_quote(db_session, task_id: int, marker: int): + quote = RecipeSubtask( + task_id=task_id, marker_number=marker, description=f"Quota {marker}", + nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm", + ) + db_session.add(quote) + await db_session.commit() + await db_session.refresh(quote) + return quote + + +async def _measure(client, user, subtask_id, version_id, value=10.0): + return await client.post( + "/api/measurements/", headers=auth_headers(user), + json={"subtask_id": subtask_id, "version_id": version_id, "value": value}, + ) + + +async def _progress(client, user, version_id): + resp = await client.get( + "/api/measurements/task-progress", + headers=auth_headers(user), + params={"version_id": version_id}, + ) + assert resp.status_code == 200 + return {row["task_id"]: row for row in resp.json()["tasks"]} + + +async def test_untouched_task_reads_as_none( + client: AsyncClient, db_session, measurement_tec_user, +): + recipe = await create_test_recipe(db_session, measurement_tec_user.id) + version, task = await _version_and_task(db_session, recipe.id) + + rows = await _progress(client, measurement_tec_user, version.id) + assert rows[task.id]["state"] == "none" + assert rows[task.id]["measured"] == 0 + + +async def test_half_measured_task_reads_as_partial( + client: AsyncClient, db_session, measurement_tec_user, +): + recipe = await create_test_recipe(db_session, measurement_tec_user.id) + version, task = await _version_and_task(db_session, recipe.id) + first = (await db_session.execute( + select(RecipeSubtask).where(RecipeSubtask.task_id == task.id) + )).scalars().first() + await _add_quote(db_session, task.id, 2) + + await _measure(client, measurement_tec_user, first.id, version.id) + + rows = await _progress(client, measurement_tec_user, version.id) + assert rows[task.id] == { + "task_id": task.id, "quotes": 2, "measured": 1, "state": "partial", + } + + +async def test_every_quote_taken_reads_as_complete( + client: AsyncClient, db_session, measurement_tec_user, +): + recipe = await create_test_recipe(db_session, measurement_tec_user.id) + version, task = await _version_and_task(db_session, recipe.id) + first = (await db_session.execute( + select(RecipeSubtask).where(RecipeSubtask.task_id == task.id) + )).scalars().first() + second = await _add_quote(db_session, task.id, 2) + + await _measure(client, measurement_tec_user, first.id, version.id) + await _measure(client, measurement_tec_user, second.id, version.id) + + rows = await _progress(client, measurement_tec_user, version.id) + assert rows[task.id]["state"] == "complete" + assert rows[task.id]["measured"] == 2 + + +async def test_measuring_the_same_quote_twice_does_not_inflate_the_count( + client: AsyncClient, db_session, measurement_tec_user, +): + """Two readings of one quote are one quote done, not two. + + Re-measuring is allowed - it is how an operator recovers from a caliper that + slipped - and counting attempts would show a task of two quotes as finished + after one of them had been measured twice. + """ + recipe = await create_test_recipe(db_session, measurement_tec_user.id) + version, task = await _version_and_task(db_session, recipe.id) + first = (await db_session.execute( + select(RecipeSubtask).where(RecipeSubtask.task_id == task.id) + )).scalars().first() + await _add_quote(db_session, task.id, 2) + + await _measure(client, measurement_tec_user, first.id, version.id, 10.0) + await _measure(client, measurement_tec_user, first.id, version.id, 10.1) + + rows = await _progress(client, measurement_tec_user, version.id) + assert rows[task.id]["measured"] == 1 + assert rows[task.id]["state"] == "partial" + + +async def test_documental_tasks_are_left_out( + client: AsyncClient, db_session, measurement_tec_user, +): + """A note cannot be half read; carrying it as unfinished for ever is noise.""" + recipe = await create_test_recipe(db_session, measurement_tec_user.id) + version, task = await _version_and_task(db_session, recipe.id) + note = RecipeTask( + version_id=version.id, order_index=5, title="Leggere prima", + task_type="note", + ) + db_session.add(note) + await db_session.commit() + await db_session.refresh(note) + + rows = await _progress(client, measurement_tec_user, version.id) + assert note.id not in rows + assert task.id in rows + + +async def test_a_measurement_task_without_quotes_is_not_reported_as_missing( + client: AsyncClient, db_session, measurement_tec_user, +): + """Declared as a measurement, no quotes entered yet: nothing to take.""" + recipe = await create_test_recipe(db_session, measurement_tec_user.id) + version, _task = await _version_and_task(db_session, recipe.id) + empty = RecipeTask( + version_id=version.id, order_index=6, title="Da compilare", + task_type="measure", + ) + db_session.add(empty) + await db_session.commit() + await db_session.refresh(empty) + + rows = await _progress(client, measurement_tec_user, version.id) + assert rows[empty.id]["state"] == "empty" + assert rows[empty.id]["quotes"] == 0 + + +async def test_another_operators_work_is_not_counted_as_yours( + client: AsyncClient, db_session, measurement_tec_user, admin_user, +): + """Outside a production the boundary is the operator, as it is everywhere else.""" + recipe = await create_test_recipe(db_session, measurement_tec_user.id) + version, task = await _version_and_task(db_session, recipe.id) + first = (await db_session.execute( + select(RecipeSubtask).where(RecipeSubtask.task_id == task.id) + )).scalars().first() + + await _measure(client, admin_user, first.id, version.id) + + rows = await _progress(client, measurement_tec_user, version.id) + assert rows[task.id]["state"] == "none" + + +async def test_inside_a_production_the_run_is_the_boundary( + client: AsyncClient, db_session, measurement_tec_user, admin_user, +): + """A quote measured before the production opened is not part of it. + + The list is read during the run, and carrying yesterday's readings into it + would show a task as finished that this production has not touched. + """ + recipe = await create_test_recipe(db_session, measurement_tec_user.id) + version, task = await _version_and_task(db_session, recipe.id) + first = (await db_session.execute( + select(RecipeSubtask).where(RecipeSubtask.task_id == task.id) + )).scalars().first() + + # Measured outside any production. + await _measure(client, measurement_tec_user, first.id, version.id) + + station = Station( + code="ST-PROG", name="Stazione", active=True, created_by=admin_user.id, + ) + db_session.add(station) + await db_session.commit() + run = (await client.post( + "/api/production-runs", + headers=auth_headers(measurement_tec_user), + json={"station_code": station.code, "recipe_id": recipe.id}, + )).json() + + resp = await client.get( + "/api/measurements/task-progress", + headers=auth_headers(measurement_tec_user), + params={"version_id": version.id, "production_run_id": run["id"]}, + ) + assert resp.status_code == 200 + rows = {row["task_id"]: row for row in resp.json()["tasks"]} + assert rows[task.id]["state"] == "none" + + await client.post( + "/api/measurements/", headers=auth_headers(measurement_tec_user), + json={ + "subtask_id": first.id, "version_id": version.id, "value": 10.0, + "production_run_id": run["id"], + }, + ) + resp = await client.get( + "/api/measurements/task-progress", + headers=auth_headers(measurement_tec_user), + params={"version_id": version.id, "production_run_id": run["id"]}, + ) + rows = {row["task_id"]: row for row in resp.json()["tasks"]} + assert rows[task.id]["state"] == "complete" + + +async def test_progress_needs_a_measurement_role( + client: AsyncClient, db_session, maker_user, measurement_tec_user, +): + recipe = await create_test_recipe(db_session, measurement_tec_user.id) + version, _task = await _version_and_task(db_session, recipe.id) + + resp = await client.get( + "/api/measurements/task-progress", + headers=auth_headers(maker_user), + params={"version_id": version.id}, + ) + assert resp.status_code == 403 diff --git a/src/frontend/flask_app/blueprints/measure.py b/src/frontend/flask_app/blueprints/measure.py index 4d94ddf..58d6ff7 100644 --- a/src/frontend/flask_app/blueprints/measure.py +++ b/src/frontend/flask_app/blueprints/measure.py @@ -119,6 +119,112 @@ def select_recipe(): ) +def _remember_traceability() -> tuple[str, str]: + """Carry lot and serial from the query string into the session.""" + lot_number = request.args.get("lot_number", session.get("lot_number", "")) + serial_number = request.args.get( + "serial_number", session.get("serial_number", ""), + ) + if lot_number: + session["lot_number"] = lot_number + if serial_number: + session["serial_number"] = serial_number + return lot_number, serial_number + + +def _missing_traceability(recipe: dict, lot: str, serial: str) -> list[str]: + """The compulsory fields this recipe is still owed.""" + missing = [] + if recipe.get("requires_lot") and not (lot or "").strip(): + missing.append(_("lotto")) + if recipe.get("requires_serial") and not (serial or "").strip(): + missing.append(_("seriale")) + return missing + + +def _ordered_tasks(recipe_id: int) -> list[dict] | None: + """Tasks of a recipe's current version in execution order, or None on error.""" + resp = api_client.get(f"/api/recipes/{recipe_id}/tasks") + if isinstance(resp, dict) and resp.get("error"): + return None + tasks = resp if isinstance(resp, list) else resp.get("items", []) + return sorted(tasks, key=lambda t: (t.get("order_index", 0), t.get("id", 0))) + + +def _task_progress(version_id, run_id) -> dict[int, dict]: + """Quotes already taken per measurement task, keyed by task id. + + Never fatal: a list that cannot say which task was left half done is still a + usable list, and losing the whole page over a badge would be the worse trade. + """ + if not version_id: + return {} + params = {"version_id": version_id} + if run_id: + params["production_run_id"] = run_id + resp = api_client.get("/api/measurements/task-progress", params=params) + if not isinstance(resp, dict) or resp.get("error"): + return {} + return {row["task_id"]: row for row in resp.get("tasks", [])} + + +def _current_run_id() -> int | None: + """The production open at this station, if there is one.""" + station_code, _overridden = _current_station() + if not station_code: + return None + # The endpoint answers with the run itself, or null when nothing is running. + resp = api_client.get( + "/api/production-runs/current", params={"station_code": station_code}, + ) + if not isinstance(resp, dict) or resp.get("error"): + return None + return resp.get("id") + + +# --------------------------------------------------------------------------- +# Route: start the guided sequence +# --------------------------------------------------------------------------- +@measure_bp.route("/start/") +@login_required +@role_required("MeasurementTec") +def start_sequence(recipe_id: int): + """Open the first task of a recipe and let the operator walk the sequence. + + Choosing a recipe used to land on the full task list, which asked the operator + to pick a starting point before they had done anything. The sequence is the + normal way through; the list is still there, one level down, for when they need + to see the whole of it. + """ + lot_number, serial_number = _remember_traceability() + + recipe_resp = api_client.get(f"/api/recipes/{recipe_id}") + if recipe_resp.get("error"): + flash( + _("Ricetta non trovata: %(detail)s", + detail=recipe_resp.get("detail", "")), + "error", + ) + return redirect(url_for("measure.select_recipe")) + + missing = _missing_traceability(recipe_resp, lot_number, serial_number) + if missing: + flash( + _("Compila prima: %(fields)s", fields=", ".join(missing)), + "error", + ) + return redirect(url_for( + "measure.select_recipe", recipe=recipe_resp.get("code", ""), + )) + + tasks = _ordered_tasks(recipe_id) + if not tasks: + flash(_("Questa ricetta non ha ancora task definiti."), "error") + return redirect(url_for("measure.task_list", recipe_id=recipe_id)) + + return redirect(url_for("measure.task_execute", task_id=tasks[0]["id"])) + + # --------------------------------------------------------------------------- # Route: Task list for a recipe # --------------------------------------------------------------------------- @@ -127,17 +233,7 @@ def select_recipe(): @role_required("MeasurementTec") def task_list(recipe_id: int): """Task list for selected recipe.""" - # Persist lot/serial from query params into session - lot_number = request.args.get( - "lot_number", session.get("lot_number", ""), - ) - serial_number = request.args.get( - "serial_number", session.get("serial_number", ""), - ) - if lot_number: - session["lot_number"] = lot_number - if serial_number: - session["serial_number"] = serial_number + lot_number, serial_number = _remember_traceability() # Load recipe details recipe_resp = api_client.get(f"/api/recipes/{recipe_id}") @@ -149,22 +245,19 @@ def task_list(recipe_id: int): ) return redirect(url_for("measure.select_recipe")) - # Load tasks for this recipe - tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks") - if isinstance(tasks_resp, dict) and tasks_resp.get("error"): - flash( - _("Errore nel caricamento dei task: %(detail)s", - detail=tasks_resp.get("detail", "")), - "error", - ) + tasks = _ordered_tasks(recipe_id) + if tasks is None: + flash(_("Errore nel caricamento dei task."), "error") tasks = [] - else: - tasks = tasks_resp if isinstance(tasks_resp, list) else tasks_resp.get("items", []) + + version = recipe_resp.get("current_version") or {} + progress = _task_progress(version.get("id"), _current_run_id()) return render_template( "measure/task_list.html", recipe=recipe_resp, tasks=tasks, + progress=progress, lot_number=lot_number, serial_number=serial_number, ) diff --git a/src/frontend/flask_app/templates/measure/select_recipe.html b/src/frontend/flask_app/templates/measure/select_recipe.html index ddcca04..c80dad4 100644 --- a/src/frontend/flask_app/templates/measure/select_recipe.html +++ b/src/frontend/flask_app/templates/measure/select_recipe.html @@ -31,8 +31,11 @@ if (recipe.requires_serial && !this.serial_number.trim()) missing.push('{{ _('seriale') }}'); return missing; }, + /* Straight into the first task. Choosing a recipe used to open the full + task list, which asked the operator to decide where to begin before they + had done anything; the list is still one tap away from inside the task. */ buildTaskUrl(recipeId) { - let url = '/measure/tasks/' + recipeId + '?'; + let url = '/measure/start/' + recipeId + '?'; const params = []; if (this.lot_number) params.push('lot_number=' + encodeURIComponent(this.lot_number)); if (this.serial_number) params.push('serial_number=' + encodeURIComponent(this.serial_number)); @@ -285,13 +288,18 @@ : 'group-hover:shadow-md'" class="btn btn-primary w-full justify-center text-sm font-semibold transition-shadow duration-200"> - - - - {{ _('Seleziona') }} - - + + + {{ _('AVVIA IN SEQUENZA') }} + + + {# The whole list, for when the operator wants to see it or go back to a + task left unfinished. Deliberately the quiet one of the two. #} + + {{ _('Vedi tutti i task') }} diff --git a/src/frontend/flask_app/templates/measure/task_execute.html b/src/frontend/flask_app/templates/measure/task_execute.html index 647703b..15995e4 100644 --- a/src/frontend/flask_app/templates/measure/task_execute.html +++ b/src/frontend/flask_app/templates/measure/task_execute.html @@ -96,7 +96,16 @@ - {# Lista task + Riepilogo buttons #} + {# + Lista task + Completato. + + The list is the second level now: the operator arrives inside the sequence + and comes here when they want the whole of it. What used to sit beside it + was «Riepilogo», which jumped out of the sequence to a recap - the wrong + thing to offer someone halfway through a task. The way forward is + «Completato», and on a measurement task it stays shut until the cycle is + closed, which is the same rule the footer enforces. + #}
@@ -105,12 +114,15 @@ {{ _('Lista task') }} -
@@ -630,18 +642,30 @@ x-text="Math.round(progressPercent) + '%'"> - {# Right: Fine ciclo misura (measurement tasks) / Completato (non-measurement or after cycle) #} + {# Right: Fine ciclo misura (measurement tasks) / Task successivo (after cycle) #}