"""Statistics router - SPC endpoints for Metrologist dashboard.""" from datetime import datetime from src.backend.models.api.tipi import UtcNaiveInput from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession from src.backend.database import get_db from src.backend.api.middleware.api_key import require_metrologist from src.backend.models.orm.measurement import Measurement from src.backend.models.orm.production import ProductionRun from src.backend.models.orm.recipe import RecipeVersion from src.backend.models.orm.task import RecipeSubtask, RecipeTask from src.backend.models.orm.task_result import TaskResult # #58: esiti dei task della sessione from src.backend.models.orm.user import User from src.backend.models.api.statistics import ( CapabilityData, ControlChartData, HistogramData, SummaryData, ) from src.backend.services.spc_service import ( compute_capability, compute_control_chart, compute_histogram, compute_summary, ) router = APIRouter(prefix="/api/statistics", tags=["statistics"]) async def _query_measurements( db: AsyncSession, recipe_id: int, version_id: int | None = None, subtask_id: int | None = None, date_from: datetime | None = None, date_to: datetime | None = None, operator_id: int | None = None, lot_number: str | None = None, serial_number: str | None = None, production_run_id: int | None = None, # #58: solo le misure di una sessione ) -> list[Measurement]: """Query measurements with common filters. Returns list of Measurement ORM objects ordered by measured_at. """ filters = [] # recipe_id filter via RecipeVersion subquery if version_id is not None: filters.append(Measurement.version_id == version_id) else: version_ids = select(RecipeVersion.id).where( RecipeVersion.recipe_id == recipe_id ) filters.append(Measurement.version_id.in_(version_ids)) if subtask_id is not None: filters.append(Measurement.subtask_id == subtask_id) if date_from is not None: filters.append(Measurement.measured_at >= date_from) if date_to is not None: filters.append(Measurement.measured_at <= date_to) if operator_id is not None: filters.append(Measurement.measured_by == operator_id) if lot_number is not None: filters.append(Measurement.lot_number == lot_number) if serial_number is not None: filters.append(Measurement.serial_number == serial_number) if production_run_id is not None: filters.append(Measurement.production_run_id == production_run_id) query = ( select(Measurement) .where(and_(*filters) if filters else True) .order_by(Measurement.measured_at.asc()) ) result = await db.execute(query) return list(result.scalars().all()) async def _get_subtask_tolerances( db: AsyncSession, subtask_id: int, ) -> dict: """Get tolerance limits for a specific subtask.""" result = await db.execute( select(RecipeSubtask).where(RecipeSubtask.id == subtask_id) ) subtask = result.scalar_one_or_none() if subtask is None: return {"utl": None, "uwl": None, "lwl": None, "ltl": None, "nominal": None} return { "utl": float(subtask.utl) if subtask.utl is not None else None, "uwl": float(subtask.uwl) if subtask.uwl is not None else None, "lwl": float(subtask.lwl) if subtask.lwl is not None else None, "ltl": float(subtask.ltl) if subtask.ltl is not None else None, "nominal": float(subtask.nominal) if subtask.nominal is not None else None, } @router.get("/summary", response_model=SummaryData) async def get_summary( recipe_id: int = Query(...), version_id: int | None = Query(None), subtask_id: int | None = Query(None), date_from: UtcNaiveInput = Query(None), date_to: UtcNaiveInput = Query(None), operator_id: int | None = Query(None), lot_number: str | None = Query(None), serial_number: str | None = Query(None), production_run_id: int | None = Query(None), # #58 user: User = Depends(require_metrologist), db: AsyncSession = Depends(get_db), ) -> SummaryData: """Get pass/fail/warning summary for filtered measurements.""" measurements = await _query_measurements( db, recipe_id, version_id, subtask_id, date_from, date_to, operator_id, lot_number, serial_number, production_run_id=production_run_id, # #58 ) pass_fail_values = [m.pass_fail for m in measurements] return compute_summary(pass_fail_values) @router.get("/capability", response_model=CapabilityData) async def get_capability( recipe_id: int = Query(...), subtask_id: int = Query(...), version_id: int | None = Query(None), date_from: UtcNaiveInput = Query(None), date_to: UtcNaiveInput = Query(None), operator_id: int | None = Query(None), lot_number: str | None = Query(None), serial_number: str | None = Query(None), production_run_id: int | None = Query(None), # #58 user: User = Depends(require_metrologist), db: AsyncSession = Depends(get_db), ) -> CapabilityData: """Get capability indices (Cp/Cpk/Pp/Ppk) for a specific subtask.""" measurements = await _query_measurements( db, recipe_id, version_id, subtask_id, date_from, date_to, operator_id, lot_number, serial_number, production_run_id=production_run_id, # #58 ) values = [float(m.value) for m in measurements] tol = await _get_subtask_tolerances(db, subtask_id) return compute_capability(values, tol["utl"], tol["ltl"], tol["nominal"]) @router.get("/control-chart", response_model=ControlChartData) async def get_control_chart( recipe_id: int = Query(...), subtask_id: int = Query(...), version_id: int | None = Query(None), date_from: UtcNaiveInput = Query(None), date_to: UtcNaiveInput = Query(None), operator_id: int | None = Query(None), lot_number: str | None = Query(None), serial_number: str | None = Query(None), production_run_id: int | None = Query(None), # #58 user: User = Depends(require_metrologist), db: AsyncSession = Depends(get_db), ) -> ControlChartData: """Get control chart data with UCL/LCL and OOC detection.""" measurements = await _query_measurements( db, recipe_id, version_id, subtask_id, date_from, date_to, operator_id, lot_number, serial_number, production_run_id=production_run_id, # #58 ) values = [float(m.value) for m in measurements] timestamps = [m.measured_at for m in measurements] tol = await _get_subtask_tolerances(db, subtask_id) return compute_control_chart( values, timestamps, tol["utl"], tol["uwl"], tol["lwl"], tol["ltl"], tol["nominal"], ) @router.get("/histogram", response_model=HistogramData) async def get_histogram( recipe_id: int = Query(...), subtask_id: int = Query(...), version_id: int | None = Query(None), date_from: UtcNaiveInput = Query(None), date_to: UtcNaiveInput = Query(None), operator_id: int | None = Query(None), lot_number: str | None = Query(None), serial_number: str | None = Query(None), production_run_id: int | None = Query(None), # #58 n_bins: int = Query(20, ge=5, le=100), user: User = Depends(require_metrologist), db: AsyncSession = Depends(get_db), ) -> HistogramData: """Get histogram data with normal curve overlay.""" measurements = await _query_measurements( db, recipe_id, version_id, subtask_id, date_from, date_to, operator_id, lot_number, serial_number, production_run_id=production_run_id, # #58 ) values = [float(m.value) for m in measurements] return compute_histogram(values, n_bins) @router.get("/subtasks") async def get_recipe_subtasks( recipe_id: int = Query(...), version_id: int | None = Query(None), user: User = Depends(require_metrologist), db: AsyncSession = Depends(get_db), ) -> list[dict]: """Get subtasks for a recipe (for filter dropdown). Returns subtask id, marker_number, description, and parent task title. """ # Get version_id: use provided or find current version if version_id is None: ver_result = await db.execute( select(RecipeVersion.id).where( RecipeVersion.recipe_id == recipe_id, RecipeVersion.is_current == True, ) ) version_id = ver_result.scalar_one_or_none() if version_id is None: return [] # Get tasks and subtasks for this version tasks_result = await db.execute( select(RecipeTask).where(RecipeTask.version_id == version_id) .order_by(RecipeTask.order_index) ) tasks = tasks_result.scalars().all() subtasks_list = [] for task in tasks: for st in sorted(task.subtasks, key=lambda s: s.marker_number): subtasks_list.append({ "id": st.id, "marker_number": st.marker_number, "description": st.description, "task_title": task.title, "nominal": float(st.nominal) if st.nominal is not None else None, "utl": float(st.utl) if st.utl is not None else None, "ltl": float(st.ltl) if st.ltl is not None else None, }) return subtasks_list @router.get("/runs") async def get_recipe_runs( recipe_id: int = Query(...), user: User = Depends(require_metrologist), db: AsyncSession = Depends(get_db), ) -> list[dict]: """#58: production runs of a recipe (for the "Sessione" filter), newest first.""" runs = (await db.execute( select(ProductionRun) .where(ProductionRun.recipe_id == recipe_id) .order_by(ProductionRun.started_at.desc(), ProductionRun.id.desc()) )).scalars().all() # #58: conta solo le misure dei run della ricetta, non tutta la tabella. run_ids = [r.id for r in runs] counts = {} if run_ids: counts = dict((await db.execute( select(Measurement.production_run_id, func.count(Measurement.id)) .where(Measurement.production_run_id.in_(run_ids)) .group_by(Measurement.production_run_id) )).all()) operator_ids = {r.operator_id for r in runs} names = {} if operator_ids: names = dict((await db.execute( select(User.id, User.display_name).where(User.id.in_(operator_ids)) )).all()) return [ { "id": r.id, "station_id": r.station_id, "station_name": r.station.name if r.station else None, "operator_id": r.operator_id, "operator_name": names.get(r.operator_id), "started_at": r.started_at.isoformat() if r.started_at else None, "closed_at": r.closed_at.isoformat() if r.closed_at else None, "status": r.status, "lot_number": r.lot_number, "serial_number": r.serial_number, "cycle_count": r.cycle_count, "measurement_count": counts.get(r.id, 0), } for r in runs ] @router.get("/runs/{run_id}/task-results") async def get_run_task_results( run_id: int, user: User = Depends(require_metrologist), db: AsyncSession = Depends(get_db), ) -> dict: """#58: esiti, note, campi e allegati dei task di una sessione di produzione. Endpoint di statistica (require_metrologist): GET /api/task-results vuole il ruolo MeasurementTec e non si puo' riusare qui. """ run = (await db.execute( select(ProductionRun.id).where(ProductionRun.id == run_id) )).scalar_one_or_none() if run is None: raise HTTPException(status_code=404, detail="Sessione non trovata") # #58: una query per i risultati della run, una per i task, una per gli utenti. results = (await db.execute( select(TaskResult) .where(TaskResult.production_run_id == run_id) .order_by(TaskResult.created_at.asc(), TaskResult.id.asc()) )).scalars().all() task_ids = {r.task_id for r in results} tasks = {} if task_ids: tasks = {t.id: t for t in (await db.execute( select(RecipeTask).where(RecipeTask.id.in_(task_ids)) )).scalars().all()} user_ids = {r.user_id for r in results} names = {} if user_ids: names = dict((await db.execute( select(User.id, User.display_name).where(User.id.in_(user_ids)) )).all()) counts = {"ok": 0, "nok": 0, "done": 0} out = [] for r in results: counts[r.outcome] = counts.get(r.outcome, 0) + 1 task = tasks.get(r.task_id) # #58: etichette dai campi del task; se il campo e' stato tolto vale la chiave. labels = { f.get("key"): f.get("label") or f.get("key") for f in (task.fields or []) if isinstance(f, dict) } if task else {} out.append({ "id": r.id, "task_id": r.task_id, "task_title": task.title if task else None, "task_type": task.task_type if task else None, "outcome": r.outcome, "note": r.note, "created_at": r.created_at.isoformat() if r.created_at else None, "user_name": names.get(r.user_id), "values": [ {"key": k, "label": labels.get(k, k), "value": v} for k, v in (r.values or {}).items() ], "attachments": [ { "key": a.get("key"), "label": labels.get(a.get("key"), a.get("key")), "name": a.get("name"), "path": a.get("path"), } for a in (r.attachments or []) if isinstance(a, dict) ], }) return {"run_id": run_id, "counts": counts, "results": out}