feat(measure): l'operatore entra nella sequenza, non in un elenco

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/<ricetta>: 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) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-07-28 21:31:42 +00:00
parent 919ee7ac79
commit 98b1c5ae85
15 changed files with 1656 additions and 659 deletions
+19
View File
@@ -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,
+16
View File
@@ -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
+68 -1
View File
@@ -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:
+246
View File
@@ -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