e2aae88858
Il punto 5 c'era a meta': la schermata apriva il modale del capoturno e bloccava l'avanzamento automatico, ma il modale si chiudeva con Annulla o con un click sullo sfondo, la misura era gia' salvata, e `pendingAdvance` veniva impostato e non letto da nessuno. Era una conferma, non uno sbarramento. Ora l'autorizzazione finisce sulla misura: due colonne, chi ha autorizzato e quando. Finche' una quota e' fuori tolleranza e nessuno l'ha autorizzata, il server rifiuta la misura della quota successiva e la chiusura del ciclo. Non e' la schermata a impedirlo: la schermata risparmia solo il viaggio. Rimisurare la stessa quota resta possibile — il calibro scivola, il pezzo si riposiziona — e una seconda lettura in tolleranza libera il blocco, perche' conta l'ultima lettura di ogni quota. Quante volte si possa riprovare e' il punto 4, e tutte le letture restano comunque a registro. Un warning non blocca: fuori dai limiti di attenzione ma dentro la tolleranza e' dentro la tolleranza. Ricaricare la pagina era il modo piu' semplice per scavalcare il vecchio gate. Non lo e' piu': la schermata chiede al server, all'apertura, se una quota sta aspettando, e si ritrova davanti lo stesso blocco. Sparisce /validate-supervisor, che verificava le credenziali e buttava via la risposta. Al suo posto un endpoint che le credenziali le usa per scrivere l'approvazione dove serve. Il controllo del capoturno si sposta in auth_service, accanto al resto delle credenziali: fermo linea, chiusura e fuori tolleranza fanno la stessa domanda, e solo una delle tre riguarda la produzione. Il file di statistica guadagna authorised_by e authorised_at: mostrava il fallimento e non la decisione, che e' la meta' che un auditor chiede. Migrazione 010: due colonne nullable sulle misure. Le righe esistenti restano nulle — retrodatare un'autorizzazione mai avvenuta sarebbe inventarsi un record di audit, e quelle produzioni sono chiuse da un pezzo. Test: +18 (309). Coprono il rifiuto della quota successiva, la rimisura ammessa, il rilascio del blocco con una lettura buona, il ciclo che non si chiude, il capoturno registrato sulla misura, le credenziali sbagliate e chi capoturno non e', il file di statistica, e il fatto che una produzione chiusa non blocchi la successiva. Aggiunto uploads/statistics/ al gitignore: i test che chiudono una produzione scrivevano nel repository. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
452 lines
16 KiB
Python
452 lines
16 KiB
Python
"""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.task import MEASURING_TASK_TYPES, RecipeTask
|
|
from src.backend.models.orm.user import User
|
|
from src.backend.services import (
|
|
auth_service, measurement_service, production_export_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 measurement_task_ids(db: AsyncSession, version_id: int) -> list[int]:
|
|
"""Ids of the measurement tasks of a version, in the order they are executed.
|
|
|
|
This is the loop the operator lives in once production starts: the client uses
|
|
the first of these to bring the measurement back on screen when the interval
|
|
expires, and the last to know which task closes a cycle.
|
|
|
|
The type is read from the declared task_type (point 2), not from whether quotes
|
|
happen to be present: a measurement task with no quotes yet is still where the
|
|
operator has to be taken.
|
|
"""
|
|
result = await db.execute(
|
|
select(RecipeTask.id)
|
|
.where(
|
|
RecipeTask.version_id == version_id,
|
|
RecipeTask.task_type.in_(MEASURING_TASK_TYPES),
|
|
)
|
|
.order_by(RecipeTask.order_index, RecipeTask.id)
|
|
)
|
|
return list(result.scalars().all())
|
|
|
|
|
|
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
|
|
# The capoturno check lives in auth_service, next to the rest of the credential
|
|
# handling: a line stop, a closure and an out-of-tolerance value all ask the same
|
|
# question, and only one of the three is about production.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _add_event(
|
|
db: AsyncSession,
|
|
run: ProductionRun,
|
|
event_type: str,
|
|
user: User,
|
|
supervisor: Optional[User] = None,
|
|
note: Optional[str] = None,
|
|
task_id: Optional[int] = 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,
|
|
task_id=task_id,
|
|
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",
|
|
)
|
|
|
|
# Traceability the recipe declares compulsory has to be there before the first
|
|
# measurement, not after it: a lot entered halfway through leaves the values
|
|
# taken up to that point unattributable.
|
|
if recipe.requires_lot and not (data.lot_number or "").strip():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail="This recipe requires a lot number to start a production",
|
|
)
|
|
if recipe.requires_serial and not (data.serial_number or "").strip():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail="This recipe requires a serial number to start a production",
|
|
)
|
|
|
|
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",
|
|
)
|
|
|
|
|
|
def _require_measurable(run: ProductionRun) -> None:
|
|
_require_open(run)
|
|
if run.status == "paused":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail="Production run is paused: resume it before measuring",
|
|
)
|
|
|
|
|
|
async def complete_cycle(
|
|
db: AsyncSession,
|
|
run: ProductionRun,
|
|
user: User,
|
|
task_id: Optional[int] = None,
|
|
note: Optional[str] = None,
|
|
) -> ProductionRun:
|
|
"""Record a finished measurement task, and restart the interval if it was the last.
|
|
|
|
A cycle spans every measurement task of the recipe, so only the last one closes
|
|
it. Restarting the interval at the first would make a recipe with three
|
|
measurement tasks measure three times as often as it was set up to.
|
|
|
|
An unrecognised task_id - or none at all - closes the cycle: a caller that does
|
|
not know about the sequence gets the behaviour that existed before it.
|
|
"""
|
|
_require_measurable(run)
|
|
|
|
# A cycle that contains a quote out of tolerance is not a finished cycle. This
|
|
# is the same gate as on the next measurement: closing the cycle would be
|
|
# another way of walking past it.
|
|
blocking = await measurement_service.pending_authorisation(
|
|
db, run.version_id, user.id, run.id,
|
|
)
|
|
if blocking is not None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=(
|
|
"A measurement is out of tolerance and awaiting the supervisor: "
|
|
"the cycle cannot be closed until it is authorised"
|
|
),
|
|
)
|
|
|
|
measure_ids = await measurement_task_ids(db, run.version_id)
|
|
closes_cycle = (
|
|
task_id is None or task_id not in measure_ids or task_id == measure_ids[-1]
|
|
)
|
|
|
|
if not closes_cycle:
|
|
_add_event(db, run, "task_measured", user, note=note, task_id=task_id)
|
|
await db.flush()
|
|
await db.refresh(run)
|
|
return run
|
|
|
|
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, task_id=task_id)
|
|
await db.flush()
|
|
await db.refresh(run)
|
|
return run
|
|
|
|
|
|
async def remeasure(
|
|
db: AsyncSession,
|
|
run: ProductionRun,
|
|
user: User,
|
|
task_id: Optional[int] = None,
|
|
note: Optional[str] = None,
|
|
) -> ProductionRun:
|
|
"""Measure the same task again without closing the cycle.
|
|
|
|
This is the piece being turned over. The deadline and the cycle count are left
|
|
exactly as they are - a second reading of the same part is not a new cycle, and
|
|
it must not buy the operator another interval's worth of time. The values
|
|
themselves are ordinary measurements: both readings stay in the statistics,
|
|
which is the point of taking them.
|
|
"""
|
|
_require_measurable(run)
|
|
_add_event(db, run, "remeasure", user, note=note, task_id=task_id)
|
|
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, stop the timer, emit the statistics.
|
|
|
|
Everything here happens without the ERP. Handing the production over to GAIA
|
|
goes at the marked seam below, once D-1 and D-2 are answered; until then the
|
|
file is produced and the run is properly closed, which is what the shop floor
|
|
needs either way.
|
|
"""
|
|
_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()
|
|
|
|
# Statistics file for the whole production, and the measurements marked as sent.
|
|
run.statistics_path = await production_export_service.export_run_statistics(db, run)
|
|
|
|
# --- ERP hand-off (GAIA) plugs in here once the protocol is defined (D-1, D-2).
|
|
# Deliberately absent rather than stubbed: an empty call that looks wired is
|
|
# worse than none at all.
|
|
|
|
await db.flush()
|
|
await db.refresh(run)
|
|
return run
|