feat(tasks): il tipo di un task si dichiara, non si deduce

Punto 2 del documento modifiche del 28/07, prima richiesta di Menoncin.

Il sistema distingueva un task di misura da uno documentale deducendolo: se aveva
quote era una misura, altrimenti una nota. Conseguenza: un task di misura a cui le
quote non erano ancora state inserite veniva trattato come nota, e il sistema si
comportava in modo diverso a seconda di quanto fosse completa la ricetta.

Nuovo campo task_type su recipe_tasks (migrazione 007) con nota, misura e disegno,
piu' xf_compare e camera_measure gia' nell'enum: allargare un enum MySQL piu' avanti
e' un ALTER su tabella viva, e non costa nulla prevederli adesso.

Il backfill riproduce la classificazione che era a schermo, cosi' nessuna ricetta
cambia comportamento all'aggiornamento: i task con quote diventano 'measure'; quelli
senza quote ma con un disegno allegato diventano 'drawing' e non 'note', perche' e'
gia' cio' che mostravano - chiamarli note sarebbe stato l'unico punto in cui questa
migrazione cambiava le carte in tavola.

Il tipo viene copiato esplicitamente nel copy-on-write del versioning: una nuova
versione che lo perdesse riclassificherebbe in silenzio tutti i task alla prima
modifica di una ricetta. Il task "Technical Drawing" creato d'ufficio quando si
carica un disegno su una ricetta senza task nasce come 'drawing'.

Lato operatore decide ora il tipo, non il conteggio delle quote: fermo linea, fine
produzione, avvio produzione e la barra di produzione seguono il tipo. Dove servono
davvero delle quote da mostrare - elenco marker, tastierino, fine ciclo misura -
resta anche il controllo che ce ne sia almeno una, e un task di misura ancora privo
di quote lo dichiara invece di somigliare a una nota. Nella lista task compare il
tipo, cosi' si vede prima di aprire.

La 007 e' stata eseguita su SQLite usa e getta con tre righe costruite apposta - una
con quote, una col solo disegno, una nota secca - e il backfill le classifica come
atteso. Il backfill girera' sui dati reali del cliente, provarlo a mano non bastava.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-07-28 18:15:18 +00:00
parent 7bc3c1f938
commit 6fbff2fe76
12 changed files with 1019 additions and 589 deletions
+171
View File
@@ -0,0 +1,171 @@
"""Tests for the explicit task type (point 2).
The type used to be deduced - quotes present meant a measurement - so a measurement
task whose quotes had not been entered yet was treated as a note, and the system
behaved differently depending on how complete the recipe was. These tests pin the
type down as something declared and carried.
"""
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.task import RecipeTask
from src.backend.tests.conftest import auth_headers, create_test_recipe
async def _current_version(db_session, recipe_id: int) -> RecipeVersion:
row = await db_session.execute(
select(RecipeVersion).where(
RecipeVersion.recipe_id == recipe_id,
RecipeVersion.is_current == True, # noqa: E712
)
)
return row.scalar_one()
async def test_task_defaults_to_note(client: AsyncClient, maker_user, db_session):
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-T1")
await db_session.commit()
resp = await client.post(
f"/api/recipes/{recipe.id}/tasks",
headers=auth_headers(maker_user),
json={"title": "Solo una nota"},
)
assert resp.status_code == 201, resp.text
assert resp.json()["task_type"] == "note"
@pytest.mark.parametrize("declared", ["note", "measure", "drawing"])
async def test_task_keeps_the_declared_type(
client: AsyncClient, maker_user, db_session, declared,
):
recipe = await create_test_recipe(
db_session, user_id=maker_user.id, code=f"REC-{declared}",
)
await db_session.commit()
resp = await client.post(
f"/api/recipes/{recipe.id}/tasks",
headers=auth_headers(maker_user),
json={"title": f"Task {declared}", "task_type": declared},
)
assert resp.status_code == 201, resp.text
assert resp.json()["task_type"] == declared
async def test_measure_task_without_quotes_is_still_a_measure_task(
client: AsyncClient, maker_user, db_session,
):
"""The criterion from the document: an unfinished recipe must not change kind."""
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-NOQ")
await db_session.commit()
created = await client.post(
f"/api/recipes/{recipe.id}/tasks",
headers=auth_headers(maker_user),
json={"title": "Misura da compilare", "task_type": "measure"},
)
task_id = created.json()["id"]
fetched = await client.get(f"/api/tasks/{task_id}", headers=auth_headers(maker_user))
body = fetched.json()
assert body["subtasks"] == [], "il task non ha ancora quote"
assert body["task_type"] == "measure", "e resta comunque un task di misura"
async def test_unknown_type_is_rejected(client: AsyncClient, maker_user, db_session):
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-BAD")
await db_session.commit()
resp = await client.post(
f"/api/recipes/{recipe.id}/tasks",
headers=auth_headers(maker_user),
json={"title": "x", "task_type": "qualcosa"},
)
assert resp.status_code == 422
async def test_type_can_be_changed(client: AsyncClient, maker_user, db_session):
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-CHG")
await db_session.commit()
created = await client.post(
f"/api/recipes/{recipe.id}/tasks",
headers=auth_headers(maker_user),
json={"title": "Nasce nota", "task_type": "note"},
)
task_id = created.json()["id"]
resp = await client.put(
f"/api/tasks/{task_id}",
headers=auth_headers(maker_user),
json={"task_type": "measure"},
)
assert resp.status_code == 200, resp.text
assert resp.json()["task_type"] == "measure"
async def test_type_survives_copy_on_write_versioning(
client: AsyncClient, maker_user, measurement_tec_user, db_session,
):
"""A new version must not silently reclassify every task of the recipe."""
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-COW")
await db_session.commit()
version = await _current_version(db_session, recipe.id)
row = await db_session.execute(
select(RecipeTask).where(RecipeTask.version_id == version.id)
)
first_task = row.scalars().first()
# Declare it a drawing. The point is that this survives, not how it got set.
await client.put(
f"/api/tasks/{first_task.id}",
headers=auth_headers(maker_user),
json={"task_type": "drawing"},
)
# Measure on the current version, which forces the next edit to copy-on-write.
await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": first_task.subtasks[0].id,
"version_id": version.id,
"value": 10.0,
},
)
# Editing now must create a new version.
await client.put(
f"/api/recipes/{recipe.id}",
headers=auth_headers(maker_user),
json={"name": "Rinominata"},
)
new_version = await _current_version(db_session, recipe.id)
assert new_version.id != version.id, "l'edit doveva creare una nuova versione"
copied = await db_session.execute(
select(RecipeTask).where(RecipeTask.version_id == new_version.id)
)
types = sorted(t.task_type for t in copied.scalars().all())
assert "drawing" in types, "il tipo dichiarato non e' sopravvissuto alla copia"
async def test_task_list_exposes_the_type(client: AsyncClient, maker_user, db_session):
"""The operator's task list reads the type, so it must come over the wire."""
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-LST")
await db_session.commit()
await client.post(
f"/api/recipes/{recipe.id}/tasks",
headers=auth_headers(maker_user),
json={"title": "Misura", "task_type": "measure"},
)
resp = await client.get(
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
)
assert resp.status_code == 200
assert all("task_type" in t for t in resp.json())