Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dffb11f9f2 |
@@ -1,4 +1,7 @@
|
||||
"""Maker blueprint - recipe creation and editing."""
|
||||
import json
|
||||
import math
|
||||
|
||||
import requests as http_requests
|
||||
|
||||
from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for
|
||||
@@ -118,7 +121,41 @@ def task_editor(recipe_id: int):
|
||||
else:
|
||||
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")
|
||||
|
||||
@@ -814,91 +814,6 @@ 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/<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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 -->
|
||||
<div>
|
||||
<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"
|
||||
class="tmf-input">
|
||||
<option value="">-</option>
|
||||
<option value="linear">{{ _('Lineare') }}</option>
|
||||
<option value="diameter">{{ _('Diametro') }}</option>
|
||||
<option value="radius">{{ _('Raggio') }}</option>
|
||||
<option value="angle">{{ _('Angolo') }}</option>
|
||||
<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>
|
||||
<template x-for="o in measurementTypeOptions()" :key="o.value">
|
||||
<option :value="o.value" :selected="o.value === newSubtask.measurement_type"
|
||||
x-text="o.label"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Nominal -->
|
||||
@@ -786,24 +782,20 @@
|
||||
<label class="tmf-label text-xs">{{ _('Nominale') }}</label>
|
||||
<input type="number" step="any"
|
||||
x-model.number="newSubtask.nominal"
|
||||
@input="onNominalInput($event)"
|
||||
class="tmf-input measure-value text-right"
|
||||
placeholder="0.000">
|
||||
</div>
|
||||
<!-- Unit -->
|
||||
<div>
|
||||
<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"
|
||||
class="tmf-input">
|
||||
<option value="mm">mm</option>
|
||||
<option value="cm">cm</option>
|
||||
<option value="m">m</option>
|
||||
<option value="μm">μm</option>
|
||||
<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>
|
||||
<template x-for="o in unitOptions()" :key="o.value">
|
||||
<option :value="o.value" :selected="o.value === newSubtask.unit"
|
||||
x-text="o.label"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -814,6 +806,7 @@
|
||||
<label class="tmf-label text-xs text-red-500">{{ _('LTL') }}</label>
|
||||
<input type="number" step="any"
|
||||
x-model.number="newSubtask.ltl"
|
||||
@input="tolAuto = false"
|
||||
class="tmf-input measure-value text-right"
|
||||
placeholder="{{ _('Lim. Tol. Inf.') }}">
|
||||
</div>
|
||||
@@ -835,10 +828,14 @@
|
||||
<label class="tmf-label text-xs text-red-500">{{ _('UTL') }}</label>
|
||||
<input type="number" step="any"
|
||||
x-model.number="newSubtask.utl"
|
||||
@input="tolAuto = false"
|
||||
class="tmf-input measure-value text-right"
|
||||
placeholder="{{ _('Lim. Tol. Sup.') }}">
|
||||
</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 -->
|
||||
<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
|
||||
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 ----
|
||||
deleteTaskModal: false,
|
||||
deleteTargetTask: null,
|
||||
@@ -1698,8 +1716,61 @@ function taskEditor() {
|
||||
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) {
|
||||
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 = {
|
||||
marker_number: task ? this.nextMarkerNumber(task) : 1,
|
||||
description: '',
|
||||
@@ -1709,7 +1780,7 @@ function taskEditor() {
|
||||
uwl: null,
|
||||
lwl: null,
|
||||
ltl: null,
|
||||
unit: 'mm',
|
||||
unit: this.defaultUnit(), // #47
|
||||
vision_output: ''
|
||||
};
|
||||
},
|
||||
@@ -1779,6 +1850,7 @@ function taskEditor() {
|
||||
// Open the same form used for adding, but pre-filled with existing data
|
||||
this.editingSubtaskId = subtask.id;
|
||||
this.addingSubtaskForTask = taskId;
|
||||
this.tolAuto = false; // #48: una quota esistente non viene mai ricalcolata
|
||||
this.newSubtask = {
|
||||
marker_number: subtask.marker_number,
|
||||
description: subtask.description || '',
|
||||
|
||||
@@ -256,88 +256,6 @@
|
||||
</div>
|
||||
{% 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 #}
|
||||
<div class="flex-1 overflow-hidden relative">
|
||||
|
||||
@@ -829,8 +747,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
{# #54 #55: a check / note-with-fields task has its own buttons in the panel #}
|
||||
<template x-if="(!isMeasureTask || subtasks.length === 0) && !hasResultPanel">
|
||||
<template x-if="!isMeasureTask || subtasks.length === 0">
|
||||
{# #60: unico «Completato», largo e alto almeno 64 px per il touch. #}
|
||||
<button @click="goToNextTask()"
|
||||
:disabled="!canLeaveTask"
|
||||
@@ -978,39 +895,6 @@
|
||||
</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
|
||||
================================================================ #}
|
||||
@@ -1096,26 +980,6 @@
|
||||
};
|
||||
</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
|
||||
measurement lives - are shared with the other screens rather than restated. #}
|
||||
<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
|
||||
// in once production starts, and what says which task closes a cycle.
|
||||
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
|
||||
// operator on their way back to the measurement. Kept so it is said once.
|
||||
dueAnnounced: false,
|
||||
@@ -1410,7 +1270,6 @@ function taskExecute() {
|
||||
this.productionStarted = run.status !== 'closed';
|
||||
this.cycleCount = run.cycle_count;
|
||||
this.measurementTaskIds = run.measurement_task_ids || [];
|
||||
this.closeTaskIds = run.close_task_ids || []; // #55
|
||||
|
||||
const seconds = run.seconds_to_next_measurement;
|
||||
if (seconds === null || seconds === undefined) {
|
||||
@@ -1837,7 +1696,6 @@ function taskExecute() {
|
||||
this.currentValue = null;
|
||||
this.errorMessage = '';
|
||||
this.inputStartedAt = Date.now();
|
||||
window.dispatchEvent(new CustomEvent('task-result-reset')); // #54: a check / note loops on the spot
|
||||
},
|
||||
|
||||
// ---- Where the measurement lives ----
|
||||
@@ -1930,33 +1788,6 @@ function taskExecute() {
|
||||
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) ----
|
||||
/* 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
|
||||
@@ -1997,12 +1828,6 @@ function taskExecute() {
|
||||
this.productionError = '{{ _("Nessuna produzione aperta su questa stazione") }}';
|
||||
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.supervisorUsername = '';
|
||||
this.supervisorPassword = '';
|
||||
@@ -2034,19 +1859,6 @@ function taskExecute() {
|
||||
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
|
||||
loud rather than silently rendering an empty screen. */
|
||||
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