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:
Adriano Dal Pastro
2026-07-28 16:20:43 +00:00
parent e7889f80a3
commit 55e5e0153f
16 changed files with 1851 additions and 147 deletions
+349
View File
@@ -0,0 +1,349 @@
"""Business logic for production runs.
Routers must call into these functions rather than manipulating models directly.
All functions are async and accept an AsyncSession; they flush but do NOT commit
(commit is handled by the FastAPI get_db dependency).
Clock convention
----------------
Every timestamp this module writes comes from ``_now()``, so the deadline and the
comparison against it are always on the same clock. Clients are handed
``seconds_to_next_measurement`` already computed rather than the raw deadline: a
naive datetime crossing the wire would be parsed in the browser's own timezone, and
the countdown would be off by the UTC offset. The number is the contract; the
timestamp is only there for the audit trail.
"""
from datetime import datetime, timedelta
from typing import Optional
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.api.production import ProductionRunCreate
from src.backend.models.orm.production import ProductionEvent, ProductionRun
from src.backend.models.orm.recipe import Recipe, RecipeVersion
from src.backend.models.orm.station import Station
from src.backend.models.orm.user import User
from src.backend.services import auth_service
def _now() -> datetime:
"""Single source of time for runs.
Naive local time, matching what the database writes for ``func.now()`` defaults,
so a run's started_at and its deadline are comparable.
"""
return datetime.now()
# ---------------------------------------------------------------------------
# Derived view
# ---------------------------------------------------------------------------
def seconds_to_next_measurement(run: ProductionRun, now: Optional[datetime] = None) -> Optional[int]:
"""Seconds until the next measurement is due; negative once it is overdue.
Letting the value go negative is deliberate: the operator has to see how long
the line has been past the interval, not merely that it elapsed.
While the run is paused the countdown is frozen at what was left when the line
stopped - a fermo linea must not eat into the measurement interval.
"""
if run.next_measurement_at is None:
return None
reference = run.paused_at if (run.status == "paused" and run.paused_at) else (now or _now())
return int(round((run.next_measurement_at - reference).total_seconds()))
def describe(run: ProductionRun) -> dict:
"""Fields the API adds on top of the stored columns."""
now = _now()
remaining = seconds_to_next_measurement(run, now)
return {
"seconds_to_next_measurement": remaining,
"overdue": remaining is not None and remaining < 0,
"server_time": now,
}
# ---------------------------------------------------------------------------
# Lookups
# ---------------------------------------------------------------------------
async def get_run(db: AsyncSession, run_id: int) -> ProductionRun:
result = await db.execute(select(ProductionRun).where(ProductionRun.id == run_id))
run = result.scalar_one_or_none()
if run is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Production run not found",
)
return run
async def get_open_run_for_station(
db: AsyncSession, station_id: int,
) -> Optional[ProductionRun]:
"""The run currently open at a station, running or paused."""
result = await db.execute(
select(ProductionRun).where(ProductionRun.active_station_id == station_id)
)
return result.scalar_one_or_none()
async def get_station_by_code(db: AsyncSession, code: str) -> Station:
result = await db.execute(select(Station).where(Station.code == code))
station = result.scalar_one_or_none()
if station is None or not station.active:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Station '{code}' not found or inactive",
)
return station
async def list_run_events(db: AsyncSession, run_id: int) -> list[ProductionEvent]:
result = await db.execute(
select(ProductionEvent)
.where(ProductionEvent.run_id == run_id)
.order_by(ProductionEvent.created_at, ProductionEvent.id)
)
return list(result.scalars().all())
# ---------------------------------------------------------------------------
# Supervisor authorisation
# ---------------------------------------------------------------------------
async def authorise_supervisor(db: AsyncSession, username: str, password: str) -> User:
"""Check the credentials of the supervisor authorising a stop or a close.
Uses authenticate_user rather than a full login: logging in would rotate the
supervisor's API key and knock out whatever session they have open elsewhere.
"""
supervisor = await auth_service.authenticate_user(db, username, password)
if supervisor is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid supervisor credentials",
)
if not supervisor.has_role("Supervisor") and not supervisor.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User is not authorised as supervisor",
)
return supervisor
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
def _add_event(
db: AsyncSession,
run: ProductionRun,
event_type: str,
user: User,
supervisor: Optional[User] = None,
note: Optional[str] = None,
) -> ProductionEvent:
"""Append to the run's trace.
Added through the session rather than run.events: touching the collection would
trigger a lazy load, which raises under async SQLAlchemy.
"""
event = ProductionEvent(
run_id=run.id,
event_type=event_type,
user_id=user.id,
supervisor_id=supervisor.id if supervisor else None,
note=note,
created_at=_now(),
)
db.add(event)
return event
async def open_run(
db: AsyncSession, data: ProductionRunCreate, operator: User,
) -> ProductionRun:
"""Start a production at a station.
Refuses if the station already has one open: two runs on the same station would
mean two timers and two histories for one physical line.
"""
station = await get_station_by_code(db, data.station_code)
existing = await get_open_run_for_station(db, station.id)
if existing is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Station '{station.code}' already has an open production run",
)
recipe_row = await db.execute(select(Recipe).where(Recipe.id == data.recipe_id))
recipe = recipe_row.scalar_one_or_none()
if recipe is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found",
)
if data.version_id is not None:
version_row = await db.execute(
select(RecipeVersion).where(
RecipeVersion.id == data.version_id,
RecipeVersion.recipe_id == recipe.id,
)
)
version = version_row.scalar_one_or_none()
if version is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Recipe version not found for this recipe",
)
else:
version_row = await db.execute(
select(RecipeVersion).where(
RecipeVersion.recipe_id == recipe.id,
RecipeVersion.is_current == True, # noqa: E712
)
)
version = version_row.scalar_one_or_none()
if version is None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Recipe has no current version",
)
now = _now()
# The interval is copied, not referenced: editing the recipe mid-production must
# not move the deadline of a run already under way.
interval = recipe.measurement_interval_minutes
run = ProductionRun(
station_id=station.id,
recipe_id=recipe.id,
version_id=version.id,
operator_id=operator.id,
lot_number=data.lot_number,
serial_number=data.serial_number,
status="running",
measurement_interval_minutes=interval,
next_measurement_at=(
now + timedelta(minutes=interval) if interval and interval > 0 else None
),
cycle_count=0,
started_at=now,
active_station_id=station.id,
)
db.add(run)
await db.flush()
_add_event(db, run, "start", operator)
await db.flush()
await db.refresh(run)
return run
def _require_open(run: ProductionRun) -> None:
if run.status == "closed":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Production run is already closed",
)
async def complete_cycle(
db: AsyncSession, run: ProductionRun, user: User, note: Optional[str] = None,
) -> ProductionRun:
"""Record a finished measurement cycle and restart the interval."""
_require_open(run)
if run.status == "paused":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Production run is paused: resume it before measuring",
)
now = _now()
run.cycle_count += 1
if run.measurement_interval_minutes and run.measurement_interval_minutes > 0:
# Measured from now, not from the previous deadline: a late measurement must
# not compress the following interval.
run.next_measurement_at = now + timedelta(
minutes=run.measurement_interval_minutes
)
_add_event(db, run, "cycle_completed", user, note=note)
await db.flush()
await db.refresh(run)
return run
async def pause_run(
db: AsyncSession,
run: ProductionRun,
user: User,
supervisor: User,
note: Optional[str] = None,
) -> ProductionRun:
"""Fermo linea: suspend the run and freeze the countdown."""
_require_open(run)
if run.status == "paused":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Production run is already paused",
)
run.status = "paused"
run.paused_at = _now()
_add_event(db, run, "line_stop", user, supervisor=supervisor, note=note)
await db.flush()
await db.refresh(run)
return run
async def resume_run(
db: AsyncSession,
run: ProductionRun,
user: User,
supervisor: User,
note: Optional[str] = None,
) -> ProductionRun:
"""Restart a paused run, pushing the deadline out by the length of the stop."""
_require_open(run)
if run.status != "paused":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Production run is not paused",
)
now = _now()
if run.next_measurement_at is not None and run.paused_at is not None:
# Shift rather than recompute: whatever was left when the line stopped is
# what the operator gets back, so a stop neither grants nor costs time.
run.next_measurement_at = run.next_measurement_at + (now - run.paused_at)
run.status = "running"
run.paused_at = None
_add_event(db, run, "resume", user, supervisor=supervisor, note=note)
await db.flush()
await db.refresh(run)
return run
async def close_run(
db: AsyncSession,
run: ProductionRun,
user: User,
supervisor: User,
note: Optional[str] = None,
) -> ProductionRun:
"""Fine produzione: close the run for good and stop the timer."""
_require_open(run)
run.status = "closed"
run.closed_at = _now()
run.closed_by = supervisor.id
run.next_measurement_at = None
run.paused_at = None
# Releasing the slot lets the station open a new run; the unique index on this
# column is what keeps exactly one open at a time.
run.active_station_id = None
_add_event(db, run, "close", user, supervisor=supervisor, note=note)
await db.flush()
await db.refresh(run)
return run