"""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"" 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""