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