feat(production): dai una vita propria alla produzione, lato server
Punto 1 del documento modifiche del 28/07, il prerequisito su cui poggiano i punti 3, 4 e 6. Lo stato di una produzione viveva dentro una pagina del browser: timer, conteggio cicli e flag "produzione avviata" erano variabili Alpine di task_execute.html, e la navigazione fra task e' un ricaricamento completo, quindi cambiando task si perdeva tutto. Da qui il loop di misura che non reggeva, il fermo linea che non aveva nulla da fermare e l'assenza di storico. Nuove tabelle production_runs e production_events (migrazione 005), endpoint REST senza stato in memoria di processo - con un'app di stazione installata su ogni PC il database e' l'unico posto condiviso - e il frontend che legge lo stato all'apertura invece di tenerlo in memoria. Tre scelte di modello: - la scadenza e' un timestamp assoluto (next_measurement_at), non un contatore: il countdown si ricalcola da li' a ogni caricamento, e lasciarne andare la differenza sotto zero dara' gratis il contatore del ritardo del punto 3. Al client vanno i secondi gia' calcolati, non il timestamp: un datetime naive verrebbe letto nel fuso del browser e il conto sarebbe sfasato dell'offset UTC; - l'intervallo di misura e' copiato sulla produzione, non referenziato: modificare la ricetta a produzione avviata non deve spostare una scadenza in corso; - active_station_id rispecchia la stazione finche' la produzione e' aperta e va a NULL alla chiusura. Con un vincolo unico sopra, "una stazione = una produzione aperta" e' una garanzia del database e non un controllo soggetto a race; i NULL non collidono, quindi le produzioni chiuse si accumulano senza disturbo. Il fermo linea congela il conto alla rovescia e alla ripresa la scadenza viene traslata della durata del fermo, non ricalcolata: un fermo non regala ne' toglie tempo all'operatore. L'autorizzazione del capoturno passa da authenticate_user e non da un login, che rigenererebbe la sua API key buttando giu' la sessione che ha aperta altrove. La migrazione e' stata eseguita davvero, non solo scritta, su uno SQLite usa e getta: upgrade e downgrade girano e le colonne coincidono con i modelli. La prova ha trovato un difetto - create_unique_constraint dopo create_table e' un ALTER, che SQLite rifiuta - ora il vincolo e' dichiarato dentro create_table. Fuori da questo commit, per stare nei confini del punto 1: l'API espone gia' pause, resume e close, ma i pulsanti fermo linea e fine produzione restano da collegare (punto 6), e il rientro forzato sulla misura allo scadere e' il punto 3. Corretti due difetti trovati strada facendo: env.py non importava ne' Station ne' ProductionRun, quindi l'autogenerate di Alembic era gia' cieco sulle stazioni; e task_execute.html, lo schermo con piu' JavaScript dell'applicazione, non era coperto dal test di sintassi. Aggiungerlo ha richiesto di correggere l'helper, che validava le espressioni Alpine solo come espressione singola e bocciava @click="a = false; b = true", forma che Alpine accetta: ora prova entrambe le letture e fallisce solo se cadono tutte e due. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -428,6 +428,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ================================================================
|
||||
PRODUCTION ERROR — the server refused to record the production state.
|
||||
Shown because the fallback keeps the operator working locally, and a timer
|
||||
that is not backed by the server must never look like one that is.
|
||||
================================================================ #}
|
||||
<div x-show="productionError"
|
||||
x-transition
|
||||
x-cloak
|
||||
class="shrink-0 bg-red-50 dark:bg-red-900/20 border-t border-red-300 dark:border-red-700 px-4 py-2">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<svg class="w-4 h-4 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 text-red-800 dark:text-red-200">
|
||||
{{ _('Produzione non registrata sul server') }}:
|
||||
<span x-text="productionError"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ================================================================
|
||||
AVVIO PRODUZIONE — visible after first cycle, before production started
|
||||
================================================================ #}
|
||||
@@ -709,6 +729,8 @@ function taskExecute() {
|
||||
showCompletionOverlay: false,
|
||||
|
||||
// ---- Measurement timer ----
|
||||
// The interval is only used for display: the deadline itself lives on the server,
|
||||
// so it survives the page. See loadProductionRun().
|
||||
measurementIntervalMinutes: {{ measurement_interval_minutes|tojson if measurement_interval_minutes else 'null' }},
|
||||
timerActive: false,
|
||||
timerRemaining: 0,
|
||||
@@ -716,6 +738,12 @@ function taskExecute() {
|
||||
cycleCount: 0,
|
||||
productionStarted: false,
|
||||
|
||||
// ---- Production run (server-side state) ----
|
||||
// Navigating between tasks is a full page load, so anything kept only here dies.
|
||||
// This is read back from the server on every load instead.
|
||||
productionRun: null,
|
||||
productionError: '',
|
||||
|
||||
// ---- Cycle & workflow state ----
|
||||
cycleConfirmed: false,
|
||||
showSupervisorModal: false,
|
||||
@@ -812,6 +840,39 @@ function taskExecute() {
|
||||
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();
|
||||
},
|
||||
|
||||
// ---- Production run: read the state back from the server ----
|
||||
|
||||
async loadProductionRun() {
|
||||
try {
|
||||
const resp = await fetch('{{ url_for("measure.api_current_production") }}');
|
||||
if (!resp.ok) return;
|
||||
const run = await resp.json();
|
||||
if (run && run.id) this.adoptProductionRun(run);
|
||||
} catch (e) {
|
||||
// Offline or server down: leave the page usable, just without the timer.
|
||||
}
|
||||
},
|
||||
|
||||
/* Take the server's word for the state of the production. Called on load and
|
||||
after every action that changes it, so the page never drifts from the truth. */
|
||||
adoptProductionRun(run) {
|
||||
this.productionRun = run;
|
||||
this.productionStarted = run.status !== 'closed';
|
||||
this.cycleCount = run.cycle_count;
|
||||
|
||||
const seconds = run.seconds_to_next_measurement;
|
||||
if (seconds === null || seconds === undefined || run.status !== 'running') {
|
||||
this.stopMeasurementTimer();
|
||||
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.
|
||||
this.startCountdownFrom(seconds);
|
||||
},
|
||||
|
||||
// ---- Check if a subtask has been measured ----
|
||||
@@ -953,21 +1014,57 @@ function taskExecute() {
|
||||
},
|
||||
|
||||
// ---- Confirm measurement cycle (Fine ciclo misura) ----
|
||||
confirmCycle() {
|
||||
async confirmCycle() {
|
||||
this.cycleConfirmed = true;
|
||||
this.showCompletionOverlay = false;
|
||||
this.cycleCount++;
|
||||
|
||||
// Start measurement timer if recipe has an interval
|
||||
// Recorded server-side so the count and the next deadline outlive this page.
|
||||
if (this.productionRun) {
|
||||
const run = await this.postProduction(
|
||||
'{{ url_for("measure.api_complete_cycle", run_id=0) }}'.replace('/0/', '/' + this.productionRun.id + '/'),
|
||||
);
|
||||
if (run) {
|
||||
this.adoptProductionRun(run);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No production open (recipe run outside a production): keep the old local
|
||||
// behaviour rather than leaving the operator without a timer.
|
||||
this.cycleCount++;
|
||||
if (this.measurementIntervalMinutes && this.measurementIntervalMinutes > 0) {
|
||||
this.startMeasurementTimer();
|
||||
this.startCountdownFrom(this.measurementIntervalMinutes * 60);
|
||||
}
|
||||
},
|
||||
|
||||
/* POST to a production endpoint, returning the updated run or null on failure. */
|
||||
async postProduction(url, body) {
|
||||
this.productionError = '';
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content || '';
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
const data = await resp.json().catch(() => null);
|
||||
if (!resp.ok) {
|
||||
this.productionError = (data && data.detail) || '{{ _("Errore di comunicazione con il server") }}';
|
||||
return null;
|
||||
}
|
||||
return data;
|
||||
} catch (e) {
|
||||
this.productionError = '{{ _("Errore di connessione") }}';
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
// ---- Measurement timer ----
|
||||
startMeasurementTimer() {
|
||||
/* Ticks locally for a smooth display, but the number it starts from always comes
|
||||
from the server, and every page load resynchronises it. */
|
||||
startCountdownFrom(seconds) {
|
||||
this.stopMeasurementTimer();
|
||||
this.timerRemaining = this.measurementIntervalMinutes * 60;
|
||||
this.timerRemaining = seconds;
|
||||
this.timerActive = true;
|
||||
var self = this;
|
||||
this._timerInterval = setInterval(function () {
|
||||
@@ -1014,11 +1111,26 @@ function taskExecute() {
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
// ---- Avvio Produzione (GAIA placeholder) ----
|
||||
startProduction() {
|
||||
this.productionStarted = true;
|
||||
// TODO: integrazione GAIA — inviare segnale per avviare timer linea
|
||||
// await fetch('/measure/api/gaia/start-production', { method: 'POST', ... });
|
||||
// ---- Avvio Produzione ----
|
||||
/* Opens a production run on the server. That row is what makes the timer, the
|
||||
cycle count and the history survive a change of task.
|
||||
The hand-off to the ERP (GAIA) plugs in on top of this, once the protocol is
|
||||
agreed: everything before it works without waiting for that. */
|
||||
async startProduction() {
|
||||
const run = await this.postProduction(
|
||||
'{{ url_for("measure.api_start_production") }}',
|
||||
{
|
||||
recipe_id: this.task.recipe_id,
|
||||
version_id: this.task.version_id,
|
||||
lot_number: this.lotNumber || null,
|
||||
serial_number: this.serialNumber || null,
|
||||
},
|
||||
);
|
||||
if (run) {
|
||||
this.adoptProductionRun(run);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// Is the current task the last one in the recipe sequence?
|
||||
@@ -1030,14 +1142,22 @@ function taskExecute() {
|
||||
|
||||
// Start production from the completion overlay (last START task) and begin
|
||||
// the measurement-interval cycle if the recipe defines one.
|
||||
startProductionFromOverlay() {
|
||||
async startProductionFromOverlay() {
|
||||
this.showCompletionOverlay = false;
|
||||
this.startProduction();
|
||||
this.cycleConfirmed = true;
|
||||
this.cycleCount++;
|
||||
if (this.measurementIntervalMinutes && this.measurementIntervalMinutes > 0) {
|
||||
this.startMeasurementTimer();
|
||||
const opened = await this.startProduction();
|
||||
if (!opened) {
|
||||
// Server refused: keep the operator working rather than stranding them
|
||||
// mid-shift, but productionError is on screen so it is not silent.
|
||||
this.cycleConfirmed = true;
|
||||
this.cycleCount++;
|
||||
if (this.measurementIntervalMinutes && this.measurementIntervalMinutes > 0) {
|
||||
this.startCountdownFrom(this.measurementIntervalMinutes * 60);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// The START-phase measurements have just been taken: record them as the first
|
||||
// cycle, which is also what starts the interval running.
|
||||
await this.confirmCycle();
|
||||
},
|
||||
|
||||
get timerDisplay() {
|
||||
|
||||
Reference in New Issue
Block a user