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:
@@ -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
|
||||
Reference in New Issue
Block a user