Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dffb11f9f2 |
@@ -1,4 +1,7 @@
|
|||||||
"""Maker blueprint - recipe creation and editing."""
|
"""Maker blueprint - recipe creation and editing."""
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
|
||||||
import requests as http_requests
|
import requests as http_requests
|
||||||
|
|
||||||
from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for
|
from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for
|
||||||
@@ -118,7 +121,41 @@ def task_editor(recipe_id: int):
|
|||||||
else:
|
else:
|
||||||
recipe["tasks"] = []
|
recipe["tasks"] = []
|
||||||
|
|
||||||
return render_template("maker/task_editor.html", recipe=recipe)
|
# #47/#48: liste e tolleranze generali dalle Impostazioni; chiave assente,
|
||||||
|
# vuota o non valida -> None -> il template usa le voci scritte a mano
|
||||||
|
settings = api_client.get("/api/settings")
|
||||||
|
if not isinstance(settings, dict) or settings.get("error"):
|
||||||
|
settings = {}
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"maker/task_editor.html",
|
||||||
|
recipe=recipe,
|
||||||
|
list_measurement_types=_settings_string_list(settings.get("list_measurement_types")),
|
||||||
|
list_units=_settings_string_list(settings.get("list_units")),
|
||||||
|
general_tolerance_plus=_settings_tolerance(settings.get("general_tolerance_plus")),
|
||||||
|
general_tolerance_minus=_settings_tolerance(settings.get("general_tolerance_minus")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _settings_string_list(value):
|
||||||
|
"""#47: array JSON di stringhe non vuote dalle Impostazioni, altrimenti None."""
|
||||||
|
try:
|
||||||
|
items = json.loads(value) if isinstance(value, str) else None
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if not isinstance(items, list):
|
||||||
|
return None
|
||||||
|
items = [i.strip() for i in items if isinstance(i, str) and i.strip()]
|
||||||
|
return items or None
|
||||||
|
|
||||||
|
|
||||||
|
def _settings_tolerance(value):
|
||||||
|
"""#48: tolleranza generale (decimale >= 0) dalle Impostazioni, altrimenti None."""
|
||||||
|
try:
|
||||||
|
number = float(str(value).strip())
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return number if math.isfinite(number) and number >= 0 else None
|
||||||
|
|
||||||
|
|
||||||
@maker_bp.route("/recipes/<int:recipe_id>/tasks/<int:task_id>/drawing")
|
@maker_bp.route("/recipes/<int:recipe_id>/tasks/<int:task_id>/drawing")
|
||||||
|
|||||||
@@ -814,91 +814,6 @@ def api_close_production(run_id: int):
|
|||||||
return _supervised_action(run_id, "close")
|
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/<int:task_id>", 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)
|
# Route: File proxy (browser can't send X-API-Key directly)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,272 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 };
|
|
||||||
@@ -767,18 +767,14 @@
|
|||||||
<!-- Measurement Type -->
|
<!-- Measurement Type -->
|
||||||
<div>
|
<div>
|
||||||
<label class="tmf-label text-xs">{{ _('Tipo') }}</label>
|
<label class="tmf-label text-xs">{{ _('Tipo') }}</label>
|
||||||
|
<!-- #47: lista da Impostazioni (list_measurement_types) o voci di default; un valore salvato fuori lista resta selezionabile -->
|
||||||
<select x-model="newSubtask.measurement_type"
|
<select x-model="newSubtask.measurement_type"
|
||||||
class="tmf-input">
|
class="tmf-input">
|
||||||
<option value="">-</option>
|
<option value="">-</option>
|
||||||
<option value="linear">{{ _('Lineare') }}</option>
|
<template x-for="o in measurementTypeOptions()" :key="o.value">
|
||||||
<option value="diameter">{{ _('Diametro') }}</option>
|
<option :value="o.value" :selected="o.value === newSubtask.measurement_type"
|
||||||
<option value="radius">{{ _('Raggio') }}</option>
|
x-text="o.label"></option>
|
||||||
<option value="angle">{{ _('Angolo') }}</option>
|
</template>
|
||||||
<option value="roughness">{{ _('Rugosita') }}</option>
|
|
||||||
<option value="torque">{{ _('Coppia') }}</option>
|
|
||||||
<option value="force">{{ _('Forza') }}</option>
|
|
||||||
<option value="weight">{{ _('Peso') }}</option>
|
|
||||||
<option value="other">{{ _('Altro') }}</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<!-- Nominal -->
|
<!-- Nominal -->
|
||||||
@@ -786,24 +782,20 @@
|
|||||||
<label class="tmf-label text-xs">{{ _('Nominale') }}</label>
|
<label class="tmf-label text-xs">{{ _('Nominale') }}</label>
|
||||||
<input type="number" step="any"
|
<input type="number" step="any"
|
||||||
x-model.number="newSubtask.nominal"
|
x-model.number="newSubtask.nominal"
|
||||||
|
@input="onNominalInput($event)"
|
||||||
class="tmf-input measure-value text-right"
|
class="tmf-input measure-value text-right"
|
||||||
placeholder="0.000">
|
placeholder="0.000">
|
||||||
</div>
|
</div>
|
||||||
<!-- Unit -->
|
<!-- Unit -->
|
||||||
<div>
|
<div>
|
||||||
<label class="tmf-label text-xs">{{ _('Unita') }}</label>
|
<label class="tmf-label text-xs">{{ _('Unita') }}</label>
|
||||||
|
<!-- #47: lista da Impostazioni (list_units) o voci di default; un valore salvato fuori lista resta selezionabile -->
|
||||||
<select x-model="newSubtask.unit"
|
<select x-model="newSubtask.unit"
|
||||||
class="tmf-input">
|
class="tmf-input">
|
||||||
<option value="mm">mm</option>
|
<template x-for="o in unitOptions()" :key="o.value">
|
||||||
<option value="cm">cm</option>
|
<option :value="o.value" :selected="o.value === newSubtask.unit"
|
||||||
<option value="m">m</option>
|
x-text="o.label"></option>
|
||||||
<option value="μm">μm</option>
|
</template>
|
||||||
<option value="in">in</option>
|
|
||||||
<option value="°">°</option>
|
|
||||||
<option value="g">g</option>
|
|
||||||
<option value="kg">kg</option>
|
|
||||||
<option value="N">N</option>
|
|
||||||
<option value="Nm">Nm</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -814,6 +806,7 @@
|
|||||||
<label class="tmf-label text-xs text-red-500">{{ _('LTL') }}</label>
|
<label class="tmf-label text-xs text-red-500">{{ _('LTL') }}</label>
|
||||||
<input type="number" step="any"
|
<input type="number" step="any"
|
||||||
x-model.number="newSubtask.ltl"
|
x-model.number="newSubtask.ltl"
|
||||||
|
@input="tolAuto = false"
|
||||||
class="tmf-input measure-value text-right"
|
class="tmf-input measure-value text-right"
|
||||||
placeholder="{{ _('Lim. Tol. Inf.') }}">
|
placeholder="{{ _('Lim. Tol. Inf.') }}">
|
||||||
</div>
|
</div>
|
||||||
@@ -835,10 +828,14 @@
|
|||||||
<label class="tmf-label text-xs text-red-500">{{ _('UTL') }}</label>
|
<label class="tmf-label text-xs text-red-500">{{ _('UTL') }}</label>
|
||||||
<input type="number" step="any"
|
<input type="number" step="any"
|
||||||
x-model.number="newSubtask.utl"
|
x-model.number="newSubtask.utl"
|
||||||
|
@input="tolAuto = false"
|
||||||
class="tmf-input measure-value text-right"
|
class="tmf-input measure-value text-right"
|
||||||
placeholder="{{ _('Lim. Tol. Sup.') }}">
|
placeholder="{{ _('Lim. Tol. Sup.') }}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- #48: promemoria delle tolleranze generali (Impostazioni) -->
|
||||||
|
<p class="text-xs text-[var(--text-muted)] mt-2"
|
||||||
|
x-show="generalTolText()" x-text="generalTolText()"></p>
|
||||||
|
|
||||||
<!-- Form Actions -->
|
<!-- Form Actions -->
|
||||||
<div class="flex items-center gap-2 justify-end mt-4">
|
<div class="flex items-center gap-2 justify-end mt-4">
|
||||||
@@ -1227,6 +1224,27 @@ function taskEditor() {
|
|||||||
// Uscite numeriche della ricetta visione per task (#46), riempite da vision_recipe.js
|
// Uscite numeriche della ricetta visione per task (#46), riempite da vision_recipe.js
|
||||||
visionOutputs: {},
|
visionOutputs: {},
|
||||||
|
|
||||||
|
// ---- Liste e tolleranze generali dalle Impostazioni (#47, #48); null = non configurato ----
|
||||||
|
configuredTypes: {{ list_measurement_types|default(none)|tojson }},
|
||||||
|
configuredUnits: {{ list_units|default(none)|tojson }},
|
||||||
|
generalTolPlus: {{ general_tolerance_plus|default(none)|tojson }},
|
||||||
|
generalTolMinus: {{ general_tolerance_minus|default(none)|tojson }},
|
||||||
|
// #48: true = utl/ltl si riempiono da soli dal nominale (solo quota nuova, finche' non li tocca l'utente)
|
||||||
|
tolAuto: false,
|
||||||
|
// Voci scritte a mano usate quando la lista non e' configurata (#47)
|
||||||
|
defaultTypeOptions: [
|
||||||
|
{value: 'linear', label: {{ _('Lineare')|tojson }}},
|
||||||
|
{value: 'diameter', label: {{ _('Diametro')|tojson }}},
|
||||||
|
{value: 'radius', label: {{ _('Raggio')|tojson }}},
|
||||||
|
{value: 'angle', label: {{ _('Angolo')|tojson }}},
|
||||||
|
{value: 'roughness', label: {{ _('Rugosita')|tojson }}},
|
||||||
|
{value: 'torque', label: {{ _('Coppia')|tojson }}},
|
||||||
|
{value: 'force', label: {{ _('Forza')|tojson }}},
|
||||||
|
{value: 'weight', label: {{ _('Peso')|tojson }}},
|
||||||
|
{value: 'other', label: {{ _('Altro')|tojson }}}
|
||||||
|
],
|
||||||
|
defaultUnits: ['mm', 'cm', 'm', '\u03bcm', 'in', '\u00b0', 'g', 'kg', 'N', 'Nm'],
|
||||||
|
|
||||||
// ---- Delete modals ----
|
// ---- Delete modals ----
|
||||||
deleteTaskModal: false,
|
deleteTaskModal: false,
|
||||||
deleteTargetTask: null,
|
deleteTargetTask: null,
|
||||||
@@ -1698,8 +1716,61 @@ function taskEditor() {
|
|||||||
return Math.max(...task.subtasks.map(s => s.marker_number || 0)) + 1;
|
return Math.max(...task.subtasks.map(s => s.marker_number || 0)) + 1;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// #47: opzioni della select Tipo; il valore corrente fuori lista viene aggiunto per non perderlo
|
||||||
|
measurementTypeOptions() {
|
||||||
|
const known = v => (this.defaultTypeOptions.find(d => d.value === v) || {label: v}).label;
|
||||||
|
const opts = this.configuredTypes
|
||||||
|
? this.configuredTypes.map(v => ({value: v, label: known(v)})) // un tipo di default noto mantiene l'etichetta tradotta
|
||||||
|
: this.defaultTypeOptions.slice();
|
||||||
|
const cur = this.newSubtask.measurement_type;
|
||||||
|
if (cur && !opts.some(o => o.value === cur)) opts.push({value: cur, label: known(cur)});
|
||||||
|
return opts;
|
||||||
|
},
|
||||||
|
|
||||||
|
// #47: opzioni della select Unita, stessa logica del Tipo
|
||||||
|
unitOptions() {
|
||||||
|
const opts = (this.configuredUnits || this.defaultUnits).map(v => ({value: v, label: v}));
|
||||||
|
const cur = this.newSubtask.unit;
|
||||||
|
if (cur && !opts.some(o => o.value === cur)) opts.push({value: cur, label: cur});
|
||||||
|
return opts;
|
||||||
|
},
|
||||||
|
|
||||||
|
// #47: unita di partenza di una quota nuova: la prima della lista configurata, altrimenti mm
|
||||||
|
defaultUnit() {
|
||||||
|
return this.configuredUnits ? this.configuredUnits[0] : 'mm';
|
||||||
|
},
|
||||||
|
|
||||||
|
// #48: testo "Tolleranze generali: +x / -y" sotto i campi; vuoto se non configurate
|
||||||
|
generalTolText() {
|
||||||
|
const parts = [];
|
||||||
|
if (this.generalTolPlus !== null) parts.push('+' + this.generalTolPlus);
|
||||||
|
if (this.generalTolMinus !== null) parts.push('\u2212' + this.generalTolMinus);
|
||||||
|
return parts.length ? {{ _('Tolleranze generali')|tojson }} + ': ' + parts.join(' / ') : '';
|
||||||
|
},
|
||||||
|
|
||||||
|
// #48: su quota nuova, scrivendo il nominale riempie utl = nominale + plus e ltl = nominale - minus
|
||||||
|
onNominalInput(ev) {
|
||||||
|
if (!this.tolAuto || this.editingSubtaskId) return;
|
||||||
|
const raw = String(ev.target.value).trim();
|
||||||
|
const nominal = parseFloat(raw);
|
||||||
|
if (raw === '' || !isFinite(nominal)) {
|
||||||
|
// nominale svuotato: i limiti riempiti da noi tornano vuoti
|
||||||
|
this.newSubtask.utl = null;
|
||||||
|
this.newSubtask.ltl = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// decimali: quelli del nominale o delle tolleranze generali, al massimo 4
|
||||||
|
const dec = s => (String(s).split('.')[1] || '').length;
|
||||||
|
const decimals = Math.min(4, Math.max(dec(raw), dec(this.generalTolPlus), dec(this.generalTolMinus)));
|
||||||
|
const round = v => Number(v.toFixed(decimals));
|
||||||
|
if (this.generalTolPlus !== null) this.newSubtask.utl = round(nominal + this.generalTolPlus);
|
||||||
|
if (this.generalTolMinus !== null) this.newSubtask.ltl = round(nominal - this.generalTolMinus);
|
||||||
|
},
|
||||||
|
|
||||||
resetNewSubtask(taskId) {
|
resetNewSubtask(taskId) {
|
||||||
const task = this.tasks.find(t => t.id === taskId);
|
const task = this.tasks.find(t => t.id === taskId);
|
||||||
|
// #48: automatismo attivo solo se c'e' almeno una tolleranza generale
|
||||||
|
this.tolAuto = this.generalTolPlus !== null || this.generalTolMinus !== null;
|
||||||
this.newSubtask = {
|
this.newSubtask = {
|
||||||
marker_number: task ? this.nextMarkerNumber(task) : 1,
|
marker_number: task ? this.nextMarkerNumber(task) : 1,
|
||||||
description: '',
|
description: '',
|
||||||
@@ -1709,7 +1780,7 @@ function taskEditor() {
|
|||||||
uwl: null,
|
uwl: null,
|
||||||
lwl: null,
|
lwl: null,
|
||||||
ltl: null,
|
ltl: null,
|
||||||
unit: 'mm',
|
unit: this.defaultUnit(), // #47
|
||||||
vision_output: ''
|
vision_output: ''
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -1779,6 +1850,7 @@ function taskEditor() {
|
|||||||
// Open the same form used for adding, but pre-filled with existing data
|
// Open the same form used for adding, but pre-filled with existing data
|
||||||
this.editingSubtaskId = subtask.id;
|
this.editingSubtaskId = subtask.id;
|
||||||
this.addingSubtaskForTask = taskId;
|
this.addingSubtaskForTask = taskId;
|
||||||
|
this.tolAuto = false; // #48: una quota esistente non viene mai ricalcolata
|
||||||
this.newSubtask = {
|
this.newSubtask = {
|
||||||
marker_number: subtask.marker_number,
|
marker_number: subtask.marker_number,
|
||||||
description: subtask.description || '',
|
description: subtask.description || '',
|
||||||
|
|||||||
@@ -256,88 +256,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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() %}
|
|
||||||
<template x-for="fld in f.fields" :key="fld.key">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1" x-text="fld.label"></label>
|
|
||||||
<input :type="fld.type === 'number' ? 'number' : 'text'"
|
|
||||||
:step="fld.type === 'number' ? 'any' : null"
|
|
||||||
:inputmode="fld.type === 'number' ? 'decimal' : null"
|
|
||||||
x-model="f.values[fld.key]"
|
|
||||||
class="input w-full text-base py-2">
|
|
||||||
<template x-if="fld.attachment">
|
|
||||||
<div class="mt-1.5">
|
|
||||||
<label class="btn btn-secondary text-xs gap-1.5 cursor-pointer">
|
|
||||||
{{ _('Allega immagine o PDF') }}
|
|
||||||
<input type="file" accept="image/*,application/pdf" class="hidden"
|
|
||||||
@change="pickFile(f, fld.key, $event)">
|
|
||||||
</label>
|
|
||||||
<span class="ml-2 text-xs text-[var(--text-muted)]" x-text="f.fileNames[fld.key]"></span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{# #54 #55: result panel of the task on screen - OK / NOK for a «check», the
|
|
||||||
fields and «Completato» for a «note» with fields. #}
|
|
||||||
<div x-show="hasResultPanel" x-cloak
|
|
||||||
x-data="taskResultForm()"
|
|
||||||
x-init="ctx = () => ({ runId: productionRun ? productionRun.id : null, done: () => advanceAfterResult() });
|
|
||||||
initForTask(task);
|
|
||||||
$watch('productionRun && productionRun.id', () => loadLast())"
|
|
||||||
@task-result-reset.window="justRecorded = false; initForTask(task)"
|
|
||||||
class="shrink-0 max-h-[65%] overflow-y-auto px-4 py-3 bg-[var(--bg-card)] border-b border-[var(--border-color)]">
|
|
||||||
<template x-for="f in forms" :key="f.task_id">
|
|
||||||
<div class="max-w-2xl mx-auto">
|
|
||||||
<div x-show="f.result" class="mb-3 px-3 py-2 rounded-lg text-sm border border-[var(--border-color)] bg-[var(--bg-secondary)]">
|
|
||||||
<span class="text-[var(--text-secondary)]">{{ _('Ultimo esito') }}:</span>
|
|
||||||
<span class="font-bold"
|
|
||||||
:class="f.result && f.result.outcome === 'nok' ? 'text-measure-fail' : 'text-measure-pass'"
|
|
||||||
x-text="resultLabel(f.result)"></span>
|
|
||||||
<span class="text-[var(--text-muted)]" x-text="f.result && f.result.note ? '— ' + f.result.note : ''"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template x-if="f.type === 'check'">
|
|
||||||
<div>
|
|
||||||
<textarea x-model="f.note" rows="2"
|
|
||||||
placeholder="{{ _('Nota (obbligatoria per un NOK)') }}"
|
|
||||||
class="input w-full text-base mb-3"></textarea>
|
|
||||||
<div class="grid grid-cols-2 gap-3">
|
|
||||||
<button @click="submit('ok')" :disabled="working"
|
|
||||||
class="btn min-h-[72px] text-2xl font-bold justify-center shadow-md bg-measure-pass text-white hover:opacity-90 disabled:opacity-50">
|
|
||||||
{{ _('OK') }}
|
|
||||||
</button>
|
|
||||||
<button @click="submit('nok')" :disabled="working"
|
|
||||||
class="btn min-h-[72px] text-2xl font-bold justify-center shadow-md bg-red-600 text-white hover:bg-red-700 disabled:opacity-50">
|
|
||||||
{{ _('NOK') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="f.type === 'note'">
|
|
||||||
<div>
|
|
||||||
{{ result_fields() }}
|
|
||||||
<button @click="submit('done')" :disabled="working"
|
|
||||||
class="btn text-lg font-bold w-full min-h-[64px] justify-center gap-2 shadow-md bg-measure-pass text-white hover:opacity-90 disabled:opacity-50">
|
|
||||||
<span>{{ _('Completato') }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<p x-show="f.error" class="mt-2 text-sm text-measure-fail" x-text="f.error"></p>
|
|
||||||
{# The result is in, but going on failed (cycle refused, network): another try, without recording twice. #}
|
|
||||||
<button x-show="justRecorded" @click="proceed()"
|
|
||||||
class="btn btn-secondary mt-3 w-full justify-center">
|
|
||||||
{{ _('Prosegui') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Main image area #}
|
{# Main image area #}
|
||||||
<div class="flex-1 overflow-hidden relative">
|
<div class="flex-1 overflow-hidden relative">
|
||||||
|
|
||||||
@@ -829,8 +747,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
{# #54 #55: a check / note-with-fields task has its own buttons in the panel #}
|
<template x-if="!isMeasureTask || subtasks.length === 0">
|
||||||
<template x-if="(!isMeasureTask || subtasks.length === 0) && !hasResultPanel">
|
|
||||||
{# #60: unico «Completato», largo e alto almeno 64 px per il touch. #}
|
{# #60: unico «Completato», largo e alto almeno 64 px per il touch. #}
|
||||||
<button @click="goToNextTask()"
|
<button @click="goToNextTask()"
|
||||||
:disabled="!canLeaveTask"
|
:disabled="!canLeaveTask"
|
||||||
@@ -978,39 +895,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# ================================================================
|
|
||||||
#55 TASK DI CHIUSURA — before the capoturno's modal, the notes marked «alla fine
|
|
||||||
della produzione» are filled in and recorded on the run. The modal below is
|
|
||||||
untouched: it opens when this panel is done.
|
|
||||||
================================================================ #}
|
|
||||||
<div x-show="open" x-cloak
|
|
||||||
x-data="taskResultForm()"
|
|
||||||
x-init="ctx = () => ({ runId: productionRun ? productionRun.id : null, done: () => closeTasksRecorded() })"
|
|
||||||
@task-close-open.window="openClose($event.detail.ids)"
|
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
|
||||||
<div class="bg-[var(--bg-card)] rounded-2xl shadow-2xl p-6 w-full max-w-xl mx-4 max-h-[90vh] overflow-y-auto border border-[var(--border-color)]">
|
|
||||||
<h3 class="text-lg font-bold text-[var(--text-primary)] mb-3">{{ _('Prima di chiudere la produzione') }}</h3>
|
|
||||||
<p x-show="working && !forms.length" class="text-sm text-[var(--text-muted)]">{{ _('Caricamento...') }}</p>
|
|
||||||
<template x-for="f in forms" :key="f.task_id">
|
|
||||||
<div class="mb-4" x-show="!f.recorded">
|
|
||||||
<div class="font-semibold text-[var(--text-primary)] mb-1" x-text="f.title"></div>
|
|
||||||
<div class="text-sm text-[var(--text-secondary)] mb-2" x-show="f.directive" x-text="f.directive"></div>
|
|
||||||
{{ result_fields() }}
|
|
||||||
<textarea x-model="f.note" rows="2" placeholder="{{ _('Nota') }}" class="input w-full text-base"></textarea>
|
|
||||||
<p x-show="f.error" class="mt-1 text-sm text-measure-fail" x-text="f.error"></p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<p x-show="error" class="mb-2 text-sm text-measure-fail" x-text="error"></p>
|
|
||||||
<div class="flex gap-3 mt-2">
|
|
||||||
<button @click="cancelClose()" class="btn btn-secondary flex-1 justify-center">{{ _('Annulla') }}</button>
|
|
||||||
<button @click="submitClose()" :disabled="working || !forms.length"
|
|
||||||
class="btn flex-1 justify-center font-bold bg-measure-pass text-white hover:opacity-90 disabled:opacity-50">
|
|
||||||
{{ _('Registra e prosegui') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# ================================================================
|
{# ================================================================
|
||||||
SUPERVISOR LOGIN MODAL
|
SUPERVISOR LOGIN MODAL
|
||||||
================================================================ #}
|
================================================================ #}
|
||||||
@@ -1096,26 +980,6 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<script src="{{ url_for('static', filename='js/vision_execute.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/vision_execute.js') }}"></script>
|
||||||
{# #53 #54 #55: result of check / note tasks (urls and words for the component) #}
|
|
||||||
<script>
|
|
||||||
window.__taskResultsCfg = {
|
|
||||||
saveUrl: '{{ url_for("measure.api_save_task_result") }}',
|
|
||||||
listUrl: '{{ url_for("measure.api_list_task_results") }}',
|
|
||||||
uploadUrl: '{{ url_for("measure.api_upload_attachment") }}',
|
|
||||||
taskUrlTpl: '{{ url_for("measure.api_get_task", task_id=0) }}',
|
|
||||||
labels: {
|
|
||||||
connection: {{ _('Errore di connessione')|tojson }},
|
|
||||||
sessionExpired: {{ _('Sessione scaduta: ricaricare la pagina')|tojson }},
|
|
||||||
serverError: {{ _('Errore di comunicazione con il server')|tojson }},
|
|
||||||
nokNeedsNote: {{ _('Per un NOK serve la nota')|tojson }},
|
|
||||||
notANumber: {{ _('Valore non numerico')|tojson }},
|
|
||||||
outcomeOk: {{ _('OK')|tojson }},
|
|
||||||
outcomeNok: {{ _('NOK')|tojson }},
|
|
||||||
outcomeDone: {{ _('Registrato')|tojson }},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
<script src="{{ url_for('static', filename='js/task-results.js') }}"></script>
|
|
||||||
{# The clock's rules - how a delay reads, when the buzzer goes, where the
|
{# The clock's rules - how a delay reads, when the buzzer goes, where the
|
||||||
measurement lives - are shared with the other screens rather than restated. #}
|
measurement lives - are shared with the other screens rather than restated. #}
|
||||||
<script src="{{ url_for('static', filename='js/production-clock.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/production-clock.js') }}"></script>
|
||||||
@@ -1164,10 +1028,6 @@ function taskExecute() {
|
|||||||
// The measurement tasks of this recipe, in order: the loop the operator stays
|
// The measurement tasks of this recipe, in order: the loop the operator stays
|
||||||
// in once production starts, and what says which task closes a cycle.
|
// in once production starts, and what says which task closes a cycle.
|
||||||
measurementTaskIds: [],
|
measurementTaskIds: [],
|
||||||
// #55: notes to fill in before the capoturno closes the production, and whether
|
|
||||||
// they were already recorded (so cancelling the modal does not ask them again).
|
|
||||||
closeTaskIds: [],
|
|
||||||
closeTasksDone: false,
|
|
||||||
// The interval has elapsed and it has been said out loud - buzzer, and the
|
// The interval has elapsed and it has been said out loud - buzzer, and the
|
||||||
// operator on their way back to the measurement. Kept so it is said once.
|
// operator on their way back to the measurement. Kept so it is said once.
|
||||||
dueAnnounced: false,
|
dueAnnounced: false,
|
||||||
@@ -1410,7 +1270,6 @@ function taskExecute() {
|
|||||||
this.productionStarted = run.status !== 'closed';
|
this.productionStarted = run.status !== 'closed';
|
||||||
this.cycleCount = run.cycle_count;
|
this.cycleCount = run.cycle_count;
|
||||||
this.measurementTaskIds = run.measurement_task_ids || [];
|
this.measurementTaskIds = run.measurement_task_ids || [];
|
||||||
this.closeTaskIds = run.close_task_ids || []; // #55
|
|
||||||
|
|
||||||
const seconds = run.seconds_to_next_measurement;
|
const seconds = run.seconds_to_next_measurement;
|
||||||
if (seconds === null || seconds === undefined) {
|
if (seconds === null || seconds === undefined) {
|
||||||
@@ -1837,7 +1696,6 @@ function taskExecute() {
|
|||||||
this.currentValue = null;
|
this.currentValue = null;
|
||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
this.inputStartedAt = Date.now();
|
this.inputStartedAt = Date.now();
|
||||||
window.dispatchEvent(new CustomEvent('task-result-reset')); // #54: a check / note loops on the spot
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---- Where the measurement lives ----
|
// ---- Where the measurement lives ----
|
||||||
@@ -1930,33 +1788,6 @@ function taskExecute() {
|
|||||||
return window.ProductionClock.format(this.timerRemaining);
|
return window.ProductionClock.format(this.timerRemaining);
|
||||||
},
|
},
|
||||||
|
|
||||||
/* #53: result recorded on a check / note task. In the loop of a production the
|
|
||||||
task counts as a step of the cycle exactly like a measurement task: the last
|
|
||||||
one closes the cycle (the server decides, as in confirmCycle), the others go
|
|
||||||
to the next one. If the server refuses, the panel stays with «Prosegui». */
|
|
||||||
async advanceAfterResult() {
|
|
||||||
if (this.productionRun && this.isMeasurementTaskOfRun) {
|
|
||||||
const countBefore = this.cycleCount;
|
|
||||||
const run = await this.postProduction(
|
|
||||||
'{{ url_for("measure.api_complete_cycle", run_id=0) }}'.replace('/0/', '/' + this.productionRun.id + '/'),
|
|
||||||
{ task_id: this.task.id },
|
|
||||||
);
|
|
||||||
if (!run) return;
|
|
||||||
this.adoptProductionRun(run);
|
|
||||||
if (run.cycle_count === countBefore && this.nextMeasurementTaskId) {
|
|
||||||
this.goToTask(this.nextMeasurementTaskId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.goToNextTask();
|
|
||||||
},
|
|
||||||
|
|
||||||
/* #55: the end-of-production notes are recorded: now the capoturno's modal. */
|
|
||||||
closeTasksRecorded() {
|
|
||||||
this.closeTasksDone = true;
|
|
||||||
this.openSupervisorModal('fine_produzione');
|
|
||||||
},
|
|
||||||
|
|
||||||
// ---- Navigate to next task (Completato) ----
|
// ---- Navigate to next task (Completato) ----
|
||||||
/* With a production open the operator stays in the measurement loop until the
|
/* With a production open the operator stays in the measurement loop until the
|
||||||
capoturno closes it: after the last measurement task they go back to the
|
capoturno closes it: after the last measurement task they go back to the
|
||||||
@@ -1997,12 +1828,6 @@ function taskExecute() {
|
|||||||
this.productionError = '{{ _("Nessuna produzione aperta su questa stazione") }}';
|
this.productionError = '{{ _("Nessuna produzione aperta su questa stazione") }}';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// #55: first the notes marked «alla fine della produzione»; the panel calls back
|
|
||||||
// here (closeTasksRecorded) when they are recorded.
|
|
||||||
if (action === 'fine_produzione' && this.closeTaskIds.length && !this.closeTasksDone) {
|
|
||||||
window.dispatchEvent(new CustomEvent('task-close-open', { detail: { ids: this.closeTaskIds } }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.supervisorAction = action;
|
this.supervisorAction = action;
|
||||||
this.supervisorUsername = '';
|
this.supervisorUsername = '';
|
||||||
this.supervisorPassword = '';
|
this.supervisorPassword = '';
|
||||||
@@ -2034,19 +1859,6 @@ function taskExecute() {
|
|||||||
return t === 'camera_measure' || t === 'dxf_compare';
|
return t === 'camera_measure' || t === 'dxf_compare';
|
||||||
},
|
},
|
||||||
|
|
||||||
/* #54: «Controllo OK/NOK» - no quotes, the result is the outcome. */
|
|
||||||
get isCheckTask() {
|
|
||||||
return this.task.task_type === 'check';
|
|
||||||
},
|
|
||||||
|
|
||||||
/* #54 #55: tasks that bring their own result panel (a check, or a note with
|
|
||||||
fields). A note without fields stays the plain «Completato» task. */
|
|
||||||
get hasResultPanel() {
|
|
||||||
if (this.isCheckTask) return true;
|
|
||||||
return this.task.task_type === 'note' && Array.isArray(this.task.fields)
|
|
||||||
&& this.task.fields.length > 0;
|
|
||||||
},
|
|
||||||
|
|
||||||
/* A measurement task with no quotes yet: the recipe is unfinished. Said out
|
/* A measurement task with no quotes yet: the recipe is unfinished. Said out
|
||||||
loud rather than silently rendering an empty screen. */
|
loud rather than silently rendering an empty screen. */
|
||||||
get isMeasureTaskWithoutQuotes() {
|
get isMeasureTaskWithoutQuotes() {
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
// Behaviour test for static/js/task-results.js (#53 #54 #55), plain node, no framework.
|
|
||||||
// Run: node tests/js/test_task_results.js (exit code != 0 on failure)
|
|
||||||
const assert = require('assert');
|
|
||||||
const { taskResultForm } = require('../../static/js/task-results.js');
|
|
||||||
|
|
||||||
const calls = [];
|
|
||||||
let responses = [];
|
|
||||||
global.window = {
|
|
||||||
__taskResultsCfg: {
|
|
||||||
saveUrl: '/s', listUrl: '/l', uploadUrl: '/u', taskUrlTpl: '/t/0',
|
|
||||||
labels: { nokNeedsNote: 'Per un NOK serve la nota' },
|
|
||||||
},
|
|
||||||
sessionStorage: { _d: {}, getItem(k) { return this._d[k] || null; }, setItem(k, v) { this._d[k] = v; } },
|
|
||||||
};
|
|
||||||
global.FormData = class { constructor() { this.d = []; } append(k, v, n) { this.d.push([k, v, n]); } };
|
|
||||||
global.fetch = async (url, opts) => {
|
|
||||||
calls.push({ url, opts });
|
|
||||||
const r = responses.shift() || { status: 200, body: {} };
|
|
||||||
return { ok: r.status < 400, status: r.status, json: async () => r.body };
|
|
||||||
};
|
|
||||||
|
|
||||||
function comp(runId) {
|
|
||||||
const c = taskResultForm();
|
|
||||||
c.ctx = () => ({ runId, done() { c._done = (c._done || 0) + 1; } });
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
// check: NOK without note is refused before any request
|
|
||||||
let c = comp(4);
|
|
||||||
c.initForTask({ id: 30, task_type: 'check' });
|
|
||||||
responses = [{ status: 200, body: [] }]; // loadLast
|
|
||||||
await c.submit('nok');
|
|
||||||
assert.strictEqual(c.forms[0].error, 'Per un NOK serve la nota');
|
|
||||||
assert.ok(!calls.some((x) => x.url === '/s'), 'no POST for a NOK without note');
|
|
||||||
|
|
||||||
// check: OK goes to the server with the run id, then hands over
|
|
||||||
calls.length = 0;
|
|
||||||
responses = [{ status: 201, body: { id: 1, outcome: 'ok' } }];
|
|
||||||
await c.submit('ok');
|
|
||||||
const post = calls.find((x) => x.url === '/s');
|
|
||||||
const body = JSON.parse(post.opts.body);
|
|
||||||
assert.deepStrictEqual([body.task_id, body.production_run_id, body.outcome, body.values], [30, 4, 'ok', null]);
|
|
||||||
assert.strictEqual(c._done, 1);
|
|
||||||
assert.strictEqual(c.forms[0].result.outcome, 'ok');
|
|
||||||
|
|
||||||
// server 422: the detail is shown, nothing is handed over
|
|
||||||
c = comp(4);
|
|
||||||
responses = [{ status: 200, body: [] }, { status: 422, body: { detail: 'Per un NOK serve la nota' } }];
|
|
||||||
c.initForTask({ id: 30, task_type: 'check' }); // consumes the first one (loadLast)
|
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
|
||||||
c.forms[0].note = 'x';
|
|
||||||
await c.submit('nok');
|
|
||||||
assert.strictEqual(c.forms[0].error, 'Per un NOK serve la nota');
|
|
||||||
assert.ok(!c._done);
|
|
||||||
|
|
||||||
// note with fields: defaults, upload of the attachment, values + attachments in the result
|
|
||||||
calls.length = 0;
|
|
||||||
c = comp(null);
|
|
||||||
const task = { id: 31, task_type: 'note', fields: [
|
|
||||||
{ key: 'peso', label: 'Peso', type: 'number', default: '1,5', attachment: false },
|
|
||||||
{ key: 'foto', label: 'Foto', type: 'text', default: null, attachment: true },
|
|
||||||
] };
|
|
||||||
c.initForTask(task);
|
|
||||||
assert.strictEqual(c.forms[0].values.peso, '1,5');
|
|
||||||
c.forms[0].files.foto = { name: 'a.png' };
|
|
||||||
responses = [
|
|
||||||
{ status: 201, body: { file_path: 'general/a.png' } },
|
|
||||||
{ status: 201, body: { id: 2, outcome: 'done' } },
|
|
||||||
];
|
|
||||||
await c.submit('done');
|
|
||||||
const saved = JSON.parse(calls.find((x) => x.url === '/s').opts.body);
|
|
||||||
assert.deepStrictEqual(saved.values, { peso: '1.5' });
|
|
||||||
assert.deepStrictEqual(saved.attachments, [{ key: 'foto', path: 'general/a.png', name: 'a.png' }]);
|
|
||||||
assert.strictEqual(saved.outcome, 'done');
|
|
||||||
assert.strictEqual(saved.production_run_id, null);
|
|
||||||
|
|
||||||
// non numeric value in a number field is refused locally
|
|
||||||
c = comp(null);
|
|
||||||
c.initForTask(task);
|
|
||||||
c.forms[0].values.peso = 'abc';
|
|
||||||
await c.submit('done');
|
|
||||||
assert.ok(c.forms[0].error.indexOf('Peso') !== -1);
|
|
||||||
|
|
||||||
// a note without fields has no panel
|
|
||||||
c = comp(null);
|
|
||||||
c.initForTask({ id: 33, task_type: 'note', fields: [] });
|
|
||||||
assert.strictEqual(c.forms.length, 0);
|
|
||||||
|
|
||||||
// end of production: tasks already recorded in the run are skipped; the rest recorded then handed over
|
|
||||||
calls.length = 0;
|
|
||||||
c = comp(4);
|
|
||||||
responses = [
|
|
||||||
{ status: 200, body: [{ task_id: 40 }] }, // results of the run
|
|
||||||
{ status: 200, body: { id: 40, title: 'A', task_type: 'note', fields: [] } },
|
|
||||||
{ status: 200, body: { id: 41, title: 'B', task_type: 'note', fields: [{ key: 'k', label: 'K', type: 'text', default: null, attachment: false }] } },
|
|
||||||
];
|
|
||||||
await c.openClose([40, 41]);
|
|
||||||
assert.strictEqual(c.forms[0].recorded, true);
|
|
||||||
assert.strictEqual(c.forms[1].recorded, false);
|
|
||||||
assert.ok(c.open && !c._done);
|
|
||||||
c.forms[1].values.k = 'ok';
|
|
||||||
responses = [{ status: 201, body: { id: 5, outcome: 'done' } }];
|
|
||||||
await c.submitClose();
|
|
||||||
assert.strictEqual(c._done, 1);
|
|
||||||
assert.strictEqual(c.open, false);
|
|
||||||
assert.strictEqual(calls.filter((x) => x.url === '/s').length, 1, 'only the pending task is posted');
|
|
||||||
|
|
||||||
console.log('ok');
|
|
||||||
})().catch((e) => { console.error(e); process.exit(1); });
|
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""#47/#48: liste Tipo/Unita e tolleranze generali dalle Impostazioni nel task editor."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.conftest import api_get_router
|
||||||
|
|
||||||
|
_RECIPE_ROUTES = {
|
||||||
|
"/api/recipes/3": {"id": 3, "code": "R", "name": "R"},
|
||||||
|
"/api/recipes/3/tasks": [{
|
||||||
|
"id": 11, "title": "T", "task_type": "measure", "subtasks": [],
|
||||||
|
"file_path": None, "order_index": 1,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _render(logged_in_client, mock_api_client, settings):
|
||||||
|
mock_api_client.get.side_effect = api_get_router({**_RECIPE_ROUTES, "/api/settings": settings})
|
||||||
|
resp = logged_in_client.get("/maker/recipes/3/tasks")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
return resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTaskEditorSettings:
|
||||||
|
def test_configured_lists_reach_template(self, logged_in_client, mock_api_client):
|
||||||
|
html = _render(logged_in_client, mock_api_client, {
|
||||||
|
"list_measurement_types": json.dumps(["Planarita", "Concentricita"]),
|
||||||
|
"list_units": json.dumps(["mm", "thou"]),
|
||||||
|
})
|
||||||
|
assert 'configuredTypes: ["Planarita", "Concentricita"]' in html
|
||||||
|
assert 'configuredUnits: ["mm", "thou"]' in html
|
||||||
|
# le select leggono le liste, non hanno piu' le option scritte a mano
|
||||||
|
assert 'x-for="o in measurementTypeOptions()"' in html
|
||||||
|
assert 'x-for="o in unitOptions()"' in html
|
||||||
|
assert '<option value="diameter">' not in html
|
||||||
|
|
||||||
|
def test_missing_keys_keep_hand_written_values(self, logged_in_client, mock_api_client):
|
||||||
|
for settings in ({}, {"list_measurement_types": "", "list_units": "non json"},
|
||||||
|
{"list_units": json.dumps({"a": 1})}, {"list_units": "[]"}):
|
||||||
|
html = _render(logged_in_client, mock_api_client, settings)
|
||||||
|
assert "configuredTypes: null," in html
|
||||||
|
assert "configuredUnits: null," in html
|
||||||
|
# le voci di oggi restano come default
|
||||||
|
assert "value: 'diameter'" in html
|
||||||
|
assert "defaultUnits: ['mm', 'cm'" in html
|
||||||
|
|
||||||
|
def test_settings_error_keeps_defaults(self, logged_in_client, mock_api_client):
|
||||||
|
html = _render(logged_in_client, mock_api_client, {"error": True, "detail": "x"})
|
||||||
|
assert "configuredTypes: null," in html
|
||||||
|
assert "generalTolPlus: null," in html
|
||||||
|
|
||||||
|
def test_general_tolerances_reach_template(self, logged_in_client, mock_api_client):
|
||||||
|
html = _render(logged_in_client, mock_api_client, {
|
||||||
|
"general_tolerance_plus": "0.1", "general_tolerance_minus": "0.25",
|
||||||
|
})
|
||||||
|
assert "generalTolPlus: 0.1," in html
|
||||||
|
assert "generalTolMinus: 0.25," in html
|
||||||
|
|
||||||
|
def test_invalid_tolerances_are_none(self, logged_in_client, mock_api_client):
|
||||||
|
for plus, minus in (("", "abc"), ("-1", "nan"), ("inf", None)):
|
||||||
|
settings = {"general_tolerance_plus": plus}
|
||||||
|
if minus is not None:
|
||||||
|
settings["general_tolerance_minus"] = minus
|
||||||
|
html = _render(logged_in_client, mock_api_client, settings)
|
||||||
|
assert "generalTolPlus: null," in html
|
||||||
|
assert "generalTolMinus: null," in html
|
||||||
|
|
||||||
|
|
||||||
|
# ---- comportamento JS (node): opzioni fuori lista e automatismo delle tolleranze ----
|
||||||
|
|
||||||
|
_NODE = shutil.which("node")
|
||||||
|
|
||||||
|
|
||||||
|
def _run_js(html, body):
|
||||||
|
"""Esegue taskEditor() estratto dalla pagina e poi `body` (che usa `ed`); stampa JSON."""
|
||||||
|
scripts = re.findall(r"<script(?![^>]*\bsrc=)[^>]*>(.*?)</script>", html, re.S | re.I)
|
||||||
|
src = next(s for s in scripts if "function taskEditor()" in s)
|
||||||
|
program = src + "\nconst ed = taskEditor();\nconst out = (function(){" + body + "})();\nconsole.log(JSON.stringify(out));"
|
||||||
|
# su file: la riga di comando di Windows non regge lo script intero
|
||||||
|
with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False, encoding="utf-8") as fh:
|
||||||
|
fh.write(program)
|
||||||
|
try:
|
||||||
|
res = subprocess.run([_NODE, fh.name], capture_output=True, text=True,
|
||||||
|
encoding="utf-8", timeout=30)
|
||||||
|
finally:
|
||||||
|
os.unlink(fh.name)
|
||||||
|
assert res.returncode == 0, res.stderr
|
||||||
|
return json.loads(res.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(_NODE is None, reason="node non disponibile")
|
||||||
|
class TestTaskEditorJs:
|
||||||
|
def test_out_of_list_value_stays_selectable(self, logged_in_client, mock_api_client):
|
||||||
|
html = _render(logged_in_client, mock_api_client, {
|
||||||
|
"list_measurement_types": json.dumps(["Planarita"]),
|
||||||
|
"list_units": json.dumps(["thou"]),
|
||||||
|
})
|
||||||
|
out = _run_js(html, """
|
||||||
|
ed.newSubtask.measurement_type = 'Vecchio'; ed.newSubtask.unit = 'mm';
|
||||||
|
return {t: ed.measurementTypeOptions().map(o => o.value),
|
||||||
|
u: ed.unitOptions().map(o => o.value), d: ed.defaultUnit()};
|
||||||
|
""")
|
||||||
|
assert out["t"] == ["Planarita", "Vecchio"]
|
||||||
|
assert out["u"] == ["thou", "mm"]
|
||||||
|
assert out["d"] == "thou"
|
||||||
|
|
||||||
|
def test_default_lists_when_not_configured(self, logged_in_client, mock_api_client):
|
||||||
|
html = _render(logged_in_client, mock_api_client, {})
|
||||||
|
out = _run_js(html, """
|
||||||
|
return {t: ed.measurementTypeOptions().map(o => o.value),
|
||||||
|
u: ed.unitOptions().map(o => o.value), d: ed.defaultUnit()};
|
||||||
|
""")
|
||||||
|
assert out["t"][0] == "linear" and out["t"][-1] == "other"
|
||||||
|
assert out["u"][0] == "mm" and "Nm" in out["u"]
|
||||||
|
assert out["d"] == "mm"
|
||||||
|
|
||||||
|
def test_tolerance_autofill(self, logged_in_client, mock_api_client):
|
||||||
|
html = _render(logged_in_client, mock_api_client, {
|
||||||
|
"general_tolerance_plus": "0.1", "general_tolerance_minus": "0.2",
|
||||||
|
})
|
||||||
|
out = _run_js(html, """
|
||||||
|
ed.tasks = [{id: 1, subtasks: []}];
|
||||||
|
ed.resetNewSubtask(1);
|
||||||
|
const r = {};
|
||||||
|
ed.newSubtask.nominal = 10; ed.onNominalInput({target: {value: '10'}});
|
||||||
|
r.first = [ed.newSubtask.utl, ed.newSubtask.ltl];
|
||||||
|
// cambia ancora il nominale prima di toccare i limiti: ricalcola
|
||||||
|
ed.onNominalInput({target: {value: '12.5'}});
|
||||||
|
r.second = [ed.newSubtask.utl, ed.newSubtask.ltl];
|
||||||
|
// modifica a mano utl: smette di riempire
|
||||||
|
ed.newSubtask.utl = 99; ed.tolAuto = false;
|
||||||
|
ed.onNominalInput({target: {value: '20'}});
|
||||||
|
r.third = [ed.newSubtask.utl, ed.newSubtask.ltl];
|
||||||
|
r.text = ed.generalTolText();
|
||||||
|
return r;
|
||||||
|
""")
|
||||||
|
assert out["first"] == [10.1, 9.8]
|
||||||
|
assert out["second"] == [12.6, 12.3]
|
||||||
|
assert out["third"] == [99, 12.3]
|
||||||
|
assert "+0.1 / −0.2" in out["text"]
|
||||||
|
|
||||||
|
def test_no_autofill_without_tolerances_or_when_editing(self, logged_in_client, mock_api_client):
|
||||||
|
html = _render(logged_in_client, mock_api_client, {})
|
||||||
|
out = _run_js(html, """
|
||||||
|
ed.tasks = [{id: 1, subtasks: []}];
|
||||||
|
ed.resetNewSubtask(1);
|
||||||
|
ed.onNominalInput({target: {value: '10'}});
|
||||||
|
return {utl: ed.newSubtask.utl, ltl: ed.newSubtask.ltl, auto: ed.tolAuto,
|
||||||
|
text: ed.generalTolText()};
|
||||||
|
""")
|
||||||
|
assert out == {"utl": None, "ltl": None, "auto": False, "text": ""}
|
||||||
|
|
||||||
|
html = _render(logged_in_client, mock_api_client, {"general_tolerance_plus": "0.1"})
|
||||||
|
out = _run_js(html, """
|
||||||
|
ed.startEditSubtask({id: 5, marker_number: 1, nominal: 10, utl: null, ltl: null}, 1);
|
||||||
|
ed.onNominalInput({target: {value: '10'}});
|
||||||
|
return {utl: ed.newSubtask.utl, auto: ed.tolAuto};
|
||||||
|
""")
|
||||||
|
assert out == {"utl": None, "auto": False}
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
"""gitea:Adriano/TieMeasureFlow#53 #54 #55 - operator side of the check / note results.
|
|
||||||
|
|
||||||
The backend (task-results, in_loop, close_task_ids) lives in another unit: api_client
|
|
||||||
is mocked and the tests work against the contract written in the issues.
|
|
||||||
"""
|
|
||||||
import io
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from tests.conftest import api_get_router
|
|
||||||
from tests.test_template_js_syntax import (
|
|
||||||
_INLINE_SCRIPT_RX, _check_alpine_attributes, _force_italian, _node_check,
|
|
||||||
)
|
|
||||||
|
|
||||||
FLASK_APP_DIR = Path(__file__).resolve().parent.parent
|
|
||||||
|
|
||||||
RECIPE = {
|
|
||||||
"id": 3, "code": "REC-1", "name": "Ricetta",
|
|
||||||
"measurement_interval_minutes": 30, "allow_manual_input": True,
|
|
||||||
"current_version": {"id": 5, "version_number": 1},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _task(task_id, task_type, **extra):
|
|
||||||
task = {
|
|
||||||
"id": task_id, "recipe_id": 3, "version_id": 5, "task_type": task_type,
|
|
||||||
"title": "Task d'esempio", "directive": None, "description": None,
|
|
||||||
"file_path": None, "file_type": None, "annotations_json": None,
|
|
||||||
"subtasks": [], "fields": None,
|
|
||||||
}
|
|
||||||
task.update(extra)
|
|
||||||
return task
|
|
||||||
|
|
||||||
|
|
||||||
def _render(client, mock_api_client, monkeypatch, task):
|
|
||||||
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
|
|
||||||
mock_api_client.get_station_recipes.return_value = {"items": [{"id": 3}]}
|
|
||||||
mock_api_client.get.side_effect = api_get_router({
|
|
||||||
f"/api/tasks/{task['id']}": task,
|
|
||||||
"/api/recipes/3/tasks": [{"id": task["id"], "order_index": 0}],
|
|
||||||
"/api/recipes/3": RECIPE,
|
|
||||||
})
|
|
||||||
return client.get(f"/measure/execute/{task['id']}")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Page
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
def test_check_task_has_ok_nok_buttons(logged_in_client, mock_api_client, monkeypatch):
|
|
||||||
resp = _render(logged_in_client, mock_api_client, monkeypatch, _task(30, "check"))
|
|
||||||
html = resp.get_data(as_text=True)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert "submit('ok')" in html and "submit('nok')" in html
|
|
||||||
assert "taskResultForm()" in html
|
|
||||||
assert "/measure/api/task-results" in html
|
|
||||||
assert "js/task-results.js" in html
|
|
||||||
|
|
||||||
|
|
||||||
def test_note_with_fields_shows_field_inputs(logged_in_client, mock_api_client, monkeypatch):
|
|
||||||
fields = [
|
|
||||||
{"key": "peso", "label": "Peso", "type": "number", "default": "1.5", "attachment": False},
|
|
||||||
{"key": "foto", "label": "Foto", "type": "text", "default": None, "attachment": True},
|
|
||||||
]
|
|
||||||
task = _task(31, "note", fields=fields)
|
|
||||||
html = _render(logged_in_client, mock_api_client, monkeypatch, task).get_data(as_text=True)
|
|
||||||
# the fields travel to the page in the task data, the inputs are drawn from them
|
|
||||||
assert '"key": "peso"' in html or '"key":"peso"' in html
|
|
||||||
assert 'x-model="f.values[fld.key]"' in html
|
|
||||||
assert "pickFile(f, fld.key, $event)" in html
|
|
||||||
assert "submit('done')" in html
|
|
||||||
|
|
||||||
|
|
||||||
def test_close_panel_and_modal_hook_present(logged_in_client, mock_api_client, monkeypatch):
|
|
||||||
html = _render(logged_in_client, mock_api_client, monkeypatch, _task(32, "check")).get_data(as_text=True)
|
|
||||||
assert "task-close-open" in html
|
|
||||||
assert "closeTasksRecorded()" in html
|
|
||||||
assert "close_task_ids" in html
|
|
||||||
assert "/measure/api/tasks/0" in html
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("task", [
|
|
||||||
_task(30, "check"),
|
|
||||||
_task(31, "note", fields=[{"key": "n", "label": "L'etichetta", "type": "text",
|
|
||||||
"default": None, "attachment": True}]),
|
|
||||||
])
|
|
||||||
def test_inline_js_and_alpine_attributes_valid(logged_in_client, mock_api_client, monkeypatch, task):
|
|
||||||
_force_italian(logged_in_client)
|
|
||||||
html = _render(logged_in_client, mock_api_client, monkeypatch, task).get_data(as_text=True)
|
|
||||||
scripts = _INLINE_SCRIPT_RX.findall(html)
|
|
||||||
assert scripts
|
|
||||||
for i, body in enumerate(scripts):
|
|
||||||
_node_check(body, f"/measure/execute check-note script[{i}]")
|
|
||||||
_check_alpine_attributes(html, "/measure/execute check-note")
|
|
||||||
|
|
||||||
|
|
||||||
def test_plain_note_keeps_old_footer(logged_in_client, mock_api_client, monkeypatch):
|
|
||||||
"""A note without fields has no result panel: it is still the plain «Completato»."""
|
|
||||||
html = _render(logged_in_client, mock_api_client, monkeypatch, _task(33, "note")).get_data(as_text=True)
|
|
||||||
assert "goToNextTask()" in html
|
|
||||||
# the panel markup is in the page either way; hasResultPanel (false here) decides
|
|
||||||
assert "hasResultPanel" in html
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Proxies
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
def test_task_result_post_is_forwarded(logged_in_client, mock_api_client):
|
|
||||||
mock_api_client.post.return_value = {"id": 9, "outcome": "nok"}
|
|
||||||
body = {
|
|
||||||
"task_id": 30, "production_run_id": 4, "outcome": "nok", "note": "graffio",
|
|
||||||
"values": None, "attachments": None, "ignored": "x",
|
|
||||||
}
|
|
||||||
resp = logged_in_client.post("/measure/api/task-results", json=body)
|
|
||||||
assert resp.status_code == 201
|
|
||||||
assert resp.get_json()["id"] == 9
|
|
||||||
args, kwargs = mock_api_client.post.call_args
|
|
||||||
assert args[0] == "/api/task-results"
|
|
||||||
assert kwargs["data"] == {
|
|
||||||
"task_id": 30, "production_run_id": 4, "outcome": "nok", "note": "graffio",
|
|
||||||
"values": None, "attachments": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_task_result_422_detail_is_passed_through(logged_in_client, mock_api_client):
|
|
||||||
mock_api_client.post.return_value = {
|
|
||||||
"error": True, "status_code": 422, "detail": "Per un NOK serve la nota",
|
|
||||||
}
|
|
||||||
resp = logged_in_client.post(
|
|
||||||
"/measure/api/task-results", json={"task_id": 30, "outcome": "nok"})
|
|
||||||
assert resp.status_code == 422
|
|
||||||
assert resp.get_json()["detail"] == "Per un NOK serve la nota"
|
|
||||||
|
|
||||||
|
|
||||||
def test_task_result_requires_task_id(logged_in_client, mock_api_client):
|
|
||||||
resp = logged_in_client.post("/measure/api/task-results", json={"outcome": "ok"})
|
|
||||||
assert resp.status_code == 400
|
|
||||||
mock_api_client.post.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_task_results_list_forwards_filters(logged_in_client, mock_api_client):
|
|
||||||
mock_api_client.get.return_value = [{"id": 1, "task_id": 30}]
|
|
||||||
resp = logged_in_client.get("/measure/api/task-results?production_run_id=4&task_id=30")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert resp.get_json() == [{"id": 1, "task_id": 30}]
|
|
||||||
args, kwargs = mock_api_client.get.call_args
|
|
||||||
assert args[0] == "/api/task-results"
|
|
||||||
assert kwargs["params"] == {"production_run_id": "4", "task_id": "30"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_attachment_upload_forwards_multipart(logged_in_client, mock_api_client):
|
|
||||||
mock_api_client.post.return_value = {"file_path": "general/foto.png", "file_type": "image/png"}
|
|
||||||
resp = logged_in_client.post(
|
|
||||||
"/measure/api/attachments",
|
|
||||||
data={"file": (io.BytesIO(b"\x89PNG fake"), "foto.png", "image/png")},
|
|
||||||
content_type="multipart/form-data",
|
|
||||||
)
|
|
||||||
assert resp.status_code == 201
|
|
||||||
assert resp.get_json()["file_path"] == "general/foto.png"
|
|
||||||
args, kwargs = mock_api_client.post.call_args
|
|
||||||
assert args[0] == "/api/files/upload"
|
|
||||||
name, content, mime = kwargs["files"]["file"]
|
|
||||||
assert name == "foto.png" and mime == "image/png" and content.startswith(b"\x89PNG")
|
|
||||||
|
|
||||||
|
|
||||||
def test_attachment_upload_requires_file(logged_in_client, mock_api_client):
|
|
||||||
resp = logged_in_client.post("/measure/api/attachments", data={})
|
|
||||||
assert resp.status_code == 400
|
|
||||||
mock_api_client.post.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_attachment_upload_error_detail_is_passed_through(logged_in_client, mock_api_client):
|
|
||||||
mock_api_client.post.return_value = {"error": True, "status_code": 400, "detail": "File type not allowed"}
|
|
||||||
resp = logged_in_client.post(
|
|
||||||
"/measure/api/attachments",
|
|
||||||
data={"file": (io.BytesIO(b"x"), "a.exe", "application/octet-stream")},
|
|
||||||
content_type="multipart/form-data",
|
|
||||||
)
|
|
||||||
assert resp.status_code == 400
|
|
||||||
assert resp.get_json()["detail"] == "File type not allowed"
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_task_proxy(logged_in_client, mock_api_client):
|
|
||||||
mock_api_client.get.return_value = {"id": 40, "task_type": "note", "fields": []}
|
|
||||||
resp = logged_in_client.get("/measure/api/tasks/40")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert resp.get_json()["id"] == 40
|
|
||||||
assert mock_api_client.get.call_args[0][0] == "/api/tasks/40"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Component logic (plain node, stubbed fetch)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
def test_task_results_component_js():
|
|
||||||
node = shutil.which("node")
|
|
||||||
if not node:
|
|
||||||
pytest.skip("node non installato: test JS di task-results.js saltato")
|
|
||||||
script = Path(__file__).resolve().parent / "js" / "test_task_results.js"
|
|
||||||
result = subprocess.run(
|
|
||||||
[node, str(script)], cwd=str(FLASK_APP_DIR),
|
|
||||||
capture_output=True, text=True, timeout=30,
|
|
||||||
)
|
|
||||||
assert result.returncode == 0, f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
|
||||||
Reference in New Issue
Block a user