fix(production): ruolo e assegnazione stazione su apertura/avanzamento run (#24 backend)

api/routers/production.py: open/cycle/remeasure/pause/resume/close accettavano
qualunque utente autenticato (get_current_user). Nuova dipendenza locale
_require_run_operator (MeasurementTec o Supervisor o admin) sui sei endpoint
di ciclo vita; le letture (current, {run_id} GET) restano aperte a tutti gli
autenticati. Per pause/resume/close si aggiunge al controllo esistente delle
credenziali del supervisore in corpo, che riguarda un'altra persona (chi
autorizza), non chi opera.

services/production_service.py open_run: nessun controllo su recipe.active ne'
sull'assegnazione ricetta-stazione. Nuovo _require_recipe_assigned_to_station:
se la stazione ha almeno un'assegnazione (StationRecipeAssignment), la ricetta
deve esserci, altrimenti 409; una stazione senza alcuna assegnazione resta
libera - stessa regola del filtro in lettura di station_service.list_station_recipes
(join che senza righe non filtra nulla). Ricetta non active -> 409.

NON TOCCATO: src/frontend/flask_app/blueprints/measure.py (stesso filtro sul
lato Flask, lotto B2) e test_measure_station_filter.py - altro agente ci
lavora in parallelo sulla stessa cartella.

Test: src/backend/tests/test_production_runs.py, 6 nuovi test (ruolo su
open/pause, assegnazione rifiutata/accettata/libera, ricetta inattiva).
PROVA: uv run --extra server --extra dev pytest src/backend/tests/test_production_runs.py -q -> 34 passed

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 22:48:17 +02:00
parent 77d95fdcc4
commit bfe66a5710
3 changed files with 143 additions and 8 deletions
+30 -1
View File
@@ -23,7 +23,7 @@ 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.station import Station, StationRecipeAssignment
from src.backend.models.orm.task import MEASURING_TASK_TYPES, RecipeTask
from src.backend.models.orm.user import User
from src.backend.services import (
@@ -106,6 +106,29 @@ async def get_station_by_code(db: AsyncSession, code: str) -> Station:
return station
async def _require_recipe_assigned_to_station(
db: AsyncSession, station_id: int, recipe_id: int,
) -> None:
"""A configured station only runs what it is assigned; an unconfigured one is free.
Mirrors station_service.list_station_recipes, the read side an operator's
recipe list is already filtered by: a station with at least one assignment
restricts to those recipes, one with none has not been set up to filter at
all and stays open to any active recipe.
"""
result = await db.execute(
select(StationRecipeAssignment.recipe_id).where(
StationRecipeAssignment.station_id == station_id,
)
)
assigned_ids = set(result.scalars().all())
if assigned_ids and recipe_id not in assigned_ids:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="This recipe is not assigned to this 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.
@@ -199,6 +222,12 @@ async def open_run(
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found",
)
if not recipe.active:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Cannot start a production run with an inactive recipe",
)
await _require_recipe_assigned_to_station(db, station.id, recipe.id)
if data.version_id is not None:
version_row = await db.execute(