Compare commits

..

2 Commits

Author SHA1 Message Date
Adriano e44ac3129f Statistiche sessioni: conteggio misure solo sui run della ricetta (#58)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:31:53 +02:00
Adriano 4e52095423 Statistiche per sessione di produzione: filtro production_run_id, elenco sessioni, select Sessione (#58)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:28:35 +02:00
11 changed files with 232 additions and 285 deletions
+4
View File
@@ -23,6 +23,7 @@ async def download_spc_report(
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
production_run_id: int | None = Query(None), # #58
user: User = Depends(require_metrologist), user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> Response: ) -> Response:
@@ -32,6 +33,7 @@ async def download_spc_report(
db, recipe_id, subtask_id, db, recipe_id, subtask_id,
version_id, date_from, date_to, version_id, date_from, date_to,
operator_id, lot_number, serial_number, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
) )
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
@@ -57,6 +59,7 @@ async def download_measurement_report(
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
production_run_id: int | None = Query(None), # #58
user: User = Depends(require_metrologist), user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> Response: ) -> Response:
@@ -66,6 +69,7 @@ async def download_measurement_report(
db, recipe_id, subtask_id, db, recipe_id, subtask_id,
version_id, date_from, date_to, version_id, date_from, date_to,
operator_id, lot_number, serial_number, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
) )
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
+60 -1
View File
@@ -3,12 +3,13 @@ from datetime import datetime
from src.backend.models.api.tipi import UtcNaiveInput from src.backend.models.api.tipi import UtcNaiveInput
from fastapi import APIRouter, Depends, HTTPException, Query, status 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 sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db from src.backend.database import get_db
from src.backend.api.middleware.api_key import require_metrologist from src.backend.api.middleware.api_key import require_metrologist
from src.backend.models.orm.measurement import Measurement 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.recipe import RecipeVersion
from src.backend.models.orm.task import RecipeSubtask, RecipeTask from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.user import User from src.backend.models.orm.user import User
@@ -38,6 +39,7 @@ async def _query_measurements(
operator_id: int | None = None, operator_id: int | None = None,
lot_number: str | None = None, lot_number: str | None = None,
serial_number: str | None = None, serial_number: str | None = None,
production_run_id: int | None = None, # #58: solo le misure di una sessione
) -> list[Measurement]: ) -> list[Measurement]:
"""Query measurements with common filters. """Query measurements with common filters.
@@ -67,6 +69,9 @@ async def _query_measurements(
if serial_number is not None: if serial_number is not None:
filters.append(Measurement.serial_number == serial_number) filters.append(Measurement.serial_number == serial_number)
if production_run_id is not None:
filters.append(Measurement.production_run_id == production_run_id)
query = ( query = (
select(Measurement) select(Measurement)
.where(and_(*filters) if filters else True) .where(and_(*filters) if filters else True)
@@ -106,6 +111,7 @@ async def get_summary(
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
production_run_id: int | None = Query(None), # #58
user: User = Depends(require_metrologist), user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> SummaryData: ) -> SummaryData:
@@ -113,6 +119,7 @@ async def get_summary(
measurements = await _query_measurements( measurements = await _query_measurements(
db, recipe_id, version_id, subtask_id, db, recipe_id, version_id, subtask_id,
date_from, date_to, operator_id, lot_number, serial_number, 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] pass_fail_values = [m.pass_fail for m in measurements]
return compute_summary(pass_fail_values) return compute_summary(pass_fail_values)
@@ -128,6 +135,7 @@ async def get_capability(
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
production_run_id: int | None = Query(None), # #58
user: User = Depends(require_metrologist), user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> CapabilityData: ) -> CapabilityData:
@@ -135,6 +143,7 @@ async def get_capability(
measurements = await _query_measurements( measurements = await _query_measurements(
db, recipe_id, version_id, subtask_id, db, recipe_id, version_id, subtask_id,
date_from, date_to, operator_id, lot_number, serial_number, 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] values = [float(m.value) for m in measurements]
tol = await _get_subtask_tolerances(db, subtask_id) tol = await _get_subtask_tolerances(db, subtask_id)
@@ -151,6 +160,7 @@ async def get_control_chart(
operator_id: int | None = Query(None), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_number: str | None = Query(None), serial_number: str | None = Query(None),
production_run_id: int | None = Query(None), # #58
user: User = Depends(require_metrologist), user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> ControlChartData: ) -> ControlChartData:
@@ -158,6 +168,7 @@ async def get_control_chart(
measurements = await _query_measurements( measurements = await _query_measurements(
db, recipe_id, version_id, subtask_id, db, recipe_id, version_id, subtask_id,
date_from, date_to, operator_id, lot_number, serial_number, 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] values = [float(m.value) for m in measurements]
timestamps = [m.measured_at 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), operator_id: int | None = Query(None),
lot_number: str | None = Query(None), lot_number: str | None = Query(None),
serial_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), n_bins: int = Query(20, ge=5, le=100),
user: User = Depends(require_metrologist), user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -186,6 +198,7 @@ async def get_histogram(
measurements = await _query_measurements( measurements = await _query_measurements(
db, recipe_id, version_id, subtask_id, db, recipe_id, version_id, subtask_id,
date_from, date_to, operator_id, lot_number, serial_number, 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] values = [float(m.value) for m in measurements]
return compute_histogram(values, n_bins) return compute_histogram(values, n_bins)
@@ -235,3 +248,49 @@ async def get_recipe_subtasks(
}) })
return subtasks_list 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, operator_id: int | None = None,
lot_number: str | None = None, lot_number: str | None = None,
serial_number: str | None = None, serial_number: str | None = None,
production_run_id: int | None = None, # #58
) -> list[Measurement]: ) -> list[Measurement]:
"""Query measurements with common filters (duplicated from statistics router).""" """Query measurements with common filters (duplicated from statistics router)."""
filters = [] filters = []
@@ -73,6 +74,9 @@ async def _query_measurements(
if serial_number is not None: if serial_number is not None:
filters.append(Measurement.serial_number == serial_number) filters.append(Measurement.serial_number == serial_number)
if production_run_id is not None:
filters.append(Measurement.production_run_id == production_run_id)
query = ( query = (
select(Measurement) select(Measurement)
.where(and_(*filters) if filters else True) .where(and_(*filters) if filters else True)
@@ -281,6 +285,7 @@ async def generate_spc_report(
operator_id: int | None = None, operator_id: int | None = None,
lot_number: str | None = None, lot_number: str | None = None,
serial_number: str | None = None, serial_number: str | None = None,
production_run_id: int | None = None, # #58
) -> bytes: ) -> bytes:
"""Generate SPC PDF report and return bytes. """Generate SPC PDF report and return bytes.
@@ -290,6 +295,7 @@ async def generate_spc_report(
measurements = await _query_measurements( measurements = await _query_measurements(
db, recipe_id, version_id, subtask_id, db, recipe_id, version_id, subtask_id,
date_from, date_to, operator_id, lot_number, serial_number, date_from, date_to, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
) )
# 2. Compute SPC data # 2. Compute SPC data
@@ -336,6 +342,8 @@ async def generate_spc_report(
filters_desc.append(f"Lot: {lot_number}") filters_desc.append(f"Lot: {lot_number}")
if serial_number: if serial_number:
filters_desc.append(f"Serial: {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 # 6. Render HTML template
template = _jinja_env.get_template("spc_report.html") template = _jinja_env.get_template("spc_report.html")
@@ -367,12 +375,14 @@ async def generate_measurement_report(
operator_id: int | None = None, operator_id: int | None = None,
lot_number: str | None = None, lot_number: str | None = None,
serial_number: str | None = None, serial_number: str | None = None,
production_run_id: int | None = None, # #58
) -> bytes: ) -> bytes:
"""Generate measurement table PDF report and return bytes.""" """Generate measurement table PDF report and return bytes."""
# 1. Query measurements # 1. Query measurements
measurements = await _query_measurements( measurements = await _query_measurements(
db, recipe_id, version_id, subtask_id, db, recipe_id, version_id, subtask_id,
date_from, date_to, operator_id, lot_number, serial_number, date_from, date_to, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
) )
# 2. Get recipe info # 2. Get recipe info
@@ -427,6 +437,8 @@ async def generate_measurement_report(
filters_desc.append(f"Lot: {lot_number}") filters_desc.append(f"Lot: {lot_number}")
if serial_number: if serial_number:
filters_desc.append(f"Serial: {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 # 7. Render template
template = _jinja_env.get_template("measurement_report.html") template = _jinja_env.get_template("measurement_report.html")
+76
View File
@@ -211,3 +211,79 @@ class TestDateFilter:
assert resp.status_code == 200 assert resp.status_code == 200
# All measurements should be within this wide range # All measurements should be within this wide range
assert resp.json()["total"] >= 2 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
@@ -377,47 +377,6 @@ def api_upload_file():
return jsonify(resp), 201 return jsonify(resp), 201
# ============================================================================
# VISION RECIPE PROXY (#46)
# ============================================================================
@maker_bp.route("/api/vision/tasks/<int:task_id>/recipe", methods=["GET"])
@login_required
@role_required("Maker")
def api_get_vision_recipe(task_id: int):
"""Proxy: ricetta visione corrente del task (cartella, file, uscite)."""
resp = api_client.get(f"/api/vision/tasks/{task_id}/recipe")
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@maker_bp.route("/api/vision/tasks/<int:task_id>/recipe", methods=["POST"])
@login_required
@role_required("Maker")
def api_upload_vision_recipe(task_id: int):
"""Proxy: carica ricetta .json + file allegati (multipart forward)."""
recipe = request.files.get("recipe")
if recipe is None or recipe.filename == "":
return jsonify({"error": True, "detail": _("Nessun file ricetta caricato")}), 400
# Ricetta + 0..n allegati (DXF, modelli .npz, calibrazione), stesso nome campo `files`
files = [("recipe", (recipe.filename, recipe.stream, recipe.content_type))]
for f in request.files.getlist("files"):
if f.filename:
files.append(("files", (f.filename, f.stream, f.content_type)))
resp = api_client.post(f"/api/vision/tasks/{task_id}/recipe", data={}, files=files)
# Il 422 (errori di validazione) arriva al browser com'è, con `errors`
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@maker_bp.route("/api/files/<path:file_path>", methods=["GET"]) @maker_bp.route("/api/files/<path:file_path>", methods=["GET"])
@login_required @login_required
def api_get_file(file_path: str): def api_get_file(file_path: str):
@@ -50,7 +50,7 @@ def _proxy_statistics(endpoint: str):
for key in [ for key in [
"recipe_id", "version_id", "subtask_id", "recipe_id", "version_id", "subtask_id",
"date_from", "date_to", "operator_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) val = request.args.get(key)
if val is not None and val != "": if val is not None and val != "":
@@ -127,6 +127,27 @@ def api_subtasks():
return jsonify(resp) 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 # PROXY REPORT PDF → FastAPI
# ============================================================================ # ============================================================================
@@ -137,7 +158,7 @@ def _proxy_report(endpoint: str):
for key in [ for key in [
"recipe_id", "version_id", "subtask_id", "recipe_id", "version_id", "subtask_id",
"date_from", "date_to", "operator_id", "date_from", "date_to", "operator_id",
"lot_number", "serial_number", "lot_number", "serial_number", "production_run_id", # #58
]: ]:
val = request.args.get(key) val = request.args.get(key)
if val is not None and val != "": if val is not None and val != "":
@@ -1,80 +0,0 @@
// Ricetta visione del Maker (#46): componente Alpine per i task camera_measure / dxf_compare.
// Carica la ricetta VisionSuite v2 + i file allegati verso /maker/api/vision/tasks/<id>/recipe,
// mostra cartella, file e uscite numeriche; notifica le uscite al task editor con un evento.
// Uso: x-data="visionRecipe(task.id, csrfToken, msgs)". Dipende da Alpine.js.
function visionRecipe(taskId, csrf, msgs) {
return {
taskId: taskId,
recipeDir: null,
files: [],
outputs: [],
errors: [], // errori 422 elencati in chiaro
loading: false,
busy: false,
recipeFile: null,
attachments: [],
msgs: msgs || {},
async init() {
// All'apertura: ricetta corrente (GET); il 404/errore non blocca l'editor
this.loading = true;
try {
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe`);
if (resp.ok) this._apply(await resp.json());
} catch (e) { console.error('visionRecipe init', e); }
this.loading = false;
},
// Aggiorna lo stato e avvisa il task editor (select vision_output delle quote)
_apply(data) {
this.recipeDir = data.recipe_dir || null;
this.files = data.files || [];
this.outputs = data.outputs || [];
window.dispatchEvent(new CustomEvent('vision-outputs', {
detail: { taskId: this.taskId, outputs: this.outputs }
}));
},
// Il 422 porta `errors` (lista) oppure `detail` (stringa o lista di voci)
_errorsFrom(data) {
if (Array.isArray(data.errors) && data.errors.length) return data.errors.map(String);
const d = data.detail;
if (Array.isArray(d)) return d.map(x => (typeof x === 'string' ? x : (x.msg || JSON.stringify(x))));
if (d && typeof d === 'object') {
if (Array.isArray(d.errors)) return d.errors.map(String);
return [JSON.stringify(d)];
}
return [d || this.msgs.genericError || 'Error'];
},
async upload() {
if (!this.recipeFile) return;
this.busy = true;
this.errors = [];
try {
const fd = new FormData();
fd.append('recipe', this.recipeFile);
for (const f of this.attachments) fd.append('files', f);
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe`, {
method: 'POST',
headers: { 'X-CSRFToken': typeof csrf === 'function' ? csrf() : csrf },
body: fd
});
const data = await resp.json();
if (!resp.ok || data.error) {
this.errors = this._errorsFrom(data);
} else {
this._apply(data);
this.recipeFile = null;
this.attachments = [];
}
} catch (e) {
this.errors = [this.msgs.genericError || String(e)];
}
this.busy = false;
},
onRecipe(ev) { this.recipeFile = ev.target.files[0] || null; },
onAttachments(ev) { this.attachments = Array.from(ev.target.files || []); }
};
}
@@ -277,8 +277,6 @@
<option value="note">{{ _('Nota') }}</option> <option value="note">{{ _('Nota') }}</option>
<option value="measure">{{ _('Misura') }}</option> <option value="measure">{{ _('Misura') }}</option>
<option value="drawing">{{ _('Disegno') }}</option> <option value="drawing">{{ _('Disegno') }}</option>
<option value="camera_measure">{{ _('Misura camera') }}</option>
<option value="dxf_compare">{{ _('Confronto profilo') }}</option>
</select> </select>
<p class="mt-1 text-xs text-[var(--text-secondary)]"> <p class="mt-1 text-xs text-[var(--text-secondary)]">
{{ _('Un task di misura resta tale anche prima di inserire le quote') }} {{ _('Un task di misura resta tale anche prima di inserire le quote') }}
@@ -462,55 +460,6 @@
<div class="tmf-card-body space-y-4"> <div class="tmf-card-body space-y-4">
<!-- ========== Ricetta visione (#46): solo camera_measure / dxf_compare ========== -->
<template x-if="task.task_type === 'camera_measure' || task.task_type === 'dxf_compare'">
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]"
x-data="visionRecipe(task.id, () => csrfToken(), {{ {'genericError': _('Errore durante il caricamento della ricetta')}|tojson_attr }})">
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
{{ _('Ricetta visione') }}
</p>
<!-- Stato corrente: cartella, file, uscite -->
<template x-if="recipeDir">
<div class="text-xs space-y-1 mb-3">
<p><span class="text-[var(--text-secondary)]">{{ _('Cartella') }}:</span>
<span class="font-mono" x-text="recipeDir"></span></p>
<p x-show="files.length"><span class="text-[var(--text-secondary)]">{{ _('File') }}:</span>
<span class="font-mono" x-text="files.join(', ')"></span></p>
<p x-show="outputs.length"><span class="text-[var(--text-secondary)]">{{ _('Uscite numeriche') }}:</span></p>
<ul class="list-disc pl-5" x-show="outputs.length">
<template x-for="o in outputs" :key="o.key">
<li><span class="font-mono" x-text="o.key"></span>
<span class="text-[var(--text-muted)]" x-text="(o.quantity || o.unit) ? '(' + [o.quantity, o.unit].filter(Boolean).join(', ') + ')' : ''"></span></li>
</template>
</ul>
</div>
</template>
<p x-show="!recipeDir && !loading" class="text-xs text-[var(--text-muted)] mb-3">
{{ _('Nessuna ricetta caricata') }}
</p>
<!-- Caricamento: ricetta .json + allegati -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="tmf-label text-xs">{{ _('Ricetta (.json)') }}</label>
<input type="file" accept=".json,application/json" class="tmf-input text-xs"
@change="onRecipe($event)">
</div>
<div>
<label class="tmf-label text-xs">{{ _('File allegati (DXF, modelli, calibrazione)') }}</label>
<input type="file" multiple class="tmf-input text-xs" @change="onAttachments($event)">
</div>
</div>
<div class="flex justify-end mt-3">
<button type="button" class="btn btn-primary text-xs" @click="upload()"
:disabled="!recipeFile || busy">{{ _('Carica ricetta') }}</button>
</div>
<!-- Errori 422 in chiaro -->
<ul x-show="errors.length" class="mt-3 text-xs text-red-500 list-disc pl-5">
<template x-for="(e, i) in errors" :key="i"><li x-text="e"></li></template>
</ul>
</div>
</template>
<!-- ========== Task File Upload / Preview ========== --> <!-- ========== Task File Upload / Preview ========== -->
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]"> <div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]">
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2"> <p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
@@ -594,8 +543,6 @@
<option value="note">{{ _('Nota') }}</option> <option value="note">{{ _('Nota') }}</option>
<option value="measure">{{ _('Misura') }}</option> <option value="measure">{{ _('Misura') }}</option>
<option value="drawing">{{ _('Disegno') }}</option> <option value="drawing">{{ _('Disegno') }}</option>
<option value="camera_measure">{{ _('Misura camera') }}</option>
<option value="dxf_compare">{{ _('Confronto profilo') }}</option>
</select> </select>
</div> </div>
<div> <div>
@@ -752,18 +699,6 @@
class="tmf-input" class="tmf-input"
placeholder="{{ _('Es. Diametro foro principale') }}"> placeholder="{{ _('Es. Diametro foro principale') }}">
</div> </div>
<!-- Uscita visione (#46): solo task camera_measure / dxf_compare -->
<div class="col-span-2 sm:col-span-3 lg:col-span-2"
x-show="task.task_type === 'camera_measure' || task.task_type === 'dxf_compare'">
<label class="tmf-label text-xs">{{ _('Uscita visione') }}</label>
<select x-model="newSubtask.vision_output" class="tmf-input">
<option value="">{{ _('Nessuna') }}</option>
<template x-for="o in (visionOutputs[task.id] || [])" :key="o.key">
<option :value="o.key" :selected="o.key === newSubtask.vision_output"
x-text="o.key + ((o.quantity || o.unit) ? ' (' + [o.quantity, o.unit].filter(Boolean).join(', ') + ')' : '')"></option>
</template>
</select>
</div>
<!-- Measurement Type --> <!-- Measurement Type -->
<div> <div>
<label class="tmf-label text-xs">{{ _('Tipo') }}</label> <label class="tmf-label text-xs">{{ _('Tipo') }}</label>
@@ -1158,7 +1093,6 @@
{% block extra_js %} {% block extra_js %}
{# Bold and line breaks in descriptions: the same rules the server renders with. #} {# Bold and line breaks in descriptions: the same rules the server renders with. #}
<script src="{{ url_for('static', filename='js/rich-text.js') }}"></script> <script src="{{ url_for('static', filename='js/rich-text.js') }}"></script>
<script src="{{ url_for('static', filename='js/vision_recipe.js') }}"></script>
<script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script> <script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script>
<script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';</script> <script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';</script>
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script> <script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
@@ -1195,11 +1129,8 @@ function taskEditor() {
addingSubtaskForTask: null, addingSubtaskForTask: null,
newSubtask: { newSubtask: {
marker_number: 1, description: '', measurement_type: '', marker_number: 1, description: '', measurement_type: '',
nominal: null, utl: null, uwl: null, lwl: null, ltl: null, unit: 'mm', nominal: null, utl: null, uwl: null, lwl: null, ltl: null, unit: 'mm'
vision_output: ''
}, },
// Uscite numeriche della ricetta visione per task (#46), riempite da vision_recipe.js
visionOutputs: {},
// ---- Delete modals ---- // ---- Delete modals ----
deleteTaskModal: false, deleteTaskModal: false,
@@ -1222,10 +1153,6 @@ function taskEditor() {
// Init // Init
// ============================================================ // ============================================================
init() { init() {
// Uscite della ricetta visione: evento dal componente visionRecipe (#46)
window.addEventListener('vision-outputs', (ev) => {
this.visionOutputs = { ...this.visionOutputs, [ev.detail.taskId]: ev.detail.outputs || [] };
});
// Sort tasks by order_index on load // Sort tasks by order_index on load
this.tasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0)); this.tasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
@@ -1683,8 +1610,7 @@ function taskEditor() {
uwl: null, uwl: null,
lwl: null, lwl: null,
ltl: null, ltl: null,
unit: 'mm', unit: 'mm'
vision_output: ''
}; };
}, },
@@ -1708,8 +1634,7 @@ function taskEditor() {
uwl: this.newSubtask.uwl, uwl: this.newSubtask.uwl,
lwl: this.newSubtask.lwl, lwl: this.newSubtask.lwl,
ltl: this.newSubtask.ltl, ltl: this.newSubtask.ltl,
unit: this.newSubtask.unit || 'mm', unit: this.newSubtask.unit || 'mm'
vision_output: this.newSubtask.vision_output || null // chiave uscita ricetta visione (#46)
}; };
try { try {
@@ -1762,8 +1687,7 @@ function taskEditor() {
uwl: subtask.uwl, uwl: subtask.uwl,
lwl: subtask.lwl, lwl: subtask.lwl,
ltl: subtask.ltl, ltl: subtask.ltl,
unit: subtask.unit || 'mm', unit: subtask.unit || 'mm'
vision_output: subtask.vision_output || ''
}; };
}, },
@@ -1787,8 +1711,7 @@ function taskEditor() {
uwl: this.newSubtask.uwl, uwl: this.newSubtask.uwl,
lwl: this.newSubtask.lwl, lwl: this.newSubtask.lwl,
ltl: this.newSubtask.ltl, ltl: this.newSubtask.ltl,
unit: this.newSubtask.unit || 'mm', unit: this.newSubtask.unit || 'mm'
vision_output: this.newSubtask.vision_output || null // chiave uscita ricetta visione (#46)
}; };
try { try {
@@ -35,6 +35,19 @@
</select> </select>
</div> </div>
<!-- Sessione di produzione (#58) -->
<div>
<label class="block text-xs font-medium text-steel mb-1">{{ _("Sessione") }}</label>
<select x-model="selectedRunId"
:disabled="runs.length === 0"
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50">
<option value="">{{ _("Tutte le sessioni") }}</option>
<template x-for="r in runs" :key="r.id">
<option :value="r.id" x-text="runLabel(r)"></option>
</template>
</select>
</div>
<!-- Subtask --> <!-- Subtask -->
<div> <div>
<label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label> <label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label>
@@ -258,6 +271,8 @@ function spcDashboard() {
// Filter state // Filter state
selectedRecipeId: '', selectedRecipeId: '',
selectedSubtaskId: '', selectedSubtaskId: '',
selectedRunId: '', // #58
runs: [], // #58 sessioni della ricetta
dateFrom: '', dateFrom: '',
dateTo: '', dateTo: '',
subtasks: [], subtasks: [],
@@ -281,6 +296,8 @@ function spcDashboard() {
async onRecipeChange() { async onRecipeChange() {
this.selectedSubtaskId = ''; this.selectedSubtaskId = '';
this.selectedRunId = ''; // #58
this.runs = [];
this.subtasks = []; this.subtasks = [];
this.hasData = false; this.hasData = false;
this.summary = null; this.summary = null;
@@ -290,6 +307,15 @@ function spcDashboard() {
if (!this.selectedRecipeId) return; 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 // Load subtasks for dropdown
try { try {
const resp = await fetch('/statistics/api/subtasks?recipe_id=' + this.selectedRecipeId); 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() { async loadData() {
if (!this.selectedRecipeId) return; if (!this.selectedRecipeId) return;
@@ -312,6 +344,7 @@ function spcDashboard() {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('recipe_id', this.selectedRecipeId); params.set('recipe_id', this.selectedRecipeId);
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId); 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.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()); 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(); const params = new URLSearchParams();
params.set('recipe_id', this.selectedRecipeId); params.set('recipe_id', this.selectedRecipeId);
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId); 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.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()); if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
@@ -161,82 +161,3 @@ class TestVersionConflictProxy:
assert resp.status_code == 409 assert resp.status_code == 409
assert resp.get_json()["detail"] == self._CONFLICT["detail"] assert resp.get_json()["detail"] == self._CONFLICT["detail"]
class TestVisionRecipeProxy:
"""Proxy ricetta visione (#46): /maker/api/vision/tasks/<id>/recipe."""
def test_upload_forwards_recipe_and_files(self, logged_in_client, mock_api_client):
import io
mock_api_client.post.return_value = {
"task_id": 7, "recipe_dir": "vision/recipes/7/abc", "files": ["a.dxf"],
"outputs": [{"key": "n1.points_in_pct", "quantity": None, "unit": None}],
}
resp = logged_in_client.post(
"/maker/api/vision/tasks/7/recipe",
data={
"recipe": (io.BytesIO(b"{}"), "r.json"),
"files": [(io.BytesIO(b"dxf"), "a.dxf"), (io.BytesIO(b"npz"), "m.npz")],
},
content_type="multipart/form-data",
)
assert resp.status_code == 200
assert resp.get_json()["outputs"][0]["key"] == "n1.points_in_pct"
endpoint = mock_api_client.post.call_args.args[0]
sent = mock_api_client.post.call_args.kwargs["files"]
assert endpoint == "/api/vision/tasks/7/recipe"
# un campo `recipe` e un campo `files` per ogni allegato
assert [name for name, _f in sent] == ["recipe", "files", "files"]
def test_upload_without_recipe_is_400(self, logged_in_client, mock_api_client):
resp = logged_in_client.post(
"/maker/api/vision/tasks/7/recipe", data={}, content_type="multipart/form-data"
)
assert resp.status_code == 400
mock_api_client.post.assert_not_called()
def test_upload_proxies_422(self, logged_in_client, mock_api_client):
import io
mock_api_client.post.return_value = {
"error": True, "status_code": 422, "detail": "file mancante: a.dxf",
}
resp = logged_in_client.post(
"/maker/api/vision/tasks/7/recipe",
data={"recipe": (io.BytesIO(b"{}"), "r.json")},
content_type="multipart/form-data",
)
assert resp.status_code == 422
assert "a.dxf" in resp.get_json()["detail"]
def test_get_recipe_proxies(self, logged_in_client, mock_api_client):
mock_api_client.get.return_value = {
"task_id": 7, "recipe_dir": None, "recipe": None, "files": [], "outputs": [],
}
resp = logged_in_client.get("/maker/api/vision/tasks/7/recipe")
assert resp.status_code == 200
mock_api_client.get.assert_called_once_with("/api/vision/tasks/7/recipe")
class TestTaskEditorVisionUi:
"""task_editor.html espone i tipi visione e la select vision_output (#46)."""
def test_editor_has_vision_types_and_section(self, logged_in_client, mock_api_client):
from tests.conftest import api_get_router
mock_api_client.get.side_effect = api_get_router({
"/api/recipes/3": {"id": 3, "code": "R", "name": "R"},
"/api/recipes/3/tasks": [{
"id": 11, "title": "T", "task_type": "dxf_compare", "subtasks": [],
"file_path": None, "order_index": 1,
}],
})
html = logged_in_client.get("/maker/recipes/3/tasks").get_data(as_text=True)
assert 'value="camera_measure"' in html
assert 'value="dxf_compare"' in html
assert "visionRecipe(" in html
assert "vision_output" in html
assert "js/vision_recipe.js" in html
@@ -112,3 +112,21 @@ class TestSPCProxy:
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.get_json() data = resp.get_json()
assert "cp" in data 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"