feat(production): fermo linea e fine produzione fanno finalmente qualcosa

Punto 6 del documento modifiche del 28/07. I due pulsanti esistevano, chiedevano
correttamente l'autorizzazione del capoturno, e poi ricadevano su un commento:
"handled by GAIA integration (future)". E' la riga di apertura del documento.

Ora agiscono sulla produzione aperta: fermo linea sospende e congela il conto alla
rovescia, la ripresa lo fa ripartire restituendo il tempo del fermo, fine
produzione chiude ed emette il file di statistica. Le credenziali del capoturno
vanno direttamente all'endpoint, che le verifica e registra sulla traccia della
produzione chi ha autorizzato cosa: validarle a parte non lascerebbe traccia, e
sarebbe un giro in piu'.

In interfaccia il pulsante di fermo diventa "Riprendi" quando la linea e' ferma, e
una banda dichiara lo stato con il valore congelato a video: una linea ferma che
sembra in marcia e' il modo in cui si salta un intervallo di misura senza
accorgersene.

Il file di statistica ha richiesto di legare le misure alla produzione (migrazione
006): Measurement conosceva solo versione, lotto e seriale, nessuno dei quali
separa una produzione dalla successiva sulla stessa ricetta e sullo stesso lotto,
quindi "le misure dell'intera produzione" non era una query. Il CSV rispetta i
separatori configurati in system_settings e porta con se' i limiti di tolleranza:
senza quelli un esito pass/fail non e' piu' ricalcolabile dal file a distanza di
anni, che e' il senso di un documento per audit. Le misure esportate vengono
marcate synced_to_csv. Se la produzione non ha misure non viene scritto nulla: un
file vuoto sarebbe rumore nella cartella, non evidenza.

L'invio a GAIA resta assente e non abbozzato, con il punto d'innesto dichiarato in
close_run: una chiamata vuota che sembra collegata e' peggio di niente. Tutto il
resto del punto 6 non dipende dal gestionale e funziona adesso.

Anche la 006 e' stata eseguita su SQLite usa e getta prima di essere considerata
buona, e anche qui la prova ha trovato un difetto: aggiungere una colonna con
foreign key fa emettere ad alembic un ALTER di vincolo, che SQLite rifiuta. Ora usa
batch_alter_table con il vincolo nominato, che su MySQL resta un ALTER normale e su
SQLite ricostruisce la tabella - cosi' la chiave esterna non va persa per far
contento il dialetto dei test.

Corretto anche il proxy di salvataggio misura, che chiamava /api/measurements senza
slash finale e pagava un redirect 307 a ogni singola misura.

Traduzioni: pybabel aveva di nuovo indovinato sette voci marcandole fuzzy, e in
italiano "Linea ferma" era diventato "Lingua Preferita" su una banda di sicurezza.
Tradotte per esteso in IT ed EN e tolti i flag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-07-28 17:32:43 +00:00
parent 55e5e0153f
commit 7bc3c1f938
16 changed files with 889 additions and 159 deletions
@@ -116,13 +116,24 @@
{# Fermo linea + Fine produzione (measurement tasks only) #}
<template x-if="subtasks.length > 0">
<div class="shrink-0 flex items-center gap-1.5">
<button @click="openSupervisorModal('fermo_linea')"
{# While the line is stopped this becomes the way to restart it, so the
capoturno never has to hunt for a second control. #}
<button x-show="!isPaused"
@click="openSupervisorModal('fermo_linea')"
class="btn text-xs py-1 px-2.5 gap-1 border-2 border-amber-500 text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-900/20 hover:bg-amber-100 dark:hover:bg-amber-900/40">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ _('Fermo linea') }}
</button>
<button x-show="isPaused" x-cloak
@click="openSupervisorModal('ripresa')"
class="btn text-xs py-1 px-2.5 gap-1 border-2 border-emerald-500 text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-900/20 hover:bg-emerald-100 dark:hover:bg-emerald-900/40">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z"/>
</svg>
{{ _('Riprendi') }}
</button>
<button @click="openSupervisorModal('fine_produzione')"
class="btn text-xs py-1 px-2.5 gap-1 border-2 border-red-500 text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/40">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
@@ -428,6 +439,30 @@
</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
interval gets silently missed.
================================================================ #}
<div x-show="isPaused"
x-transition
x-cloak
class="shrink-0 bg-amber-100 dark:bg-amber-900/40 border-t-2 border-amber-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-amber-700 dark:text-amber-300 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span class="text-sm font-semibold text-amber-900 dark:text-amber-100">
{{ _('Linea ferma') }}
</span>
<span class="text-xs text-amber-800 dark:text-amber-200">
{{ _('Il conto alla rovescia è congelato a') }}
<span class="font-mono font-bold" x-text="timerDisplay"></span>
— {{ _('serve il capoturno per riprendere') }}
</span>
</div>
</div>
{# ================================================================
PRODUCTION ERROR — the server refused to record the production state.
Shown because the fallback keeps the operator working locally, and a timer
@@ -668,12 +703,12 @@
<div>
<label class="tmf-label text-xs">{{ _('Username') }}</label>
<input type="text" x-model="supervisorUsername" class="tmf-input text-sm"
placeholder="{{ _('Username capoturno') }}" @keydown.enter="validateSupervisor()">
placeholder="{{ _('Username capoturno') }}" @keydown.enter="submitSupervisor()">
</div>
<div>
<label class="tmf-label text-xs">{{ _('Password') }}</label>
<input type="password" x-model="supervisorPassword" class="tmf-input text-sm"
placeholder="••••••••" @keydown.enter="validateSupervisor()">
placeholder="••••••••" @keydown.enter="submitSupervisor()">
</div>
<p x-show="supervisorError" class="text-xs text-red-600" x-text="supervisorError"></p>
</div>
@@ -683,7 +718,7 @@
class="btn btn-secondary flex-1 text-sm">
{{ _('Annulla') }}
</button>
<button @click="validateSupervisor()"
<button @click="submitSupervisor()"
:disabled="!supervisorUsername || !supervisorPassword || supervisorValidating"
class="btn btn-primary flex-1 text-sm gap-1">
<svg x-show="supervisorValidating" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
@@ -865,10 +900,17 @@ function taskExecute() {
this.cycleCount = run.cycle_count;
const seconds = run.seconds_to_next_measurement;
if (seconds === null || seconds === undefined || run.status !== 'running') {
if (seconds === null || seconds === undefined) {
this.stopMeasurementTimer();
return;
}
if (run.status !== 'running') {
// Paused: keep the figure on screen, frozen, so the banner can show how
// much of the interval is being held rather than going blank.
this.stopMeasurementTimer();
this.timerRemaining = seconds;
return;
}
// The server hands over seconds already computed rather than a timestamp: a
// naive datetime would be read in the browser's timezone and the countdown
// would be off by the UTC offset.
@@ -922,6 +964,9 @@ function taskExecute() {
serial_number: this.serialNumber,
input_method: inputMethod || 'manual',
input_duration_ms: inputDurationMs,
// Ties the value to the production under way, so fine produzione can
// emit the statistics file for the whole run.
production_run_id: this.productionRun ? this.productionRun.id : null,
}),
});
@@ -1193,14 +1238,30 @@ function taskExecute() {
this.supervisorError = '';
},
// ---- Is the production stopped? ----
get isPaused() {
return !!this.productionRun && this.productionRun.status === 'paused';
},
// ---- Supervisor reason text ----
get supervisorReason() {
if (this.supervisorAction === 'out_of_tolerance') return '{{ _("Misurazione fuori tolleranza") }}';
if (this.supervisorAction === 'fermo_linea') return '{{ _("Fermo linea richiesto") }}';
if (this.supervisorAction === 'ripresa') return '{{ _("Ripresa della produzione") }}';
if (this.supervisorAction === 'fine_produzione') return '{{ _("Fine produzione richiesta") }}';
return '';
},
/* 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. */
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();
},
// ---- Validate supervisor credentials ----
async validateSupervisor() {
this.supervisorError = '';
@@ -1235,7 +1296,6 @@ function taskExecute() {
this.advanceToNext();
}
}
// fermo_linea and fine_produzione are handled by GAIA integration (future)
} catch (err) {
this.supervisorError = '{{ _("Errore di connessione") }}';
@@ -1243,6 +1303,42 @@ function taskExecute() {
}
},
/* Fermo linea, ripresa e fine produzione.
These used to open the modal and then do nothing. They now act on the
production run: the credentials go to the endpoint, which checks them and
records on the run's trace who authorised the stop or the closure. */
async runSupervisedAction(action) {
if (!this.productionRun) {
this.supervisorError = '{{ _("Nessuna produzione aperta su questa stazione") }}';
return;
}
this.supervisorError = '';
this.supervisorValidating = true;
const base = '{{ url_for("measure.api_pause_production", run_id=0) }}'
.replace('/0/pause', '/' + this.productionRun.id + '/' + action);
const run = await this.postProduction(base, {
supervisor_username: this.supervisorUsername,
supervisor_password: this.supervisorPassword,
});
this.supervisorValidating = false;
if (!run) {
// postProduction already holds the reason; show it inside the modal so the
// capoturno sees why the action was refused, rather than a silent no-op.
this.supervisorError = this.productionError || '{{ _("Azione non riuscita") }}';
this.productionError = '';
return;
}
this.closeSupervisorModal();
this.adoptProductionRun(run);
if (run.status === 'closed') {
this.goToSummary();
}
},
// ---- Go to summary ----
goToSummary() {
const recipeId = this.task.recipe_id || 0;