fix(task_execute): letture doppie, sessione scaduta, ciclo rifiutato dal server (#13, #12, #29, #37)

#13 — handleMeasurement era rientrante: saving tornava false prima della
pausa di 1s di feedback, currentIndex avanzava dopo. Una seconda lettura in
quella finestra salvava due volte la stessa quota e saltava la successiva.
Guardia unica this.busy: true all'ingresso, false su ogni uscita della
funzione; l'ingresso è bloccato anche con showCompletionOverlay,
showSupervisorModal, isPaused o cycleConfirmed pendenti. Una lettura scartata
non sparisce in silenzio: errorMessage "Lettura ignorata: attendere".

#12 — dopo la scadenza del token CSRF (config lato server, lotto B2) il POST
tornava 400 in HTML: response.json() lanciava, finiva nel catch generico e
l'operatore vedeva "Errore di rete" invece che "Sessione scaduta". Aggiunto
try/catch dedicato sul parsing e controllo di result.csrf_expired /
session_expired, sia in handleMeasurement che in postProduction(); la lettura
non salvata resta sul display.

#29 — confirmCycle mostrava il ciclo chiuso anche quando il server rifiutava
/cycle (produzione aperta, postProduction torna null): cadeva nel ramo "nessuna
produzione", incrementando cycleCount in locale. Ora, con produzione aperta, un
rifiuto del server ripristina cycleConfirmed=false, riapre l'overlay di
completamento e mostra l'errore (già in productionError); il ramo locale resta
riservato a quando non c'è produzione aperta.

#37 punto 9 — il blocco capoturno era deciso su passFailStatus (preview
client, prima del salvataggio) invece che su result.pass_fail del server;
ora la risposta del server decide sia il blocco che i conteggi locali.

Completa anche il lato pagina di #27: aggiunto il listener per l'evento
numpad-error (dispatchato da numpad.js) cosi' la lettura rifiutata dal calibro
è visibile all'operatore, non solo intercettata in silenzio.

Stringhe nuove in messages.po (it/en): "Sessione scaduta: ricaricare la
pagina", "Lettura ignorata: attendere", "Lettura del calibro rifiutata: valore
troppo grande".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 22:46:32 +02:00
parent ac11cec276
commit 31fa78bdfd
3 changed files with 114 additions and 5 deletions
@@ -68,6 +68,7 @@
x-init="init()"
@numpad-confirm.window="handleMeasurement($event.detail.value, $event.detail.inputMethod)"
@numpad-rejected.window="onNumpadRejected($event.detail)"
@numpad-error.window="onNumpadError($event.detail)"
@marker-click.window="goToSubtaskByMarker($event.detail.marker_number)">
{# ================================================================
@@ -932,6 +933,11 @@ function taskExecute() {
currentIndex: 0,
measurements: [], // [{subtask_id, value, pass_fail, deviation}, ...]
saving: false,
// The single reentrancy guard for handleMeasurement (#13): true for the
// whole time a reading is being processed, including the 1s feedback
// pause after the save where "saving" alone used to go false too early.
// "saving" stays as it was - it only drives the UI spinner.
busy: false,
errorMessage: '',
showCompletionOverlay: false,
@@ -942,6 +948,7 @@ function taskExecute() {
timerActive: false,
timerRemaining: 0,
_timerInterval: null,
_timerDeadline: null, // wall-clock ms the countdown is aimed at (#37.7)
cycleCount: 0,
productionStarted: false,
@@ -1094,6 +1101,13 @@ function taskExecute() {
// Rejoin whatever production is already open at this station.
await this.loadProductionRun();
await this.loadPendingAuthorisation();
// A tablet left to sleep drifts (#37.7): catch the countdown back up to
// the server's own clock, the same source adoptProductionRun already
// trusts on load, as soon as the tab is visible again.
var self = this;
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') self.loadProductionRun();
});
},
/* Is a quote already waiting for the capoturno?
@@ -1187,10 +1201,30 @@ function taskExecute() {
}
},
/* The numpad refused the reading itself (e.g. a caliper burst whose
integer part does not fit) rather than a rule about who may type it.
Same error line, so there is still one place to look. */
onNumpadError(detail) {
this.errorMessage = '{{ _("Lettura del calibro rifiutata: valore troppo grande") }}';
},
// ---- Handle numpad confirm ----
async handleMeasurement(value, inputMethod) {
if (!this.currentSubtask || this.saving) return;
if (!this.currentSubtask) return;
// Single reentrancy guard (#13): a second reading arriving while the
// first one is still being saved - or while the operator is stuck
// behind the modal/overlay the first one opened - must not become its
// own measurement (double record, one quote skipped). It is not
// silently dropped either: the same error line other rejections use
// says so.
if (this.busy || this.showCompletionOverlay || this.showSupervisorModal ||
this.isPaused || this.cycleConfirmed) {
this.errorMessage = '{{ _("Lettura ignorata: attendere") }}';
return;
}
this.busy = true;
this.currentValue = value;
this.errorMessage = '';
@@ -1225,17 +1259,43 @@ function taskExecute() {
}),
});
const result = await response.json();
let result;
try {
result = await response.json();
} catch (parseErr) {
// The server answered but not with JSON: a CSRF-expired POST gets
// the login page's HTML back, not a payload. Distinguish that from
// a dead network so the operator is told to reload rather than to
// retry blindly; the unsaved reading stays on screen either way.
this.errorMessage = '{{ _("Sessione scaduta: ricaricare la pagina") }}';
this.saving = false;
this.busy = false;
return;
}
if (result && (result.csrf_expired || result.session_expired)) {
this.errorMessage = '{{ _("Sessione scaduta: ricaricare la pagina") }}';
this.saving = false;
this.busy = false;
return;
}
if (!response.ok || result.error) {
this.errorMessage = result.detail || '{{ _("Errore nel salvataggio della misurazione") }}';
this.saving = false;
this.busy = false;
return;
}
// The server decides pass/fail from the recipe's official tolerances;
// passFailStatus is only a live preview while the value is on screen,
// not what a capoturno block or the pass/warning/fail counts should
// be keyed on.
const serverPassFail = result.pass_fail || pf;
// Record measurement locally
const existingIdx = this.measurements.findIndex(m => m.subtask_id === this.currentSubtask.id);
const mEntry = { subtask_id: this.currentSubtask.id, value, pass_fail: pf, deviation: dev };
const mEntry = { subtask_id: this.currentSubtask.id, value, pass_fail: serverPassFail, deviation: dev };
if (existingIdx !== -1) {
this.measurements.splice(existingIdx, 1, mEntry);
} else {
@@ -1249,9 +1309,10 @@ function taskExecute() {
// Out of tolerance: nothing moves until the capoturno says so, or until
// this same quote is measured again and comes out inside tolerance.
if (pf === 'fail') {
if (serverPassFail === 'fail') {
this.pendingFail = { id: result.id, subtask_id: this.currentSubtask.id };
this.openSupervisorModal('out_of_tolerance');
this.busy = false;
return;
}
@@ -1270,18 +1331,22 @@ function taskExecute() {
// Produzione") or once production is running ("Conferma ciclo").
if (!this.productionStarted && !this.isLastTask) {
this.goToNextTask();
this.busy = false;
return;
}
this.showCompletionOverlay = true;
this.busy = false;
return;
}
this.advanceToNext();
this.busy = false;
} catch (err) {
console.error('Save measurement error:', err);
this.errorMessage = '{{ _("Errore di rete. Riprovare.") }}';
this.saving = false;
this.busy = false;
}
},
@@ -1374,6 +1439,13 @@ function taskExecute() {
}
return;
}
// The server refused to close the cycle (line stopped, production
// closed, network down): the screen must not show a cycle that never
// actually closed. productionError already carries why.
this.cycleConfirmed = false;
this.showCompletionOverlay = true;
return;
}
// No production open (recipe run outside a production): keep the old local
@@ -1414,6 +1486,13 @@ function taskExecute() {
body: JSON.stringify(body || {}),
});
const data = await resp.json().catch(() => null);
// Not JSON at all (CSRF-expired POSTs get the login page's HTML
// back), or the server flagged the session as gone: same "reload"
// message handleMeasurement gives for the same failure (#12).
if (!data || data.csrf_expired || data.session_expired) {
this.productionError = '{{ _("Sessione scaduta: ricaricare la pagina") }}';
return null;
}
if (!resp.ok) {
this.productionError = (data && data.detail) || '{{ _("Errore di comunicazione con il server") }}';
return null;
@@ -1434,6 +1513,12 @@ function taskExecute() {
this.stopMeasurementTimer();
this.timerRemaining = seconds;
this.timerActive = true;
// The wall clock, not a tick count (#37.7): a tablet asleep for a few
// minutes has to catch the countdown up in one jump when it wakes, not
// replay the ticks it missed while suspended - setInterval does not
// fire while a tab is backgrounded, so a tick-based countdown drifted
// behind by exactly however long the tab was hidden.
this._timerDeadline = Date.now() + seconds * 1000;
// Landing on a page that is already late is the same event as going late
// while sitting on it; being back in time re-arms the announcement.
if (seconds <= 0) {
@@ -1448,7 +1533,7 @@ function taskExecute() {
onSecond() {
const wasAnnounced = this.dueAnnounced;
this.timerRemaining--;
this.timerRemaining = Math.ceil((this._timerDeadline - Date.now()) / 1000);
if (this.timerRemaining <= 0) this.onMeasurementDue();
if (wasAnnounced && this.redirectIn > 0) {