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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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