From c6d4260ede37249b38beaef8170d035033b6f586 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Wed, 7 Oct 2026 12:43:04 +0200 Subject: [PATCH] Esecuzione: esiti OK/NOK, campi dei task note, task di chiusura prima del capoturno (#53 #54 #55) Co-Authored-By: Claude Sonnet 5.5 --- src/frontend/flask_app/blueprints/measure.py | 85 ++++++ .../flask_app/static/js/task-results.js | 272 ++++++++++++++++++ .../templates/measure/task_execute.html | 190 +++++++++++- .../flask_app/tests/js/test_task_results.js | 110 +++++++ .../tests/test_measure_task_results.py | 205 +++++++++++++ 5 files changed, 861 insertions(+), 1 deletion(-) create mode 100644 src/frontend/flask_app/static/js/task-results.js create mode 100644 src/frontend/flask_app/tests/js/test_task_results.js create mode 100644 src/frontend/flask_app/tests/test_measure_task_results.py diff --git a/src/frontend/flask_app/blueprints/measure.py b/src/frontend/flask_app/blueprints/measure.py index 6c61b01..9d197ff 100644 --- a/src/frontend/flask_app/blueprints/measure.py +++ b/src/frontend/flask_app/blueprints/measure.py @@ -814,6 +814,91 @@ def api_close_production(run_id: int): return _supervised_action(run_id, "close") +# --------------------------------------------------------------------------- +# Routes: task results (#53 #54 #55) - outcome of a "check" task and the values / +# attachments of a "note" task with fields. The browser cannot send X-API-Key, +# so each call goes through here. +# --------------------------------------------------------------------------- +def _relay(resp, ok_status: int = 200): + """JSON back to the browser: backend errors keep their status and detail. + + The detail is what the operator reads («Per un NOK serve la nota»), so it is + passed through as it came, never replaced by a generic message. + """ + if isinstance(resp, dict) and resp.get("error"): + body = {"error": True, "detail": resp.get("detail", _("Errore nel salvataggio"))} + if resp.get("session_expired"): + body["session_expired"] = True + status_code = resp.get("status_code", 500) + return jsonify(body), status_code if status_code >= 400 else 500 + return jsonify(resp), ok_status + + +@measure_bp.route("/api/tasks/", methods=["GET"]) +@login_required +@role_required("MeasurementTec") +def api_get_task(task_id: int): + """Proxy: a task with its fields (#55: the tasks to fill at the end of production).""" + return _relay(api_client.get(f"/api/tasks/{task_id}")) + + +@measure_bp.route("/api/task-results", methods=["POST"]) +@login_required +@role_required("MeasurementTec") +def api_save_task_result(): + """Proxy: record the result of a check / note task (#54, #55).""" + data = request.get_json(silent=True) or {} + if data.get("task_id") is None: + return jsonify({ + "error": True, "detail": _("Dati mancanti: task_id è obbligatorio"), + }), 400 + payload = { + "task_id": data.get("task_id"), + "production_run_id": data.get("production_run_id"), + "outcome": data.get("outcome"), + "note": data.get("note"), + "values": data.get("values"), + "attachments": data.get("attachments"), + } + return _relay(api_client.post("/api/task-results", data=payload), 201) + + +@measure_bp.route("/api/task-results", methods=["GET"]) +@login_required +@role_required("MeasurementTec") +def api_list_task_results(): + """Proxy: results already recorded, filtered by production run and/or task.""" + params = { + key: request.args[key] + for key in ("production_run_id", "task_id") + if request.args.get(key) + } + return _relay(api_client.get("/api/task-results", params=params)) + + +@measure_bp.route("/api/attachments", methods=["POST"]) +@login_required +@role_required("MeasurementTec") +def api_upload_attachment(): + """Proxy: upload an attachment of a note task (image or PDF), multipart. + + Answers with the backend's reply: the `file_path` in it is what goes into the + `attachments` of the result. + """ + upload = request.files.get("file") + if upload is None or not upload.filename: + return jsonify({"error": True, "detail": _("Nessun file caricato")}), 400 + resp = api_client.post( + "/api/files/upload", + files={"file": ( + upload.filename, + upload.read(), + upload.mimetype or "application/octet-stream", + )}, + ) + return _relay(resp, 201) + + # --------------------------------------------------------------------------- # Route: File proxy (browser can't send X-API-Key directly) # --------------------------------------------------------------------------- diff --git a/src/frontend/flask_app/static/js/task-results.js b/src/frontend/flask_app/static/js/task-results.js new file mode 100644 index 0000000..25486ce --- /dev/null +++ b/src/frontend/flask_app/static/js/task-results.js @@ -0,0 +1,272 @@ +/** + * Task results (#53 #54 #55) - Alpine component for the tasks that record a result. + * + * - «check» task (#54): the operator answers OK or NOK, with a note (mandatory for NOK). + * - «note» task with `fields` (#55): one input per field, an optional attachment + * (image / PDF) for the fields that ask for it, outcome 'done'. + * + * The same component serves the task being executed (one form) and the panel shown + * at the end of production (one form per at_close task), so the field rules live here + * once. The page sets `window.__taskResultsCfg` (urls + labels) and the component's + * `ctx` (production run id + callbacks) from its x-init, the way visionExecute() does. + * + * Plain functions only (no framework imports) so tests/js/test_task_results.js can + * run them under node with a stubbed fetch. + */ +function taskResultForm() { + return { + // Set by the page's x-init: () => ({ runId: int|null, done: () => void }) + ctx: null, + forms: [], + working: false, + error: '', + // true once a result was recorded during this page view (shows «Prosegui») + justRecorded: false, + // Panel open state (used by the end-of-production panel only) + open: false, + + cfg() { return window.__taskResultsCfg || { labels: {} }; }, + label(key, fallback) { + return (this.cfg().labels && this.cfg().labels[key]) || fallback; + }, + runId() { + const c = this.ctx ? this.ctx() : null; + return c && c.runId ? c.runId : null; + }, + + /* One form per task: values start from the field's default, the way the maker set it. */ + makeForm(task) { + const fields = Array.isArray(task.fields) ? task.fields : []; + const values = {}; + fields.forEach((f) => { values[f.key] = (f.default === null || f.default === undefined) ? '' : String(f.default); }); + return { + task_id: task.id, + title: task.title || '', + directive: task.directive || '', + type: task.task_type, + fields: fields, + values: values, + files: {}, + fileNames: {}, + note: '', + outcome: '', + error: '', + result: null, + recorded: false, + }; + }, + + /* The single form of the task on screen. */ + initForTask(task) { + if (!task) return; + const isCheck = task.task_type === 'check'; + const isNoteWithFields = task.task_type === 'note' && Array.isArray(task.fields) && task.fields.length > 0; + if (!isCheck && !isNoteWithFields) return; + this.forms = [this.makeForm(task)]; + this.loadLast(); + }, + + pickFile(form, key, ev) { + const f = ev && ev.target && ev.target.files ? ev.target.files[0] : null; + if (f) { form.files[key] = f; form.fileNames[key] = f.name; } + else { delete form.files[key]; form.fileNames[key] = ''; } + }, + + csrf() { + const m = typeof document !== 'undefined' ? document.querySelector('meta[name=csrf-token]') : null; + return (m && m.content) || ''; + }, + + /* Fetch + JSON with the page's usual error reading: the detail of the server is + what the operator sees; an HTML answer (expired CSRF / session) means «reload». */ + async call(url, opts) { + let resp; + try { + resp = await fetch(url, opts); + } catch (e) { + return { ok: false, detail: this.label('connection', 'Errore di connessione') }; + } + const data = await resp.json().catch(() => null); + if (!data || data.csrf_expired || data.session_expired) { + return { ok: false, detail: this.label('sessionExpired', 'Sessione scaduta: ricaricare la pagina') }; + } + if (!resp.ok) { + return { ok: false, detail: data.detail || this.label('serverError', 'Errore di comunicazione con il server') }; + } + return { ok: true, data: data }; + }, + + /* The last result of this task: in the run under way if there is one, otherwise + the one given in this browser session (no run = nothing on the server to ask). + Only shown: the buttons stay active, a loop gives a new result every cycle. */ + async loadLast() { + const form = this.forms[0]; + if (!form) return; + const rid = this.runId(); + if (rid && this.cfg().listUrl) { + const r = await this.call( + this.cfg().listUrl + '?production_run_id=' + encodeURIComponent(rid) + + '&task_id=' + encodeURIComponent(form.task_id)); + if (r.ok && Array.isArray(r.data) && r.data.length) { + form.result = r.data[r.data.length - 1]; + return; + } + } + try { + const raw = window.sessionStorage.getItem('tmf-task-result-' + form.task_id); + if (raw && !rid) form.result = JSON.parse(raw); + } catch (e) { /* storage may be unavailable: the result is simply not shown */ } + }, + + /* Validation before anything goes on the wire. Returns the message or ''. */ + validate(form, outcome) { + if (form.type === 'check') { + if (outcome === 'nok' && !(form.note || '').trim()) { + return this.label('nokNeedsNote', 'Per un NOK serve la nota'); + } + return ''; + } + for (const f of form.fields) { + const v = form.values[f.key]; + if (f.type === 'number' && v !== '' && v !== null && v !== undefined && + !isFinite(Number(String(v).replace(',', '.')))) { + return this.label('notANumber', 'Valore non numerico') + ': ' + f.label; + } + } + return ''; + }, + + /* Upload the attachments of a form, then POST the result. + Returns true when the result is recorded (forms already recorded are skipped). */ + async record(form, outcome) { + if (form.recorded) return true; + form.error = this.validate(form, outcome); + if (form.error) return false; + + const attachments = []; + for (const f of form.fields) { + const file = form.files[f.key]; + if (!f.attachment || !file) continue; + const fd = new FormData(); + fd.append('file', file, file.name); + const up = await this.call(this.cfg().uploadUrl, { + method: 'POST', headers: { 'X-CSRFToken': this.csrf() }, body: fd, + }); + if (!up.ok) { form.error = up.detail; return false; } + // The backend answers {file_path, ...}: that path is what the result keeps. + attachments.push({ key: f.key, path: up.data.file_path || up.data.path, name: file.name }); + } + + let values = null; + if (form.type === 'note' && form.fields.length) { + values = {}; + form.fields.forEach((f) => { + const v = form.values[f.key]; + if (v === '' || v === null || v === undefined) return; + values[f.key] = f.type === 'number' ? String(v).replace(',', '.') : String(v); + }); + } + + const body = { + task_id: form.task_id, + production_run_id: this.runId(), + outcome: form.type === 'check' ? outcome : 'done', + note: (form.note || '').trim() || null, + values: values, + attachments: attachments.length ? attachments : null, + }; + const r = await this.call(this.cfg().saveUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrf() }, + body: JSON.stringify(body), + }); + if (!r.ok) { form.error = r.detail; return false; } + form.result = r.data; + form.recorded = true; + try { + window.sessionStorage.setItem('tmf-task-result-' + form.task_id, JSON.stringify(r.data)); + } catch (e) { /* storage may be unavailable */ } + return true; + }, + + /* Task on screen: OK / NOK button, or «Completato» of a note with fields. */ + async submit(outcome) { + const form = this.forms[0]; + if (!form || this.working) return; + this.working = true; + this.error = ''; + const ok = await this.record(form, outcome); + this.working = false; + if (!ok) return; + this.justRecorded = true; + this.proceed(); + }, + + /* Hand over to the page: close the cycle / go on. Also bound to «Prosegui», for + when that step failed after the result was already recorded. */ + proceed() { + const c = this.ctx ? this.ctx() : null; + if (c && c.done) c.done(); + }, + + /* The result as the operator reads it. */ + resultLabel(result) { + if (!result) return ''; + if (result.outcome === 'ok') return this.label('outcomeOk', 'OK'); + if (result.outcome === 'nok') return this.label('outcomeNok', 'NOK'); + return this.label('outcomeDone', 'Registrato'); + }, + + // ---- End of production panel (#55) ---- + + /* Open the panel for the at_close tasks of the run. A task that already has a + result in this run (page reloaded in between) is not asked again. */ + async openClose(ids) { + this.error = ''; + this.working = true; + this.open = true; + this.forms = []; + const rid = this.runId(); + let done = []; + if (rid && this.cfg().listUrl) { + const r = await this.call(this.cfg().listUrl + '?production_run_id=' + encodeURIComponent(rid)); + if (r.ok && Array.isArray(r.data)) done = r.data.map((x) => x.task_id); + } + const forms = []; + for (const id of ids) { + const r = await this.call(this.cfg().taskUrlTpl.replace('/0', '/' + id)); + if (!r.ok) { this.error = r.detail; this.working = false; return; } + const form = this.makeForm(r.data); + if (done.indexOf(id) !== -1) form.recorded = true; + forms.push(form); + } + this.forms = forms; + this.working = false; + // Nothing left to ask: straight on to the capoturno. + if (forms.every((f) => f.recorded)) this.finishClose(); + }, + + cancelClose() { this.open = false; this.forms = []; this.error = ''; }, + + async submitClose() { + if (this.working) return; + this.working = true; + this.error = ''; + let allOk = true; + for (const form of this.forms) { + // Keep going after a failure: each form shows its own error, and the ones + // already recorded are not sent again on the next try. + if (!(await this.record(form, 'done'))) allOk = false; + } + this.working = false; + if (allOk) this.finishClose(); + }, + + finishClose() { + this.open = false; + this.proceed(); + }, + }; +} + +if (typeof module !== 'undefined') module.exports = { taskResultForm }; diff --git a/src/frontend/flask_app/templates/measure/task_execute.html b/src/frontend/flask_app/templates/measure/task_execute.html index 35d0e0c..5db8718 100644 --- a/src/frontend/flask_app/templates/measure/task_execute.html +++ b/src/frontend/flask_app/templates/measure/task_execute.html @@ -256,6 +256,88 @@ {% endif %} + {# #55: the inputs of the fields of a «note» task. One block for the task on screen + and for the panel at the end of production, so the rules are written once. #} + {% macro result_fields() %} + + {% endmacro %} + + {# #54 #55: result panel of the task on screen - OK / NOK for a «check», the + fields and «Completato» for a «note» with fields. #} +
+ +
+ {# Main image area #}
@@ -747,7 +829,8 @@
-