feat(measure): il fuori tolleranza si autorizza, non si chiude

Il punto 5 c'era a meta': la schermata apriva il modale del capoturno e bloccava
l'avanzamento automatico, ma il modale si chiudeva con Annulla o con un click
sullo sfondo, la misura era gia' salvata, e `pendingAdvance` veniva impostato e
non letto da nessuno. Era una conferma, non uno sbarramento.

Ora l'autorizzazione finisce sulla misura: due colonne, chi ha autorizzato e
quando. Finche' una quota e' fuori tolleranza e nessuno l'ha autorizzata, il
server rifiuta la misura della quota successiva e la chiusura del ciclo. Non e'
la schermata a impedirlo: la schermata risparmia solo il viaggio.

Rimisurare la stessa quota resta possibile — il calibro scivola, il pezzo si
riposiziona — e una seconda lettura in tolleranza libera il blocco, perche' conta
l'ultima lettura di ogni quota. Quante volte si possa riprovare e' il punto 4, e
tutte le letture restano comunque a registro. Un warning non blocca: fuori dai
limiti di attenzione ma dentro la tolleranza e' dentro la tolleranza.

Ricaricare la pagina era il modo piu' semplice per scavalcare il vecchio gate.
Non lo e' piu': la schermata chiede al server, all'apertura, se una quota sta
aspettando, e si ritrova davanti lo stesso blocco.

Sparisce /validate-supervisor, che verificava le credenziali e buttava via la
risposta. Al suo posto un endpoint che le credenziali le usa per scrivere
l'approvazione dove serve. Il controllo del capoturno si sposta in auth_service,
accanto al resto delle credenziali: fermo linea, chiusura e fuori tolleranza
fanno la stessa domanda, e solo una delle tre riguarda la produzione.

Il file di statistica guadagna authorised_by e authorised_at: mostrava il
fallimento e non la decisione, che e' la meta' che un auditor chiede.

Migrazione 010: due colonne nullable sulle misure. Le righe esistenti restano
nulle — retrodatare un'autorizzazione mai avvenuta sarebbe inventarsi un record
di audit, e quelle produzioni sono chiuse da un pezzo.

Test: +18 (309). Coprono il rifiuto della quota successiva, la rimisura ammessa,
il rilascio del blocco con una lettura buona, il ciclo che non si chiude, il
capoturno registrato sulla misura, le credenziali sbagliate e chi capoturno non
e', il file di statistica, e il fatto che una produzione chiusa non blocchi la
successiva. Aggiunto uploads/statistics/ al gitignore: i test che chiudono una
produzione scrivevano nel repository.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-07-28 20:54:24 +00:00
parent 5aa3d595ad
commit e2aae88858
17 changed files with 1341 additions and 441 deletions
@@ -493,6 +493,32 @@
</div>
</div>
{# ================================================================
QUOTA FUORI TOLLERANZA — nothing moves from here without the capoturno or a
new reading of this same quote. Kept on screen after the modal is dismissed,
because the block outlives the modal: the server refuses the next value.
================================================================ #}
<div x-show="blockedByTolerance"
x-transition
x-cloak
class="shrink-0 bg-red-50 dark:bg-red-900/30 border-t-2 border-red-500 px-4 py-2">
<div class="flex flex-wrap items-center justify-center gap-x-3 gap-y-1">
<svg class="w-5 h-5 text-red-600 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
</svg>
<span class="text-sm font-semibold text-red-900 dark:text-red-100">
{{ _('Quota fuori tolleranza') }}
</span>
<span class="text-xs text-red-800 dark:text-red-200">
{{ _('serve il capoturno, oppure una nuova misura della stessa quota') }}
</span>
<button @click="openSupervisorModal('out_of_tolerance')"
class="btn text-xs py-1 px-2.5 bg-red-600 hover:bg-red-700 text-white">
{{ _('Autorizza') }}
</button>
</div>
</div>
{# ================================================================
LINEA FERMA — the countdown is frozen, not merely hidden. Stated plainly
because a stopped line that looks like a running one is how a measurement
@@ -826,9 +852,13 @@
</div>
<div class="flex gap-2">
{# Out of tolerance, the way out of this modal is measuring the quote
again - not cancelling. Closing it changes nothing: the quote stays
blocked, and the banner behind says so. #}
<button @click="closeSupervisorModal()"
class="btn btn-secondary flex-1 text-sm">
{{ _('Annulla') }}
<span x-show="supervisorAction !== 'out_of_tolerance'">{{ _('Annulla') }}</span>
<span x-show="supervisorAction === 'out_of_tolerance'">{{ _('Rimisura la quota') }}</span>
</button>
<button @click="submitSupervisor()"
:disabled="!supervisorUsername || !supervisorPassword || supervisorValidating"
@@ -909,7 +939,10 @@ function taskExecute() {
supervisorPassword: '',
supervisorError: '',
supervisorValidating: false,
pendingAdvance: false,
/* The out-of-tolerance measurement waiting for the capoturno:
{ id, subtask_id }, or null. While it is set the operator stays on that
quote - the server refuses the next one anyway. */
pendingFail: null,
// ---- Value from numpad / caliper ----
currentValue: null,
@@ -994,11 +1027,40 @@ function taskExecute() {
},
// ---- Init ----
init() {
async init() {
this.subtasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
this.inputStartedAt = Date.now();
// Rejoin whatever production is already open at this station.
this.loadProductionRun();
await this.loadProductionRun();
await this.loadPendingAuthorisation();
},
/* Is a quote already waiting for the capoturno?
Reloading the page was the simplest way past the old gate. It is not one
now - the server refuses the next measurement either way - so the screen
picks the block back up as soon as it opens, on the quote it belongs to. */
async loadPendingAuthorisation() {
if (!this.isMeasureTask || !this.subtasks.length) return;
try {
const params = new URLSearchParams({ version_id: this.task.version_id });
if (this.productionRun) {
params.set('production_run_id', this.productionRun.id);
}
const resp = await fetch(
'{{ url_for("measure.api_pending_authorisation") }}?' + params.toString(),
);
if (!resp.ok) return;
const pending = await resp.json();
if (!pending || !pending.id) return;
this.pendingFail = { id: pending.id, subtask_id: pending.subtask_id };
const idx = this.subtasks.findIndex(s => s.id === pending.subtask_id);
if (idx !== -1) this.currentIndex = idx;
this.openSupervisorModal('out_of_tolerance');
} catch (e) {
// Offline: the banner is missing, but so is the ability to save anything.
}
},
// ---- Production run: read the state back from the server ----
@@ -1124,13 +1186,21 @@ function taskExecute() {
// Pause to show result feedback
await new Promise(r => setTimeout(r, 1000));
// Out-of-tolerance: block advancement, require supervisor
// 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') {
this.pendingAdvance = true;
this.pendingFail = { id: result.id, subtask_id: this.currentSubtask.id };
this.openSupervisorModal('out_of_tolerance');
return;
}
// A second reading of the blocked quote, this time in tolerance: the quote
// is in tolerance now, so the block goes. Both readings stay on record -
// how many attempts are allowed is point 4's business, not this one's.
if (this.pendingFail && this.pendingFail.subtask_id === this.currentSubtask.id) {
this.pendingFail = null;
}
// Check if all done
if (this.completedCount >= this.totalSubtasks) {
// START run (production not yet started): don't block with the
@@ -1173,14 +1243,38 @@ function taskExecute() {
}
},
/* Out of tolerance and nobody has authorised it: the operator stays put.
This is the whole of point 5. The screen used to open a modal that could be
dismissed, and the measurement was already saved by then, so a value outside
tolerance could be left behind by clicking the backdrop. The server refuses
the next measurement now; these checks only spare the operator the trip. */
get blockedByTolerance() {
return this.pendingFail !== null;
},
/* Say why nothing is moving, and offer the way out. */
refuseWhileBlocked() {
// |tojson, not quotes: the Italian carries an apostrophe, and an apostrophe
// inside a single-quoted literal is how every Alpine binding on the page dies.
this.errorMessage = {{ _("Quota fuori tolleranza: serve l'autorizzazione del capoturno, oppure misurare di nuovo la quota")|tojson }};
this.openSupervisorModal('out_of_tolerance');
},
// ---- Navigation ----
goToSubtask(index) {
if (index >= 0 && index < this.totalSubtasks) {
this.currentIndex = index;
this.currentValue = null;
this.errorMessage = '';
this.inputStartedAt = Date.now();
if (index < 0 || index >= this.totalSubtasks) return;
// Moving to another quote is exactly what a blocked quote forbids; going
// back to the blocked one to measure it again is not.
if (this.blockedByTolerance
&& this.subtasks[index].id !== this.pendingFail.subtask_id) {
this.refuseWhileBlocked();
return;
}
this.currentIndex = index;
this.currentValue = null;
this.errorMessage = '';
this.inputStartedAt = Date.now();
},
goToSubtaskByMarker(markerNumber) {
@@ -1195,6 +1289,12 @@ function taskExecute() {
it if it is the last. The server decides that - it knows the sequence - and
the cycle count coming back tells us which of the two happened. */
async confirmCycle() {
// Closing the cycle would be another way of walking past a quote that is out
// of tolerance. The server refuses it too.
if (this.blockedByTolerance) {
this.refuseWhileBlocked();
return;
}
this.cycleConfirmed = true;
this.showCompletionOverlay = false;
@@ -1441,6 +1541,10 @@ function taskExecute() {
documental tasks that come before the measurement, the plain sequence
applies - that is the run-up, not the loop. */
goToNextTask() {
if (this.blockedByTolerance) {
this.refuseWhileBlocked();
return;
}
if (this.productionStarted && this.isMeasurementTaskOfRun) {
const next = this.nextMeasurementTaskId || this.measurementTaskIds[0];
// A recipe with a single measurement task loops on the spot: reloading the
@@ -1511,47 +1615,57 @@ function taskExecute() {
/* Route the modal's confirm to what the action actually does. Fermo linea,
ripresa and fine produzione act on the production run; the out-of-tolerance
gate stays a pure credential check. */
one is recorded on the measurement itself. */
async submitSupervisor() {
if (this.supervisorAction === 'fermo_linea') return this.runSupervisedAction('pause');
if (this.supervisorAction === 'ripresa') return this.runSupervisedAction('resume');
if (this.supervisorAction === 'fine_produzione') return this.runSupervisedAction('close');
return this.validateSupervisor();
return this.authoriseOutOfTolerance();
},
// ---- Validate supervisor credentials ----
async validateSupervisor() {
/* The capoturno lets an out-of-tolerance value stand.
The credentials go to the endpoint that writes the approval onto that
measurement. It used to be a bare credential check whose answer was thrown
away: nothing recorded who had said yes, and nothing depended on it, so
closing the modal was enough to walk past a value out of tolerance. */
async authoriseOutOfTolerance() {
if (!this.pendingFail) {
this.closeSupervisorModal();
return;
}
this.supervisorError = '';
this.supervisorValidating = true;
try {
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content || '';
const resp = await fetch('{{ url_for("measure.validate_supervisor") }}', {
const url = '{{ url_for("measure.api_authorise_measurement", measurement_id=0) }}'
.replace('/0/', '/' + this.pendingFail.id + '/');
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify({ username: this.supervisorUsername, password: this.supervisorPassword })
body: JSON.stringify({
supervisor_username: this.supervisorUsername,
supervisor_password: this.supervisorPassword,
}),
});
const data = await resp.json();
const data = await resp.json().catch(() => null);
if (!resp.ok || data.error) {
this.supervisorError = data.detail || '{{ _("Credenziali non valide o utente non autorizzato") }}';
if (!resp.ok || (data && data.error)) {
this.supervisorError = (data && data.detail)
|| '{{ _("Credenziali non valide o utente non autorizzato") }}';
this.supervisorValidating = false;
return;
}
// Authorized — close modal and proceed
this.showSupervisorModal = false;
this.supervisorUsername = '';
this.supervisorPassword = '';
this.supervisorValidating = false;
this.closeSupervisorModal();
this.pendingFail = null;
if (this.supervisorAction === 'out_of_tolerance') {
this.pendingAdvance = false;
if (this.completedCount >= this.totalSubtasks) {
this.showCompletionOverlay = true;
} else {
this.advanceToNext();
}
if (this.completedCount >= this.totalSubtasks) {
this.showCompletionOverlay = true;
} else {
this.advanceToNext();
}
} catch (err) {