Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e44ac3129f | |||
| 4e52095423 |
@@ -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(
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -377,47 +377,6 @@ def api_upload_file():
|
||||
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"])
|
||||
@login_required
|
||||
def api_get_file(file_path: str):
|
||||
|
||||
@@ -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 != "":
|
||||
|
||||
@@ -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="measure">{{ _('Misura') }}</option>
|
||||
<option value="drawing">{{ _('Disegno') }}</option>
|
||||
<option value="camera_measure">{{ _('Misura camera') }}</option>
|
||||
<option value="dxf_compare">{{ _('Confronto profilo') }}</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-[var(--text-secondary)]">
|
||||
{{ _('Un task di misura resta tale anche prima di inserire le quote') }}
|
||||
@@ -462,55 +460,6 @@
|
||||
|
||||
<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 ========== -->
|
||||
<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">
|
||||
@@ -594,8 +543,6 @@
|
||||
<option value="note">{{ _('Nota') }}</option>
|
||||
<option value="measure">{{ _('Misura') }}</option>
|
||||
<option value="drawing">{{ _('Disegno') }}</option>
|
||||
<option value="camera_measure">{{ _('Misura camera') }}</option>
|
||||
<option value="dxf_compare">{{ _('Confronto profilo') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -752,18 +699,6 @@
|
||||
class="tmf-input"
|
||||
placeholder="{{ _('Es. Diametro foro principale') }}">
|
||||
</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 -->
|
||||
<div>
|
||||
<label class="tmf-label text-xs">{{ _('Tipo') }}</label>
|
||||
@@ -1158,7 +1093,6 @@
|
||||
{% block extra_js %}
|
||||
{# 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/vision_recipe.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 src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
|
||||
@@ -1195,11 +1129,8 @@ function taskEditor() {
|
||||
addingSubtaskForTask: null,
|
||||
newSubtask: {
|
||||
marker_number: 1, description: '', measurement_type: '',
|
||||
nominal: null, utl: null, uwl: null, lwl: null, ltl: null, unit: 'mm',
|
||||
vision_output: ''
|
||||
nominal: null, utl: null, uwl: null, lwl: null, ltl: null, unit: 'mm'
|
||||
},
|
||||
// Uscite numeriche della ricetta visione per task (#46), riempite da vision_recipe.js
|
||||
visionOutputs: {},
|
||||
|
||||
// ---- Delete modals ----
|
||||
deleteTaskModal: false,
|
||||
@@ -1222,10 +1153,6 @@ function taskEditor() {
|
||||
// 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
|
||||
this.tasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
|
||||
|
||||
@@ -1683,8 +1610,7 @@ function taskEditor() {
|
||||
uwl: null,
|
||||
lwl: null,
|
||||
ltl: null,
|
||||
unit: 'mm',
|
||||
vision_output: ''
|
||||
unit: 'mm'
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1708,8 +1634,7 @@ function taskEditor() {
|
||||
uwl: this.newSubtask.uwl,
|
||||
lwl: this.newSubtask.lwl,
|
||||
ltl: this.newSubtask.ltl,
|
||||
unit: this.newSubtask.unit || 'mm',
|
||||
vision_output: this.newSubtask.vision_output || null // chiave uscita ricetta visione (#46)
|
||||
unit: this.newSubtask.unit || 'mm'
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -1762,8 +1687,7 @@ function taskEditor() {
|
||||
uwl: subtask.uwl,
|
||||
lwl: subtask.lwl,
|
||||
ltl: subtask.ltl,
|
||||
unit: subtask.unit || 'mm',
|
||||
vision_output: subtask.vision_output || ''
|
||||
unit: subtask.unit || 'mm'
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1787,8 +1711,7 @@ function taskEditor() {
|
||||
uwl: this.newSubtask.uwl,
|
||||
lwl: this.newSubtask.lwl,
|
||||
ltl: this.newSubtask.ltl,
|
||||
unit: this.newSubtask.unit || 'mm',
|
||||
vision_output: this.newSubtask.vision_output || null // chiave uscita ricetta visione (#46)
|
||||
unit: this.newSubtask.unit || 'mm'
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -35,6 +35,19 @@
|
||||
</select>
|
||||
</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 -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label>
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -161,82 +161,3 @@ class TestVersionConflictProxy:
|
||||
|
||||
assert resp.status_code == 409
|
||||
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
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user