feat(recipes): la ricetta decide cosa pretendere e come si misura

Tre cose che prima non erano di nessuno diventano regole della ricetta, decise da
chi la scrive e fatte valere dal server.

Punto 8 — tracciabilita' obbligatoria. Lotto e seriale si dichiarano obbligatori
sulla ricetta. L'operatore li inserisce alla selezione, dove il pulsante non si
attiva finche' mancano, e la stessa regola vale sulla lista task, raggiungibile
anche per link diretto, e sul barcode: uno sbarramento che dura solo finche'
qualcuno prende in mano il lettore non e' uno sbarramento. La produzione non si
apre e la misura non si salva senza cio' che la ricetta pretende, perche' un
valore senza il suo lotto non e' riconducibile a niente, e accorgersene dopo
significa accorgersene tardi.

Punto 9 — inserimento manuale. Una ricetta puo' vietare i valori digitati, ed e'
il valore predefinito: il calibro e' lo strumento, digitare e' cio' che va
concesso. Dove e' vietato il tastierino non viene disegnato (non nascosto con i
CSS: il markup nascosto e' markup che si puo' rimostrare) e restano correzione e
conferma, perche' una lettura sbagliata va cancellata. Il controllo vero e' sul
server: una regola che vive solo nel frontend e' un consiglio.

Migliorato al passaggio il riconoscimento del calibro. Contava solo la raffica di
cifre, cosi' una lettura corta come "9.5" — tre battute — finiva registrata come
digitata a mano; ora conta anche l'Invio che il wedge manda dentro la stessa
raffica. Senza questa correzione il divieto avrebbe respinto misure legittime.

Punto 11 — formattazione delle descrizioni. A capo e grassetto sopravvivono: chi
scrive le ricette incolla dal PDF della scheda tecnica e il testo arrivava
appiattito, da risistemare a mano ogni volta. Nessun HTML viene accettato o
salvato — il testo viene escapato e gli unici tag nel risultato sono quelli
prodotti dal renderer. La sanificazione e' questa: non c'e' niente da sanificare
perche' non si accetta niente. Le stesse due regole in Jinja e in JS, cosi' una
descrizione si legge uguale ovunque. E la descrizione ora si vede anche in
esecuzione: era scritta per l'operatore e la vedeva solo chi la scriveva.

Migrazione 009: tre colonne sulla ricetta. Le due di tracciabilita' partono
false, che e' il comportamento di oggi; l'inserimento manuale parte *vero* sulle
ricette gia' esistenti — il default della colonna e' falso, quindi le ricette
nuove sono solo-calibro, ma spegnerlo d'ufficio su quelle in uso fermerebbe una
linea alla misura successiva. Chi possiede la ricetta lo decide dall'editor.

Test: +26 (291). Coprono il rifiuto sul server per lotto, seriale e valore
digitato, il calibro sempre ammesso, le regole che sopravvivono alla nuova
versione, il tastierino assente in pagina, l'Avvia sbarrato, e il renderer delle
descrizioni compreso il caso in cui si prova a farci passare un tag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-07-28 20:27:19 +00:00
parent bde8fafd77
commit 5aa3d595ad
26 changed files with 1945 additions and 745 deletions
@@ -0,0 +1,57 @@
"""traceability and manual input become rules of the recipe
Three settings that were nowhere: whether the lot and the serial are compulsory,
and whether a value may be typed instead of read from the caliper. Until now lot
and serial were optional everywhere and the keypad was always available, so a
value that happens to be in tolerance could simply be entered by hand.
Backfill: the two traceability flags start false, which is exactly today's
behaviour. Manual input starts *true* on the recipes that already exist - the
column default is false, so recipes written from now on are caliper-only, but
flipping the ones already in use would stop a running line at the next
measurement. Turning them off is a decision for whoever owns the recipe, taken in
the editor, not a side effect of an upgrade.
Revision ID: 009_recipe_rules
Revises: 008_loop_events
Create Date: 2026-07-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '009_recipe_rules'
down_revision: Union[str, None] = '008_loop_events'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'recipes',
sa.Column(
'requires_lot', sa.Boolean(), nullable=False, server_default='0',
),
)
op.add_column(
'recipes',
sa.Column(
'requires_serial', sa.Boolean(), nullable=False, server_default='0',
),
)
op.add_column(
'recipes',
sa.Column(
'allow_manual_input', sa.Boolean(), nullable=False, server_default='0',
),
)
# Recipes already in production keep the behaviour they were written under.
op.execute("UPDATE recipes SET allow_manual_input = 1")
def downgrade() -> None:
op.drop_column('recipes', 'allow_manual_input')
op.drop_column('recipes', 'requires_serial')
op.drop_column('recipes', 'requires_lot')
+13
View File
@@ -15,6 +15,11 @@ class RecipeCreate(BaseModel):
description: Optional[str] = None
image_path: Optional[str] = Field(None, max_length=500)
measurement_interval_minutes: Optional[int] = Field(None, ge=1, le=1440)
# Rules of the recipe: what the operator must supply, and how a value may be
# entered. Manual input defaults to forbidden - the caliper is the instrument.
requires_lot: bool = False
requires_serial: bool = False
allow_manual_input: bool = False
# Optional task-level fields for the initial technical drawing
file_path: Optional[str] = Field(None, max_length=500)
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
@@ -27,6 +32,9 @@ class RecipeUpdate(BaseModel):
description: Optional[str] = None
image_path: Optional[str] = Field(None, max_length=500)
measurement_interval_minutes: Optional[int] = Field(None, ge=1, le=1440)
requires_lot: Optional[bool] = None
requires_serial: Optional[bool] = None
allow_manual_input: Optional[bool] = None
change_notes: Optional[str] = None
# Task-level fields: saved to the first task of the new version
file_path: Optional[str] = Field(None, max_length=500)
@@ -58,6 +66,11 @@ class RecipeResponse(BaseModel):
description: Optional[str] = None
image_path: Optional[str] = None
measurement_interval_minutes: Optional[int] = None
# The operator's screen reads these to know whether to ask for lot and serial
# before starting, and whether to offer the keypad at all.
requires_lot: bool = False
requires_serial: bool = False
allow_manual_input: bool = False
created_by: int
created_at: datetime
active: bool
+4
View File
@@ -53,6 +53,10 @@ class RecipeSummary(BaseModel):
active: bool
image_path: Optional[str] = None
description: Optional[str] = None
# The selection screen has to know before the operator starts: what a recipe
# demands is the difference between an enabled Avvia and a disabled one.
requires_lot: bool = False
requires_serial: bool = False
class StationWithRecipesResponse(StationResponse):
+16
View File
@@ -26,6 +26,22 @@ class Recipe(Base):
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
measurement_interval_minutes: Mapped[Optional[int]] = mapped_column(SmallInteger, nullable=True)
# Traceability, decided when the recipe is written rather than left to whoever
# is at the machine: a measurement without the lot it belongs to cannot be
# traced back afterwards, and tracing back is half of what the system is for.
requires_lot: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="0"
)
requires_serial: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="0"
)
# Whether a value may be typed instead of read from the caliper. Forbidden by
# default: without the rule, a value that happens to be in tolerance can simply
# be entered by hand, which is the case this setting exists to prevent.
allow_manual_input: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="0"
)
# Relationships
versions: Mapped[list["RecipeVersion"]] = relationship(
back_populates="recipe", cascade="all, delete-orphan", lazy="selectin"
@@ -1,10 +1,12 @@
"""Measurement service - pass/fail calculation, data storage."""
from decimal import Decimal
from fastapi import HTTPException, status
from sqlalchemy import select
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
@@ -39,6 +41,48 @@ def calculate_pass_fail(
return "pass", deviation
async def recipe_of_version(db: AsyncSession, version_id: int) -> Recipe | None:
"""The recipe a version belongs to - where the rules of measurement live."""
result = await db.execute(
select(Recipe)
.join(RecipeVersion, RecipeVersion.recipe_id == Recipe.id)
.where(RecipeVersion.id == version_id)
)
return result.scalar_one_or_none()
def _enforce_recipe_rules(
recipe: Recipe | None,
lot_number: str | None,
serial_number: str | None,
input_method: str,
) -> None:
"""Refuse a measurement the recipe does not allow.
Checked here rather than only on the screen because a rule that lives in the
frontend is a suggestion: the keypad can be hidden and the same request still
sent. This is the one place every measurement passes through.
"""
if recipe is None:
return
if recipe.requires_lot and not (lot_number or "").strip():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="This recipe requires a lot number",
)
if recipe.requires_serial and not (serial_number or "").strip():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="This recipe requires a serial number",
)
if input_method == "manual" and not recipe.allow_manual_input:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="This recipe does not allow values typed by hand: use the caliper",
)
async def save_measurement(
db: AsyncSession,
subtask_id: int,
@@ -60,6 +104,9 @@ async def save_measurement(
if subtask is None:
raise ValueError(f"Subtask {subtask_id} not found")
recipe = await recipe_of_version(db, version_id)
_enforce_recipe_rules(recipe, lot_number, serial_number, input_method)
pass_fail, deviation = calculate_pass_fail(value, subtask)
measurement = Measurement(
@@ -243,6 +243,20 @@ async def open_run(
detail="Recipe has no current version",
)
# Traceability the recipe declares compulsory has to be there before the first
# measurement, not after it: a lot entered halfway through leaves the values
# taken up to that point unattributable.
if recipe.requires_lot and not (data.lot_number or "").strip():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="This recipe requires a lot number to start a production",
)
if recipe.requires_serial and not (data.serial_number or "").strip():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="This recipe requires a serial number to start a production",
)
now = _now()
# The interval is copied, not referenced: editing the recipe mid-production must
# not move the deadline of a run already under way.
+29 -19
View File
@@ -19,6 +19,30 @@ from src.backend.models.api.recipe import RecipeCreate, RecipeUpdate
# Helpers
# ---------------------------------------------------------------------------
# Columns of the recipe header itself, as opposed to the versioned content. Both
# update paths - copy-on-write and in-place - write exactly these, so the list
# lives once: a field added to one and forgotten in the other would appear to save
# and then quietly not, depending on whether the version had measurements.
HEADER_FIELDS = (
"name",
"description",
"image_path",
"measurement_interval_minutes",
"requires_lot",
"requires_serial",
"allow_manual_input",
)
def _header_fields(data: RecipeUpdate) -> dict:
"""The header columns the caller actually asked to change."""
return {
field: getattr(data, field)
for field in HEADER_FIELDS
if getattr(data, field) is not None
}
async def _get_recipe_or_404(db: AsyncSession, recipe_id: int) -> Recipe:
"""Return a recipe or raise 404."""
result = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
@@ -138,6 +162,9 @@ async def create_recipe(
description=data.description,
image_path=data.image_path,
measurement_interval_minutes=data.measurement_interval_minutes,
requires_lot=data.requires_lot,
requires_serial=data.requires_serial,
allow_manual_input=data.allow_manual_input,
created_by=user.id,
)
db.add(recipe)
@@ -271,15 +298,7 @@ async def create_new_version(
await db.flush()
# Apply header updates
update_fields: dict = {}
if data.name is not None:
update_fields["name"] = data.name
if data.description is not None:
update_fields["description"] = data.description
if data.image_path is not None:
update_fields["image_path"] = data.image_path
if data.measurement_interval_minutes is not None:
update_fields["measurement_interval_minutes"] = data.measurement_interval_minutes
update_fields = _header_fields(data)
if update_fields:
await db.execute(
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
@@ -368,16 +387,7 @@ async def update_current_version(
data: RecipeUpdate,
) -> RecipeVersion:
"""Update recipe header in-place on the current version (no copy-on-write)."""
# Apply header updates (name, description, image_path)
update_fields: dict = {}
if data.name is not None:
update_fields["name"] = data.name
if data.description is not None:
update_fields["description"] = data.description
if data.image_path is not None:
update_fields["image_path"] = data.image_path
if data.measurement_interval_minutes is not None:
update_fields["measurement_interval_minutes"] = data.measurement_interval_minutes
update_fields = _header_fields(data)
if update_fields:
await db.execute(
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
+4
View File
@@ -224,6 +224,10 @@ async def create_test_recipe(
code=code,
name=name,
description="A recipe for testing",
# Typing is allowed here so that tests about something else can still save
# a measurement. The rule itself is exercised by test_recipe_rules.py, on
# recipes that declare it explicitly.
allow_manual_input=True,
created_by=user_id,
)
session.add(recipe)
+280
View File
@@ -0,0 +1,280 @@
"""What a recipe is allowed to demand of the operator (points 8 and 9).
Two rules that used to be nobody's: whether the lot and the serial are compulsory,
and whether a value may be typed rather than read from the caliper. Both are
declared on the recipe and enforced on the server, because a rule enforced only by
the screen is a suggestion - the keypad can be hidden and the same request still
sent by hand.
"""
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from src.backend.models.orm.recipe import Recipe, 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 _rules(db_session, recipe: Recipe, **flags) -> Recipe:
for key, value in flags.items():
setattr(recipe, key, value)
await db_session.commit()
await db_session.refresh(recipe)
return recipe
async def _first_subtask(db_session, recipe_id: int) -> RecipeSubtask:
row = await db_session.execute(
select(RecipeSubtask)
.join(RecipeTask, RecipeTask.id == RecipeSubtask.task_id)
.join(RecipeVersion, RecipeVersion.id == RecipeTask.version_id)
.where(RecipeVersion.recipe_id == recipe_id)
)
return row.scalars().first()
async def _current_version_id(db_session, recipe_id: int) -> int:
row = await db_session.execute(
select(RecipeVersion).where(
RecipeVersion.recipe_id == recipe_id,
RecipeVersion.is_current == True, # noqa: E712
)
)
return row.scalar_one().id
async def _measure(client, user, subtask_id, version_id, **extra):
return await client.post(
"/api/measurements/",
headers=auth_headers(user),
json={
"subtask_id": subtask_id, "version_id": version_id, "value": 10.0, **extra,
},
)
# ---------------------------------------------------------------------------
# The rules are part of the recipe
# ---------------------------------------------------------------------------
async def test_new_recipe_forbids_typing_by_default(client: AsyncClient, maker_user):
"""The caliper is the instrument: typing is what has to be asked for."""
resp = await client.post(
"/api/recipes",
headers=auth_headers(maker_user),
json={"code": "REG-DEF", "name": "Regole di default"},
)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["allow_manual_input"] is False
assert body["requires_lot"] is False
assert body["requires_serial"] is False
async def test_recipe_carries_the_rules_it_was_created_with(
client: AsyncClient, maker_user,
):
resp = await client.post(
"/api/recipes",
headers=auth_headers(maker_user),
json={
"code": "REG-SET", "name": "Con regole",
"requires_lot": True, "requires_serial": True, "allow_manual_input": True,
},
)
body = resp.json()
assert (body["requires_lot"], body["requires_serial"], body["allow_manual_input"]) \
== (True, True, True)
async def test_rules_can_be_switched_off_again(
client: AsyncClient, maker_user, db_session,
):
"""False must reach the database: a flag that only ever turns on is a trap."""
recipe = await create_test_recipe(db_session, maker_user.id, code="REG-OFF")
await _rules(db_session, recipe, requires_lot=True, allow_manual_input=True)
resp = await client.put(
f"/api/recipes/{recipe.id}",
headers=auth_headers(maker_user),
json={"requires_lot": False, "allow_manual_input": False},
)
assert resp.status_code == 200, resp.text
assert resp.json()["requires_lot"] is False
assert resp.json()["allow_manual_input"] is False
async def test_rules_survive_a_new_version(
client: AsyncClient, maker_user, measurement_tec_user, db_session,
):
"""Editing a recipe with measurements copies it; the rules must come along."""
recipe = await create_test_recipe(db_session, maker_user.id, code="REG-VER")
await _rules(db_session, recipe, requires_lot=True)
subtask = await _first_subtask(db_session, recipe.id)
version_id = await _current_version_id(db_session, recipe.id)
# A measurement on the current version forces copy-on-write on the next edit.
await _measure(
client, measurement_tec_user, subtask.id, version_id, lot_number="L-1",
)
resp = await client.put(
f"/api/recipes/{recipe.id}",
headers=auth_headers(maker_user),
json={"name": "Rinominata"},
)
assert resp.status_code == 200
assert resp.json()["requires_lot"] is True
# ---------------------------------------------------------------------------
# Point 8 - traceability
# ---------------------------------------------------------------------------
async def test_measurement_without_the_required_lot_is_refused(
client: AsyncClient, maker_user, measurement_tec_user, db_session,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="TRC-LOT")
await _rules(db_session, recipe, requires_lot=True)
subtask = await _first_subtask(db_session, recipe.id)
version_id = await _current_version_id(db_session, recipe.id)
refused = await _measure(client, measurement_tec_user, subtask.id, version_id)
assert refused.status_code == 422
# Blank is not a lot number either.
blank = await _measure(
client, measurement_tec_user, subtask.id, version_id, lot_number=" ",
)
assert blank.status_code == 422
accepted = await _measure(
client, measurement_tec_user, subtask.id, version_id, lot_number="LOT-9",
)
assert accepted.status_code == 200
async def test_measurement_without_the_required_serial_is_refused(
client: AsyncClient, maker_user, measurement_tec_user, db_session,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="TRC-SER")
await _rules(db_session, recipe, requires_serial=True)
subtask = await _first_subtask(db_session, recipe.id)
version_id = await _current_version_id(db_session, recipe.id)
assert (await _measure(
client, measurement_tec_user, subtask.id, version_id,
)).status_code == 422
assert (await _measure(
client, measurement_tec_user, subtask.id, version_id, serial_number="SN-1",
)).status_code == 200
async def test_optional_traceability_still_accepts_nothing(
client: AsyncClient, maker_user, measurement_tec_user, db_session,
):
"""Recipes that do not ask for a lot must go on working as they did."""
recipe = await create_test_recipe(db_session, maker_user.id, code="TRC-OPT")
subtask = await _first_subtask(db_session, recipe.id)
version_id = await _current_version_id(db_session, recipe.id)
assert (await _measure(
client, measurement_tec_user, subtask.id, version_id,
)).status_code == 200
async def test_production_cannot_start_without_the_required_lot(
client: AsyncClient, maker_user, measurement_tec_user, admin_user, db_session,
):
"""The lot has to be there before the first value, not after it."""
recipe = await create_test_recipe(db_session, maker_user.id, code="TRC-RUN")
await _rules(db_session, recipe, requires_lot=True)
station = Station(
code="ST-TRC", name="Stazione", active=True, created_by=admin_user.id,
)
db_session.add(station)
await db_session.commit()
refused = await client.post(
"/api/production-runs",
headers=auth_headers(measurement_tec_user),
json={"station_code": station.code, "recipe_id": recipe.id},
)
assert refused.status_code == 422
started = await client.post(
"/api/production-runs",
headers=auth_headers(measurement_tec_user),
json={
"station_code": station.code, "recipe_id": recipe.id, "lot_number": "L-7",
},
)
assert started.status_code == 201
# ---------------------------------------------------------------------------
# Point 9 - typed values
# ---------------------------------------------------------------------------
async def test_typed_value_is_refused_when_the_recipe_forbids_it(
client: AsyncClient, maker_user, measurement_tec_user, db_session,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="MAN-NO")
await _rules(db_session, recipe, allow_manual_input=False)
subtask = await _first_subtask(db_session, recipe.id)
version_id = await _current_version_id(db_session, recipe.id)
refused = await _measure(
client, measurement_tec_user, subtask.id, version_id, input_method="manual",
)
assert refused.status_code == 422
assert "caliper" in refused.json()["detail"].lower()
async def test_the_caliper_is_always_welcome(
client: AsyncClient, maker_user, measurement_tec_user, db_session,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="MAN-USB")
await _rules(db_session, recipe, allow_manual_input=False)
subtask = await _first_subtask(db_session, recipe.id)
version_id = await _current_version_id(db_session, recipe.id)
resp = await _measure(
client, measurement_tec_user, subtask.id, version_id,
input_method="usb_caliper",
)
assert resp.status_code == 200
assert resp.json()["input_method"] == "usb_caliper"
async def test_typing_is_allowed_where_the_recipe_says_so(
client: AsyncClient, maker_user, measurement_tec_user, db_session,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="MAN-YES")
subtask = await _first_subtask(db_session, recipe.id)
version_id = await _current_version_id(db_session, recipe.id)
resp = await _measure(
client, measurement_tec_user, subtask.id, version_id, input_method="manual",
)
assert resp.status_code == 200
async def test_the_default_input_method_is_covered_by_the_rule(
client: AsyncClient, maker_user, measurement_tec_user, db_session,
):
"""A request that says nothing about how the value was entered counts as typed.
Otherwise omitting the field would be the way around the rule.
"""
recipe = await create_test_recipe(db_session, maker_user.id, code="MAN-DEF")
await _rules(db_session, recipe, allow_manual_input=False)
subtask = await _first_subtask(db_session, recipe.id)
version_id = await _current_version_id(db_session, recipe.id)
assert (await _measure(
client, measurement_tec_user, subtask.id, version_id,
)).status_code == 422