feat(production): dai una vita propria alla produzione, lato server
Punto 1 del documento modifiche del 28/07, il prerequisito su cui poggiano i punti 3, 4 e 6. Lo stato di una produzione viveva dentro una pagina del browser: timer, conteggio cicli e flag "produzione avviata" erano variabili Alpine di task_execute.html, e la navigazione fra task e' un ricaricamento completo, quindi cambiando task si perdeva tutto. Da qui il loop di misura che non reggeva, il fermo linea che non aveva nulla da fermare e l'assenza di storico. Nuove tabelle production_runs e production_events (migrazione 005), endpoint REST senza stato in memoria di processo - con un'app di stazione installata su ogni PC il database e' l'unico posto condiviso - e il frontend che legge lo stato all'apertura invece di tenerlo in memoria. Tre scelte di modello: - la scadenza e' un timestamp assoluto (next_measurement_at), non un contatore: il countdown si ricalcola da li' a ogni caricamento, e lasciarne andare la differenza sotto zero dara' gratis il contatore del ritardo del punto 3. Al client vanno i secondi gia' calcolati, non il timestamp: un datetime naive verrebbe letto nel fuso del browser e il conto sarebbe sfasato dell'offset UTC; - l'intervallo di misura e' copiato sulla produzione, non referenziato: modificare la ricetta a produzione avviata non deve spostare una scadenza in corso; - active_station_id rispecchia la stazione finche' la produzione e' aperta e va a NULL alla chiusura. Con un vincolo unico sopra, "una stazione = una produzione aperta" e' una garanzia del database e non un controllo soggetto a race; i NULL non collidono, quindi le produzioni chiuse si accumulano senza disturbo. Il fermo linea congela il conto alla rovescia e alla ripresa la scadenza viene traslata della durata del fermo, non ricalcolata: un fermo non regala ne' toglie tempo all'operatore. L'autorizzazione del capoturno passa da authenticate_user e non da un login, che rigenererebbe la sua API key buttando giu' la sessione che ha aperta altrove. La migrazione e' stata eseguita davvero, non solo scritta, su uno SQLite usa e getta: upgrade e downgrade girano e le colonne coincidono con i modelli. La prova ha trovato un difetto - create_unique_constraint dopo create_table e' un ALTER, che SQLite rifiuta - ora il vincolo e' dichiarato dentro create_table. Fuori da questo commit, per stare nei confini del punto 1: l'API espone gia' pause, resume e close, ma i pulsanti fermo linea e fine produzione restano da collegare (punto 6), e il rientro forzato sulla misura allo scadere e' il punto 3. Corretti due difetti trovati strada facendo: env.py non importava ne' Station ne' ProductionRun, quindi l'autogenerate di Alembic era gia' cieco sulle stazioni; e task_execute.html, lo schermo con piu' JavaScript dell'applicazione, non era coperto dal test di sintassi. Aggiungerlo ha richiesto di correggere l'helper, che validava le espressioni Alpine solo come espressione singola e bocciava @click="a = false; b = true", forma che Alpine accetta: ora prova entrambe le letture e fallisce solo se cadono tutte e due. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
"""ProductionRun and ProductionEvent models.
|
||||
|
||||
A production run is what an operator opens when production starts and a supervisor
|
||||
closes when it ends. It is the thing that outlives a page: before this, the timer,
|
||||
the cycle count and the "production started" flag were Alpine variables in
|
||||
task_execute.html, and navigating between tasks - a full page load - destroyed them.
|
||||
|
||||
The measurement deadline is stored as an absolute timestamp (next_measurement_at)
|
||||
rather than a remaining count. Any client can then derive the countdown from it, get
|
||||
the same answer, survive a reload, and tell how far *past* the interval it is by
|
||||
letting the difference go negative.
|
||||
|
||||
With one station app installed per PC (see the install architecture of 28/07) the
|
||||
database is the only place this state can live: the API keeps nothing in process
|
||||
memory.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
DateTime, Enum, 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.recipe import Recipe, RecipeVersion
|
||||
from src.backend.models.orm.station import Station
|
||||
|
||||
|
||||
RUN_STATUSES = ("running", "paused", "closed")
|
||||
EVENT_TYPES = ("start", "cycle_completed", "line_stop", "resume", "close")
|
||||
|
||||
|
||||
class ProductionRun(Base):
|
||||
__tablename__ = "production_runs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
station_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("stations.id"), nullable=False, index=True
|
||||
)
|
||||
recipe_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("recipes.id"), nullable=False, index=True
|
||||
)
|
||||
version_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("recipe_versions.id"), nullable=False, index=True
|
||||
)
|
||||
operator_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=False, index=True
|
||||
)
|
||||
|
||||
# Traceability travels with the run instead of the Flask session, so it stays
|
||||
# attached to every measurement taken during it.
|
||||
lot_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
|
||||
serial_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
|
||||
|
||||
status: Mapped[str] = mapped_column(
|
||||
Enum(*RUN_STATUSES, name="production_run_status_enum"),
|
||||
nullable=False,
|
||||
default="running",
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Snapshot of the recipe interval at start: editing the recipe mid-run must not
|
||||
# move the deadline of a production already under way.
|
||||
measurement_interval_minutes: Mapped[Optional[int]] = mapped_column(
|
||||
SmallInteger, nullable=True
|
||||
)
|
||||
# When the next measurement falls due. Null when the recipe has no interval.
|
||||
# Past this instant the run is overdue, and by how much is simply now - this.
|
||||
next_measurement_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime, nullable=True, index=True
|
||||
)
|
||||
cycle_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
started_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now()
|
||||
)
|
||||
paused_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
closed_by: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=True
|
||||
)
|
||||
|
||||
# Mirrors station_id while the run is open and goes NULL when it closes. A unique
|
||||
# index on it lets the database - not a check-then-insert race - guarantee that a
|
||||
# station never has two open runs. Repeated NULLs do not collide in a unique index.
|
||||
active_station_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
|
||||
station: Mapped["Station"] = relationship(lazy="selectin")
|
||||
recipe: Mapped["Recipe"] = relationship(lazy="selectin")
|
||||
version: Mapped["RecipeVersion"] = relationship(lazy="selectin")
|
||||
events: Mapped[list["ProductionEvent"]] = relationship(
|
||||
back_populates="run",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="ProductionEvent.created_at",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("active_station_id", name="uq_production_runs_active_station"),
|
||||
Index("ix_production_runs_station_status", "station_id", "status"),
|
||||
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
|
||||
)
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
return self.status in ("running", "paused")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ProductionRun {self.id} station={self.station_id} {self.status}>"
|
||||
|
||||
|
||||
class ProductionEvent(Base):
|
||||
"""Append-only trace of what happened during a run.
|
||||
|
||||
This is the record that did not exist before: without it there is no history of
|
||||
a production, and nothing for the statistics file or the ERP hand-off to stand on.
|
||||
"""
|
||||
|
||||
__tablename__ = "production_events"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
run_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("production_runs.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True,
|
||||
)
|
||||
event_type: Mapped[str] = mapped_column(
|
||||
Enum(*EVENT_TYPES, name="production_event_type_enum"), nullable=False, index=True
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=False, index=True
|
||||
)
|
||||
# Who authorised it, when the action needed a supervisor (fermo linea, chiusura).
|
||||
supervisor_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=True
|
||||
)
|
||||
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now(), index=True
|
||||
)
|
||||
|
||||
run: Mapped["ProductionRun"] = relationship(back_populates="events")
|
||||
|
||||
__table_args__ = (
|
||||
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ProductionEvent run={self.run_id} {self.event_type}>"
|
||||
Reference in New Issue
Block a user