5aa3d595ad
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>
83 lines
3.6 KiB
Python
83 lines
3.6 KiB
Python
"""Recipe and RecipeVersion models with immutable versioning."""
|
|
from datetime import datetime
|
|
from typing import TYPE_CHECKING, Optional
|
|
|
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, SmallInteger, String, Text, UniqueConstraint, func
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
from src.backend.database import Base
|
|
|
|
if TYPE_CHECKING:
|
|
from src.backend.models.orm.task import RecipeTask
|
|
|
|
|
|
class Recipe(Base):
|
|
__tablename__ = "recipes"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
code: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
|
created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime, nullable=False, server_default=func.now()
|
|
)
|
|
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"
|
|
)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<Recipe {self.code} '{self.name}'>"
|
|
|
|
|
|
class RecipeVersion(Base):
|
|
__tablename__ = "recipe_versions"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
recipe_id: Mapped[int] = mapped_column(Integer, ForeignKey("recipes.id"), nullable=False)
|
|
version_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
is_current: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
|
|
# Audit
|
|
created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime, nullable=False, server_default=func.now()
|
|
)
|
|
change_notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
|
|
# Relationships
|
|
recipe: Mapped["Recipe"] = relationship(back_populates="versions")
|
|
tasks: Mapped[list["RecipeTask"]] = relationship(
|
|
back_populates="version", cascade="all, delete-orphan", lazy="selectin"
|
|
)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("recipe_id", "version_number", name="uq_recipe_version"),
|
|
Index("ix_recipe_version_current", "recipe_id", "is_current"),
|
|
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
|
|
)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<RecipeVersion recipe={self.recipe_id} v{self.version_number}>"
|