From 31fa78bdfd982a73adae9a77d24f7331a759cb07 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Mon, 21 Sep 2026 22:46:32 +0200 Subject: [PATCH] fix(task_execute): letture doppie, sessione scaduta, ciclo rifiutato dal server (#13, #12, #29, #37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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 --- .../templates/measure/task_execute.html | 95 ++++++++++++++++++- .../translations/en/LC_MESSAGES/messages.po | 12 +++ .../translations/it/LC_MESSAGES/messages.po | 12 +++ 3 files changed, 114 insertions(+), 5 deletions(-) diff --git a/src/frontend/flask_app/templates/measure/task_execute.html b/src/frontend/flask_app/templates/measure/task_execute.html index 4ecbb1a..0bc349d 100644 --- a/src/frontend/flask_app/templates/measure/task_execute.html +++ b/src/frontend/flask_app/templates/measure/task_execute.html @@ -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) { diff --git a/src/frontend/flask_app/translations/en/LC_MESSAGES/messages.po b/src/frontend/flask_app/translations/en/LC_MESSAGES/messages.po index 34ae646..b39e326 100644 --- a/src/frontend/flask_app/translations/en/LC_MESSAGES/messages.po +++ b/src/frontend/flask_app/translations/en/LC_MESSAGES/messages.po @@ -2748,3 +2748,15 @@ msgstr "Error generating report" #~ msgid "Errore nel caricamento dei task: %(detail)s" #~ msgstr "Error loading tasks: %(detail)s" +#: templates/measure/task_execute.html:1268 +msgid "Sessione scaduta: ricaricare la pagina" +msgstr "Session expired: reload the page" + +#: templates/measure/task_execute.html:1219 +msgid "Lettura ignorata: attendere" +msgstr "Reading ignored: please wait" + +#: templates/measure/task_execute.html:1194 +msgid "Lettura del calibro rifiutata: valore troppo grande" +msgstr "Caliper reading refused: value too large" + diff --git a/src/frontend/flask_app/translations/it/LC_MESSAGES/messages.po b/src/frontend/flask_app/translations/it/LC_MESSAGES/messages.po index bb7482a..8f45205 100644 --- a/src/frontend/flask_app/translations/it/LC_MESSAGES/messages.po +++ b/src/frontend/flask_app/translations/it/LC_MESSAGES/messages.po @@ -2736,3 +2736,15 @@ msgstr "Errore nella generazione del report" #~ msgid "Errore nel caricamento dei task: %(detail)s" #~ msgstr "Errore nel caricamento dei task: %(detail)s" +#: templates/measure/task_execute.html:1268 +msgid "Sessione scaduta: ricaricare la pagina" +msgstr "Sessione scaduta: ricaricare la pagina" + +#: templates/measure/task_execute.html:1219 +msgid "Lettura ignorata: attendere" +msgstr "Lettura ignorata: attendere" + +#: templates/measure/task_execute.html:1194 +msgid "Lettura del calibro rifiutata: valore troppo grande" +msgstr "Lettura del calibro rifiutata: valore troppo grande" +