bde8fafd77
Il conto alla rovescia non si ferma piu' a zero. Oltre la scadenza continua nell'altro senso e dice da quanto la linea e' in ritardo, in rosso, su tutte le schermate del percorso di misura: lista task, riepilogo, scelta ricetta. Un ritardo va letto, non dedotto. Allo scadere dell'intervallo la misura si ripropone da sola: cicalino, un avviso di cinque secondi, e l'operatore torna al primo task di misura da qualunque schermata si trovi. Un ciclo copre tutti i task di misura della ricetta, quindi solo l'ultimo lo chiude e fa ripartire l'intervallo. Farlo ripartire al primo avrebbe fatto misurare una ricetta con tre task di misura tre volte piu' spesso di come e' configurata. E' il server a decidere quale task chiude il ciclo, perche' e' lui a conoscere la sequenza: la lista dei task di misura viaggia con la produzione (measurement_task_ids), che e' anche cio' che permette a una schermata qualsiasi di sapere dove riportare l'operatore. Aggiunta la rimisura: si gira il pezzo e si misura di nuovo dentro lo stesso ciclo, senza chiudere niente e senza guadagnare tempo sulla scadenza. Le due letture restano entrambe in statistica, che e' il motivo per cui si prendono. Chi sta gia' misurando quando l'intervallo scade e' in ritardo, non perso: la banda diventa rossa e lo si lascia lavorare. Portarlo altrove a meta' ciclo cancellerebbe quote che ha davanti agli occhi senza guadagnare nulla. Sistemato anche il pulsante "Conferma ciclo" dell'overlay: alzava una bandierina locale e basta, quindi il ciclo non veniva mai registrato sul server da quella strada. Ora passa da confirmCycle come il pulsante della barra. Migrazione 008: il registro eventi della produzione impara task_measured e remeasure, e la colonna task_id — un task_measured che non dice quale task non registra niente di utile. I valori nuovi entrano tutti insieme perche' allargare una enum MySQL riscrive la tabella, stesso ragionamento dei tipi di task in 007. Verificata su SQLite (batch mode) e in MySQL con --sql. La logica dell'orologio — come si legge un ritardo, quando suona, dove sta la misura — vive in un solo posto (production-clock.js) e la schermata di misura la usa invece di riscriverla. Test: +10 (265). Coprono il ciclo che non riparte a meta', la rimisura che non sposta la scadenza, il rifiuto a linea ferma, la sequenza dei task di misura esposta dalla produzione, i proxy Flask e la validita' JS della lista task. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
167 lines
6.9 KiB
Python
167 lines
6.9 KiB
Python
"""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")
|
|
# task_measured marks a measurement task finished inside a cycle that is not over
|
|
# yet; only the last one produces cycle_completed and restarts the interval.
|
|
# remeasure is the piece being turned and measured again without closing the cycle.
|
|
EVENT_TYPES = (
|
|
"start", "cycle_completed", "task_measured", "remeasure",
|
|
"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
|
|
)
|
|
# Statistics file emitted when the run was closed, relative to the upload dir.
|
|
statistics_path: Mapped[Optional[str]] = mapped_column(String(500), 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
|
|
)
|
|
# Which task the event is about, for the ones that happen inside a cycle. A
|
|
# task_measured or a remeasure that does not say which task is not a record of
|
|
# anything; null for the events that concern the run as a whole.
|
|
task_id: Mapped[Optional[int]] = mapped_column(
|
|
Integer, ForeignKey("recipe_tasks.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}>"
|