Merge lavoro/daitarn-trafilo-statsessione

This commit is contained in:
2026-10-07 12:33:02 +02:00
7 changed files with 227 additions and 3 deletions
+4
View File
@@ -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(
+60 -1
View File
@@ -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,49 @@ 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."""
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
]
+12
View File
@@ -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")
+76
View File
@@ -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