From 4e52095423003a203846e217180b63127f748962 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Wed, 7 Oct 2026 12:28:35 +0200 Subject: [PATCH 1/2] Statistiche per sessione di produzione: filtro production_run_id, elenco sessioni, select Sessione (#58) Co-Authored-By: Claude Sonnet 5.5 --- src/backend/api/routers/reports.py | 4 + src/backend/api/routers/statistics.py | 58 +++++++++++++- src/backend/services/report_service.py | 12 +++ src/backend/tests/test_statistics.py | 76 +++++++++++++++++++ .../flask_app/blueprints/statistics.py | 25 +++++- .../templates/statistics/dashboard.html | 34 +++++++++ .../flask_app/tests/test_statistics.py | 18 +++++ 7 files changed, 224 insertions(+), 3 deletions(-) diff --git a/src/backend/api/routers/reports.py b/src/backend/api/routers/reports.py index 201c3e5..ba29e97 100644 --- a/src/backend/api/routers/reports.py +++ b/src/backend/api/routers/reports.py @@ -23,6 +23,7 @@ async def download_spc_report( 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), ) -> Response: @@ -32,6 +33,7 @@ async def download_spc_report( db, recipe_id, subtask_id, version_id, date_from, date_to, operator_id, lot_number, serial_number, + production_run_id=production_run_id, # #58 ) except Exception as e: raise HTTPException( @@ -57,6 +59,7 @@ async def download_measurement_report( 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), ) -> Response: @@ -66,6 +69,7 @@ async def download_measurement_report( db, recipe_id, subtask_id, version_id, date_from, date_to, operator_id, lot_number, serial_number, + production_run_id=production_run_id, # #58 ) except Exception as e: raise HTTPException( diff --git a/src/backend/api/routers/statistics.py b/src/backend/api/routers/statistics.py index b121ab7..5fc7d67 100644 --- a/src/backend/api/routers/statistics.py +++ b/src/backend/api/routers/statistics.py @@ -3,12 +3,13 @@ from datetime import datetime from src.backend.models.api.tipi import UtcNaiveInput from fastapi import APIRouter, Depends, HTTPException, Query, status -from sqlalchemy import and_, select +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.user import User @@ -38,6 +39,7 @@ async def _query_measurements( 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. @@ -67,6 +69,9 @@ async def _query_measurements( 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) @@ -106,6 +111,7 @@ async def get_summary( 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: @@ -113,6 +119,7 @@ async def get_summary( 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) @@ -128,6 +135,7 @@ async def get_capability( 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: @@ -135,6 +143,7 @@ async def get_capability( 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) @@ -151,6 +160,7 @@ async def get_control_chart( 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: @@ -158,6 +168,7 @@ async def get_control_chart( 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] @@ -178,6 +189,7 @@ async def get_histogram( 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), @@ -186,6 +198,7 @@ async def get_histogram( 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) @@ -235,3 +248,46 @@ async def get_recipe_subtasks( }) 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.""" + # Misure per sessione in una sola query, poi unite alle sessioni. + counts = dict((await db.execute( + select(Measurement.production_run_id, func.count(Measurement.id)) + .where(Measurement.production_run_id.is_not(None)) + .group_by(Measurement.production_run_id) + )).all()) + runs = (await db.execute( + select(ProductionRun) + .where(ProductionRun.recipe_id == recipe_id) + .order_by(ProductionRun.started_at.desc(), ProductionRun.id.desc()) + )).scalars().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 + ] diff --git a/src/backend/services/report_service.py b/src/backend/services/report_service.py index 975a41d..fd7ef9e 100644 --- a/src/backend/services/report_service.py +++ b/src/backend/services/report_service.py @@ -48,6 +48,7 @@ async def _query_measurements( operator_id: int | None = None, lot_number: str | None = None, serial_number: str | None = None, + production_run_id: int | None = None, # #58 ) -> list[Measurement]: """Query measurements with common filters (duplicated from statistics router).""" filters = [] @@ -73,6 +74,9 @@ async def _query_measurements( 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) @@ -281,6 +285,7 @@ async def generate_spc_report( operator_id: int | None = None, lot_number: str | None = None, serial_number: str | None = None, + production_run_id: int | None = None, # #58 ) -> bytes: """Generate SPC PDF report and return bytes. @@ -290,6 +295,7 @@ async def generate_spc_report( 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 ) # 2. Compute SPC data @@ -336,6 +342,8 @@ async def generate_spc_report( filters_desc.append(f"Lot: {lot_number}") if serial_number: filters_desc.append(f"Serial: {serial_number}") + if production_run_id is not None: + filters_desc.append(f"Run: {production_run_id}") # #58 # 6. Render HTML template template = _jinja_env.get_template("spc_report.html") @@ -367,12 +375,14 @@ async def generate_measurement_report( operator_id: int | None = None, lot_number: str | None = None, serial_number: str | None = None, + production_run_id: int | None = None, # #58 ) -> bytes: """Generate measurement table PDF report and return bytes.""" # 1. Query 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 ) # 2. Get recipe info @@ -427,6 +437,8 @@ async def generate_measurement_report( filters_desc.append(f"Lot: {lot_number}") if serial_number: filters_desc.append(f"Serial: {serial_number}") + if production_run_id is not None: + filters_desc.append(f"Run: {production_run_id}") # #58 # 7. Render template template = _jinja_env.get_template("measurement_report.html") diff --git a/src/backend/tests/test_statistics.py b/src/backend/tests/test_statistics.py index 3529e3c..e9e41ad 100644 --- a/src/backend/tests/test_statistics.py +++ b/src/backend/tests/test_statistics.py @@ -211,3 +211,79 @@ class TestDateFilter: assert resp.status_code == 200 # All measurements should be within this wide range assert resp.json()["total"] >= 2 + + +class TestRunFilter: + """#58: statistiche per sessione di produzione.""" + + async def _setup(self, db_session, user, metro): + from datetime import datetime + from src.backend.models.orm.production import ProductionRun + from src.backend.models.orm.station import Station + from src.backend.models.orm.recipe import RecipeVersion + from sqlalchemy import select + recipe = await create_test_recipe(db_session, user.id) + version = (await db_session.execute( + select(RecipeVersion).where(RecipeVersion.recipe_id == recipe.id) + )).scalars().first() + station = Station(code="ST-58", name="Stazione 58", active=True, created_by=user.id) + db_session.add(station) + await db_session.commit() + runs = [] + for i, lot in enumerate(["L1", "L2"]): + run = ProductionRun( + station_id=station.id, recipe_id=recipe.id, version_id=version.id, + operator_id=user.id, status="closed", lot_number=lot, + started_at=datetime(2026, 1, 1 + i), + ) + db_session.add(run) + runs.append(run) + await db_session.commit() + return recipe, runs + + async def test_filter_by_run_and_unfiltered( + self, client, measurement_tec_user, metrologist_user, db_session, + ): + from src.backend.models.orm.measurement import Measurement + recipe, runs = await self._setup(db_session, measurement_tec_user, metrologist_user) + subtask_id, version_id = await _seed_measurements( + client, measurement_tec_user, recipe.id, [10.0, 10.1, 10.2], + ) + # Assegna le prime due misure al run 1, la terza al run 2. + from sqlalchemy import select + ms = (await db_session.execute( + select(Measurement).order_by(Measurement.id) + )).scalars().all() + ms[0].production_run_id = runs[0].id + ms[1].production_run_id = runs[0].id + ms[2].production_run_id = runs[1].id + await db_session.commit() + + h = auth_headers(metrologist_user) + r1 = await client.get("/api/statistics/summary", headers=h, + params={"recipe_id": recipe.id, "production_run_id": runs[0].id}) + assert r1.json()["total"] == 2 + r2 = await client.get("/api/statistics/summary", headers=h, + params={"recipe_id": recipe.id, "production_run_id": runs[1].id}) + assert r2.json()["total"] == 1 + r_all = await client.get("/api/statistics/summary", headers=h, + params={"recipe_id": recipe.id}) + assert r_all.json()["total"] == 3 + + runs_resp = await client.get("/api/statistics/runs", headers=h, + params={"recipe_id": recipe.id}) + assert runs_resp.status_code == 200 + data = runs_resp.json() + assert [r["id"] for r in data] == [runs[1].id, runs[0].id] # più recente prima + assert [r["measurement_count"] for r in data] == [1, 2] + assert data[0]["lot_number"] == "L2" + assert data[0]["station_name"] == "Stazione 58" + assert data[0]["closed_at"] is None + + async def test_runs_require_metrologist( + self, client, measurement_tec_user, db_session, + ): + resp = await client.get("/api/statistics/runs", + headers=auth_headers(measurement_tec_user), + params={"recipe_id": 1}) + assert resp.status_code == 403 diff --git a/src/frontend/flask_app/blueprints/statistics.py b/src/frontend/flask_app/blueprints/statistics.py index cfbf070..178716a 100644 --- a/src/frontend/flask_app/blueprints/statistics.py +++ b/src/frontend/flask_app/blueprints/statistics.py @@ -50,7 +50,7 @@ def _proxy_statistics(endpoint: str): for key in [ "recipe_id", "version_id", "subtask_id", "date_from", "date_to", "operator_id", - "lot_number", "serial_number", "n_bins", + "lot_number", "serial_number", "n_bins", "production_run_id", # #58 ]: val = request.args.get(key) if val is not None and val != "": @@ -127,6 +127,27 @@ def api_subtasks(): return jsonify(resp) +@statistics_bp.route("/api/runs") +@login_required +@role_required("Metrologist") +def api_runs(): + """Proxy: production runs of a recipe (#58, "Sessione" filter).""" + params = {} + recipe_id = request.args.get("recipe_id") + if recipe_id: + params["recipe_id"] = recipe_id + + resp = api_client.get("/api/statistics/runs", params=params) + + if isinstance(resp, dict) and resp.get("error"): + error_body = {"error": True, "detail": resp.get("detail", "")} + if resp.get("session_expired"): + error_body["session_expired"] = True + return jsonify(error_body), resp.get("status_code", 500) + + return jsonify(resp) + + # ============================================================================ # PROXY REPORT PDF → FastAPI # ============================================================================ @@ -137,7 +158,7 @@ def _proxy_report(endpoint: str): for key in [ "recipe_id", "version_id", "subtask_id", "date_from", "date_to", "operator_id", - "lot_number", "serial_number", + "lot_number", "serial_number", "production_run_id", # #58 ]: val = request.args.get(key) if val is not None and val != "": diff --git a/src/frontend/flask_app/templates/statistics/dashboard.html b/src/frontend/flask_app/templates/statistics/dashboard.html index 5945cad..4b4400c 100644 --- a/src/frontend/flask_app/templates/statistics/dashboard.html +++ b/src/frontend/flask_app/templates/statistics/dashboard.html @@ -35,6 +35,19 @@ + +
+ + +
+
@@ -258,6 +271,8 @@ function spcDashboard() { // Filter state selectedRecipeId: '', selectedSubtaskId: '', + selectedRunId: '', // #58 + runs: [], // #58 sessioni della ricetta dateFrom: '', dateTo: '', subtasks: [], @@ -281,6 +296,8 @@ function spcDashboard() { async onRecipeChange() { this.selectedSubtaskId = ''; + this.selectedRunId = ''; // #58 + this.runs = []; this.subtasks = []; this.hasData = false; this.summary = null; @@ -290,6 +307,15 @@ function spcDashboard() { if (!this.selectedRecipeId) return; + // #58: sessioni della ricetta per il filtro «Sessione» + try { + const rresp = await fetch('/statistics/api/runs?recipe_id=' + this.selectedRecipeId); + const rdata = await rresp.json(); + if (Array.isArray(rdata)) this.runs = rdata; + } catch (e) { + console.error('Error loading runs:', e); + } + // Load subtasks for dropdown try { const resp = await fetch('/statistics/api/subtasks?recipe_id=' + this.selectedRecipeId); @@ -302,6 +328,12 @@ function spcDashboard() { } }, + // #58: etichetta sessione = data, stazione, operatore, lotto + runLabel(r) { + const d = r.started_at ? new Date(r.started_at + (r.started_at.endsWith('Z') ? '' : 'Z')).toLocaleString() : ''; + return [d, r.station_name, r.operator_name, r.lot_number].filter(Boolean).join(' · '); + }, + async loadData() { if (!this.selectedRecipeId) return; @@ -312,6 +344,7 @@ function spcDashboard() { const params = new URLSearchParams(); params.set('recipe_id', this.selectedRecipeId); if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId); + if (this.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58 if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString()); if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString()); @@ -417,6 +450,7 @@ function spcDashboard() { const params = new URLSearchParams(); params.set('recipe_id', this.selectedRecipeId); if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId); + if (this.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58 if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString()); if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString()); diff --git a/src/frontend/flask_app/tests/test_statistics.py b/src/frontend/flask_app/tests/test_statistics.py index f5b2049..8a6eb72 100644 --- a/src/frontend/flask_app/tests/test_statistics.py +++ b/src/frontend/flask_app/tests/test_statistics.py @@ -112,3 +112,21 @@ class TestSPCProxy: assert resp.status_code == 200 data = resp.get_json() assert "cp" in data + + +class TestRunsProxy: + """#58: filtro per sessione di produzione.""" + + def test_runs_proxy(self, logged_in_client, mock_api_client): + mock_api_client.get.return_value = [{"id": 5, "measurement_count": 3}] + resp = logged_in_client.get("/statistics/api/runs?recipe_id=7") + assert resp.status_code == 200 + assert resp.get_json()[0]["id"] == 5 + args, kwargs = mock_api_client.get.call_args + assert args[0] == "/api/statistics/runs" + assert kwargs["params"] == {"recipe_id": "7"} + + def test_summary_forwards_production_run_id(self, logged_in_client, mock_api_client): + mock_api_client.get.return_value = {"total": 1} + logged_in_client.get("/statistics/api/summary?recipe_id=1&production_run_id=5") + assert mock_api_client.get.call_args.kwargs["params"]["production_run_id"] == "5" From e44ac3129f3278d73d5aec81c2a20af9533fd955 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Wed, 7 Oct 2026 12:31:53 +0200 Subject: [PATCH 2/2] Statistiche sessioni: conteggio misure solo sui run della ricetta (#58) Co-Authored-By: Claude Sonnet 5.5 --- src/backend/api/routers/statistics.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/backend/api/routers/statistics.py b/src/backend/api/routers/statistics.py index 5fc7d67..1f8321d 100644 --- a/src/backend/api/routers/statistics.py +++ b/src/backend/api/routers/statistics.py @@ -257,17 +257,20 @@ async def get_recipe_runs( db: AsyncSession = Depends(get_db), ) -> list[dict]: """#58: production runs of a recipe (for the "Sessione" filter), newest first.""" - # Misure per sessione in una sola query, poi unite alle sessioni. - counts = dict((await db.execute( - select(Measurement.production_run_id, func.count(Measurement.id)) - .where(Measurement.production_run_id.is_not(None)) - .group_by(Measurement.production_run_id) - )).all()) 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: