feat(measure): il fuori tolleranza si autorizza, non si chiude
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>
This commit is contained in:
@@ -3,6 +3,7 @@ import secrets
|
||||
from datetime import datetime
|
||||
|
||||
import bcrypt
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -39,6 +40,31 @@ async def authenticate_user(
|
||||
return user
|
||||
|
||||
|
||||
async def authorise_supervisor(db: AsyncSession, username: str, password: str) -> User:
|
||||
"""Check the credentials of the supervisor authorising something.
|
||||
|
||||
Used wherever the shop floor needs a capoturno: a line stop, a closure, an
|
||||
out-of-tolerance value allowed to stand. Lives here rather than beside any one
|
||||
of them because it is the same question every time - who is this, and are they
|
||||
allowed to say yes.
|
||||
|
||||
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 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
|
||||
|
||||
|
||||
async def login_user(db: AsyncSession, user: User) -> str:
|
||||
"""Generate API key and update last_login for user."""
|
||||
api_key = generate_api_key()
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"""Measurement service - pass/fail calculation, data storage."""
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeSubtask
|
||||
from src.backend.models.orm.user import User
|
||||
|
||||
|
||||
def calculate_pass_fail(
|
||||
@@ -83,6 +85,67 @@ def _enforce_recipe_rules(
|
||||
)
|
||||
|
||||
|
||||
def _scope(query, version_id: int, measured_by: int, production_run_id: int | None):
|
||||
"""Narrow a measurement query to the run in progress, or to this operator's work.
|
||||
|
||||
Inside a production the run is the boundary. Outside one - a recipe executed on
|
||||
its own - the boundary is the version and the person doing it, so two operators
|
||||
on the same recipe do not block each other.
|
||||
"""
|
||||
if production_run_id is not None:
|
||||
return query.where(Measurement.production_run_id == production_run_id)
|
||||
return query.where(
|
||||
Measurement.version_id == version_id,
|
||||
Measurement.measured_by == measured_by,
|
||||
Measurement.production_run_id.is_(None),
|
||||
)
|
||||
|
||||
|
||||
async def pending_authorisation(
|
||||
db: AsyncSession,
|
||||
version_id: int,
|
||||
measured_by: int,
|
||||
production_run_id: int | None = None,
|
||||
) -> Measurement | None:
|
||||
"""The out-of-tolerance measurement that is holding everything up, if any.
|
||||
|
||||
Only the *latest* reading of each quote counts. Measuring the same quote again
|
||||
is allowed - the caliper slips, the piece is reseated - and a second reading
|
||||
within tolerance releases the block. What is not allowed is moving on to the
|
||||
next quote while the one in hand is out of tolerance and nobody has said so.
|
||||
|
||||
How many attempts that permits is point 4's business, not this one's. Every
|
||||
reading stays in the record either way.
|
||||
"""
|
||||
latest_per_subtask = _scope(
|
||||
select(func.max(Measurement.id)),
|
||||
version_id, measured_by, production_run_id,
|
||||
).group_by(Measurement.subtask_id)
|
||||
|
||||
result = await db.execute(
|
||||
select(Measurement)
|
||||
.where(
|
||||
Measurement.id.in_(latest_per_subtask),
|
||||
Measurement.pass_fail == "fail",
|
||||
Measurement.supervisor_id.is_(None),
|
||||
)
|
||||
.order_by(Measurement.id)
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def authorise_measurement(
|
||||
db: AsyncSession, measurement: Measurement, supervisor: User,
|
||||
) -> Measurement:
|
||||
"""Let an out-of-tolerance value stand, on the supervisor's name."""
|
||||
measurement.supervisor_id = supervisor.id
|
||||
measurement.authorised_at = datetime.now()
|
||||
await db.flush()
|
||||
await db.refresh(measurement)
|
||||
return measurement
|
||||
|
||||
|
||||
async def save_measurement(
|
||||
db: AsyncSession,
|
||||
subtask_id: int,
|
||||
@@ -107,6 +170,21 @@ async def save_measurement(
|
||||
recipe = await recipe_of_version(db, version_id)
|
||||
_enforce_recipe_rules(recipe, lot_number, serial_number, input_method)
|
||||
|
||||
# No moving on while a quote is out of tolerance and unauthorised. Measuring
|
||||
# that same quote again is the way out that does not need the capoturno; the
|
||||
# next quote is not.
|
||||
blocking = await pending_authorisation(
|
||||
db, version_id, measured_by, production_run_id,
|
||||
)
|
||||
if blocking is not None and blocking.subtask_id != subtask_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=(
|
||||
"A measurement is out of tolerance and awaiting the supervisor: "
|
||||
"authorise it or measure that quote again before going on"
|
||||
),
|
||||
)
|
||||
|
||||
pass_fail, deviation = calculate_pass_fail(value, subtask)
|
||||
|
||||
measurement = Measurement(
|
||||
|
||||
@@ -55,6 +55,11 @@ HEADER = [
|
||||
"input_duration_ms",
|
||||
"measured_by",
|
||||
"measured_at",
|
||||
# A value outside tolerance that was allowed to stand carries the name of who
|
||||
# allowed it. Without these two columns the file would show the failure and
|
||||
# not the decision, which is the half an auditor asks about.
|
||||
"authorised_by",
|
||||
"authorised_at",
|
||||
]
|
||||
|
||||
|
||||
@@ -126,6 +131,8 @@ async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
|
||||
"" if measurement.input_duration_ms is None else measurement.input_duration_ms,
|
||||
measurement.measured_by,
|
||||
measurement.measured_at.isoformat() if measurement.measured_at else "",
|
||||
measurement.supervisor_id or "",
|
||||
measurement.authorised_at.isoformat() if measurement.authorised_at else "",
|
||||
])
|
||||
|
||||
return output.getvalue()
|
||||
|
||||
@@ -26,7 +26,9 @@ 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, production_export_service
|
||||
from src.backend.services import (
|
||||
auth_service, measurement_service, production_export_service,
|
||||
)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
@@ -137,27 +139,9 @@ async def list_run_events(db: AsyncSession, run_id: int) -> list[ProductionEvent
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
# 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.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -320,6 +304,21 @@ async def complete_cycle(
|
||||
"""
|
||||
_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]
|
||||
|
||||
Reference in New Issue
Block a user