7 Commits

Author SHA1 Message Date
Adriano Dal Pastro bde8fafd77 feat(production): la misura torna a cercare l'operatore
Il conto alla rovescia non si ferma piu' a zero. Oltre la scadenza continua
nell'altro senso e dice da quanto la linea e' in ritardo, in rosso, su tutte le
schermate del percorso di misura: lista task, riepilogo, scelta ricetta. Un
ritardo va letto, non dedotto. Allo scadere dell'intervallo la misura si
ripropone da sola: cicalino, un avviso di cinque secondi, e l'operatore torna al
primo task di misura da qualunque schermata si trovi.

Un ciclo copre tutti i task di misura della ricetta, quindi solo l'ultimo lo
chiude e fa ripartire l'intervallo. Farlo ripartire al primo avrebbe fatto
misurare una ricetta con tre task di misura tre volte piu' spesso di come e'
configurata. E' il server a decidere quale task chiude il ciclo, perche' e' lui
a conoscere la sequenza: la lista dei task di misura viaggia con la produzione
(measurement_task_ids), che e' anche cio' che permette a una schermata qualsiasi
di sapere dove riportare l'operatore.

Aggiunta la rimisura: si gira il pezzo e si misura di nuovo dentro lo stesso
ciclo, senza chiudere niente e senza guadagnare tempo sulla scadenza. Le due
letture restano entrambe in statistica, che e' il motivo per cui si prendono.

Chi sta gia' misurando quando l'intervallo scade e' in ritardo, non perso: la
banda diventa rossa e lo si lascia lavorare. Portarlo altrove a meta' ciclo
cancellerebbe quote che ha davanti agli occhi senza guadagnare nulla.

Sistemato anche il pulsante "Conferma ciclo" dell'overlay: alzava una bandierina
locale e basta, quindi il ciclo non veniva mai registrato sul server da quella
strada. Ora passa da confirmCycle come il pulsante della barra.

Migrazione 008: il registro eventi della produzione impara task_measured e
remeasure, e la colonna task_id — un task_measured che non dice quale task non
registra niente di utile. I valori nuovi entrano tutti insieme perche' allargare
una enum MySQL riscrive la tabella, stesso ragionamento dei tipi di task in 007.
Verificata su SQLite (batch mode) e in MySQL con --sql.

La logica dell'orologio — come si legge un ritardo, quando suona, dove sta la
misura — vive in un solo posto (production-clock.js) e la schermata di misura la
usa invece di riscriverla.

Test: +10 (265). Coprono il ciclo che non riparte a meta', la rimisura che non
sposta la scadenza, il rifiuto a linea ferma, la sequenza dei task di misura
esposta dalla produzione, i proxy Flask e la validita' JS della lista task.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:31:51 +00:00
Adriano Dal Pastro 6fbff2fe76 feat(tasks): il tipo di un task si dichiara, non si deduce
Punto 2 del documento modifiche del 28/07, prima richiesta di Menoncin.

Il sistema distingueva un task di misura da uno documentale deducendolo: se aveva
quote era una misura, altrimenti una nota. Conseguenza: un task di misura a cui le
quote non erano ancora state inserite veniva trattato come nota, e il sistema si
comportava in modo diverso a seconda di quanto fosse completa la ricetta.

Nuovo campo task_type su recipe_tasks (migrazione 007) con nota, misura e disegno,
piu' xf_compare e camera_measure gia' nell'enum: allargare un enum MySQL piu' avanti
e' un ALTER su tabella viva, e non costa nulla prevederli adesso.

Il backfill riproduce la classificazione che era a schermo, cosi' nessuna ricetta
cambia comportamento all'aggiornamento: i task con quote diventano 'measure'; quelli
senza quote ma con un disegno allegato diventano 'drawing' e non 'note', perche' e'
gia' cio' che mostravano - chiamarli note sarebbe stato l'unico punto in cui questa
migrazione cambiava le carte in tavola.

Il tipo viene copiato esplicitamente nel copy-on-write del versioning: una nuova
versione che lo perdesse riclassificherebbe in silenzio tutti i task alla prima
modifica di una ricetta. Il task "Technical Drawing" creato d'ufficio quando si
carica un disegno su una ricetta senza task nasce come 'drawing'.

Lato operatore decide ora il tipo, non il conteggio delle quote: fermo linea, fine
produzione, avvio produzione e la barra di produzione seguono il tipo. Dove servono
davvero delle quote da mostrare - elenco marker, tastierino, fine ciclo misura -
resta anche il controllo che ce ne sia almeno una, e un task di misura ancora privo
di quote lo dichiara invece di somigliare a una nota. Nella lista task compare il
tipo, cosi' si vede prima di aprire.

La 007 e' stata eseguita su SQLite usa e getta con tre righe costruite apposta - una
con quote, una col solo disegno, una nota secca - e il backfill le classifica come
atteso. Il backfill girera' sui dati reali del cliente, provarlo a mano non bastava.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:15:18 +00:00
Adriano Dal Pastro 7bc3c1f938 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>
2026-07-28 17:32:43 +00:00
Adriano Dal Pastro 55e5e0153f 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>
2026-07-28 16:20:43 +00:00
Adriano Dal Pastro e7889f80a3 docs: aggiungi il documento delle modifiche del 28/07/2026
Elenco dei 15 punti concordati con Trafilo, con lo stato verificato sul codice al
commit 2a56632, l'architettura d'installazione decisa il 28/07 e le nove domande
la cui risposta dipende dal cliente. E' il riferimento del lavoro su V3.0.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 15:55:59 +00:00
Adriano Dal Pastro a159eb2b8b feat(stations): ricette in lista, reset per riga e cambio stazione al volo
Punto 7 del documento modifiche del 28/07, terza richiesta di Menoncin.

GET /api/stations ora usa StationWithRecipesResponse, che era gia' definita nello
schema senza che nessun endpoint la usasse. Non costa query in piu':
Station.assignments e' gia' selectin-loaded. In lista compaiono le sole ricette
attive, coerente con cio' che l'operatore vede davvero a quella stazione.

DELETE /api/stations/{id}/recipes azzera le assegnazioni di una stazione senza
eliminarla, cosi' si riassegna da capo. E' idempotente: azzerare una stazione gia'
vuota risponde removed=0, non 404. In tabella e' un pulsante per riga, con
conferma perche' l'azione e' distruttiva.

Il cambio stazione al volo (?station=CODE, override in sessione) serve al
collaudo: senza, provare N stazioni richiede N PC. E' dietro il flag
STATION_SWITCH_ENABLED, default 0. Non e' legato al ruolo admin perche'
/measure/select richiede MeasurementTec, e gatearlo su is_admin avrebbe escluso
proprio chi fa il collaudo. In fabbrica l'identita' della stazione viene
dall'installazione locale, e misurare contro le ricette di un'altra stazione
romperebbe in silenzio la tracciabilita': per questo il codice richiesto e'
validato prima di essere memorizzato - un refuso lascia l'operatore sulla
stazione configurata con un messaggio, invece di incastrarlo su una stazione
inesistente - e quando l'override e' attivo la pagina lo dichiara, con il codice
configurato accanto e un link per ripristinare.

Le traduzioni: pybabel update aveva indovinato dieci voci da stringhe simili
marcandole fuzzy, in entrambi i cataloghi. "Azzera Stazione" era diventato "Crea
Stazione", l'opposto, su un dialogo distruttivo. Non si vedeva perche' la
compilazione salta le fuzzy e ricade sul sorgente italiano, ma sarebbe emerso al
primo build con --use-fuzzy. Tradotte per esteso le 17 stringhe nuove in IT ed EN
e tolti i flag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 15:42:17 +00:00
Adriano Dal Pastro 28ee44bcf1 feat(client): includi librerie e font in locale per il funzionamento offline
Punto 12 del documento modifiche del 28/07. L'installazione a Trafilo e'
on-premise su rete di produzione isolata: finche' Alpine.js, Plotly, PDF.js,
Fabric.js e i Google Fonts venivano scaricati da CDN a ogni apertura di pagina,
senza internet l'interfaccia non partiva affatto (Alpine governa tutta la UI).

Le librerie passano in static/vendor/ con la versione nel nome del file, e la
CSP del backend si chiude a solo-origine. Il worker di PDF.js e' ripuntato in
tutti e quattro i template che lo usano: ripuntando solo lo script principale la
libreria si carica in locale ma il worker continua a cercare internet, e sembra
funzionare finche' non si apre un disegno.

Alpine e' pinnato a 3.15.12, cioe' la versione a cui "3.x.x" risolveva oggi: il
congelamento non cambia il comportamento di quanto e' gia' in esercizio, e toglie
il fatto che l'applicazione cambiasse da sola a ogni rilascio degli autori -
rilevante per le evidenze ISO 9001 / IATF 16949.

Inter e JetBrains Mono sono font variabili: un solo woff2 per subset copre tutti
i pesi, dove Google serviva lo stesso file sotto otto URL. Tenuti i soli subset
latin e latin-ext, che coprono per intero italiano e inglese.

Provenienza, versioni e impronte SHA-256 in static/vendor/VERSIONS.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 15:41:57 +00:00
64 changed files with 6795 additions and 1256 deletions
+5
View File
@@ -24,6 +24,11 @@ API_SERVER_URL=http://localhost:8000
# Each physical tablet/PC deployment must set this unique per-station value.
# Leave empty only for a single-station all-in-one demo using ST-DEFAULT.
STATION_CODE=ST-DEFAULT
# Allow switching station from the URL (?station=CODE) during commissioning, so a
# single PC can exercise several stations. Leave at 0 in production: on the shop
# floor the station identity comes from the local install, and measuring against
# another station's recipes would silently break traceability.
STATION_SWITCH_ENABLED=0
# --- File Storage ---
# Resolved against the project root in src/backend/config.py.
+614
View File
@@ -0,0 +1,614 @@
# TieMeasureFlow — modifiche da apportare
**Data:** 28 luglio 2026 (agg. serale: architettura d'installazione, punti 7-8 precisati, punti 14-15 nuovi, timer bidirezionale nel punto 3)
**Riferimento codice:** branch `V2.0.0`, commit `2a56632`
**Destinatari:** Parte 1 leggibile da cliente e rivenditore · Parte 2 per chi mette mano al codice
---
## Come leggere questo documento
La **Parte 1** dice *cosa deve fare* il sistema e *perché*: si può mandare a Ricerca e
Misure e a Tràfilo. La **Parte 2** dice *dove si interviene*, con file e criteri di
completamento. La **Parte 3** elenca le domande la cui risposta non dipende da noi.
Ogni punto porta lo **stato verificato sul codice**, non riferito: `già fatto`,
`parziale`, `da fare`. Serve a evitare l'equivoco emerso in questi giorni — diverse
funzioni risultano "già implementate" perché **l'interfaccia c'è**, ma dietro non
succede nulla.
**Fonti:** specifica *Schema sviluppo SW TieFlow rev. 04-2026*; *MODIFICHE TIEMEASURE
v260519*; call del 28/07/2026; rettifica di Marco Menoncin del 28/07; lettura diretta
del codice.
---
## Il punto che regge tutti gli altri
**Lo stato della produzione oggi vive dentro una pagina del browser.**
Il timer, il conteggio dei cicli, il flag "produzione avviata" e le misure in corso
sono variabili del componente Alpine di `task_execute.html`. La navigazione fra un
task e l'altro è un **ricaricamento completo di pagina** (`window.location.href`).
Conseguenza diretta: **cambiando task si perde tutto.** Il timer smette di esistere,
il conteggio dei cicli riparte, "produzione avviata" si dimentica.
Questo spiega, senza bisogno di altre ipotesi:
- perché il loop di misura non regge quando la ricetta ha altri task dopo la misura;
- perché con più task di misura non si sa a quale tornare allo scadere del timer;
- perché *fermo linea* e *fine produzione* non hanno nulla da fermare o da chiudere;
- perché non esiste storico di cosa è successo durante una produzione.
**Va risolto per primo**: i punti 2, 3, 6 e 8 dipendono da questa scelta, e affrontarli
prima significa rifarli dopo.
---
## Architettura d'installazione (decisa il 28/07)
Chiude la domanda D-3 lato Tielogic. L'installazione a Tràfilo è così composta:
```
SERVER (uno, del cliente) STAZIONE (una per PC, installata in locale)
┌─────────────────────────┐ ┌──────────────────────────────────────┐
│ MySQL │◄──API──│ App di stazione: │
│ Backend dati (API) │ │ · interfaccia operatore (frontend) │
│ File dei disegni │ │ · agente hardware: calibro USB, │
│ Migrazioni dello schema │ │ driver camera, colonnina/cicalino │
│ Adattatore GAIA │ │ · elaborazione visione (futura: │
└─────────────────────────┘ │ XF compare, pattern matching) │
└──────────────────────────────────────┘
```
- **Sul server vive tutto ciò che è stato**: il database, i file dei disegni, le
migrazioni dello schema e l'unico punto di contatto col gestionale GAIA.
Aggiornare la logica dati = un deploy, in un posto solo.
- **Sulla stazione vive tutto ciò che tocca l'hardware**: calibro, telecamera,
segnalazione luminosa/acustica, e in prospettiva l'elaborazione visione — che
così non carica il server (rilevante per il dimensionamento, D-4).
L'app di stazione è **senza stato**: non tocca il database direttamente, parla
col backend dati via API e gli manda risultati e immagini.
- **L'identità della stazione è data dall'installazione** (configurata sul PC),
non più dal container sul server: il vincolo "una stazione = un container"
decade.
- La **licenza per postazione** ha l'aggancio naturale nell'app di stazione
installata.
**Correzione a quanto detto in call:** i disegni **non stanno nel database**
stanno su filesystem (`uploads/`), serviti dal backend. Con questa architettura
restano sul server, in un posto solo: nessuna cartella da sincronizzare fra i PC.
Conseguenza per lo sviluppo: lo stato della produzione (punto 1) va nel database
**per obbligo architetturale**, non per scelta — con 20 app di stazione non esiste
altro posto dove possa vivere.
---
# Parte 1 — Cosa deve fare il sistema
## Quadro: cosa già funziona
Da mettere a verbale, perché in parte era dato per mancante:
| Funzione | Stato |
|---|---|
| Stazioni e assegnazione ricette alle stazioni | ✅ presente |
| Ruolo **capoturno** con autorizzazione a login | ✅ presente |
| Timer d'intervallo misura con **conto alla rovescia a video** e cicalino | ✅ presente |
| Logout automatico per inattività, configurabile | ✅ presente |
| Tolleranze con soglie di attenzione (UTL/UWL/LWL/LTL) ed esito per quota | ✅ presente |
| Versionamento delle ricette con storico delle modifiche | ✅ presente |
| Registrazione del **tempo di inserimento** di ogni misura | ✅ presente (28/07) |
| Pulsanti *Fermo linea*, *Fine produzione*, *Avvio produzione* | ⚠️ solo interfaccia |
L'ultima riga è la più importante: i tre pulsanti esistono, chiedono correttamente
l'autorizzazione del capoturno, **e poi non fanno nulla**.
---
## 1. Memoria della produzione in corso `da fare` · prerequisito
**Oggi:** lo stato di una produzione esiste solo finché l'operatore resta sulla stessa
schermata.
**Deve:** la produzione diventa un'entità con una vita propria — si apre quando parte,
registra ciò che accade (avvio, cicli di misura, fermo linea, ripresa, chiusura) e si
chiude quando il capoturno la chiude. Se l'operatore cambia task, esce e rientra, o il
tablet si riavvia, la produzione è ancora lì con il suo timer.
**Perché conta:** senza questo, *fermo linea* e *fine produzione* non possono
funzionare, il loop di misura non è realizzabile e non esiste il dato storico su cui
poggiano sia la statistica sia l'integrazione col gestionale.
---
## 2. Tipo di task esplicito `da fare`
**Oggi:** il sistema distingue un task di misura da un task documentale **deducendolo**:
se ha delle quote è una misura, altrimenti è una nota. Non esiste un tipo dichiarato.
**Deve:** ogni task nasce con un tipo scelto da chi crea la ricetta — **nota**,
**misura**, **disegno** — predisposto per i tipi futuri (confronto profilo/DXF, misura
con camera).
**Perché conta:** è la richiesta n. 1 di Menoncin, ed è ciò a cui si agganciano timer e
loop. Con la deduzione attuale un task di misura a cui non sono ancora state inserite le
quote viene trattato come una nota: il sistema si comporta in modo diverso a seconda di
quanto è completa la ricetta.
---
## 3. Loop di misura e ripetizione `parziale`
**Oggi:** allo scadere del timer suona il cicalino e il ciclo si riazzera — ma solo se
l'operatore è rimasto su quella schermata.
**Deve:**
- l'operatore esegue i task in sequenza fino al primo task di misura;
- da lì **resta in loop sulla misura** finché la produzione non viene chiusa;
- il conto alla rovescia di **quanto manca alla prossima misura** è sempre visibile;
- **arrivato a zero, il contatore riparte nell'altro senso**: mostra da quanto tempo
si è **oltre** l'intervallo di misura, in evidenza — così il ritardo si vede, non
si deduce (richiesta dell'operatore, 28/07);
- allo scadere del timer la misura **si ripropone** ovunque si trovi l'operatore;
- se la ricetta ha **più task di misura**, il timer riparte alla fine dell'**ultimo**;
- dev'essere possibile **girare il pezzo e rimisurare** senza chiudere il ciclo.
**Perché conta:** è la seconda richiesta di Menoncin e il comportamento descritto al
punto 4.10 della specifica del 19/05. La logica c'è già in gran parte: manca che
sopravviva al cambio di schermata (punto 1).
---
## 4. Limite di tentativi prima del capoturno `da fare`
**Oggi:** l'operatore può ripetere la misura quante volte vuole.
**Deve:** dopo un numero di tentativi definito nella ricetta, per proseguire serve
l'autorizzazione del capoturno.
**Perché conta:** è nella specifica ed è la contromisura al caso in cui si ripete finché
non "viene bene". Il meccanismo di autorizzazione esiste già: manca il contatore e la
soglia.
---
## 5. Avanzamento solo se in tolleranza `da verificare`
**Deve:** si passa alla quota successiva in autonomia **solo se la quota è in
tolleranza**; per confermare una quota fuori tolleranza serve il capoturno.
**Nota:** il gate del capoturno per il fuori tolleranza è implementato. Va verificato
sul campo che **blocchi davvero l'avanzamento** e non sia solo una richiesta di conferma.
Se blocca, il punto si chiude senza sviluppo.
---
## 6. Fermo linea e Fine produzione: dare effetto `parziale`
**Oggi:** entrambi chiedono l'autorizzazione del capoturno, poi non succede niente.
**Deve:**
- **Fermo linea** — sospende il timer e la produzione; il capoturno può riattivarla;
- **Fine produzione** — chiude la produzione, ferma il timer definitivamente e **invia
i dati di misura dell'intera produzione** al file di statistica.
**Perché conta:** il comportamento verso il gestionale è ancora da definire (Parte 3),
ma **tutto ciò che sta prima del gestionale si può e si deve fare adesso**: sospendere,
riprendere, chiudere, registrare. Consegnare i pulsanti funzionanti senza il gestionale
è possibile; il contrario no.
---
## 7. Gestione delle stazioni `parziale`
**Deve:**
- la lista stazioni mostra, oltre a codice e postazione, **le ricette collegate**;
- esiste un **reset della stazione**, con un pulsante **per riga** nella lista:
la stazione torna senza ricette associate e si riassegna (precisazione
dell'operatore, 28/07);
- la **stazione corrente si può cambiare al volo**, per poter provare più stazioni da un
solo computer senza riconfigurare l'installazione.
**Perché conta:** è la terza richiesta di Menoncin. Il cambio al volo non è un vezzo da
sviluppatori: senza, la sessione di collaudo con Menoncin richiede tanti PC quante sono
le stazioni da provare.
---
## 8. Tracciabilità obbligatoria `parziale`
**Oggi:** numero di lotto e numero seriale sono facoltativi e si inseriscono nella lista
task, cioè dopo aver iniziato.
**Deve:** l'obbligatorietà di lotto e seriale si **decide alla creazione della
ricetta** (obbligatori sì/no); l'operatore li inserisce **alla selezione della
ricetta**, e finché mancano il pulsante *Avvia* **non si attiva** (precisazione
dell'operatore, 28/07).
**Perché conta:** una misura senza lotto non è tracciabile a posteriori, e la
tracciabilità è metà del valore del sistema in un audit.
---
## 9. Blocco dell'inserimento manuale `da fare`
**Oggi:** il sistema registra **come** è stata inserita una misura (calibro o tastiera),
ma accetta sempre entrambi.
**Deve:** un'impostazione della ricetta consente o vieta l'inserimento manuale, **con
divieto come impostazione predefinita**: si misura col calibro.
**Perché conta:** è il punto sollevato in call — senza questo vincolo un valore in
tolleranza si può digitare. Il dato su *come* è stata inserita c'è già: manca la regola
che lo impedisce.
---
## 10. Interfaccia operatore: sequenza e conferme `da fare`
Richieste del 19/05, tutte di interfaccia:
- un pulsante che **avvia i task in sequenza**, senza sceglierli a uno a uno;
- la lista completa dei task retrocessa a **secondo livello**, per tornare a vedere i
task precedenti;
- «inizia misure» rinominato **«visualizza singolo TASK»**;
- dentro il task, «Riepilogo» sostituito da **«Completato»** per passare al successivo;
- un task lasciato a metà **resta incompiuto** e si vede;
- «fine ciclo misura» **cliccabile solo quando tutte le quote hanno un valore**.
---
## 11. Formattazione delle descrizioni `da fare`
**Deve:** le descrizioni dei task accettano andate a capo e grassetto.
**Perché conta:** chi crea le ricette fa **copia e incolla dal PDF della scheda
tecnica**; oggi il testo arriva appiattito e va risistemato a mano ogni volta.
---
## 12. Funzionamento senza internet `da fare` · bloccante per l'installazione
**Oggi:** l'applicazione **non funziona senza collegamento a internet**. Cinque librerie
vengono scaricate al volo da servizi esterni ogni volta che si apre una pagina.
**In una rete di produzione isolata — la norma in fabbrica — il risultato è una pagina
bianca.** Non un degrado: l'interfaccia non parte proprio, e senza le altre non si vedono
i disegni tecnici né i grafici statistici.
**Deve:** tutte le librerie sono incluse nell'installazione e l'applicazione funziona a
rete staccata.
**Perché conta ora:** l'installazione a Tràfilo è on-premise e prevista per settembre.
È poco lavoro, ma va fatto **prima**, non in fabbrica il giorno dell'installazione.
**Beneficio collaterale non ovvio:** oggi una delle librerie è agganciata a una versione
"qualunque della serie 3" — cioè **l'applicazione cambia da sola** quando gli autori
pubblicano un aggiornamento, senza che nessuno l'abbia validata. Per un sistema che
produce evidenze per audit ISO 9001 / IATF 16949 questo è di per sé un problema.
Includendo le librerie le versioni si congelano: da difetto diventa argomento di vendita.
---
## 13. Generazione dei task dalla scheda tecnica con l'AI `fuori offerta`
**Richiesta:** leggere il PDF della scheda tecnica e **creare un task per blocco**,
invece del copia-incolla manuale.
**Storia:** posta il **19/05/2026** nel documento delle modifiche, rimasta senza
risposta; **rilanciata da Tràfilo il 28/07** come elaborazione massiva iniziale delle
schede, «senza installare agenti nel sistema».
**Stato:** non è in nessuna offerta. Prima di quotare servono tre informazioni: quante
sono le schede, se il formato è standard, e se l'elaborazione è una-tantum in fase di
avviamento o una funzione permanente del prodotto. Sono domande da fare, non da
supporre — vedi Parte 3.
---
## 14. Stabilità del layout `da fare` · da circoscrivere
**Oggi:** le dimensioni delle viste **cambiano a seconda del menu**: passando da una
schermata all'altra la finestra non mantiene proporzioni stabili.
**Deve:** il layout resta stabile nel passaggio fra le viste.
**Nota:** segnalazione dell'operatore del 28/07, non ancora circoscritta sul codice —
prima di intervenire va riprodotta e va stilato l'elenco delle viste interessate.
---
## 15. Statistica: si registra sempre, si consulta a parte `già fatto` · da confermare sul campo
**Richiesta (28/07):** a fine misura l'operatore **non va portato nella pagina della
statistica**. I dati **entrano comunque in statistica**: cambia solo chi la consulta —
serve l'**utente con il ruolo adeguato**, che apre la pagina dedicata.
**Verificato sul codice:** è già così. Tutte le pagine di statistica richiedono il
ruolo **Metrologo** (`role_required("Metrologist")` su ogni route), e a fine ciclo
l'operatore viene portato al **riepilogo**, non alla statistica.
**Resta da fare:** niente sviluppo; il requisito entra come **criterio di collaudo**
(l'operatore non deve poter raggiungere la statistica da nessun percorso) e va tenuto
fermo quando il punto 10 ridisegna la navigazione a fine task.
---
# Parte 2 — Dove si interviene
Riferimenti al branch `V2.0.0`, commit `2a56632`.
## Ordine consigliato
```
12 (offline) ──────────────► indipendente, si può fare subito
bloccante per l'installazione
1 (stato produzione) ──┬───► 3 (loop misura)
├───► 6 (fermo linea / fine produzione)
└───► 4 (limite tentativi)
2 (tipo task) ─────────────► 3, 10
7 (stazioni) · 8 (tracciabilità) · 9 (inserimento manuale) · 11 (formattazione)
indipendenti fra loro
5 (avanzamento in tolleranza) ──► prima verificare, forse è già a posto
14 (layout) ────────────────► prima riprodurre e circoscrivere le viste
15 (statistica riservata) ──► già a posto: solo criterio di collaudo,
da non rompere lavorando sul punto 10
```
La separazione **backend dati sul server / app di stazione in locale** (vedi
*Architettura d'installazione*) non è un punto di questa lista: è il contesto in
cui i punti 1, 3, 6 e 7 vanno progettati. In pratica: API senza stato, stato solo
nel database, niente dipendenze dal container per l'identità della stazione.
## 1 · Stato della produzione lato server
**Problema tecnico:** `task_execute.html` tiene in variabili Alpine
(`timerActive`, `timerRemaining`, `_timerInterval`, `cycleCount`, `cycleConfirmed`,
`productionStarted`, `measurements`) uno stato che deve sopravvivere alla pagina.
`goToNextTask()` fa `window.location.href` → il componente viene distrutto.
Lato server la sessione conserva soltanto `lot_number` e `serial_number`.
**Intervento:**
- nuove tabelle `production_runs` e `production_events` (avvio, ciclo completato, fermo
linea, ripresa, chiusura), con `station_id`, `recipe_version_id`, `operator_id`,
`supervisor_id` dove serve, timestamp;
- endpoint REST per aprire, interrogare e aggiornare la produzione corrente della
stazione;
- il frontend legge lo stato all'apertura di ogni pagina invece di tenerlo in memoria;
il conto alla rovescia si **ricalcola dall'orario di scadenza** salvato lato server,
non da un contatore locale;
- gli endpoint vanno progettati **senza stato in memoria di processo**: con l'app di
stazione installata su ogni PC (vedi *Architettura d'installazione*) il database è
l'unico posto condiviso.
**File:** `src/backend/models/orm/` (nuovo modulo), `src/backend/migrations/versions/`
(migrazione 005), `src/backend/api/routers/`, `src/frontend/flask_app/blueprints/measure.py`,
`src/frontend/flask_app/templates/measure/task_execute.html`.
**Fatto quando:** avviata una produzione, si naviga fra i task, si esce e si rientra, e
il timer prosegue coerente; il riavvio del browser non azzera nulla.
---
## 2 · Tipo di task
**Problema tecnico:** `RecipeTask` non ha campo tipo; il frontend decide con
`subtasks.length > 0` (`task_execute.html`, `task_list.html`).
**Intervento:** campo `type` su `recipe_tasks` con valori `note | measure | drawing`
(predisposto per `xf_compare`, `camera_measure`), migrazione con valorizzazione dei dati
esistenti secondo la regola attuale, selezione del tipo nell'editor ricetta,
sostituzione dei controlli su `subtasks.length` con il tipo.
**File:** `src/backend/models/orm/task.py`, nuova migrazione,
`src/backend/models/api/`, `src/frontend/flask_app/templates/maker/task_editor.html`,
`templates/measure/task_execute.html`, `templates/measure/task_list.html`.
**Fatto quando:** una ricetta con un task di misura ancora privo di quote si comporta da
task di misura.
---
## 3 · Loop di misura
**Base già presente:** `confirmCycle()`, `startMeasurementTimer()`, `onTimerExpired()`,
`timerDisplay`, `playBuzzer()` in `task_execute.html` (righe ~955-1050). La logica è
corretta; il problema è la persistenza (punto 1) e il fatto che allo scadere non si può
riportare l'operatore sul task giusto.
**Intervento:** spostare la scadenza sul server; alla scadenza, **redirezione al task di
misura** della produzione corrente; con più task di misura far ripartire il timer al
completamento dell'**ultimo**; aggiungere «rimisura» che riapre il ciclo senza chiuderlo.
**Timer bidirezionale:** oggi il contatore **solo decrementa e si ferma a zero**
(`timerRemaining--`, poi `onTimerExpired()`); `timerDisplay` formatta minuti:secondi
dal residuo. Va esteso: sotto zero il valore continua **in negativo** e la
visualizzazione passa a "oltre da m:s", con stile in evidenza. Calcolando dal
timestamp di scadenza lato server (come sopra), il ritardo è coerente su qualunque
schermata e sopravvive al ricaricamento.
**Fatto quando:** con una ricetta a due task di misura e task documentali in coda, allo
scadere del timer l'operatore viene riportato alla misura da qualunque schermata.
---
## 4 · Limite di tentativi
**Problema tecnico:** nessun `max_retries` nel codice.
**Intervento:** campo sulla ricetta (accanto a `measurement_interval_minutes`, che segue
lo stesso schema), contatore per quota nella produzione corrente, superata la soglia
riuso del modale capoturno esistente con motivo `max_retries`.
**File:** `src/backend/models/orm/recipe.py`, migrazione,
`templates/maker/recipe_editor.html` (accanto al timer), `task_execute.html`.
---
## 5 · Avanzamento in tolleranza — prima verificare
Il modale capoturno gestisce già `out_of_tolerance` (`task_execute.html`,
`openSupervisorModal`, `validateSupervisor`, endpoint
`measure.validate_supervisor`). **Prima di sviluppare, provare**: se il rifiuto blocca
l'avanzamento, il punto è chiuso. Se è solo una conferma, va reso vincolante.
---
## 6 · Fermo linea e Fine produzione
**Problema tecnico:** in `task_execute.html` i due pulsanti aprono il modale e poi
ricadono su un commento: `fermo_linea and fine_produzione are handled by GAIA
integration (future)`. `startProduction()` è un `TODO` con la chiamata commentata.
**Intervento (senza gestionale):** scrivere gli eventi su `production_events`,
sospendere e riprendere il timer, chiudere la produzione, ed **emettere il file di
statistica** con tutte le misure della produzione. L'invio al gestionale resta un
adattatore separato da riempire quando il protocollo sarà definito (Parte 3): va
previsto il punto d'innesto, non l'implementazione.
**Fatto quando:** *fermo linea* congela il timer e solo il capoturno lo riattiva; *fine
produzione* chiude e produce il file.
---
## 7 · Stazioni
**Base presente:** `Station` e `StationRecipeAssignment` (`models/orm/station.py`,
migrazione 002).
**Intervento:** ricette collegate nella lista stazioni; pulsante di reset **per riga**
che rimuove le assegnazioni; selezione della stazione corrente da parametro URL con
ricaduta sulla variabile d'ambiente, per il collaudo da una sola macchina.
**Nota (28/07, chiude il dubbio che era in D-3):** in produzione l'identità della
stazione è data dall'**installazione locale** dell'app di stazione, non dal container.
Il cambio al volo via URL resta come strumento di **collaudo**.
---
## 8 · Tracciabilità
**Base presente:** `lot_number` e `serial_number` su `Measurement`, salvataggio in
sessione (`measure.save_traceability`), inserimento in `task_list.html`.
**Intervento:** flag sulla ricetta (`richiede lotto`, `richiede seriale`); spostare
l'inserimento sull'avvio produzione; *Avvia* disabilitato finché mancano.
---
## 9 · Inserimento manuale
**Base presente:** `Measurement.input_method` (`manual | usb_caliper`), valorizzato dal
frontend.
**Intervento:** flag sulla ricetta `consente inserimento manuale`, **predefinito falso**;
validazione **lato server** — un flag solo nel frontend non protegge da nulla; tastierino
nascosto quando vietato.
---
## 10 · Interfaccia operatore
Interventi su `templates/measure/task_list.html` e `task_execute.html`: pulsante di
avvio in sequenza, retrocessione della lista a secondo livello, rinomina dei due
pulsanti, stato «incompiuto» sui task abbandonati, «fine ciclo misura» abilitato solo a
quote complete.
---
## 11 · Formattazione descrizioni
`RecipeTask.description` è già `Text`. Serve un editor minimale (grassetto e a capo) e
la resa corrispondente in esecuzione, con **sanificazione dell'HTML** in ingresso.
**File:** `templates/maker/task_editor.html`, `templates/measure/task_execute.html`.
---
## 12 · Funzionamento senza internet
**Verificato sul codice.** Cinque librerie esterne in sei template:
| Libreria | Dove | Senza rete si perde |
|---|---|---|
| Alpine.js `3.x.x` | `base.html` | **tutta l'interfaccia** |
| Plotly `2.32.0` | `statistics/dashboard.html` | carte di controllo e istogrammi |
| PDF.js `3.11.174` | `task_execute`, `recipe_preview`, `task_drawing`, `task_editor` | visualizzazione dei disegni |
| Fabric.js `5.3.1` | `maker/task_drawing.html` | editor delle annotazioni |
| Google Fonts | `base.html` | estetica e attese al caricamento |
**Intervento:** scaricare le librerie in `src/frontend/flask_app/static/vendor/` (la
cartella **esiste già ed è vuota**), ripuntare i tag, chiudere la policy di sicurezza in
`security_headers.py` da elenco-di-CDN a solo-origine-locale.
⚠️ **Trappola da non mancare:** PDF.js ha una **seconda** referenza al CDN,
`pdfjsLib.GlobalWorkerOptions.workerSrc`, presente in **quattro file**. Ripuntando solo
lo script principale la libreria si carica in locale **e il worker continua a cercare
internet**: sembra funzionare finché non si apre un disegno.
**Fatto quando:** con la rete staccata si percorre login → scelta ricetta → esecuzione
task → annotazione disegno → statistiche → report, senza errori in console.
---
# Parte 3 — Decisioni che non dipendono da noi
Da chiudere **prima** che i punti collegati entrino in sviluppo. Vanno girate a Tràfilo
tramite Menoncin.
| # | Domanda | Blocca | Chi risponde |
|---|---|---|---|
| **D-1** | **Protocollo del gestionale GAIA**: come si scambiano i dati — servizi web, database condiviso, file? | avvio produzione, fermo linea, fine produzione verso il gestionale; lettura dei codici articolo per stazione | IT Tràfilo + fornitore GAIA |
| **D-2** | **Rete e credenziali** per raggiungere GAIA dal server dove sarà installato | come sopra | IT Tràfilo |
| ~~**D-3**~~ | ~~Una applicazione per stazione o una sola per tutte?~~ **Decisa il 28/07** lato Tielogic: backend dati sul server, app di stazione installata su ogni PC — vedi *Architettura d'installazione*. Resta la validazione con l'IT di Tràfilo (macchina e rete) | — | chiusa (noi); validazione in D-4 |
| **D-4** | **Server**: quale macchina, quanto spazio disco. Sul server stanno database **e file dei disegni** (`uploads/`); l'elaborazione visione **non** è sul server (sta sull'app di stazione), quindi pesa lo spazio disco, non la potenza di calcolo | installazione | IT Tràfilo |
| **D-5** | **Cicalino**: basta il suono del browser o serve una segnalazione luminosa? La specifica chiede luce **e** suono accesi per tutta la misura, visibili da lontano. Con l'architettura del 28/07 l'app di stazione **può pilotare una colonnina**: la domanda diventa *quale hardware* | punto 3 | Tràfilo |
| **D-6** | **Autorizzazione capoturno**: username e password come oggi, o PIN rapido / badge? Venti volte al giorno la password è un attrito | punti 4, 5, 6 | Tràfilo |
| **D-7** | **Numero di tentativi** consentiti prima del capoturno: quanti, e uguali per tutte le ricette? | punto 4 | Tràfilo |
| **D-8** | **Schede tecniche**: quante sono, il formato è standard, e serve una conversione una-tantum o una funzione permanente? | punto 13 e la sua quotazione | Tràfilo |
| **D-9** | Modificare i parametri di una ricetta (timer, tentativi) **crea una nuova versione** o no? Sono parametri di esercizio, non di prodotto | punti 4, 3 | noi, con conferma cliente |
**Nota su D-1 e D-2:** finché non hanno risposta, del gestionale si può solo predisporre
il punto d'innesto. Tutto il resto del punto 6 — sospendere, riprendere, chiudere,
registrare, produrre il file — **si fa comunque e va fatto adesso**.
---
## Appendice — Come è stato verificato
Ogni «già fatto» e ogni «da fare» viene dalla lettura del codice al commit `2a56632`,
non dai documenti. In particolare:
- il **tipo di task** è dedotto da `subtasks.length > 0` in `task_execute.html:117` e
`task_list.html:169`;
- il **ruolo capoturno** esiste come `Supervisor`
(`api/middleware/api_key.py:71`, `blueprints/measure.py:350`);
- **fermo linea / fine produzione / avvio produzione** hanno interfaccia e gate ma
nessun effetto (`task_execute.html:1017-1021`, `1118`);
- il **timer** è un `setInterval` locale alla pagina (`task_execute.html:968-995`) e la
navigazione fra task è un ricaricamento (`task_execute.html:1054`);
- **`max_retries`** e **`production_events`** non compaiono in nessun file;
- le **cinque librerie da CDN** sono ai riferimenti citati al punto 12, e
`static/vendor/` contiene solo `.gitkeep`;
- i **disegni stanno su filesystem**, non nel database: cartella `uploads/` servita
dal backend (`api/routers/files.py:201`, `FileResponse`) — verifica del 28/07 sera,
corregge quanto detto in call;
- la **statistica è già riservata**: ogni route di `blueprints/statistics.py` porta
`@role_required("Metrologist")`; a fine ciclo il frontend va al riepilogo
(`task_execute.html:1126`), non alla statistica;
- il **timer si ferma a zero**: decremento in `task_execute.html:974-975` con uscita
su `onTimerExpired()` — il conteggio del ritardo (punto 3) oggi non esiste;
- lo stack attuale è a 4 container (`docker-compose.yml`): MySQL 8, backend FastAPI,
frontend Flask, nginx — base della sezione *Architettura d'installazione*.
Il punto 14 (layout) è l'unico **non verificato sul codice**: è una segnalazione
dell'operatore del 28/07, da riprodurre.
@@ -10,16 +10,18 @@ from starlette.middleware.base import BaseHTTPMiddleware
from src.backend.config import settings
# Content Security Policy - allows CDN resources used by the client
# Content Security Policy - same-origin only: every third-party library now ships with
# the app (see frontend static/vendor/), so no CDN host needs allowing. The install runs
# on an isolated shop-floor network where an outbound fetch would simply fail.
# Note: 'unsafe-eval' required for Plotly.js runtime evaluation in SPC charts
CSP = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval' "
"https://cdn.tailwindcss.com https://cdn.jsdelivr.net https://cdn.plot.ly; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
"font-src 'self' https://fonts.gstatic.com; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline'; "
"font-src 'self'; "
"img-src 'self' data: blob:; "
"connect-src 'self'"
"connect-src 'self'; "
"worker-src 'self' blob:"
)
+2
View File
@@ -47,6 +47,7 @@ async def create_measurement(
serial_number=data.serial_number,
input_method=data.input_method,
input_duration_ms=data.input_duration_ms,
production_run_id=data.production_run_id,
)
return MeasurementResponse.model_validate(measurement)
except ValueError as e:
@@ -76,6 +77,7 @@ async def create_measurement_batch(
serial_number=measurement_data.serial_number,
input_method=measurement_data.input_method,
input_duration_ms=measurement_data.input_duration_ms,
production_run_id=measurement_data.production_run_id,
)
measurements.append(measurement)
return [MeasurementResponse.model_validate(m) for m in measurements]
+163
View File
@@ -0,0 +1,163 @@
"""Production runs router - open, read, and drive the life of a production.
Deliberately stateless: nothing lives in process memory. With one station app
installed per PC the database is the only shared place, and any station app must be
able to ask "what is going on here?" and get the same answer.
"""
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.api.middleware.api_key import get_current_user
from src.backend.database import get_db
from src.backend.models.api.production import (
CycleCompletePayload,
ProductionEventResponse,
ProductionRunCreate,
ProductionRunResponse,
ProductionRunWithEventsResponse,
RemeasurePayload,
SupervisorAction,
)
from src.backend.models.orm.production import ProductionRun
from src.backend.models.orm.user import User
from src.backend.services import production_service
router = APIRouter(prefix="/api/production-runs", tags=["production"])
_DERIVED = {
"seconds_to_next_measurement", "overdue", "server_time", "measurement_task_ids",
}
async def _as_response(db: AsyncSession, run: ProductionRun) -> ProductionRunResponse:
"""The stored run plus everything the client cannot work out on its own."""
return ProductionRunResponse(
**ProductionRunResponse.model_validate(run).model_dump(exclude=_DERIVED),
**production_service.describe(run),
measurement_task_ids=await production_service.measurement_task_ids(
db, run.version_id,
),
)
@router.post("", response_model=ProductionRunResponse, status_code=status.HTTP_201_CREATED)
async def open_production_run(
data: ProductionRunCreate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Start a production at a station."""
run = await production_service.open_run(db, data, user)
return await _as_response(db, run)
@router.get("/current", response_model=ProductionRunResponse | None)
async def get_current_production_run(
station_code: str = Query(..., min_length=1),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""The run open at this station, or null.
This is what every page asks on load instead of keeping the timer in memory.
"""
station = await production_service.get_station_by_code(db, station_code)
run = await production_service.get_open_run_for_station(db, station.id)
return await _as_response(db, run) if run is not None else None
@router.get("/{run_id}", response_model=ProductionRunWithEventsResponse)
async def get_production_run(
run_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""A run with its full trace - the history a production did not have before."""
run = await production_service.get_run(db, run_id)
events = await production_service.list_run_events(db, run_id)
return ProductionRunWithEventsResponse(
**(await _as_response(db, run)).model_dump(),
events=[ProductionEventResponse.model_validate(e) for e in events],
)
@router.post("/{run_id}/cycle", response_model=ProductionRunResponse)
async def complete_measurement_cycle(
run_id: int,
payload: CycleCompletePayload | None = None,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Record a finished measurement task; the last one restarts the interval."""
run = await production_service.get_run(db, run_id)
run = await production_service.complete_cycle(
db, run, user,
task_id=payload.task_id if payload else None,
note=payload.note if payload else None,
)
return await _as_response(db, run)
@router.post("/{run_id}/remeasure", response_model=ProductionRunResponse)
async def remeasure_in_cycle(
run_id: int,
payload: RemeasurePayload | None = None,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Turn the piece over and measure again, without closing the cycle."""
run = await production_service.get_run(db, run_id)
run = await production_service.remeasure(
db, run, user,
task_id=payload.task_id if payload else None,
note=payload.note if payload else None,
)
return await _as_response(db, run)
@router.post("/{run_id}/pause", response_model=ProductionRunResponse)
async def pause_production_run(
run_id: int,
action: SupervisorAction,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Fermo linea - requires a supervisor."""
run = await production_service.get_run(db, run_id)
supervisor = await production_service.authorise_supervisor(
db, action.supervisor_username, action.supervisor_password,
)
run = await production_service.pause_run(db, run, user, supervisor, note=action.note)
return await _as_response(db, run)
@router.post("/{run_id}/resume", response_model=ProductionRunResponse)
async def resume_production_run(
run_id: int,
action: SupervisorAction,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Restart a stopped line - requires a supervisor."""
run = await production_service.get_run(db, run_id)
supervisor = await production_service.authorise_supervisor(
db, action.supervisor_username, action.supervisor_password,
)
run = await production_service.resume_run(db, run, user, supervisor, note=action.note)
return await _as_response(db, run)
@router.post("/{run_id}/close", response_model=ProductionRunResponse)
async def close_production_run(
run_id: int,
action: SupervisorAction,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Fine produzione - requires a supervisor. Stops the timer for good."""
run = await production_service.get_run(db, run_id)
supervisor = await production_service.authorise_supervisor(
db, action.supervisor_username, action.supervisor_password,
)
run = await production_service.close_run(db, run, user, supervisor, note=action.note)
return await _as_response(db, run)
+42 -3
View File
@@ -11,6 +11,8 @@ from src.backend.models.api.station import (
StationResponse,
StationRecipeAssignmentCreate,
StationRecipeAssignmentResponse,
StationResetResponse,
StationWithRecipesResponse,
RecipeSummary,
)
from src.backend.services import station_service
@@ -18,15 +20,36 @@ from src.backend.services import station_service
router = APIRouter(prefix="/api/stations", tags=["stations"])
@router.get("", response_model=list[StationResponse])
@router.get("", response_model=list[StationWithRecipesResponse])
async def list_stations(
active_only: bool = False,
admin: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db),
):
"""List all stations (admin only)."""
"""List all stations with the recipes assigned to each (admin only).
The admin station list shows which products a station handles, so the
assignments travel with the station instead of needing a call per row.
Station.assignments is selectin-loaded, so this costs no extra query.
Only active recipes are listed, matching what an operator actually sees
at that station via /by-code/{code}/recipes.
"""
stations = await station_service.list_stations(db, active_only=active_only)
return [StationResponse.model_validate(s) for s in stations]
return [
StationWithRecipesResponse(
**StationResponse.model_validate(s).model_dump(),
recipes=sorted(
(
RecipeSummary.model_validate(a.recipe)
for a in s.assignments
if a.recipe is not None and a.recipe.active
),
key=lambda r: r.code,
),
)
for s in stations
]
@router.post("", response_model=StationResponse, status_code=status.HTTP_201_CREATED)
@@ -128,6 +151,22 @@ async def assign_recipe_to_station(
return StationRecipeAssignmentResponse.model_validate(assignment)
@router.delete("/{station_id}/recipes", response_model=StationResetResponse)
async def reset_station_recipes(
station_id: int,
admin: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Clear every recipe assignment of a station (admin only).
Backs the per-row reset in the admin list: the station is left with no
recipes and can be reassigned from scratch, without deleting the station
itself. Idempotent - resetting an already empty station reports removed=0.
"""
removed = await station_service.unassign_all_recipes(db, station_id)
return StationResetResponse(station_id=station_id, removed=removed)
@router.delete(
"/{station_id}/recipes/{recipe_id}",
status_code=status.HTTP_204_NO_CONTENT,
+1
View File
@@ -172,6 +172,7 @@ async def create_task(
new_task = RecipeTask(
version_id=new_version.id,
order_index=max_order + 1,
task_type=data.task_type,
title=data.title,
directive=data.directive,
description=data.description,
+2
View File
@@ -21,6 +21,7 @@ from src.backend.api.routers.reports import router as reports_router
from src.backend.api.routers.statistics import router as statistics_router
from src.backend.api.routers.setup import router as setup_router
from src.backend.api.routers.stations import router as stations_router
from src.backend.api.routers.production import router as production_router
@asynccontextmanager
@@ -73,6 +74,7 @@ app.include_router(statistics_router)
app.include_router(reports_router)
app.include_router(setup_router)
app.include_router(stations_router)
app.include_router(production_router)
@app.get("/api/health")
+2
View File
@@ -36,6 +36,8 @@ from src.backend.models.orm.task import RecipeTask, RecipeSubtask # noqa: F401
from src.backend.models.orm.measurement import Measurement # noqa: F401
from src.backend.models.orm.access_log import AccessLog # noqa: F401
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit # noqa: F401
from src.backend.models.orm.station import Station, StationRecipeAssignment # noqa: F401
from src.backend.models.orm.production import ProductionRun, ProductionEvent # noqa: F401
target_metadata = Base.metadata
@@ -0,0 +1,100 @@
"""add production_runs and production_events
Gives a production a life of its own: before this its state lived in the Alpine
component of task_execute.html, so changing task - a full page load - lost the timer,
the cycle count and the "production started" flag.
Revision ID: 005_production_runs
Revises: 004_input_duration
Create Date: 2026-07-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '005_production_runs'
down_revision: Union[str, None] = '004_input_duration'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'production_runs',
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
sa.Column('station_id', sa.Integer, sa.ForeignKey('stations.id'), nullable=False),
sa.Column('recipe_id', sa.Integer, sa.ForeignKey('recipes.id'), nullable=False),
sa.Column('version_id', sa.Integer, sa.ForeignKey('recipe_versions.id'), nullable=False),
sa.Column('operator_id', sa.Integer, sa.ForeignKey('users.id'), nullable=False),
sa.Column('lot_number', sa.String(100), nullable=True),
sa.Column('serial_number', sa.String(100), nullable=True),
sa.Column(
'status',
sa.Enum('running', 'paused', 'closed', name='production_run_status_enum'),
nullable=False,
server_default='running',
),
sa.Column('measurement_interval_minutes', sa.SmallInteger, nullable=True),
sa.Column('next_measurement_at', sa.DateTime, nullable=True),
sa.Column('cycle_count', sa.Integer, nullable=False, server_default='0'),
sa.Column('started_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
sa.Column('paused_at', sa.DateTime, nullable=True),
sa.Column('closed_at', sa.DateTime, nullable=True),
sa.Column('closed_by', sa.Integer, sa.ForeignKey('users.id'), nullable=True),
# Mirrors station_id while open, NULL once closed. The unique constraint makes
# "one open run per station" a database guarantee rather than a race; repeated
# NULLs do not collide, so closed runs are free to pile up.
sa.Column('active_station_id', sa.Integer, nullable=True),
# Declared inline rather than added afterwards: adding a constraint is an ALTER,
# which SQLite cannot do, and the test databases are SQLite.
sa.UniqueConstraint('active_station_id', name='uq_production_runs_active_station'),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
)
op.create_index('ix_production_runs_station_id', 'production_runs', ['station_id'])
op.create_index('ix_production_runs_recipe_id', 'production_runs', ['recipe_id'])
op.create_index('ix_production_runs_version_id', 'production_runs', ['version_id'])
op.create_index('ix_production_runs_operator_id', 'production_runs', ['operator_id'])
op.create_index('ix_production_runs_lot_number', 'production_runs', ['lot_number'])
op.create_index('ix_production_runs_serial_number', 'production_runs', ['serial_number'])
op.create_index('ix_production_runs_status', 'production_runs', ['status'])
op.create_index(
'ix_production_runs_next_measurement_at', 'production_runs', ['next_measurement_at'],
)
op.create_index(
'ix_production_runs_station_status', 'production_runs', ['station_id', 'status'],
)
op.create_table(
'production_events',
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
sa.Column(
'run_id', sa.Integer,
sa.ForeignKey('production_runs.id', ondelete='CASCADE'), nullable=False,
),
sa.Column(
'event_type',
sa.Enum(
'start', 'cycle_completed', 'line_stop', 'resume', 'close',
name='production_event_type_enum',
),
nullable=False,
),
sa.Column('user_id', sa.Integer, sa.ForeignKey('users.id'), nullable=False),
sa.Column('supervisor_id', sa.Integer, sa.ForeignKey('users.id'), nullable=True),
sa.Column('note', sa.Text, nullable=True),
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
)
op.create_index('ix_production_events_run_id', 'production_events', ['run_id'])
op.create_index('ix_production_events_event_type', 'production_events', ['event_type'])
op.create_index('ix_production_events_user_id', 'production_events', ['user_id'])
op.create_index('ix_production_events_created_at', 'production_events', ['created_at'])
def downgrade() -> None:
op.drop_table('production_events')
op.drop_table('production_runs')
@@ -0,0 +1,63 @@
"""link measurements to their production run
Fine produzione has to hand the measurements of the whole production to the
statistics file. Without this column "the measurements of this production" is not a
query: measurements only knew their recipe version, lot and serial, none of which
delimits one run from the next on the same recipe and lot.
Nullable on purpose: measurements taken before this - and any taken outside a
production - simply have no run.
Revision ID: 006_measurement_run
Revises: 005_production_runs
Create Date: 2026-07-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '006_measurement_run'
down_revision: Union[str, None] = '005_production_runs'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Batch mode, because the column carries a foreign key: adding a constraint is
# an ALTER that SQLite cannot do, and alembic raises rather than silently
# dropping it. On MySQL this is a plain ALTER; on SQLite it rebuilds the table.
# Keeping the key is worth the ceremony - ON DELETE SET NULL means a measurement,
# the record that matters in an audit, survives its run being deleted.
with op.batch_alter_table('measurements') as batch_op:
batch_op.add_column(
sa.Column(
'production_run_id', sa.Integer,
sa.ForeignKey(
'production_runs.id',
ondelete='SET NULL',
# Named because batch mode requires it, and because an anonymous
# constraint cannot be referred to later.
name='fk_measurements_production_run',
),
nullable=True,
),
)
op.create_index(
'ix_measurements_production_run_id', 'measurements', ['production_run_id'],
)
# Where the statistics file for a closed run was written. No constraint, so a
# plain ALTER is enough.
op.add_column(
'production_runs',
sa.Column('statistics_path', sa.String(500), nullable=True),
)
def downgrade() -> None:
op.drop_column('production_runs', 'statistics_path')
op.drop_index('ix_measurements_production_run_id', table_name='measurements')
with op.batch_alter_table('measurements') as batch_op:
batch_op.drop_column('production_run_id')
@@ -0,0 +1,64 @@
"""add an explicit type to recipe tasks
Until now the kind of a task was deduced: quotes present meant a measurement,
otherwise a note. A measurement task whose quotes had not been entered yet was
therefore treated as a note - the system behaved differently depending on how
complete the recipe happened to be.
Existing rows are classified by the rule that was in force, so nothing changes
behaviour on upgrade: tasks with quotes become 'measure'. Tasks with no quotes but
a drawing attached become 'drawing' rather than 'note' - that is what they already
displayed as, and calling them notes would be the one place this migration did
change behaviour.
Revision ID: 007_task_type
Revises: 006_measurement_run
Create Date: 2026-07-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '007_task_type'
down_revision: Union[str, None] = '006_measurement_run'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
TASK_TYPES = ('note', 'measure', 'drawing', 'xf_compare', 'camera_measure')
def upgrade() -> None:
op.add_column(
'recipe_tasks',
sa.Column(
'task_type',
sa.Enum(*TASK_TYPES, name='task_type_enum'),
nullable=False,
server_default='note',
),
)
op.create_index('ix_recipe_tasks_task_type', 'recipe_tasks', ['task_type'])
# Backfill by the rule the frontend used, so no recipe changes behaviour.
op.execute(
"""
UPDATE recipe_tasks
SET task_type = 'measure'
WHERE id IN (SELECT DISTINCT task_id FROM recipe_subtasks)
"""
)
op.execute(
"""
UPDATE recipe_tasks
SET task_type = 'drawing'
WHERE task_type = 'note'
AND file_path IS NOT NULL
"""
)
def downgrade() -> None:
op.drop_index('ix_recipe_tasks_task_type', table_name='recipe_tasks')
op.drop_column('recipe_tasks', 'task_type')
@@ -0,0 +1,74 @@
"""widen the production event trace for the measurement loop
The loop needs to distinguish two things the trace could not say before: a
measurement task finished while the cycle is still running (task_measured), and the
piece turned over and measured again without closing the cycle (remeasure). Only
the last measurement task of a recipe still produces cycle_completed, because only
that one restarts the interval.
Both values are added in one go. Widening a MySQL enum rewrites the table, so the
values that are already planned go in now rather than one migration at a time -
the same reasoning as the task types in 007.
The events that happen inside a cycle also need to say which task they are about,
so the column comes with them: a task_measured that does not name its task records
nothing usable.
Revision ID: 008_loop_events
Revises: 007_task_type
Create Date: 2026-07-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '008_loop_events'
down_revision: Union[str, None] = '007_task_type'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
OLD_TYPES = ('start', 'cycle_completed', 'line_stop', 'resume', 'close')
NEW_TYPES = (
'start', 'cycle_completed', 'task_measured', 'remeasure',
'line_stop', 'resume', 'close',
)
def _alter(values: Sequence[str]) -> None:
"""Restate the column with a new set of allowed values.
On MySQL this is the ALTER that rewrites the enum. On SQLite, where an enum is
stored as a plain VARCHAR, batch mode rebuilds the table instead of emitting an
ALTER the engine does not support - the tests run there.
"""
with op.batch_alter_table('production_events') as batch:
batch.alter_column(
'event_type',
existing_type=sa.Enum(*OLD_TYPES, name='production_event_type_enum'),
type_=sa.Enum(*values, name='production_event_type_enum'),
existing_nullable=False,
)
def upgrade() -> None:
_alter(NEW_TYPES)
with op.batch_alter_table('production_events') as batch:
batch.add_column(sa.Column('task_id', sa.Integer(), nullable=True))
batch.create_foreign_key(
'fk_production_events_task_id', 'recipe_tasks', ['task_id'], ['id'],
)
def downgrade() -> None:
with op.batch_alter_table('production_events') as batch:
batch.drop_constraint('fk_production_events_task_id', type_='foreignkey')
batch.drop_column('task_id')
# The new kinds of event have no equivalent in the old vocabulary, and a row
# left with a value the column no longer accepts would fail the rebuild.
op.execute(
"DELETE FROM production_events "
"WHERE event_type IN ('task_measured', 'remeasure')"
)
_alter(OLD_TYPES)
+3
View File
@@ -14,6 +14,8 @@ class MeasurementCreate(BaseModel):
serial_number: Optional[str] = Field(None, max_length=100)
input_method: str = Field("manual", pattern="^(usb_caliper|manual)$")
input_duration_ms: Optional[int] = Field(None, ge=0)
# The production this belongs to, when one is open at the station.
production_run_id: Optional[int] = Field(None, gt=0)
class MeasurementBatchCreate(BaseModel):
@@ -36,6 +38,7 @@ class MeasurementResponse(BaseModel):
serial_number: Optional[str] = None
input_method: str
input_duration_ms: Optional[int] = None
production_run_id: Optional[int] = None
measured_at: datetime
synced_to_csv: bool
+89
View File
@@ -0,0 +1,89 @@
"""Pydantic schemas for production runs and their events."""
from datetime import datetime
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
class ProductionRunCreate(BaseModel):
station_code: str = Field(..., min_length=1, max_length=100)
recipe_id: int = Field(..., gt=0)
version_id: Optional[int] = Field(default=None, gt=0)
lot_number: Optional[str] = Field(default=None, max_length=100)
serial_number: Optional[str] = Field(default=None, max_length=100)
class ProductionEventResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
event_type: str
user_id: int
supervisor_id: Optional[int]
task_id: Optional[int] = None
note: Optional[str]
created_at: datetime
class ProductionRunResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
station_id: int
recipe_id: int
version_id: int
operator_id: int
lot_number: Optional[str]
serial_number: Optional[str]
status: str
measurement_interval_minutes: Optional[int]
next_measurement_at: Optional[datetime]
cycle_count: int
started_at: datetime
paused_at: Optional[datetime]
closed_at: Optional[datetime]
closed_by: Optional[int]
statistics_path: Optional[str] = None
# Derived server-side so every client agrees on the countdown regardless of
# clock skew. Negative once the interval has elapsed: how long the run has been
# overdue is a fact the operator must see, not deduce.
seconds_to_next_measurement: Optional[int] = None
overdue: bool = False
server_time: Optional[datetime] = None
# The measurement tasks of this run's version, in execution order. The client
# needs them to bring the operator back to the measurement when the interval
# expires, whichever screen they are on, and to know which task closes a cycle.
measurement_task_ids: list[int] = Field(default_factory=list)
class ProductionRunWithEventsResponse(ProductionRunResponse):
events: list[ProductionEventResponse] = Field(default_factory=list)
class SupervisorAction(BaseModel):
"""Credentials of the supervisor authorising a line stop or a close."""
supervisor_username: str = Field(..., min_length=1)
supervisor_password: str = Field(..., min_length=1)
note: Optional[str] = None
class CycleCompletePayload(BaseModel):
# Which measurement task was just finished. The interval restarts only on the
# last one of the recipe; omitting it closes the cycle outright.
task_id: Optional[int] = Field(default=None, gt=0)
note: Optional[str] = None
class RemeasurePayload(BaseModel):
"""The piece was turned over: measure it again inside the same cycle."""
task_id: Optional[int] = Field(default=None, gt=0)
note: Optional[str] = None
ProductionEventType = Literal[
"start", "cycle_completed", "task_measured", "remeasure",
"line_stop", "resume", "close",
]
+7
View File
@@ -57,3 +57,10 @@ class RecipeSummary(BaseModel):
class StationWithRecipesResponse(StationResponse):
recipes: list[RecipeSummary] = Field(default_factory=list)
class StationResetResponse(BaseModel):
"""Outcome of clearing every recipe assignment of a station."""
station_id: int
removed: int
+9 -1
View File
@@ -1,8 +1,13 @@
"""Pydantic schemas for RecipeTask and RecipeSubtask operations."""
from typing import Any, Optional
from typing import Any, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
from src.backend.models.orm.task import TASK_TYPES
# Kept in step with the ORM enum so the two cannot drift apart.
TaskType = Literal[TASK_TYPES]
class SubtaskCreate(BaseModel):
"""Schema for creating a subtask."""
@@ -53,6 +58,7 @@ class SubtaskResponse(BaseModel):
class TaskCreate(BaseModel):
"""Schema for creating a task."""
title: str = Field(..., min_length=1, max_length=255)
task_type: TaskType = "note"
directive: Optional[str] = None
description: Optional[str] = None
file_path: Optional[str] = Field(None, max_length=500)
@@ -64,6 +70,7 @@ class TaskCreate(BaseModel):
class TaskUpdate(BaseModel):
"""Schema for updating a task."""
title: Optional[str] = Field(None, min_length=1, max_length=255)
task_type: Optional[TaskType] = None
directive: Optional[str] = None
description: Optional[str] = None
file_path: Optional[str] = Field(None, max_length=500)
@@ -79,6 +86,7 @@ class TaskResponse(BaseModel):
version_id: int
recipe_id: Optional[int] = None
order_index: int
task_type: str
title: str
directive: Optional[str] = None
description: Optional[str] = None
+3
View File
@@ -6,6 +6,7 @@ from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.access_log import AccessLog
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
from src.backend.models.orm.station import Station, StationRecipeAssignment
from src.backend.models.orm.production import ProductionRun, ProductionEvent
__all__ = [
"User",
@@ -19,4 +20,6 @@ __all__ = [
"RecipeVersionAudit",
"Station",
"StationRecipeAssignment",
"ProductionRun",
"ProductionEvent",
]
+11
View File
@@ -48,6 +48,17 @@ class Measurement(Base):
Integer, nullable=True
)
# The production this was taken during, when there was one. Nullable: rows
# predating production runs, and measurements taken outside a production, have
# none. This is what makes "the measurements of this production" a query, which
# fine produzione needs to emit the statistics file.
production_run_id: Mapped[Optional[int]] = mapped_column(
Integer,
ForeignKey("production_runs.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
# Timestamp
measured_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now(), index=True
+166
View File
@@ -0,0 +1,166 @@
"""ProductionRun and ProductionEvent models.
A production run is what an operator opens when production starts and a supervisor
closes when it ends. It is the thing that outlives a page: before this, the timer,
the cycle count and the "production started" flag were Alpine variables in
task_execute.html, and navigating between tasks - a full page load - destroyed them.
The measurement deadline is stored as an absolute timestamp (next_measurement_at)
rather than a remaining count. Any client can then derive the countdown from it, get
the same answer, survive a reload, and tell how far *past* the interval it is by
letting the difference go negative.
With one station app installed per PC (see the install architecture of 28/07) the
database is the only place this state can live: the API keeps nothing in process
memory.
"""
from datetime import datetime
from typing import TYPE_CHECKING, Optional
from sqlalchemy import (
DateTime, Enum, ForeignKey, Index, Integer, SmallInteger, String, Text,
UniqueConstraint, func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.backend.database import Base
if TYPE_CHECKING:
from src.backend.models.orm.recipe import Recipe, RecipeVersion
from src.backend.models.orm.station import Station
RUN_STATUSES = ("running", "paused", "closed")
# task_measured marks a measurement task finished inside a cycle that is not over
# yet; only the last one produces cycle_completed and restarts the interval.
# remeasure is the piece being turned and measured again without closing the cycle.
EVENT_TYPES = (
"start", "cycle_completed", "task_measured", "remeasure",
"line_stop", "resume", "close",
)
class ProductionRun(Base):
__tablename__ = "production_runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
station_id: Mapped[int] = mapped_column(
Integer, ForeignKey("stations.id"), nullable=False, index=True
)
recipe_id: Mapped[int] = mapped_column(
Integer, ForeignKey("recipes.id"), nullable=False, index=True
)
version_id: Mapped[int] = mapped_column(
Integer, ForeignKey("recipe_versions.id"), nullable=False, index=True
)
operator_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id"), nullable=False, index=True
)
# Traceability travels with the run instead of the Flask session, so it stays
# attached to every measurement taken during it.
lot_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
serial_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
status: Mapped[str] = mapped_column(
Enum(*RUN_STATUSES, name="production_run_status_enum"),
nullable=False,
default="running",
index=True,
)
# Snapshot of the recipe interval at start: editing the recipe mid-run must not
# move the deadline of a production already under way.
measurement_interval_minutes: Mapped[Optional[int]] = mapped_column(
SmallInteger, nullable=True
)
# When the next measurement falls due. Null when the recipe has no interval.
# Past this instant the run is overdue, and by how much is simply now - this.
next_measurement_at: Mapped[Optional[datetime]] = mapped_column(
DateTime, nullable=True, index=True
)
cycle_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
started_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now()
)
paused_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
closed_by: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("users.id"), nullable=True
)
# Statistics file emitted when the run was closed, relative to the upload dir.
statistics_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
# Mirrors station_id while the run is open and goes NULL when it closes. A unique
# index on it lets the database - not a check-then-insert race - guarantee that a
# station never has two open runs. Repeated NULLs do not collide in a unique index.
active_station_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
station: Mapped["Station"] = relationship(lazy="selectin")
recipe: Mapped["Recipe"] = relationship(lazy="selectin")
version: Mapped["RecipeVersion"] = relationship(lazy="selectin")
events: Mapped[list["ProductionEvent"]] = relationship(
back_populates="run",
cascade="all, delete-orphan",
order_by="ProductionEvent.created_at",
)
__table_args__ = (
UniqueConstraint("active_station_id", name="uq_production_runs_active_station"),
Index("ix_production_runs_station_status", "station_id", "status"),
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
)
@property
def is_open(self) -> bool:
return self.status in ("running", "paused")
def __repr__(self) -> str:
return f"<ProductionRun {self.id} station={self.station_id} {self.status}>"
class ProductionEvent(Base):
"""Append-only trace of what happened during a run.
This is the record that did not exist before: without it there is no history of
a production, and nothing for the statistics file or the ERP hand-off to stand on.
"""
__tablename__ = "production_events"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
run_id: Mapped[int] = mapped_column(
Integer, ForeignKey("production_runs.id", ondelete="CASCADE"),
nullable=False, index=True,
)
event_type: Mapped[str] = mapped_column(
Enum(*EVENT_TYPES, name="production_event_type_enum"), nullable=False, index=True
)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id"), nullable=False, index=True
)
# Who authorised it, when the action needed a supervisor (fermo linea, chiusura).
supervisor_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("users.id"), nullable=True
)
# Which task the event is about, for the ones that happen inside a cycle. A
# task_measured or a remeasure that does not say which task is not a record of
# anything; null for the events that concern the run as a whole.
task_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("recipe_tasks.id"), nullable=True
)
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now(), index=True
)
run: Mapped["ProductionRun"] = relationship(back_populates="events")
__table_args__ = (
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
)
def __repr__(self) -> str:
return f"<ProductionEvent run={self.run_id} {self.event_type}>"
+19
View File
@@ -12,6 +12,15 @@ if TYPE_CHECKING:
from src.backend.models.orm.recipe import RecipeVersion
# What a task is, declared by whoever writes the recipe instead of guessed from its
# contents. The last two are not built yet; they are in the enum from the start
# because widening a MySQL enum later is an ALTER on a live table.
TASK_TYPES = ("note", "measure", "drawing", "xf_compare", "camera_measure")
# Types the operator screen treats as "there are quotes to take here".
MEASURING_TASK_TYPES = ("measure", "camera_measure")
class RecipeTask(Base):
__tablename__ = "recipe_tasks"
@@ -20,6 +29,16 @@ class RecipeTask(Base):
Integer, ForeignKey("recipe_versions.id"), nullable=False
)
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Declared, not deduced. The old rule - "has subtasks, therefore a measurement" -
# meant a measurement task whose quotes had not been entered yet behaved as a
# note, so the system acted differently depending on how finished the recipe was.
task_type: Mapped[str] = mapped_column(
Enum(*TASK_TYPES, name="task_type_enum"),
nullable=False,
default="note",
server_default="note",
index=True,
)
title: Mapped[str] = mapped_column(String(255), nullable=False)
directive: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
@@ -49,6 +49,7 @@ async def save_measurement(
serial_number: str | None = None,
input_method: str = "manual",
input_duration_ms: int | None = None,
production_run_id: int | None = None,
) -> Measurement:
"""Save a single measurement with auto-calculated pass/fail."""
# Get subtask for tolerance values
@@ -72,6 +73,7 @@ async def save_measurement(
serial_number=serial_number,
input_method=input_method,
input_duration_ms=input_duration_ms,
production_run_id=production_run_id,
)
db.add(measurement)
await db.flush()
@@ -0,0 +1,161 @@
"""Statistics file emitted when a production is closed.
Fine produzione has to hand the measurements of the whole production over. That part
does not depend on the ERP: the file is produced now, and the hand-off to GAIA plugs
in beside it once the protocol is agreed (questions D-1 and D-2). Keeping it in its
own module makes that seam obvious rather than buried in the close path.
The CSV uses the delimiters configured in system_settings, the same ones the manual
export honours, so a shop floor set to the Italian convention (';' and ',') gets
files that open correctly in their Excel.
"""
import csv
import io
from datetime import datetime
from pathlib import Path
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.config import settings
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.production import ProductionRun
from src.backend.models.orm.recipe import Recipe
from src.backend.models.orm.setting import SystemSetting
from src.backend.models.orm.station import Station
from src.backend.models.orm.task import RecipeSubtask
# Written under the upload directory: it is the volume that is already mounted and
# backed up, so the files survive a container being replaced.
EXPORT_SUBDIR = "statistics"
HEADER = [
"production_run_id",
"station_code",
"recipe_code",
"version_id",
"lot_number",
"serial_number",
"measurement_id",
"subtask_id",
"marker_number",
"subtask_description",
"nominal",
"value",
"deviation",
"pass_fail",
# The limits travel with the row: without them a pass/fail cannot be re-derived
# from the file years later, which is the whole point of an audit record.
"ltl",
"lwl",
"uwl",
"utl",
"unit",
"input_method",
"input_duration_ms",
"measured_by",
"measured_at",
]
async def _csv_format(db: AsyncSession) -> tuple[str, str]:
"""Delimiter and decimal separator as configured for this installation."""
delimiter_row = await db.execute(
select(SystemSetting).where(SystemSetting.setting_key == "csv_delimiter")
)
delimiter = delimiter_row.scalar_one_or_none()
decimal_row = await db.execute(
select(SystemSetting).where(
SystemSetting.setting_key == "csv_decimal_separator"
)
)
decimal = decimal_row.scalar_one_or_none()
return (
delimiter.setting_value if delimiter else ",",
decimal.setting_value if decimal else ".",
)
async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
"""Render every measurement of a run as CSV text."""
delimiter, decimal_separator = await _csv_format(db)
station = (await db.execute(
select(Station).where(Station.id == run.station_id)
)).scalar_one_or_none()
recipe = (await db.execute(
select(Recipe).where(Recipe.id == run.recipe_id)
)).scalar_one_or_none()
rows = (await db.execute(
select(Measurement, RecipeSubtask)
.join(RecipeSubtask, RecipeSubtask.id == Measurement.subtask_id, isouter=True)
.where(Measurement.production_run_id == run.id)
.order_by(Measurement.measured_at, Measurement.id)
)).all()
def number(value) -> str:
return "" if value is None else str(value).replace(".", decimal_separator)
output = io.StringIO()
writer = csv.writer(output, delimiter=delimiter, lineterminator="\n")
writer.writerow(HEADER)
for measurement, subtask in rows:
writer.writerow([
run.id,
station.code if station else "",
recipe.code if recipe else "",
measurement.version_id,
measurement.lot_number or "",
measurement.serial_number or "",
measurement.id,
measurement.subtask_id,
subtask.marker_number if subtask else "",
subtask.description if subtask else "",
number(subtask.nominal) if subtask else "",
number(measurement.value),
number(measurement.deviation),
measurement.pass_fail,
number(subtask.ltl) if subtask else "",
number(subtask.lwl) if subtask else "",
number(subtask.uwl) if subtask else "",
number(subtask.utl) if subtask else "",
(subtask.unit or "") if subtask else "",
measurement.input_method,
"" if measurement.input_duration_ms is None else measurement.input_duration_ms,
measurement.measured_by,
measurement.measured_at.isoformat() if measurement.measured_at else "",
])
return output.getvalue()
async def export_run_statistics(db: AsyncSession, run: ProductionRun) -> str | None:
"""Write the run's statistics file and mark its measurements as exported.
Returns the path relative to the upload directory, or None when the run has no
measurements - an empty file would only be noise in the folder.
"""
content = await build_statistics_csv(db, run)
# Header only: nothing was measured during this production.
if len(content.strip().splitlines()) <= 1:
return None
directory = Path(settings.upload_path) / EXPORT_SUBDIR
directory.mkdir(parents=True, exist_ok=True)
stamp = (run.closed_at or datetime.now()).strftime("%Y%m%d_%H%M%S")
filename = f"production_{run.id}_{stamp}.csv"
(directory / filename).write_text(content, encoding="utf-8-sig")
# utf-8-sig: the BOM is what makes Excel read accented characters correctly,
# and these files are opened in Excel on the shop floor.
await db.execute(
update(Measurement)
.where(Measurement.production_run_id == run.id)
.values(synced_to_csv=True)
)
return f"{EXPORT_SUBDIR}/{filename}"
+438
View File
@@ -0,0 +1,438 @@
"""Business logic for production runs.
Routers must call into these functions rather than manipulating models directly.
All functions are async and accept an AsyncSession; they flush but do NOT commit
(commit is handled by the FastAPI get_db dependency).
Clock convention
----------------
Every timestamp this module writes comes from ``_now()``, so the deadline and the
comparison against it are always on the same clock. Clients are handed
``seconds_to_next_measurement`` already computed rather than the raw deadline: a
naive datetime crossing the wire would be parsed in the browser's own timezone, and
the countdown would be off by the UTC offset. The number is the contract; the
timestamp is only there for the audit trail.
"""
from datetime import datetime, timedelta
from typing import Optional
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.api.production import ProductionRunCreate
from src.backend.models.orm.production import ProductionEvent, ProductionRun
from src.backend.models.orm.recipe import Recipe, RecipeVersion
from src.backend.models.orm.station import Station
from src.backend.models.orm.task import MEASURING_TASK_TYPES, RecipeTask
from src.backend.models.orm.user import User
from src.backend.services import auth_service, production_export_service
def _now() -> datetime:
"""Single source of time for runs.
Naive local time, matching what the database writes for ``func.now()`` defaults,
so a run's started_at and its deadline are comparable.
"""
return datetime.now()
# ---------------------------------------------------------------------------
# Derived view
# ---------------------------------------------------------------------------
def seconds_to_next_measurement(run: ProductionRun, now: Optional[datetime] = None) -> Optional[int]:
"""Seconds until the next measurement is due; negative once it is overdue.
Letting the value go negative is deliberate: the operator has to see how long
the line has been past the interval, not merely that it elapsed.
While the run is paused the countdown is frozen at what was left when the line
stopped - a fermo linea must not eat into the measurement interval.
"""
if run.next_measurement_at is None:
return None
reference = run.paused_at if (run.status == "paused" and run.paused_at) else (now or _now())
return int(round((run.next_measurement_at - reference).total_seconds()))
def describe(run: ProductionRun) -> dict:
"""Fields the API adds on top of the stored columns."""
now = _now()
remaining = seconds_to_next_measurement(run, now)
return {
"seconds_to_next_measurement": remaining,
"overdue": remaining is not None and remaining < 0,
"server_time": now,
}
# ---------------------------------------------------------------------------
# Lookups
# ---------------------------------------------------------------------------
async def get_run(db: AsyncSession, run_id: int) -> ProductionRun:
result = await db.execute(select(ProductionRun).where(ProductionRun.id == run_id))
run = result.scalar_one_or_none()
if run is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Production run not found",
)
return run
async def get_open_run_for_station(
db: AsyncSession, station_id: int,
) -> Optional[ProductionRun]:
"""The run currently open at a station, running or paused."""
result = await db.execute(
select(ProductionRun).where(ProductionRun.active_station_id == station_id)
)
return result.scalar_one_or_none()
async def get_station_by_code(db: AsyncSession, code: str) -> Station:
result = await db.execute(select(Station).where(Station.code == code))
station = result.scalar_one_or_none()
if station is None or not station.active:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Station '{code}' not found or inactive",
)
return station
async def measurement_task_ids(db: AsyncSession, version_id: int) -> list[int]:
"""Ids of the measurement tasks of a version, in the order they are executed.
This is the loop the operator lives in once production starts: the client uses
the first of these to bring the measurement back on screen when the interval
expires, and the last to know which task closes a cycle.
The type is read from the declared task_type (point 2), not from whether quotes
happen to be present: a measurement task with no quotes yet is still where the
operator has to be taken.
"""
result = await db.execute(
select(RecipeTask.id)
.where(
RecipeTask.version_id == version_id,
RecipeTask.task_type.in_(MEASURING_TASK_TYPES),
)
.order_by(RecipeTask.order_index, RecipeTask.id)
)
return list(result.scalars().all())
async def list_run_events(db: AsyncSession, run_id: int) -> list[ProductionEvent]:
result = await db.execute(
select(ProductionEvent)
.where(ProductionEvent.run_id == run_id)
.order_by(ProductionEvent.created_at, ProductionEvent.id)
)
return list(result.scalars().all())
# ---------------------------------------------------------------------------
# Supervisor authorisation
# ---------------------------------------------------------------------------
async def authorise_supervisor(db: AsyncSession, username: str, password: str) -> User:
"""Check the credentials of the supervisor authorising a stop or a close.
Uses authenticate_user rather than a full login: logging in would rotate the
supervisor's API key and knock out whatever session they have open elsewhere.
"""
supervisor = await auth_service.authenticate_user(db, username, password)
if supervisor is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid supervisor credentials",
)
if not supervisor.has_role("Supervisor") and not supervisor.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User is not authorised as supervisor",
)
return supervisor
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
def _add_event(
db: AsyncSession,
run: ProductionRun,
event_type: str,
user: User,
supervisor: Optional[User] = None,
note: Optional[str] = None,
task_id: Optional[int] = None,
) -> ProductionEvent:
"""Append to the run's trace.
Added through the session rather than run.events: touching the collection would
trigger a lazy load, which raises under async SQLAlchemy.
"""
event = ProductionEvent(
run_id=run.id,
event_type=event_type,
user_id=user.id,
supervisor_id=supervisor.id if supervisor else None,
note=note,
task_id=task_id,
created_at=_now(),
)
db.add(event)
return event
async def open_run(
db: AsyncSession, data: ProductionRunCreate, operator: User,
) -> ProductionRun:
"""Start a production at a station.
Refuses if the station already has one open: two runs on the same station would
mean two timers and two histories for one physical line.
"""
station = await get_station_by_code(db, data.station_code)
existing = await get_open_run_for_station(db, station.id)
if existing is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Station '{station.code}' already has an open production run",
)
recipe_row = await db.execute(select(Recipe).where(Recipe.id == data.recipe_id))
recipe = recipe_row.scalar_one_or_none()
if recipe is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found",
)
if data.version_id is not None:
version_row = await db.execute(
select(RecipeVersion).where(
RecipeVersion.id == data.version_id,
RecipeVersion.recipe_id == recipe.id,
)
)
version = version_row.scalar_one_or_none()
if version is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Recipe version not found for this recipe",
)
else:
version_row = await db.execute(
select(RecipeVersion).where(
RecipeVersion.recipe_id == recipe.id,
RecipeVersion.is_current == True, # noqa: E712
)
)
version = version_row.scalar_one_or_none()
if version is None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Recipe has no current version",
)
now = _now()
# The interval is copied, not referenced: editing the recipe mid-production must
# not move the deadline of a run already under way.
interval = recipe.measurement_interval_minutes
run = ProductionRun(
station_id=station.id,
recipe_id=recipe.id,
version_id=version.id,
operator_id=operator.id,
lot_number=data.lot_number,
serial_number=data.serial_number,
status="running",
measurement_interval_minutes=interval,
next_measurement_at=(
now + timedelta(minutes=interval) if interval and interval > 0 else None
),
cycle_count=0,
started_at=now,
active_station_id=station.id,
)
db.add(run)
await db.flush()
_add_event(db, run, "start", operator)
await db.flush()
await db.refresh(run)
return run
def _require_open(run: ProductionRun) -> None:
if run.status == "closed":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Production run is already closed",
)
def _require_measurable(run: ProductionRun) -> None:
_require_open(run)
if run.status == "paused":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Production run is paused: resume it before measuring",
)
async def complete_cycle(
db: AsyncSession,
run: ProductionRun,
user: User,
task_id: Optional[int] = None,
note: Optional[str] = None,
) -> ProductionRun:
"""Record a finished measurement task, and restart the interval if it was the last.
A cycle spans every measurement task of the recipe, so only the last one closes
it. Restarting the interval at the first would make a recipe with three
measurement tasks measure three times as often as it was set up to.
An unrecognised task_id - or none at all - closes the cycle: a caller that does
not know about the sequence gets the behaviour that existed before it.
"""
_require_measurable(run)
measure_ids = await measurement_task_ids(db, run.version_id)
closes_cycle = (
task_id is None or task_id not in measure_ids or task_id == measure_ids[-1]
)
if not closes_cycle:
_add_event(db, run, "task_measured", user, note=note, task_id=task_id)
await db.flush()
await db.refresh(run)
return run
now = _now()
run.cycle_count += 1
if run.measurement_interval_minutes and run.measurement_interval_minutes > 0:
# Measured from now, not from the previous deadline: a late measurement must
# not compress the following interval.
run.next_measurement_at = now + timedelta(
minutes=run.measurement_interval_minutes
)
_add_event(db, run, "cycle_completed", user, note=note, task_id=task_id)
await db.flush()
await db.refresh(run)
return run
async def remeasure(
db: AsyncSession,
run: ProductionRun,
user: User,
task_id: Optional[int] = None,
note: Optional[str] = None,
) -> ProductionRun:
"""Measure the same task again without closing the cycle.
This is the piece being turned over. The deadline and the cycle count are left
exactly as they are - a second reading of the same part is not a new cycle, and
it must not buy the operator another interval's worth of time. The values
themselves are ordinary measurements: both readings stay in the statistics,
which is the point of taking them.
"""
_require_measurable(run)
_add_event(db, run, "remeasure", user, note=note, task_id=task_id)
await db.flush()
await db.refresh(run)
return run
async def pause_run(
db: AsyncSession,
run: ProductionRun,
user: User,
supervisor: User,
note: Optional[str] = None,
) -> ProductionRun:
"""Fermo linea: suspend the run and freeze the countdown."""
_require_open(run)
if run.status == "paused":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Production run is already paused",
)
run.status = "paused"
run.paused_at = _now()
_add_event(db, run, "line_stop", user, supervisor=supervisor, note=note)
await db.flush()
await db.refresh(run)
return run
async def resume_run(
db: AsyncSession,
run: ProductionRun,
user: User,
supervisor: User,
note: Optional[str] = None,
) -> ProductionRun:
"""Restart a paused run, pushing the deadline out by the length of the stop."""
_require_open(run)
if run.status != "paused":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Production run is not paused",
)
now = _now()
if run.next_measurement_at is not None and run.paused_at is not None:
# Shift rather than recompute: whatever was left when the line stopped is
# what the operator gets back, so a stop neither grants nor costs time.
run.next_measurement_at = run.next_measurement_at + (now - run.paused_at)
run.status = "running"
run.paused_at = None
_add_event(db, run, "resume", user, supervisor=supervisor, note=note)
await db.flush()
await db.refresh(run)
return run
async def close_run(
db: AsyncSession,
run: ProductionRun,
user: User,
supervisor: User,
note: Optional[str] = None,
) -> ProductionRun:
"""Fine produzione: close the run for good, stop the timer, emit the statistics.
Everything here happens without the ERP. Handing the production over to GAIA
goes at the marked seam below, once D-1 and D-2 are answered; until then the
file is produced and the run is properly closed, which is what the shop floor
needs either way.
"""
_require_open(run)
run.status = "closed"
run.closed_at = _now()
run.closed_by = supervisor.id
run.next_measurement_at = None
run.paused_at = None
# Releasing the slot lets the station open a new run; the unique index on this
# column is what keeps exactly one open at a time.
run.active_station_id = None
_add_event(db, run, "close", user, supervisor=supervisor, note=note)
await db.flush()
# Statistics file for the whole production, and the measurements marked as sent.
run.statistics_path = await production_export_service.export_run_statistics(db, run)
# --- ERP hand-off (GAIA) plugs in here once the protocol is defined (D-1, D-2).
# Deliberately absent rather than stubbed: an empty call that looks wired is
# worse than none at all.
await db.flush()
await db.refresh(run)
return run
+4
View File
@@ -55,6 +55,9 @@ async def _copy_tasks_to_version(
new_task = RecipeTask(
version_id=target_version.id,
order_index=task.order_index,
# Carried over explicitly: a copy-on-write version that dropped the type
# would silently reclassify every task the moment a recipe is edited.
task_type=task.task_type,
title=task.title,
directive=task.directive,
description=task.description,
@@ -254,6 +257,7 @@ async def create_new_version(
default_task = RecipeTask(
version_id=new_version.id,
order_index=0,
task_type="drawing",
title="Technical Drawing",
file_path=data.file_path,
file_type=data.file_type or (
+20
View File
@@ -138,6 +138,26 @@ async def unassign_recipe(
await db.flush()
async def unassign_all_recipes(db: AsyncSession, station_id: int) -> int:
"""Drop every recipe assignment of a station and return how many were removed.
Used by the per-row reset in the admin station list: the station goes back to
having no recipes so it can be reassigned from scratch. Unlike unassign_recipe
this is idempotent - resetting an already empty station returns 0 rather than 404.
"""
await get_station(db, station_id)
result = await db.execute(
select(StationRecipeAssignment).where(
StationRecipeAssignment.station_id == station_id
)
)
assignments = list(result.scalars().all())
for assignment in assignments:
await db.delete(assignment)
await db.flush()
return len(assignments)
async def list_station_recipes(
db: AsyncSession, station_id: int,
) -> list[Recipe]:
+4
View File
@@ -243,6 +243,10 @@ async def create_test_recipe(
version_id=version.id,
order_index=0,
title="Test Task",
# Declared, like any task since the type stopped being deduced: this one has
# a quote under it, so a fixture that called it a note would be modelling
# exactly the inconsistency point 2 removed.
task_type="measure",
directive="Measure the part",
description="First measurement task",
)
+747
View File
@@ -0,0 +1,747 @@
"""Integration tests for /api/production-runs.
The point of these tables is that a production outlives the page it was started
from, so the tests lean on what must survive: the deadline, the cycle count, the
trace of what happened, and the rule that a station has one production at a time.
"""
from datetime import datetime, timedelta
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from src.backend.models.orm.production import ProductionRun
from src.backend.models.orm.recipe import Recipe
from src.backend.models.orm.station import Station
from src.backend.models.orm.task import RecipeTask
from src.backend.services import auth_service, production_service
from src.backend.tests.conftest import auth_headers, create_test_recipe
async def _station(db_session, user_id: int, code: str = "ST-PROD") -> Station:
station = Station(code=code, name=f"Stazione {code}", active=True, created_by=user_id)
db_session.add(station)
await db_session.commit()
await db_session.refresh(station)
return station
async def _recipe_with_interval(db_session, user_id: int, minutes, code="REC-PROD") -> Recipe:
recipe = await create_test_recipe(db_session, user_id=user_id, code=code)
recipe.measurement_interval_minutes = minutes
await db_session.commit()
await db_session.refresh(recipe)
return recipe
async def _supervisor(db_session, username="capoturno", password="TurnoPwd1"):
user = await auth_service.create_user(
db_session,
username=username,
password=password,
display_name="Capo Turno",
roles=["Supervisor"],
)
await db_session.commit()
return user, password
async def _advance_clock(db_session, run_id: int, delta: timedelta) -> None:
"""Simulate wall-clock time passing, without sleeping in a test.
Winding every instant stored on the run back by D is indistinguishable from D
having elapsed. Moving only one of them would not be time passing - it would be
rewriting history, and would measure the wrong thing.
"""
run = (await db_session.execute(
select(ProductionRun).where(ProductionRun.id == run_id)
)).scalar_one()
run.started_at = run.started_at - delta
if run.next_measurement_at is not None:
run.next_measurement_at = run.next_measurement_at - delta
if run.paused_at is not None:
run.paused_at = run.paused_at - delta
await db_session.commit()
async def _only_task(db_session, version_id: int):
"""The single task the fixture recipe comes with - a measurement one."""
row = await db_session.execute(
select(RecipeTask)
.where(RecipeTask.version_id == version_id)
.order_by(RecipeTask.order_index)
)
return row.scalars().first()
async def _add_task(
db_session, version_id: int, order_index: int, title: str, task_type: str = "measure",
) -> RecipeTask:
"""A further task in the sequence, of a declared type."""
task = RecipeTask(
version_id=version_id, order_index=order_index, title=title, task_type=task_type,
)
db_session.add(task)
await db_session.commit()
await db_session.refresh(task)
return task
async def _open(client, user, station, recipe, **extra):
return await client.post(
"/api/production-runs",
headers=auth_headers(user),
json={"station_code": station.code, "recipe_id": recipe.id, **extra},
)
# ---------------------------------------------------------------------------
# Opening
# ---------------------------------------------------------------------------
async def test_open_run_requires_auth(client: AsyncClient):
resp = await client.post("/api/production-runs", json={"station_code": "X", "recipe_id": 1})
assert resp.status_code == 401
async def test_open_run_starts_the_countdown(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
station = await _station(db_session, admin_user.id)
recipe = await _recipe_with_interval(db_session, admin_user.id, minutes=30)
resp = await _open(client, measurement_tec_user, station, recipe)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["status"] == "running"
assert body["cycle_count"] == 0
assert body["measurement_interval_minutes"] == 30
# About half an hour out, allowing for the round trip.
assert 29 * 60 <= body["seconds_to_next_measurement"] <= 30 * 60
assert body["overdue"] is False
async def test_open_run_without_interval_has_no_deadline(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
station = await _station(db_session, admin_user.id, code="ST-NOINT")
recipe = await _recipe_with_interval(db_session, admin_user.id, None, code="REC-NOINT")
body = (await _open(client, measurement_tec_user, station, recipe)).json()
assert body["next_measurement_at"] is None
assert body["seconds_to_next_measurement"] is None
assert body["overdue"] is False
async def test_open_run_snapshots_the_interval(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
"""Editing the recipe mid-production must not move a running deadline."""
station = await _station(db_session, admin_user.id, code="ST-SNAP")
recipe = await _recipe_with_interval(db_session, admin_user.id, 20, code="REC-SNAP")
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
recipe.measurement_interval_minutes = 90
await db_session.commit()
resp = await client.get(
f"/api/production-runs/{run_id}", headers=auth_headers(measurement_tec_user),
)
assert resp.json()["measurement_interval_minutes"] == 20
async def test_station_cannot_have_two_open_runs(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
station = await _station(db_session, admin_user.id, code="ST-ONE")
recipe = await _recipe_with_interval(db_session, admin_user.id, 15, code="REC-ONE")
first = await _open(client, measurement_tec_user, station, recipe)
assert first.status_code == 201
second = await _open(client, measurement_tec_user, station, recipe)
assert second.status_code == 409
async def test_open_run_rejects_unknown_station(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
recipe = await _recipe_with_interval(db_session, admin_user.id, 15, code="REC-NOSTAT")
resp = await client.post(
"/api/production-runs",
headers=auth_headers(measurement_tec_user),
json={"station_code": "ST-GHOST", "recipe_id": recipe.id},
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Reading the current run - what replaces the in-page state
# ---------------------------------------------------------------------------
async def test_current_run_is_null_when_nothing_is_running(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
station = await _station(db_session, admin_user.id, code="ST-IDLE")
resp = await client.get(
"/api/production-runs/current",
params={"station_code": station.code},
headers=auth_headers(measurement_tec_user),
)
assert resp.status_code == 200
assert resp.json() is None
async def test_current_run_survives_and_keeps_counting_down(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
"""The deadline is stored, so a fresh page load rejoins the same countdown."""
station = await _station(db_session, admin_user.id, code="ST-KEEP")
recipe = await _recipe_with_interval(db_session, admin_user.id, 45, code="REC-KEEP")
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
later = await client.get(
"/api/production-runs/current",
params={"station_code": station.code},
headers=auth_headers(measurement_tec_user),
)
body = later.json()
assert body["id"] == opened["id"]
assert body["next_measurement_at"] == opened["next_measurement_at"]
assert body["seconds_to_next_measurement"] <= opened["seconds_to_next_measurement"]
# ---------------------------------------------------------------------------
# Cycles
# ---------------------------------------------------------------------------
async def test_cycle_restarts_the_interval_and_counts(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
station = await _station(db_session, admin_user.id, code="ST-CYC")
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-CYC")
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
resp = await client.post(
f"/api/production-runs/{run_id}/cycle", headers=auth_headers(measurement_tec_user),
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["cycle_count"] == 1
assert 9 * 60 <= body["seconds_to_next_measurement"] <= 10 * 60
async def test_measurement_tasks_travel_with_the_run(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
"""Where the measurement is cannot be guessed by the client: the run says so.
This is what lets a screen bring the operator back to the measurement when the
interval expires, whatever page they wandered off to.
"""
station = await _station(db_session, admin_user.id, code="ST-SEQ")
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-SEQ")
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
first = await _only_task(db_session, opened["version_id"])
# A drawing between the two measurements, to prove it is left out.
await _add_task(db_session, opened["version_id"], 1, "Disegno", "drawing")
second = await _add_task(db_session, opened["version_id"], 2, "Seconda misura")
body = (await client.get(
"/api/production-runs/current",
params={"station_code": station.code},
headers=auth_headers(measurement_tec_user),
)).json()
assert body["measurement_task_ids"] == [first.id, second.id]
async def test_cycle_closes_only_on_the_last_measurement_task(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
"""Restarting at the first would measure three times as often as configured."""
station = await _station(db_session, admin_user.id, code="ST-MULTI")
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-MULTI")
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
run_id = opened["id"]
first = await _only_task(db_session, opened["version_id"])
second = await _add_task(db_session, opened["version_id"], 1, "Seconda misura")
headers = auth_headers(measurement_tec_user)
# Four minutes of the interval have gone by when the first task is finished.
await _advance_clock(db_session, run_id, timedelta(minutes=4))
mid = (await client.post(
f"/api/production-runs/{run_id}/cycle", headers=headers, json={"task_id": first.id},
)).json()
assert mid["cycle_count"] == 0, "il ciclo non e' ancora finito"
assert 5 * 60 <= mid["seconds_to_next_measurement"] <= 6 * 60, (
"l'intervallo non deve ripartire a meta' ciclo"
)
end = (await client.post(
f"/api/production-runs/{run_id}/cycle", headers=headers, json={"task_id": second.id},
)).json()
assert end["cycle_count"] == 1
assert 9 * 60 <= end["seconds_to_next_measurement"] <= 10 * 60
events = (await client.get(
f"/api/production-runs/{run_id}", headers=headers,
)).json()["events"]
by_type = {e["event_type"]: e for e in events}
assert by_type["task_measured"]["task_id"] == first.id
assert by_type["cycle_completed"]["task_id"] == second.id
async def test_cycle_without_a_task_closes_as_before(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
"""A caller that knows nothing of the sequence keeps the old behaviour."""
station = await _station(db_session, admin_user.id, code="ST-PLAIN")
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-PLAIN")
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
await _add_task(db_session, opened["version_id"], 1, "Seconda misura")
body = (await client.post(
f"/api/production-runs/{opened['id']}/cycle",
headers=auth_headers(measurement_tec_user),
)).json()
assert body["cycle_count"] == 1
# ---------------------------------------------------------------------------
# Rimisura - turning the piece over
# ---------------------------------------------------------------------------
async def test_remeasure_does_not_buy_another_interval(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
"""A second reading of the same part is not a new cycle."""
station = await _station(db_session, admin_user.id, code="ST-AGAIN")
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-AGAIN")
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
run_id = opened["id"]
task = await _only_task(db_session, opened["version_id"])
headers = auth_headers(measurement_tec_user)
await _advance_clock(db_session, run_id, timedelta(minutes=7))
body = (await client.post(
f"/api/production-runs/{run_id}/remeasure", headers=headers,
json={"task_id": task.id},
)).json()
assert body["cycle_count"] == 0
assert 2 * 60 <= body["seconds_to_next_measurement"] <= 3 * 60, (
"rigirare il pezzo non deve spostare la scadenza"
)
events = (await client.get(
f"/api/production-runs/{run_id}", headers=headers,
)).json()["events"]
remeasured = [e for e in events if e["event_type"] == "remeasure"]
assert len(remeasured) == 1
assert remeasured[0]["task_id"] == task.id
async def test_remeasure_refused_while_the_line_is_stopped(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
station = await _station(db_session, admin_user.id, code="ST-AGPAUSE")
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-AGPAUSE")
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
supervisor, password = await _supervisor(db_session, username="capo-again")
await client.post(
f"/api/production-runs/{run_id}/pause",
headers=auth_headers(measurement_tec_user),
json={"supervisor_username": supervisor.username, "supervisor_password": password},
)
resp = await client.post(
f"/api/production-runs/{run_id}/remeasure",
headers=auth_headers(measurement_tec_user),
)
assert resp.status_code == 409
async def test_overdue_run_reports_negative_seconds(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
"""Past the interval the countdown keeps going the other way, so the delay shows."""
station = await _station(db_session, admin_user.id, code="ST-LATE")
recipe = await _recipe_with_interval(db_session, admin_user.id, 5, code="REC-LATE")
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
await _advance_clock(db_session, run_id, timedelta(minutes=8))
body = (await client.get(
"/api/production-runs/current",
params={"station_code": station.code},
headers=auth_headers(measurement_tec_user),
)).json()
assert body["overdue"] is True
assert -190 <= body["seconds_to_next_measurement"] <= -170
# ---------------------------------------------------------------------------
# Fermo linea / ripresa / chiusura
# ---------------------------------------------------------------------------
async def test_pause_requires_supervisor_credentials(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
station = await _station(db_session, admin_user.id, code="ST-PAUSE1")
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-PAUSE1")
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
resp = await client.post(
f"/api/production-runs/{run_id}/pause",
headers=auth_headers(measurement_tec_user),
json={"supervisor_username": "nobody", "supervisor_password": "wrong"},
)
assert resp.status_code == 401
async def test_pause_rejects_non_supervisor(
client: AsyncClient, measurement_tec_user, maker_user, admin_user, db_session,
):
station = await _station(db_session, admin_user.id, code="ST-PAUSE2")
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-PAUSE2")
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
resp = await client.post(
f"/api/production-runs/{run_id}/pause",
headers=auth_headers(measurement_tec_user),
json={
"supervisor_username": maker_user.username,
"supervisor_password": "testpassword123",
},
)
assert resp.status_code in (401, 403)
async def test_pause_freezes_the_countdown(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
"""A fermo linea must not eat the measurement interval."""
station = await _station(db_session, admin_user.id, code="ST-FREEZE")
recipe = await _recipe_with_interval(db_session, admin_user.id, 30, code="REC-FREEZE")
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
supervisor, password = await _supervisor(db_session, username="capo-freeze")
paused = (await client.post(
f"/api/production-runs/{run_id}/pause",
headers=auth_headers(measurement_tec_user),
json={"supervisor_username": supervisor.username, "supervisor_password": password},
)).json()
assert paused["status"] == "paused"
frozen = paused["seconds_to_next_measurement"]
# An hour goes by with the line stopped: were the countdown still ticking, the
# run would now be deeply overdue.
await _advance_clock(db_session, run_id, timedelta(hours=1))
still = (await client.get(
"/api/production-runs/current",
params={"station_code": station.code},
headers=auth_headers(measurement_tec_user),
)).json()
assert still["status"] == "paused"
assert still["seconds_to_next_measurement"] == frozen
assert still["overdue"] is False
async def test_resume_gives_back_the_time_the_stop_took(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
station = await _station(db_session, admin_user.id, code="ST-RESUME")
recipe = await _recipe_with_interval(db_session, admin_user.id, 30, code="REC-RESUME")
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
supervisor, password = await _supervisor(db_session, username="capo-resume")
creds = {"supervisor_username": supervisor.username, "supervisor_password": password}
frozen = (await client.post(
f"/api/production-runs/{run_id}/pause",
headers=auth_headers(measurement_tec_user), json=creds,
)).json()["seconds_to_next_measurement"]
# The line stays down for ten minutes.
await _advance_clock(db_session, run_id, timedelta(minutes=10))
resumed = (await client.post(
f"/api/production-runs/{run_id}/resume",
headers=auth_headers(measurement_tec_user), json=creds,
)).json()
assert resumed["status"] == "running"
# The ten minutes of stop were handed back, not charged to the operator.
assert abs(resumed["seconds_to_next_measurement"] - frozen) <= 2
async def test_cycle_refused_while_paused(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
station = await _station(db_session, admin_user.id, code="ST-PCYC")
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-PCYC")
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
supervisor, password = await _supervisor(db_session, username="capo-pcyc")
await client.post(
f"/api/production-runs/{run_id}/pause",
headers=auth_headers(measurement_tec_user),
json={"supervisor_username": supervisor.username, "supervisor_password": password},
)
resp = await client.post(
f"/api/production-runs/{run_id}/cycle", headers=auth_headers(measurement_tec_user),
)
assert resp.status_code == 409
async def test_close_stops_the_timer_and_frees_the_station(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
station = await _station(db_session, admin_user.id, code="ST-CLOSE")
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-CLOSE")
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
supervisor, password = await _supervisor(db_session, username="capo-close")
closed = (await client.post(
f"/api/production-runs/{run_id}/close",
headers=auth_headers(measurement_tec_user),
json={"supervisor_username": supervisor.username, "supervisor_password": password},
)).json()
assert closed["status"] == "closed"
assert closed["closed_by"] == supervisor.id
assert closed["seconds_to_next_measurement"] is None
# Nothing is open at the station any more...
current = await client.get(
"/api/production-runs/current",
params={"station_code": station.code},
headers=auth_headers(measurement_tec_user),
)
assert current.json() is None
# ...and a new production can start there.
again = await _open(client, measurement_tec_user, station, recipe)
assert again.status_code == 201
async def test_closed_run_refuses_further_actions(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
station = await _station(db_session, admin_user.id, code="ST-DEAD")
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-DEAD")
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
supervisor, password = await _supervisor(db_session, username="capo-dead")
creds = {"supervisor_username": supervisor.username, "supervisor_password": password}
await client.post(
f"/api/production-runs/{run_id}/close",
headers=auth_headers(measurement_tec_user), json=creds,
)
cycle = await client.post(
f"/api/production-runs/{run_id}/cycle", headers=auth_headers(measurement_tec_user),
)
assert cycle.status_code == 409
pause = await client.post(
f"/api/production-runs/{run_id}/pause",
headers=auth_headers(measurement_tec_user), json=creds,
)
assert pause.status_code == 409
# ---------------------------------------------------------------------------
# The trace
# ---------------------------------------------------------------------------
async def test_run_records_what_happened(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
"""The history a production never had: start, cycles, stop, resume, close."""
station = await _station(db_session, admin_user.id, code="ST-TRACE")
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-TRACE")
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
supervisor, password = await _supervisor(db_session, username="capo-trace")
creds = {"supervisor_username": supervisor.username, "supervisor_password": password}
headers = auth_headers(measurement_tec_user)
await client.post(f"/api/production-runs/{run_id}/cycle", headers=headers)
await client.post(f"/api/production-runs/{run_id}/pause", headers=headers, json=creds)
await client.post(f"/api/production-runs/{run_id}/resume", headers=headers, json=creds)
await client.post(f"/api/production-runs/{run_id}/close", headers=headers, json=creds)
events = (await client.get(
f"/api/production-runs/{run_id}", headers=headers,
)).json()["events"]
assert [e["event_type"] for e in events] == [
"start", "cycle_completed", "line_stop", "resume", "close",
]
# Who authorised what is on the record.
by_type = {e["event_type"]: e for e in events}
assert by_type["line_stop"]["supervisor_id"] == supervisor.id
assert by_type["close"]["supervisor_id"] == supervisor.id
assert by_type["cycle_completed"]["supervisor_id"] is None
async def test_traceability_travels_with_the_run(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
station = await _station(db_session, admin_user.id, code="ST-LOT")
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-LOT")
body = (await _open(
client, measurement_tec_user, station, recipe,
lot_number="LOT-42", serial_number="SN-7",
)).json()
assert body["lot_number"] == "LOT-42"
assert body["serial_number"] == "SN-7"
# ---------------------------------------------------------------------------
# Fine produzione: the statistics file (point 6)
# ---------------------------------------------------------------------------
async def _measure(client, user, run_id, subtask_id, version_id, value):
return await client.post(
"/api/measurements/",
headers=auth_headers(user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": value,
"lot_number": "LOT-STAT",
"production_run_id": run_id,
},
)
async def _first_subtask(db_session, recipe_id: int):
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
row = await db_session.execute(
select(RecipeSubtask)
.join(RecipeTask, RecipeTask.id == RecipeSubtask.task_id)
.join(RecipeVersion, RecipeVersion.id == RecipeTask.version_id)
.where(RecipeVersion.recipe_id == recipe_id)
)
return row.scalars().first()
async def test_measurement_is_tied_to_the_run(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
"""Without this link 'the measurements of this production' is not a query."""
station = await _station(db_session, admin_user.id, code="ST-LINK")
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-LINK")
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
subtask = await _first_subtask(db_session, recipe.id)
resp = await _measure(
client, measurement_tec_user, opened["id"], subtask.id, opened["version_id"], 10.0,
)
assert resp.status_code == 200, resp.text
assert resp.json()["production_run_id"] == opened["id"]
async def test_close_emits_the_statistics_file(
client: AsyncClient, measurement_tec_user, admin_user, db_session, tmp_path,
monkeypatch,
):
from src.backend.config import settings
from src.backend.services import production_export_service
monkeypatch.setattr(
type(settings), "upload_path", property(lambda self: tmp_path), raising=False,
)
station = await _station(db_session, admin_user.id, code="ST-STAT")
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-STAT")
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
subtask = await _first_subtask(db_session, recipe.id)
await _measure(
client, measurement_tec_user, opened["id"], subtask.id, opened["version_id"], 10.0,
)
supervisor, password = await _supervisor(db_session, username="capo-stat")
closed = (await client.post(
f"/api/production-runs/{opened['id']}/close",
headers=auth_headers(measurement_tec_user),
json={"supervisor_username": supervisor.username, "supervisor_password": password},
)).json()
assert closed["statistics_path"], "la chiusura deve produrre il file di statistica"
written = tmp_path / closed["statistics_path"]
assert written.exists()
text = written.read_text(encoding="utf-8-sig")
lines = [line for line in text.splitlines() if line.strip()]
assert len(lines) == 2, "intestazione piu' una misura"
assert "production_run_id" in lines[0]
# The tolerance limits travel with the row: a pass/fail must stay re-derivable.
for column in ("ltl", "lwl", "uwl", "utl", "nominal"):
assert column in lines[0]
assert "LOT-STAT" in lines[1]
assert str(opened["id"]) in lines[1]
async def test_close_marks_measurements_as_exported(
client: AsyncClient, measurement_tec_user, admin_user, db_session, tmp_path,
monkeypatch,
):
from src.backend.config import settings
from src.backend.models.orm.measurement import Measurement
monkeypatch.setattr(
type(settings), "upload_path", property(lambda self: tmp_path), raising=False,
)
station = await _station(db_session, admin_user.id, code="ST-SYNC")
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-SYNC")
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
subtask = await _first_subtask(db_session, recipe.id)
created = (await _measure(
client, measurement_tec_user, opened["id"], subtask.id, opened["version_id"], 10.0,
)).json()
assert created["synced_to_csv"] is False
supervisor, password = await _supervisor(db_session, username="capo-sync")
await client.post(
f"/api/production-runs/{opened['id']}/close",
headers=auth_headers(measurement_tec_user),
json={"supervisor_username": supervisor.username, "supervisor_password": password},
)
row = (await db_session.execute(
select(Measurement).where(Measurement.id == created["id"])
)).scalar_one()
await db_session.refresh(row)
assert row.synced_to_csv is True
async def test_close_without_measurements_writes_no_file(
client: AsyncClient, measurement_tec_user, admin_user, db_session, tmp_path,
monkeypatch,
):
"""An empty file would be noise in the folder, not evidence."""
from src.backend.config import settings
monkeypatch.setattr(
type(settings), "upload_path", property(lambda self: tmp_path), raising=False,
)
station = await _station(db_session, admin_user.id, code="ST-EMPTY")
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-EMPTY")
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
supervisor, password = await _supervisor(db_session, username="capo-empty")
closed = (await client.post(
f"/api/production-runs/{opened['id']}/close",
headers=auth_headers(measurement_tec_user),
json={"supervisor_username": supervisor.username, "supervisor_password": password},
)).json()
assert closed["statistics_path"] is None
assert not (tmp_path / "statistics").exists()
+173
View File
@@ -201,3 +201,176 @@ async def test_duplicate_assignment_returns_409(
json={"recipe_id": recipe.id},
)
assert second.status_code == 409
# ---------------------------------------------------------------------------
# Station list carries assigned recipes (point 7)
# ---------------------------------------------------------------------------
async def test_list_stations_includes_assigned_recipes(
client: AsyncClient, admin_user, db_session,
):
"""The admin list shows which recipes a station handles, without a call per row."""
recipe = await create_test_recipe(db_session, user_id=admin_user.id, code="REC-LIST")
await db_session.commit()
created = await client.post(
"/api/stations",
headers=auth_headers(admin_user),
json={"code": "ST-WITHREC", "name": "Con ricette"},
)
sid = created.json()["id"]
await client.post(
f"/api/stations/{sid}/recipes",
headers=auth_headers(admin_user),
json={"recipe_id": recipe.id},
)
resp = await client.get("/api/stations", headers=auth_headers(admin_user))
assert resp.status_code == 200
station = next(s for s in resp.json() if s["id"] == sid)
assert [r["code"] for r in station["recipes"]] == ["REC-LIST"]
async def test_list_stations_empty_recipes_when_unassigned(
client: AsyncClient, admin_user,
):
created = await client.post(
"/api/stations",
headers=auth_headers(admin_user),
json={"code": "ST-NOREC", "name": "Senza ricette"},
)
sid = created.json()["id"]
resp = await client.get("/api/stations", headers=auth_headers(admin_user))
station = next(s for s in resp.json() if s["id"] == sid)
assert station["recipes"] == []
async def test_list_stations_hides_inactive_recipes(
client: AsyncClient, admin_user, db_session,
):
"""An assigned but deactivated recipe must not show: the operator won't see it."""
recipe = await create_test_recipe(db_session, user_id=admin_user.id, code="REC-OFF")
await db_session.commit()
created = await client.post(
"/api/stations",
headers=auth_headers(admin_user),
json={"code": "ST-INACT", "name": "Con ricetta spenta"},
)
sid = created.json()["id"]
await client.post(
f"/api/stations/{sid}/recipes",
headers=auth_headers(admin_user),
json={"recipe_id": recipe.id},
)
recipe.active = False
await db_session.commit()
resp = await client.get("/api/stations", headers=auth_headers(admin_user))
station = next(s for s in resp.json() if s["id"] == sid)
assert station["recipes"] == []
# ---------------------------------------------------------------------------
# Per-row station reset (point 7)
# ---------------------------------------------------------------------------
async def test_reset_station_removes_all_assignments(
client: AsyncClient, admin_user, db_session,
):
first = await create_test_recipe(db_session, user_id=admin_user.id, code="REC-R1")
second = await create_test_recipe(db_session, user_id=admin_user.id, code="REC-R2")
await db_session.commit()
created = await client.post(
"/api/stations",
headers=auth_headers(admin_user),
json={"code": "ST-RESET", "name": "Da azzerare"},
)
sid = created.json()["id"]
for recipe in (first, second):
await client.post(
f"/api/stations/{sid}/recipes",
headers=auth_headers(admin_user),
json={"recipe_id": recipe.id},
)
resp = await client.delete(
f"/api/stations/{sid}/recipes", headers=auth_headers(admin_user),
)
assert resp.status_code == 200, resp.text
assert resp.json() == {"station_id": sid, "removed": 2}
left = await client.get(
f"/api/stations/{sid}/recipes", headers=auth_headers(admin_user),
)
assert left.json() == []
async def test_reset_station_keeps_the_station(
client: AsyncClient, admin_user, db_session,
):
"""Reset clears assignments only - the station stays and can be reassigned."""
recipe = await create_test_recipe(db_session, user_id=admin_user.id, code="REC-KEEP")
await db_session.commit()
created = await client.post(
"/api/stations",
headers=auth_headers(admin_user),
json={"code": "ST-KEEP", "name": "Resta"},
)
sid = created.json()["id"]
await client.post(
f"/api/stations/{sid}/recipes",
headers=auth_headers(admin_user),
json={"recipe_id": recipe.id},
)
await client.delete(f"/api/stations/{sid}/recipes", headers=auth_headers(admin_user))
still_there = await client.get(
f"/api/stations/{sid}", headers=auth_headers(admin_user),
)
assert still_there.status_code == 200
reassigned = await client.post(
f"/api/stations/{sid}/recipes",
headers=auth_headers(admin_user),
json={"recipe_id": recipe.id},
)
assert reassigned.status_code == 201
async def test_reset_empty_station_is_idempotent(client: AsyncClient, admin_user):
created = await client.post(
"/api/stations",
headers=auth_headers(admin_user),
json={"code": "ST-IDEM", "name": "Gia vuota"},
)
sid = created.json()["id"]
resp = await client.delete(
f"/api/stations/{sid}/recipes", headers=auth_headers(admin_user),
)
assert resp.status_code == 200
assert resp.json()["removed"] == 0
async def test_reset_station_non_admin_is_403(
client: AsyncClient, admin_user, maker_user,
):
created = await client.post(
"/api/stations",
headers=auth_headers(admin_user),
json={"code": "ST-RBAC", "name": "Protetta"},
)
sid = created.json()["id"]
resp = await client.delete(
f"/api/stations/{sid}/recipes", headers=auth_headers(maker_user),
)
assert resp.status_code == 403
async def test_reset_unknown_station_is_404(client: AsyncClient, admin_user):
resp = await client.delete(
"/api/stations/999999/recipes", headers=auth_headers(admin_user),
)
assert resp.status_code == 404
+171
View File
@@ -0,0 +1,171 @@
"""Tests for the explicit task type (point 2).
The type used to be deduced - quotes present meant a measurement - so a measurement
task whose quotes had not been entered yet was treated as a note, and the system
behaved differently depending on how complete the recipe was. These tests pin the
type down as something declared and carried.
"""
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.task import RecipeTask
from src.backend.tests.conftest import auth_headers, create_test_recipe
async def _current_version(db_session, recipe_id: int) -> RecipeVersion:
row = await db_session.execute(
select(RecipeVersion).where(
RecipeVersion.recipe_id == recipe_id,
RecipeVersion.is_current == True, # noqa: E712
)
)
return row.scalar_one()
async def test_task_defaults_to_note(client: AsyncClient, maker_user, db_session):
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-T1")
await db_session.commit()
resp = await client.post(
f"/api/recipes/{recipe.id}/tasks",
headers=auth_headers(maker_user),
json={"title": "Solo una nota"},
)
assert resp.status_code == 201, resp.text
assert resp.json()["task_type"] == "note"
@pytest.mark.parametrize("declared", ["note", "measure", "drawing"])
async def test_task_keeps_the_declared_type(
client: AsyncClient, maker_user, db_session, declared,
):
recipe = await create_test_recipe(
db_session, user_id=maker_user.id, code=f"REC-{declared}",
)
await db_session.commit()
resp = await client.post(
f"/api/recipes/{recipe.id}/tasks",
headers=auth_headers(maker_user),
json={"title": f"Task {declared}", "task_type": declared},
)
assert resp.status_code == 201, resp.text
assert resp.json()["task_type"] == declared
async def test_measure_task_without_quotes_is_still_a_measure_task(
client: AsyncClient, maker_user, db_session,
):
"""The criterion from the document: an unfinished recipe must not change kind."""
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-NOQ")
await db_session.commit()
created = await client.post(
f"/api/recipes/{recipe.id}/tasks",
headers=auth_headers(maker_user),
json={"title": "Misura da compilare", "task_type": "measure"},
)
task_id = created.json()["id"]
fetched = await client.get(f"/api/tasks/{task_id}", headers=auth_headers(maker_user))
body = fetched.json()
assert body["subtasks"] == [], "il task non ha ancora quote"
assert body["task_type"] == "measure", "e resta comunque un task di misura"
async def test_unknown_type_is_rejected(client: AsyncClient, maker_user, db_session):
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-BAD")
await db_session.commit()
resp = await client.post(
f"/api/recipes/{recipe.id}/tasks",
headers=auth_headers(maker_user),
json={"title": "x", "task_type": "qualcosa"},
)
assert resp.status_code == 422
async def test_type_can_be_changed(client: AsyncClient, maker_user, db_session):
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-CHG")
await db_session.commit()
created = await client.post(
f"/api/recipes/{recipe.id}/tasks",
headers=auth_headers(maker_user),
json={"title": "Nasce nota", "task_type": "note"},
)
task_id = created.json()["id"]
resp = await client.put(
f"/api/tasks/{task_id}",
headers=auth_headers(maker_user),
json={"task_type": "measure"},
)
assert resp.status_code == 200, resp.text
assert resp.json()["task_type"] == "measure"
async def test_type_survives_copy_on_write_versioning(
client: AsyncClient, maker_user, measurement_tec_user, db_session,
):
"""A new version must not silently reclassify every task of the recipe."""
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-COW")
await db_session.commit()
version = await _current_version(db_session, recipe.id)
row = await db_session.execute(
select(RecipeTask).where(RecipeTask.version_id == version.id)
)
first_task = row.scalars().first()
# Declare it a drawing. The point is that this survives, not how it got set.
await client.put(
f"/api/tasks/{first_task.id}",
headers=auth_headers(maker_user),
json={"task_type": "drawing"},
)
# Measure on the current version, which forces the next edit to copy-on-write.
await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": first_task.subtasks[0].id,
"version_id": version.id,
"value": 10.0,
},
)
# Editing now must create a new version.
await client.put(
f"/api/recipes/{recipe.id}",
headers=auth_headers(maker_user),
json={"name": "Rinominata"},
)
new_version = await _current_version(db_session, recipe.id)
assert new_version.id != version.id, "l'edit doveva creare una nuova versione"
copied = await db_session.execute(
select(RecipeTask).where(RecipeTask.version_id == new_version.id)
)
types = sorted(t.task_type for t in copied.scalars().all())
assert "drawing" in types, "il tipo dichiarato non e' sopravvissuto alla copia"
async def test_task_list_exposes_the_type(client: AsyncClient, maker_user, db_session):
"""The operator's task list reads the type, so it must come over the wire."""
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-LST")
await db_session.commit()
await client.post(
f"/api/recipes/{recipe.id}/tasks",
headers=auth_headers(maker_user),
json={"title": "Misura", "task_type": "measure"},
)
resp = await client.get(
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
)
assert resp.status_code == 200
assert all("task_type" in t for t in resp.json())
@@ -220,3 +220,14 @@ def api_unassign_recipe(station_id: int, recipe_id: int):
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify({"deleted": True}), 200
@admin_bp.route("/api/stations/<int:station_id>/recipes", methods=["DELETE"])
@login_required
@admin_required
def api_reset_station_recipes(station_id: int):
"""Proxy: Clear every recipe assignment of a station."""
resp = api_client.delete(f"/api/stations/{station_id}/recipes")
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
+203 -5
View File
@@ -12,6 +12,61 @@ from services.file_proxy import proxy_file
measure_bp = Blueprint("measure", __name__)
# Session key holding a station chosen at runtime, overriding Config.STATION_CODE.
STATION_OVERRIDE_KEY = "station_override"
def _current_station() -> tuple[str | None, bool]:
"""Return the station this client acts as, and whether it is an override."""
override = session.get(STATION_OVERRIDE_KEY)
if override:
return override, True
return Config.STATION_CODE, False
def _apply_station_switch() -> None:
"""Honour ?station=CODE, used to exercise several stations from one PC.
Only active when STATION_SWITCH_ENABLED is set: in production the station
identity comes from the local install, and silently measuring against another
station's recipes would break traceability. An empty value clears the override
and falls back to the configured station.
The requested code is validated against the server before being stored, so a
typo leaves the operator on the current station with an explanation rather
than stranded on a station that does not exist.
"""
if "station" not in request.args:
return
requested = (request.args.get("station") or "").strip().upper()
if not Config.STATION_SWITCH_ENABLED:
flash(_("Il cambio stazione da URL non è abilitato su questa postazione."), "error")
return
if not requested:
if session.pop(STATION_OVERRIDE_KEY, None):
flash(_("Stazione riportata a quella configurata."), "info")
return
try:
probe = api_client.get_station_recipes(requested)
except Exception:
flash(_("Impossibile contattare il server per cambiare stazione."), "error")
return
if isinstance(probe, dict) and probe.get("error"):
flash(
_("Stazione '%(code)s' inesistente o disattivata: stazione invariata.",
code=requested),
"error",
)
return
session[STATION_OVERRIDE_KEY] = requested
flash(_("Stazione corrente: %(code)s", code=requested), "info")
# ---------------------------------------------------------------------------
# Route: Recipe selection
@@ -21,13 +76,16 @@ measure_bp = Blueprint("measure", __name__)
@role_required("MeasurementTec")
def select_recipe():
"""Recipe selection page with search and barcode support."""
# Fail-fast if STATION_CODE is not configured
if not Config.STATION_CODE:
_apply_station_switch()
station_code, station_overridden = _current_station()
# Fail-fast if no station is configured and none was chosen
if not station_code:
return render_template("errors/station_not_configured.html"), 503
# Load recipes filtered by station
try:
resp = api_client.get_station_recipes(Config.STATION_CODE)
resp = api_client.get_station_recipes(station_code)
except Exception as e:
return render_template(
"errors/station_not_configured.html", error=str(e),
@@ -52,7 +110,9 @@ def select_recipe():
return render_template(
"measure/select_recipe.html",
recipes=recipes,
station_code=Config.STATION_CODE,
station_code=station_code,
station_overridden=station_overridden,
station_configured=Config.STATION_CODE,
auto_recipe_code=auto_recipe_code,
auto_lot=auto_lot,
auto_serial=auto_serial,
@@ -312,9 +372,14 @@ def save_measurement():
"serial_number": data.get("serial_number", session.get("serial_number", "")),
"input_method": data.get("input_method", "manual"),
"input_duration_ms": data.get("input_duration_ms"),
# Attaches the measurement to the production under way, so fine produzione
# can hand the whole run to the statistics file.
"production_run_id": data.get("production_run_id"),
}
resp = api_client.post("/api/measurements", data=payload)
# Trailing slash matters: the route is declared as "/", so omitting it costs
# a 307 redirect on every single measurement saved.
resp = api_client.post("/api/measurements/", data=payload)
if resp.get("error"):
status_code = resp.get("status_code", 500)
@@ -354,6 +419,139 @@ def validate_supervisor():
return jsonify({"authorized": True, "supervisor": user.get("display_name", username)}), 200
# ---------------------------------------------------------------------------
# Routes: Production run (state that must outlive the page)
# ---------------------------------------------------------------------------
@measure_bp.route("/api/production/current", methods=["GET"])
@login_required
@role_required("MeasurementTec")
def api_current_production():
"""Proxy: the production open at this station, or null.
Every page asks this on load. Before, the timer and the cycle count lived in the
Alpine component and a change of task - a full page load - wiped them.
"""
station_code, _overridden = _current_station()
if not station_code:
return jsonify({"error": True, "detail": _("Stazione non configurata")}), 503
resp = api_client.get(
"/api/production-runs/current", params={"station_code": station_code},
)
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@measure_bp.route("/api/production/start", methods=["POST"])
@login_required
@role_required("MeasurementTec")
def api_start_production():
"""Proxy: open a production at this station."""
station_code, _overridden = _current_station()
if not station_code:
return jsonify({"error": True, "detail": _("Stazione non configurata")}), 503
data = request.get_json(silent=True) or {}
payload = {
"station_code": station_code,
"recipe_id": data.get("recipe_id"),
"version_id": data.get("version_id"),
"lot_number": data.get("lot_number") or session.get("lot_number") or None,
"serial_number": data.get("serial_number") or session.get("serial_number") or None,
}
resp = api_client.post("/api/production-runs", data=payload)
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 201
@measure_bp.route("/api/production/<int:run_id>/cycle", methods=["POST"])
@login_required
@role_required("MeasurementTec")
def api_complete_cycle(run_id: int):
"""Proxy: record a finished measurement task.
The task travels with the call: only the last measurement task of the recipe
closes the cycle and restarts the interval, and the server is what decides.
"""
data = request.get_json(silent=True) or {}
resp = api_client.post(
f"/api/production-runs/{run_id}/cycle",
data={"task_id": data.get("task_id"), "note": data.get("note")},
)
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@measure_bp.route("/api/production/<int:run_id>/remeasure", methods=["POST"])
@login_required
@role_required("MeasurementTec")
def api_remeasure(run_id: int):
"""Proxy: the piece was turned over - measure again inside the same cycle."""
data = request.get_json(silent=True) or {}
resp = api_client.post(
f"/api/production-runs/{run_id}/remeasure",
data={"task_id": data.get("task_id"), "note": data.get("note")},
)
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
def _supervised_action(run_id: int, action: str):
"""Forward an action that needs the capoturno's authorisation.
The credentials go straight to the API, which checks them and records who
authorised what on the run's trace. Validating them separately first would
leave no such record, and is an extra round trip besides.
"""
data = request.get_json(silent=True) or {}
username = (data.get("supervisor_username") or "").strip()
password = data.get("supervisor_password") or ""
if not username or not password:
return jsonify({
"error": True, "detail": _("Username e password richiesti"),
}), 400
resp = api_client.post(
f"/api/production-runs/{run_id}/{action}",
data={
"supervisor_username": username,
"supervisor_password": password,
"note": data.get("note"),
},
)
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@measure_bp.route("/api/production/<int:run_id>/pause", methods=["POST"])
@login_required
@role_required("MeasurementTec")
def api_pause_production(run_id: int):
"""Proxy: fermo linea - suspends the run and freezes the countdown."""
return _supervised_action(run_id, "pause")
@measure_bp.route("/api/production/<int:run_id>/resume", methods=["POST"])
@login_required
@role_required("MeasurementTec")
def api_resume_production(run_id: int):
"""Proxy: restart a stopped line, giving back the time the stop took."""
return _supervised_action(run_id, "resume")
@measure_bp.route("/api/production/<int:run_id>/close", methods=["POST"])
@login_required
@role_required("MeasurementTec")
def api_close_production(run_id: int):
"""Proxy: fine produzione - closes the run and emits the statistics file."""
return _supervised_action(run_id, "close")
# ---------------------------------------------------------------------------
# Route: File proxy (browser can't send X-API-Key directly)
# ---------------------------------------------------------------------------
+8
View File
@@ -26,6 +26,14 @@ class Config:
# code it belongs to. Empty/None means "not configured".
STATION_CODE: str | None = os.getenv("STATION_CODE") or None
# Allow switching the current station from the URL (?station=CODE), keeping the
# override in the session. Meant for commissioning: it lets one PC exercise
# several stations instead of needing one machine per station.
# Off by default - on the shop floor the station identity comes from the local
# install, and measuring against the wrong station's recipes would silently
# break traceability.
STATION_SWITCH_ENABLED: bool = os.getenv("STATION_SWITCH_ENABLED", "0") == "1"
# Babel i18n
BABEL_DEFAULT_LOCALE = "it"
BABEL_DEFAULT_TIMEZONE = "Europe/Rome"
@@ -0,0 +1,176 @@
/**
* The measurement clock, shared by every screen an operator can be on.
*
* Two things the shop floor asked for live here. First, the countdown does not stop
* at zero: it keeps going the other way, so how late a measurement is can be read
* off the screen instead of worked out. Second, when the interval expires the
* measurement comes back on its own, wherever the operator happens to be - the task
* list, the summary, the recipe picker.
*
* The number always comes from the server (seconds_to_next_measurement, already
* signed). Ticking locally only keeps the display smooth between round trips, and
* every resync overwrites it: a tab that was asleep, or a machine whose clock is
* off, still shows the same figure as the station next to it.
*
* ProductionClock holds the pure parts, used here and by the measurement screen,
* which has its own component but must not grow a second copy of these rules.
*/
(function (global) {
'use strict';
var ProductionClock = {
/* mm:ss of the distance from the deadline, in whichever direction. The sign is
carried by the wording around it ("tra" / "in ritardo di"), not by a minus. */
format: function (seconds) {
var total = Math.abs(Math.round(seconds || 0));
var m = Math.floor(total / 60);
var s = total % 60;
return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
},
hasClock: function (seconds) {
return seconds !== null && seconds !== undefined;
},
isOverdue: function (seconds) {
return ProductionClock.hasClock(seconds) && seconds <= 0;
},
/* Three short beeps. Wrapped because a browser refuses to make noise before the
page has been touched, and a silent buzzer must not take the screen with it.
Whether a light column replaces this is question D-5. */
playBuzzer: function () {
try {
var ctx = new (global.AudioContext || global.webkitAudioContext)();
[0, 0.25, 0.5].forEach(function (delay) {
var osc = ctx.createOscillator();
var gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 880;
osc.type = 'square';
gain.gain.value = 0.3;
osc.start(ctx.currentTime + delay);
osc.stop(ctx.currentTime + delay + 0.15);
});
} catch (_) {}
},
/* The task the operator has to be taken back to: the first measurement task of
the recipe. Null when the recipe has none, which is the whole reason this is
a lookup and not an assumption. */
measurementUrl: function (run, executeUrlTemplate) {
var ids = (run && run.measurement_task_ids) || [];
if (!ids.length) return null;
return executeUrlTemplate.replace('/0', '/' + ids[0]);
},
};
global.ProductionClock = ProductionClock;
/* How long the operator gets to see why the screen is about to change. Long
enough to read it, short enough that it is not a way of putting the
measurement off. */
var ANNOUNCE_SECONDS = 5;
/* The server is asked again on this cadence: the run may have been stopped or
closed from another screen, and a sleeping tab drifts. */
var RESYNC_MS = 60000;
/**
* Banner component for the screens that only watch the clock.
*
* options.currentUrl - endpoint returning the run open at this station, or null
* options.executeUrl - url of a task execution page with 0 as the task id
*/
global.productionClock = function (options) {
var opts = options || {};
return {
run: null,
seconds: null,
announced: false,
redirectIn: 0,
_tick: null,
_resync: null,
init: function () {
var self = this;
this.load();
this._tick = setInterval(function () { self.onSecond(); }, 1000);
this._resync = setInterval(function () { self.load(); }, RESYNC_MS);
},
destroy: function () {
if (this._tick) clearInterval(this._tick);
if (this._resync) clearInterval(this._resync);
},
load: async function () {
try {
var resp = await fetch(opts.currentUrl);
if (!resp.ok) return;
var run = await resp.json();
this.adopt(run && run.id ? run : null);
} catch (e) {
// Offline or server down: the page stays usable, just without the clock.
}
},
adopt: function (run) {
this.run = run;
this.seconds = run ? run.seconds_to_next_measurement : null;
if (!run || run.status !== 'running') {
// Closed or stopped: nothing is due, and an announcement left on screen
// would be telling the operator to go and measure a stopped line.
this.announced = false;
this.redirectIn = 0;
return;
}
// Arriving on a page that is already late is the same event as going late
// while sitting on it.
if (this.isDue) this.announce();
},
get running() { return !!this.run && this.run.status === 'running'; },
get paused() { return !!this.run && this.run.status === 'paused'; },
get visible() {
return !!this.run && ProductionClock.hasClock(this.seconds);
},
get isDue() { return this.running && ProductionClock.isOverdue(this.seconds); },
get display() { return ProductionClock.format(this.seconds); },
get measurementUrl() {
return ProductionClock.measurementUrl(this.run, opts.executeUrl || '');
},
onSecond: function () {
var wasAnnounced = this.announced;
if (this.running && ProductionClock.hasClock(this.seconds)) {
// Past zero it keeps counting, into the negative: that is the figure the
// operator reads as "how long I am late by".
this.seconds--;
if (this.seconds <= 0) this.announce();
}
if (wasAnnounced && this.redirectIn > 0) {
this.redirectIn--;
if (this.redirectIn === 0) this.goToMeasurement();
}
},
announce: function () {
if (this.announced) return;
this.announced = true;
ProductionClock.playBuzzer();
// Without a measurement task to go to there is nothing to announce beyond
// the banner going red, and a redirect to nowhere would be a broken link.
this.redirectIn = this.measurementUrl ? ANNOUNCE_SECONDS : 0;
},
goToMeasurement: function () {
var url = this.measurementUrl;
if (url) global.location.href = url;
},
};
};
})(window);
+77
View File
@@ -0,0 +1,77 @@
# Librerie di terze parti — copie locali
Queste copie servono al **punto 12** del documento delle modifiche: l'installazione a
Tràfilo è on-premise, su rete di produzione isolata. Finché queste librerie venivano
scaricate da CDN a ogni apertura di pagina, senza internet l'interfaccia non partiva
affatto (Alpine.js governa tutta la UI).
Secondo effetto, non meno importante: le versioni sono **congelate**. Prima Alpine.js
era agganciato a `alpinejs@3.x.x`, cioè l'applicazione cambiava da sola a ogni rilascio
degli autori, senza validazione. Per un sistema che produce evidenze per audit
ISO 9001 / IATF 16949 questo è di per sé un difetto.
Scaricate il **28 luglio 2026**.
## JavaScript
| File | Versione | Origine |
|---|---|---|
| `alpinejs-3.15.12.min.js` | 3.15.12 | `https://cdn.jsdelivr.net/npm/alpinejs@3.15.12/dist/cdn.min.js` |
| `plotly-2.32.0.min.js` | 2.32.0 | `https://cdn.plot.ly/plotly-2.32.0.min.js` |
| `pdf-3.11.174.min.js` | 3.11.174 | `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js` |
| `pdf.worker-3.11.174.min.js` | 3.11.174 | `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js` |
| `fabric-5.3.1.min.js` | 5.3.1 | `https://cdnjs.cloudflare.com/ajax/libs/fabric.js/5.3.1/fabric.min.js` |
La versione di Alpine è quella a cui `3.x.x` risolveva il giorno del congelamento:
il pin non cambia il comportamento rispetto a quanto già in esercizio.
`pdf.worker` è un file a sé che PDF.js carica **a runtime** via
`pdfjsLib.GlobalWorkerOptions.workerSrc`. Va ripuntato in tutti i template che usano
PDF.js, non basta lo script principale: altrimenti la libreria si carica in locale e il
worker continua a cercare internet — sembra funzionare finché non si apre un disegno.
## Font
`fonts.css` sostituisce il `<link>` a `fonts.googleapis.com` in `base.html`.
| File | Famiglia | Subset |
|---|---|---|
| `fonts/inter-latin.woff2` | Inter | latin |
| `fonts/inter-latin-ext.woff2` | Inter | latin-ext |
| `fonts/jetbrains-mono-latin.woff2` | JetBrains Mono | latin |
| `fonts/jetbrains-mono-latin-ext.woff2` | JetBrains Mono | latin-ext |
Entrambe le famiglie sono **font variabili** (asse `wght`: Inter 100900, JetBrains Mono
400800): un solo file per subset copre tutti i pesi usati dall'interfaccia. Google
serviva lo stesso woff2 sotto otto URL diversi, uno per peso dichiarato.
Tenuti i soli subset **latin** e **latin-ext**, che coprono per intero italiano e
inglese. Per gli altri alfabeti il browser ricade sul font di sistema — scelta
consapevole: cirillico, greco e vietnamita valevano 600 KB di peso morto.
## Impronte SHA-256
```
57b37d7cae9a27d965fdae4adcc844245dfdc407e655aee85dcfff3a08036a3f alpinejs-3.15.12.min.js
bb0442d69a4d4673320f689bc6e16c05a0e3f16ccfbf551f394d90a7e7cfc772 fabric-5.3.1.min.js
5b5799e6f8c680663207ac5b42ee14eed2a406fa7af48f50c154f0c0b1566946 pdf-3.11.174.min.js
feabdf309770ed24bba31a5467836cdc8cf639c705af27d52b585b041bb8527b pdf.worker-3.11.174.min.js
0a17719a72751704861215da0e5c5cdb3f9a8d50eff5cb84cb6f8b80786682b0 plotly-2.32.0.min.js
34b9c504cab7a73e37b746343a449132e56cf7b5481af2cb81dc74dcff25c956 fonts/inter-latin-ext.woff2
3100e775e8616cd2611beecfa23a4263d7037586789b43f035236a2e6fbd4c62 fonts/inter-latin.woff2
db5ff4db83e580426280e9337a58dc57d3a83784a1b03ad80914651594441d52 fonts/jetbrains-mono-latin-ext.woff2
83c005d49d8a6a50474c73a5a36ac0468076e9c4a29da7bdb14995d80560a5be fonts/jetbrains-mono-latin.woff2
```
Verifica: `sha256sum -c` da questa cartella, oppure
`sha256sum *.js fonts/*.woff2`.
## Aggiornare una libreria
1. Scaricare la nuova versione con il nome comprensivo di versione.
2. Ripuntare i `<script>`/`<link>` nei template (`grep -rn vendor/ ../../templates/`).
3. Aggiornare questa tabella e le impronte.
4. Rimuovere il file vecchio.
Il nome del file porta la versione apposta: un aggiornamento non passa inosservato in
revisione, e la modifica resta tracciata in git.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+44
View File
@@ -0,0 +1,44 @@
/* Inter + JetBrains Mono self-hosted — subset latin + latin-ext, font variabili.
Sostituisce il <link> a fonts.googleapis.com: l'app deve partire a rete staccata
(punto 12). Un solo woff2 per subset copre tutti i pesi via asse wght.
Provenienza e hash dei file: vendor/VERSIONS.md */
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url(fonts/inter-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url(fonts/inter-latin-ext.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400 800;
font-display: swap;
src: url(fonts/jetbrains-mono-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400 800;
font-display: swap;
src: url(fonts/jetbrains-mono-latin-ext.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -49,6 +49,7 @@
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Codice') }}</th>
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Nome') }}</th>
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden md:table-cell">{{ _('Postazione') }}</th>
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden lg:table-cell">{{ _('Ricette collegate') }}</th>
<th class="text-center px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Stato') }}</th>
<th class="text-right px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Azioni') }}</th>
</tr>
@@ -62,6 +63,24 @@
</td>
<td class="px-4 py-3 text-sm text-[var(--text-primary)]" x-text="station.name"></td>
<td class="px-4 py-3 text-sm text-[var(--text-secondary)] hidden md:table-cell" x-text="station.location || '-'"></td>
<td class="px-4 py-3 hidden lg:table-cell">
<template x-if="!(station.recipes && station.recipes.length)">
<span class="text-xs italic text-[var(--text-secondary)]">{{ _('Nessuna ricetta') }}</span>
</template>
<template x-if="station.recipes && station.recipes.length">
<div class="flex flex-wrap items-center gap-1">
<template x-for="r in station.recipes.slice(0, 3)" :key="r.id">
<span class="inline-flex px-1.5 py-0.5 rounded font-mono text-[11px] font-medium
bg-primary-50 dark:bg-primary-900/20 text-primary"
:title="r.name" x-text="r.code"></span>
</template>
<template x-if="station.recipes.length > 3">
<span class="text-[11px] text-[var(--text-secondary)]"
x-text="'+' + (station.recipes.length - 3)"></span>
</template>
</div>
</template>
</td>
<td class="px-4 py-3 text-center">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium"
:class="station.active
@@ -78,6 +97,17 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
</svg>
</button>
<button @click="confirmReset(station)"
:disabled="!(station.recipes && station.recipes.length)"
class="p-1.5 rounded-lg text-[var(--text-secondary)] hover:text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-colors
disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:text-[var(--text-secondary)] disabled:hover:bg-transparent"
:title="(station.recipes && station.recipes.length)
? '{{ _('Azzera le ricette della stazione') }}'
: '{{ _('Nessuna ricetta da azzerare') }}'">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992V4.356m-.582 9.66a8.25 8.25 0 01-15.356 2.008M3.985 14.652h4.992v4.992m.581-9.66a8.25 8.25 0 0115.356-2.008"/>
</svg>
</button>
<button @click="openEditModal(station)"
class="p-1.5 rounded-lg text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors"
:title="'{{ _('Modifica') }}'">
@@ -335,6 +365,38 @@
</div>
</div>
<!-- Confirm Reset Modal -->
<div x-show="showResetConfirm" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@keydown.escape.window="showResetConfirm = false">
<div class="absolute inset-0 bg-black/50" @click="showResetConfirm = false"></div>
<div x-show="showResetConfirm"
x-transition
class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-sm p-6">
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2">{{ _('Azzera Stazione') }}</h3>
<p class="text-sm text-[var(--text-secondary)] mb-4">
{{ _('Rimuovere tutte le ricette assegnate a') }}
<strong x-text="resetTarget?.code"></strong>?
<br>
<span class="text-xs text-[var(--text-secondary)]">
<span x-text="resetTarget?.recipes?.length || 0"></span>
{{ _('assegnazioni verranno rimosse. La stazione resta, e si potrà riassegnare.') }}
</span>
</p>
<div class="flex justify-end gap-3">
<button @click="showResetConfirm = false"
class="px-4 py-2 text-sm font-medium text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-secondary)] transition-colors">
{{ _('Annulla') }}
</button>
<button @click="performReset()"
:disabled="saving"
class="px-4 py-2 text-sm font-medium text-white rounded-lg shadow-sm bg-amber-600 hover:bg-amber-700 disabled:opacity-50 transition-colors">
{{ _('Azzera') }}
</button>
</div>
</div>
</div>
<!-- Confirm Delete Modal -->
<div x-show="showDeleteConfirm" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center p-4">
@@ -375,11 +437,13 @@ function stationManagement(initialStations, initialRecipes) {
showModal: false,
showAssignments: false,
showDeleteConfirm: false,
showResetConfirm: false,
isEditing: false,
editingId: null,
saving: false,
errorMsg: '',
deleteTarget: null,
resetTarget: null,
assignmentStation: null,
assignedRecipes: [],
recipeSearch: '',
@@ -486,7 +550,9 @@ function stationManagement(initialStations, initialRecipes) {
return;
}
const idx = this.stations.findIndex(s => s.id === this.editingId);
if (idx >= 0) this.stations[idx] = result;
// PUT returns the station without its assignments; keep the ones the
// row already knows about or the "ricette collegate" column blanks out.
if (idx >= 0) this.stations[idx] = { ...result, recipes: this.stations[idx].recipes || [] };
} else {
if (!this.form.code || !this.form.name) {
this.errorMsg = '{{ _("Codice e nome sono obbligatori") }}';
@@ -509,7 +575,7 @@ function stationManagement(initialStations, initialRecipes) {
this.errorMsg = result.detail || '{{ _("Errore nella creazione") }}';
return;
}
this.stations.push(result);
this.stations.push({ ...result, recipes: [] });
}
this.closeModal();
} catch (e) {
@@ -524,6 +590,40 @@ function stationManagement(initialStations, initialRecipes) {
this.showDeleteConfirm = true;
},
confirmReset(station) {
if (!station.recipes || station.recipes.length === 0) return;
this.resetTarget = station;
this.showResetConfirm = true;
},
async performReset() {
if (!this.resetTarget) return;
this.saving = true;
try {
const resp = await fetch(`/admin/api/stations/${this.resetTarget.id}/recipes`, {
method: 'DELETE',
headers: { 'X-CSRFToken': this.csrfToken },
});
if (!resp.ok) {
const result = await resp.json().catch(() => ({}));
alert(result.detail || '{{ _("Errore nell azzeramento") }}');
return;
}
// resetTarget is the same object the row renders, so clearing it here
// updates the chips column without reloading the page.
this.resetTarget.recipes = [];
if (this.assignmentStation && this.assignmentStation.id === this.resetTarget.id) {
this.assignedRecipes = [];
}
this.showResetConfirm = false;
this.resetTarget = null;
} catch (e) {
alert('{{ _("Errore di connessione al server") }}');
} finally {
this.saving = false;
}
},
async performDelete() {
if (!this.deleteTarget) return;
this.saving = true;
@@ -591,7 +691,11 @@ function stationManagement(initialStations, initialRecipes) {
return;
}
const recipe = this.allRecipes.find(r => r.id === id);
if (recipe) this.assignedRecipes.push({ id: recipe.id, code: recipe.code, name: recipe.name, active: recipe.active });
if (recipe) {
const summary = { id: recipe.id, code: recipe.code, name: recipe.name, active: recipe.active };
this.assignedRecipes.push(summary);
this._syncStationRecipes();
}
} catch (e) {
this.errorMsg = '{{ _("Errore di connessione al server") }}';
} finally {
@@ -614,12 +718,23 @@ function stationManagement(initialStations, initialRecipes) {
return;
}
this.assignedRecipes = this.assignedRecipes.filter(r => r.id !== recipeId);
this._syncStationRecipes();
} catch (e) {
this.errorMsg = '{{ _("Errore di connessione al server") }}';
} finally {
this.saving = false;
}
},
/* Mirror the modal's assignment list onto the table row, so the chips in
"ricette collegate" stay true after assigning or unassigning. Sorted by
code to match the order the server returns. */
_syncStationRecipes() {
if (!this.assignmentStation) return;
this.assignmentStation.recipes = [...this.assignedRecipes]
.filter(r => r.active !== false)
.sort((a, b) => (a.code || '').localeCompare(b.code || ''));
},
};
}
</script>
+4 -6
View File
@@ -13,10 +13,8 @@
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='img/tmflow-icon.svg') }}">
<!-- Google Fonts: Inter + JetBrains Mono -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<!-- Fonts: Inter + JetBrains Mono (copie locali, vedi static/vendor/VERSIONS.md) -->
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/fonts.css') }}">
<!-- TailwindCSS (compiled) -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/tailwind.css') }}">
@@ -186,7 +184,7 @@
</div>
{% endif %}
<!-- Alpine.js CDN (defer) - must load AFTER extra_js so component functions are defined -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<!-- Alpine.js (defer) - must load AFTER extra_js so component functions are defined -->
<script defer src="{{ url_for('static', filename='vendor/alpinejs-3.15.12.min.js') }}"></script>
</body>
</html>
@@ -0,0 +1,108 @@
{#
The measurement clock, for every screen that is not the measurement itself.
Two requirements of 28/07 are in here. The countdown is always on screen while a
production is open, and past zero it turns round and shows how long the line has
been over the interval - in red, because a late measurement that looks like an
early one is how it stays late. And when the interval expires the measurement
comes back on its own: the operator is told, and taken there.
The measurement screen has its own component and does not include this: being
already on the measurement is the one place there is nothing to announce.
#}
<script src="{{ url_for('static', filename='js/production-clock.js') }}"></script>
<div x-data="productionClock({
currentUrl: '{{ url_for('measure.api_current_production') }}',
executeUrl: '{{ url_for('measure.task_execute', task_id=0) }}'
})"
x-init="init()"
x-cloak>
{# ---- Countdown bar: sticky, so scrolling never hides it ---- #}
<div x-show="visible"
x-transition
class="sticky top-0 z-30 -mx-4 sm:-mx-6 lg:-mx-8 mb-4 px-4 py-2 border-b shadow-sm"
:class="isDue
? 'bg-red-50 dark:bg-red-900/30 border-red-400 dark:border-red-700'
: paused
? 'bg-amber-100 dark:bg-amber-900/40 border-amber-500'
: 'bg-amber-50 dark:bg-amber-900/20 border-amber-300 dark:border-amber-700'">
<div class="flex flex-wrap items-center justify-center gap-x-3 gap-y-1">
<svg class="w-5 h-5 shrink-0"
:class="isDue ? 'text-red-600 animate-pulse' : 'text-amber-600'"
fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{# In time #}
<template x-if="!isDue && !paused">
<span class="text-sm font-medium text-amber-800 dark:text-amber-200">
{{ _('Prossima misurazione tra') }}
</span>
</template>
{# Over the interval: the delay is the headline, not a footnote #}
<template x-if="isDue">
<span class="text-sm font-bold text-red-800 dark:text-red-200">
{{ _('Misurazione in ritardo di') }}
</span>
</template>
{# Line stopped: the figure is frozen, and says so. Frozen past the interval
it is a delay held in place, not a wait - the wording has to say which. #}
<template x-if="paused && seconds > 0">
<span class="text-sm font-semibold text-amber-900 dark:text-amber-100">
{{ _('Linea ferma — conto alla rovescia congelato a') }}
</span>
</template>
<template x-if="paused && seconds <= 0">
<span class="text-sm font-semibold text-amber-900 dark:text-amber-100">
{{ _('Linea ferma') }} — {{ _('Misurazione in ritardo di') }}
</span>
</template>
<span class="text-lg font-bold font-mono px-2 py-0.5 rounded"
:class="isDue
? 'text-red-900 dark:text-red-100 bg-red-100 dark:bg-red-900/40'
: 'text-amber-900 dark:text-amber-100 bg-amber-100 dark:bg-amber-900/40'"
x-text="display"></span>
<span class="text-xs"
:class="isDue ? 'text-red-600 dark:text-red-300' : 'text-amber-600 dark:text-amber-400'">
({{ _('Ciclo') }} #<span x-text="run ? run.cycle_count : 0"></span>)
</span>
</div>
</div>
{# ---- Time to measure: announced, then taken there ---- #}
<div x-show="announced && redirectIn > 0"
x-transition
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div class="bg-[var(--bg-card)] rounded-2xl shadow-2xl p-8 max-w-sm mx-4 text-center border-2 border-red-500">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-red-100 dark:bg-red-900/30 mb-4">
<svg class="w-8 h-8 text-red-600 animate-pulse" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<h3 class="text-lg font-bold text-[var(--text-primary)] mb-1">
{{ _('È ora di misurare') }}
</h3>
<p class="text-sm text-[var(--text-secondary)] mb-5">
{{ _('Ritorno alla misurazione tra') }}
<span class="font-bold font-mono" x-text="redirectIn"></span> s
</p>
<button @click="goToMeasurement()"
class="btn w-full justify-center gap-2 bg-red-600 hover:bg-red-700 text-white font-bold py-2.5">
{{ _('Vai alla misura') }}
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
</svg>
</button>
</div>
</div>
</div>
@@ -432,9 +432,9 @@
{% endblock %}
{% block extra_js %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script>
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = '{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';
</script>
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
<script>
@@ -379,10 +379,10 @@
{% endblock %}
{% block extra_js %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/5.3.1/fabric.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script src="{{ url_for('static', filename='vendor/fabric-5.3.1.min.js') }}"></script>
<script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script>
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = '{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';
</script>
<script src="{{ url_for('static', filename='js/annotation-editor.js') }}?v=25"></script>
<script>
@@ -270,6 +270,18 @@
class="tmf-input"
placeholder="{{ _('Es. Controllo dimensionale flangia') }}">
</div>
<!-- Tipo: dichiarato qui, non dedotto dal contenuto -->
<div>
<label class="tmf-label">{{ _('Tipo') }} <span class="text-red-500">*</span></label>
<select x-model="newTask.task_type" class="tmf-input">
<option value="note">{{ _('Nota') }}</option>
<option value="measure">{{ _('Misura') }}</option>
<option value="drawing">{{ _('Disegno') }}</option>
</select>
<p class="mt-1 text-xs text-[var(--text-secondary)]">
{{ _('Un task di misura resta tale anche prima di inserire le quote') }}
</p>
</div>
<!-- Direttiva -->
<div>
<label class="tmf-label">{{ _('Direttiva') }}</label>
@@ -516,6 +528,14 @@
<!-- Edit Task Extra Fields (when in edit mode) -->
<template x-if="editingTask === task.id">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/10 border border-blue-200 dark:border-blue-800">
<div>
<label class="tmf-label">{{ _('Tipo') }}</label>
<select x-model="editTaskData.task_type" class="tmf-input text-sm">
<option value="note">{{ _('Nota') }}</option>
<option value="measure">{{ _('Misura') }}</option>
<option value="drawing">{{ _('Disegno') }}</option>
</select>
</div>
<div>
<label class="tmf-label">{{ _('Direttiva') }}</label>
<input type="text"
@@ -1056,8 +1076,8 @@
{% endblock %}
{% block extra_js %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';</script>
<script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script>
<script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';</script>
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
<script>
// Force reload when page is restored from browser bfcache (back/forward navigation).
@@ -1084,8 +1104,8 @@ function taskEditor() {
// ---- Task CRUD state ----
editingTask: null,
editTaskData: { title: '', directive: '', description: '' },
newTask: { title: '', directive: '', description: '' },
editTaskData: { title: '', directive: '', description: '', task_type: 'note' },
newTask: { title: '', directive: '', description: '', task_type: 'note' },
// ---- Subtask CRUD state ----
editingSubtaskId: null,
@@ -1207,7 +1227,7 @@ function taskEditor() {
// ============================================================
resetNewTask() {
this.newTask = { title: '', directive: '', description: '' };
this.newTask = { title: '', directive: '', description: '', task_type: 'note' };
},
// ============================================================
@@ -1298,6 +1318,7 @@ function taskEditor() {
},
body: JSON.stringify({
title: this.newTask.title.trim(),
task_type: this.newTask.task_type || 'note',
directive: this.newTask.directive.trim() || null,
description: this.newTask.description.trim() || null
})
@@ -1328,6 +1349,7 @@ function taskEditor() {
this.editingTask = task.id;
this.editTaskData = {
title: task.title,
task_type: task.task_type || 'note',
directive: task.directive || '',
description: task.description || ''
};
@@ -1337,7 +1359,7 @@ function taskEditor() {
cancelEditTask() {
this.editingTask = null;
this.editTaskData = { title: '', directive: '', description: '' };
this.editTaskData = { title: '', directive: '', description: '', task_type: 'note' };
},
async updateTask(taskId) {
@@ -1354,6 +1376,7 @@ function taskEditor() {
},
body: JSON.stringify({
title: this.editTaskData.title.trim(),
task_type: this.editTaskData.task_type || 'note',
directive: this.editTaskData.directive.trim() || null,
description: this.editTaskData.description.trim() || null
})
@@ -59,6 +59,10 @@
}
}">
{# A production open at this station keeps its clock on screen even here: the
interval runs whether or not the operator is looking at the measurement. #}
{% include "components/production_clock.html" %}
<!-- Page Header -->
<div class="mb-8">
<div class="flex items-center justify-between flex-wrap gap-4">
@@ -78,6 +82,23 @@
<p class="mt-1 text-sm text-steel-500 dark:text-steel-400">
{{ _('Stazione') }}: <span class="font-mono font-bold">{{ station_code }}</span>
</p>
{% if station_overridden %}
<p class="mt-2 inline-flex flex-wrap items-center gap-2 px-2.5 py-1 rounded-lg
bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700
text-xs text-amber-800 dark:text-amber-200">
<svg class="w-4 h-4 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>
{{ _('Stazione forzata per collaudo') }}{% if station_configured %} — {{ _('configurata') }}:
<span class="font-mono">{{ station_configured }}</span>{% endif %}
</span>
<a href="{{ url_for('measure.select_recipe', station='') }}"
class="font-medium underline underline-offset-2 hover:no-underline">
{{ _('Ripristina') }}
</a>
</p>
{% endif %}
</div>
</div>
@@ -4,6 +4,9 @@
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{# The summary is where an operator lingers: the clock has to be here as well. #}
{% include "components/production_clock.html" %}
<!-- Breadcrumb -->
<nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
@@ -114,15 +114,26 @@
</div>
{# Fermo linea + Fine produzione (measurement tasks only) #}
<template x-if="subtasks.length > 0">
<template x-if="isMeasureTask">
<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">
@@ -171,7 +182,7 @@
{# ──────────────────────────────────────────────
LEFT SIDEBAR — Marker list (vertical, hidden when no subtasks)
────────────────────────────────────────────── #}
<div x-show="subtasks.length > 0"
<div x-show="isMeasureTask && subtasks.length > 0"
class="shrink-0 w-14 md:w-16 bg-[var(--bg-card)] border-r border-[var(--border-color)] flex flex-col sidebar-markers overflow-y-auto">
<template x-for="(st, idx) in subtasks" :key="st.id">
<button @click="goToSubtask(idx)"
@@ -261,7 +272,7 @@
{# ──────────────────────────────────────────────
RIGHT PANEL — Info + tolerances + numpad (hidden when no subtasks)
────────────────────────────────────────────── #}
<div x-show="subtasks.length > 0"
<div x-show="isMeasureTask && subtasks.length > 0"
class="shrink-0 w-72 lg:w-80 bg-[var(--bg-card)] border-l border-[var(--border-color)] flex flex-col right-panel overflow-y-auto">
{# ---- Subtask header ---- #}
@@ -409,29 +420,109 @@
{# ================================================================
MEASUREMENT TIMER BANNER
================================================================ #}
{# Past zero the counter turns round: it stops saying how long is left and starts
saying how late the line is, in red. A delay has to be read, not deduced. #}
<div x-show="timerActive"
x-transition
x-cloak
class="shrink-0 bg-amber-50 dark:bg-amber-900/20 border-t border-amber-300 dark:border-amber-700 px-4 py-2">
class="shrink-0 border-t px-4 py-2"
:class="isOverdue
? 'bg-red-50 dark:bg-red-900/30 border-red-400 dark:border-red-700'
: 'bg-amber-50 dark:bg-amber-900/20 border-amber-300 dark:border-amber-700'">
<div class="flex items-center justify-center gap-3">
<svg class="w-5 h-5 text-amber-600 animate-pulse" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<svg class="w-5 h-5 animate-pulse"
:class="isOverdue ? 'text-red-600' : 'text-amber-600'"
fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span class="text-sm font-medium text-amber-800 dark:text-amber-200">
<span x-show="!isOverdue" class="text-sm font-medium text-amber-800 dark:text-amber-200">
{{ _('Prossima misurazione tra') }}
</span>
<span class="text-lg font-bold font-mono text-amber-900 dark:text-amber-100 bg-amber-100 dark:bg-amber-900/40 px-2 py-0.5 rounded"
<span x-show="isOverdue" class="text-sm font-bold text-red-800 dark:text-red-200">
{{ _('Misurazione in ritardo di') }}
</span>
<span class="text-lg font-bold font-mono px-2 py-0.5 rounded"
:class="isOverdue
? 'text-red-900 dark:text-red-100 bg-red-100 dark:bg-red-900/40'
: 'text-amber-900 dark:text-amber-100 bg-amber-100 dark:bg-amber-900/40'"
x-text="timerDisplay"></span>
<span class="text-xs text-amber-600 dark:text-amber-400">
<span class="text-xs"
:class="isOverdue ? 'text-red-600 dark:text-red-300' : 'text-amber-600 dark:text-amber-400'">
({{ _('Ciclo') }} #<span x-text="cycleCount"></span>)
</span>
</div>
</div>
{# ================================================================
MISURA SENZA QUOTE — declared a measurement task but none configured yet.
Under the old rule this screen quietly looked like a note; now the task keeps
its identity and says what is missing instead.
================================================================ #}
<div x-show="isMeasureTaskWithoutQuotes"
x-transition
x-cloak
class="shrink-0 bg-amber-50 dark:bg-amber-900/20 border-t border-amber-300 dark:border-amber-700 px-4 py-2">
<div class="flex items-center justify-center gap-2">
<svg class="w-4 h-4 text-amber-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-amber-800 dark:text-amber-200">
{{ _('Task di misura senza quote configurate: la ricetta è incompleta') }}
</span>
</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>
{# A line stopped past its interval is frozen at a delay, not at a wait. Both
read as mm:ss, so the wording is what has to change. #}
<span class="text-xs text-amber-800 dark:text-amber-200">
<span x-show="timerRemaining > 0">{{ _('Il conto alla rovescia è congelato a') }}</span>
<span x-show="timerRemaining <= 0">{{ _('Misurazione in ritardo di') }}</span>
<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
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
================================================================ #}
<div x-show="cycleConfirmed && cycleCount === 1 && !productionStarted && subtasks.length > 0"
<div x-show="cycleConfirmed && cycleCount === 1 && !productionStarted && isMeasureTask"
x-transition
x-cloak
class="shrink-0 border-t border-[var(--border-color)] bg-[var(--bg-card)] px-4 py-3">
@@ -450,7 +541,7 @@
{{ _('Invia segnale al gestionale per avviare il timer della linea') }}
</p>
</div>
<div x-show="productionStarted && subtasks.length > 0"
<div x-show="productionStarted && isMeasureTask"
x-transition
x-cloak
class="shrink-0 border-t border-emerald-200 dark:border-emerald-800 bg-emerald-50 dark:bg-emerald-900/20 px-4 py-1.5">
@@ -493,7 +584,7 @@
</div>
{# Right: Fine ciclo misura (measurement tasks) / Completato (non-measurement or after cycle) #}
<template x-if="subtasks.length > 0">
<template x-if="isMeasureTask && subtasks.length > 0">
<div class="flex items-center gap-1.5">
<button x-show="isComplete && !cycleConfirmed"
x-transition
@@ -505,6 +596,17 @@
</svg>
{{ _('Fine ciclo misura') }}
</button>
{# Girare il pezzo e rimisurare, senza chiudere il ciclo: a second reading
of the same part is not a new one, and must not restart the interval. #}
<button x-show="isComplete && productionStarted"
x-transition
@click="remeasure()"
class="btn btn-secondary text-xs shrink-0 gap-1 py-1.5 px-3">
<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="M16.023 9.348h4.992V4.356M3.977 14.652H8.97v4.992m10.058-9.348a8.25 8.25 0 00-14.02-3.34L3.977 9.348m0 5.304a8.25 8.25 0 0014.02 3.34l1.03-1.03"/>
</svg>
{{ _('Rimisura') }}
</button>
<button x-show="cycleConfirmed"
x-transition
@click="goToNextTask()"
@@ -520,7 +622,7 @@
</button>
</div>
</template>
<template x-if="subtasks.length === 0">
<template x-if="!isMeasureTask || subtasks.length === 0">
<button @click="goToNextTask()"
class="btn text-xs shrink-0 gap-1 py-1.5 px-3 shadow-md
bg-measure-pass text-white hover:opacity-90">
@@ -536,6 +638,37 @@
</div>
</div>
{# ================================================================
È ORA DI MISURARE — the interval expired on a task that is not the
measurement. The operator is told why the screen is about to change, then
taken there; there is no way to dismiss it, because putting the measurement
off is exactly what the interval exists to prevent.
================================================================ #}
<div x-show="announcedRedirect"
x-transition
x-cloak
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div class="bg-[var(--bg-card)] rounded-2xl shadow-2xl p-8 max-w-sm mx-4 text-center border-2 border-red-500">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-red-100 dark:bg-red-900/30 mb-4">
<svg class="w-8 h-8 text-red-600 animate-pulse" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<h3 class="text-lg font-bold text-[var(--text-primary)] mb-1">{{ _('È ora di misurare') }}</h3>
<p class="text-sm text-[var(--text-secondary)] mb-5">
{{ _('Ritorno alla misurazione tra') }}
<span class="font-bold font-mono" x-text="redirectIn"></span> s
</p>
<button @click="goToMeasurementTask()"
class="btn w-full justify-center gap-2 bg-red-600 hover:bg-red-700 text-white font-bold py-2.5">
{{ _('Vai alla misura') }}
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
</svg>
</button>
</div>
</div>
{# ================================================================
COMPLETION OVERLAY
================================================================ #}
@@ -585,9 +718,10 @@
{{ _('Riepilogo') }}
</button>
{# Production phase: confirm the cycle (starts the interval timer) #}
{# Production phase: confirm the cycle. Goes through confirmCycle so the
server records it - setting the flag here only looked like it had. #}
<button x-show="productionStarted"
@click="showCompletionOverlay = false; cycleConfirmed = true"
@click="confirmCycle()"
class="btn btn-primary flex-1 justify-center gap-2">
{{ _('Conferma ciclo') }}
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
@@ -615,6 +749,18 @@
</svg>
</button>
</div>
{# The piece can be turned over here, where the operator actually is when the
quotes are done - not only from the bar behind the overlay. #}
<button x-show="productionStarted"
@click="remeasure()"
class="mt-3 w-full text-sm text-[var(--text-secondary)] hover:text-primary
inline-flex items-center justify-center gap-1.5 py-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992V4.356M3.977 14.652H8.97v4.992m10.058-9.348a8.25 8.25 0 00-14.02-3.34L3.977 9.348m0 5.304a8.25 8.25 0 0014.02 3.34l1.03-1.03"/>
</svg>
{{ _('Girare il pezzo e rimisurare') }}
</button>
</div>
</div>
@@ -648,12 +794,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>
@@ -663,7 +809,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">
@@ -680,13 +826,16 @@
{% endblock %}
{% block extra_js %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script>
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = '{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';
</script>
<script src="{{ url_for('static', filename='js/numpad.js') }}"></script>
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
<script src="{{ url_for('static', filename='js/caliper.js') }}"></script>
{# The clock's rules - how a delay reads, when the buzzer goes, where the
measurement lives - are shared with the other screens rather than restated. #}
<script src="{{ url_for('static', filename='js/production-clock.js') }}"></script>
<script>
/**
@@ -709,6 +858,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 +867,19 @@ 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: '',
// The measurement tasks of this recipe, in order: the loop the operator stays
// in once production starts, and what says which task closes a cycle.
measurementTaskIds: [],
// The interval has elapsed and it has been said out loud - buzzer, and the
// operator on their way back to the measurement. Kept so it is said once.
dueAnnounced: false,
redirectIn: 0,
// ---- Cycle & workflow state ----
cycleConfirmed: false,
showSupervisorModal: false,
@@ -812,6 +976,47 @@ 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;
this.measurementTaskIds = run.measurement_task_ids || [];
const seconds = run.seconds_to_next_measurement;
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.
this.startCountdownFrom(seconds);
},
// ---- Check if a subtask has been measured ----
@@ -861,6 +1066,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,
}),
});
@@ -953,29 +1161,109 @@ function taskExecute() {
},
// ---- Confirm measurement cycle (Fine ciclo misura) ----
confirmCycle() {
/* A cycle spans every measurement task of the recipe, so this one only closes
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() {
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 countBefore = this.cycleCount;
const run = await this.postProduction(
'{{ url_for("measure.api_complete_cycle", run_id=0) }}'.replace('/0/', '/' + this.productionRun.id + '/'),
{ task_id: this.task.id },
);
if (run) {
this.adoptProductionRun(run);
// Cycle still open: on to the next measurement task, interval untouched.
if (run.cycle_count === countBefore && this.nextMeasurementTaskId) {
this.goToTask(this.nextMeasurementTaskId);
}
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);
}
},
/* Rimisura: turn the piece over and measure it again, inside the same cycle.
Not a new cycle - the deadline and the count stay where they are, so a second
reading of the same part does not buy another interval's worth of time. Both
readings stay in the statistics: that is what they are for. */
async remeasure() {
if (this.productionRun) {
const run = await this.postProduction(
'{{ url_for("measure.api_remeasure", run_id=0) }}'.replace('/0/', '/' + this.productionRun.id + '/'),
{ task_id: this.task.id },
);
// Refused - the line is stopped, or the production is closed. The screen
// stays as it is: clearing it would hide the reason.
if (!run) return;
this.adoptProductionRun(run);
}
this.resetForNewCycle();
},
/* 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.
It does not stop at zero: past the deadline it keeps counting the other way,
and the banner reads the delay off it. */
startCountdownFrom(seconds) {
this.stopMeasurementTimer();
this.timerRemaining = this.measurementIntervalMinutes * 60;
this.timerRemaining = seconds;
this.timerActive = true;
// 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) {
this.onMeasurementDue();
} else {
this.dueAnnounced = false;
this.redirectIn = 0;
}
var self = this;
this._timerInterval = setInterval(function () {
self.timerRemaining--;
if (self.timerRemaining <= 0) {
self.onTimerExpired();
}
}, 1000);
this._timerInterval = setInterval(function () { self.onSecond(); }, 1000);
},
onSecond() {
const wasAnnounced = this.dueAnnounced;
this.timerRemaining--;
if (this.timerRemaining <= 0) this.onMeasurementDue();
if (wasAnnounced && this.redirectIn > 0) {
this.redirectIn--;
if (this.redirectIn === 0) this.goToMeasurementTask();
}
},
stopMeasurementTimer() {
@@ -986,39 +1274,101 @@ function taskExecute() {
this.timerActive = false;
},
onTimerExpired() {
this.stopMeasurementTimer();
this.playBuzzer();
// Reset for new measurement cycle
/* The interval has run out. Said once, then acted on.
Someone already measuring is late, not lost: the banner turns red and they
are left alone. Taking them elsewhere mid-cycle would wipe quotes they can
see on the screen and gain nothing.
Otherwise a new cycle starts, and it starts at the first measurement task of
the recipe - re-arming wherever the operator happens to be standing would
skip whatever comes before it and still count as a full cycle. */
onMeasurementDue() {
if (this.dueAnnounced) return;
this.dueAnnounced = true;
window.ProductionClock.playBuzzer();
if (this.isMeasurementTaskOfRun && !this.cycleConfirmed) return;
if (this.task.id === this.measurementTaskIds[0]) {
this.resetForNewCycle();
return;
}
this.redirectIn = this.measurementTaskUrl ? 5 : 0;
},
/* Clear the screen for another pass over the same quotes.
Local only: everything already measured is on the server, and the next
reading is saved beside it rather than replacing it. */
resetForNewCycle() {
this.cycleConfirmed = false;
this.showCompletionOverlay = false;
this.measurements = [];
this.currentIndex = 0;
this.currentValue = null;
this.errorMessage = '';
this.inputStartedAt = Date.now();
},
playBuzzer() {
try {
var ctx = new (window.AudioContext || window.webkitAudioContext)();
// 3 short beeps
[0, 0.25, 0.5].forEach(function (delay) {
var osc = ctx.createOscillator();
var gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 880;
osc.type = 'square';
gain.gain.value = 0.3;
osc.start(ctx.currentTime + delay);
osc.stop(ctx.currentTime + delay + 0.15);
});
} catch (_) {}
// ---- Where the measurement lives ----
get isMeasurementTaskOfRun() {
return this.measurementTaskIds.indexOf(this.task.id) !== -1;
},
// ---- 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', ... });
get measurementTaskUrl() {
return window.ProductionClock.measurementUrl(
this.productionRun, '{{ url_for("measure.task_execute", task_id=0) }}',
);
},
/* The next measurement task of the recipe, or null if this is the last one -
which is the one that closes the cycle and restarts the interval. */
get nextMeasurementTaskId() {
const ids = this.measurementTaskIds;
const idx = ids.indexOf(this.task.id);
if (idx === -1 || idx >= ids.length - 1) return null;
return ids[idx + 1];
},
get isOverdue() {
return this.timerActive && this.timerRemaining <= 0;
},
get announcedRedirect() {
return this.dueAnnounced && this.redirectIn > 0;
},
goToMeasurementTask() {
const url = this.measurementTaskUrl;
if (url) window.location.href = url;
},
goToTask(taskId) {
window.location.href =
'{{ url_for("measure.task_execute", task_id=0) }}'.replace('/0', '/' + taskId);
},
// ---- 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,28 +1380,53 @@ 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();
},
/* mm:ss of the distance from the deadline, either side of it. The direction is
carried by the wording in the banner, not by a minus sign. */
get timerDisplay() {
var m = Math.floor(this.timerRemaining / 60);
var s = this.timerRemaining % 60;
return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
return window.ProductionClock.format(this.timerRemaining);
},
// ---- Navigate to next task (Completato) ----
/* With a production open the operator stays in the measurement loop until the
capoturno closes it: after the last measurement task they go back to the
first, not out to the summary. Before production starts, and on the
documental tasks that come before the measurement, the plain sequence
applies - that is the run-up, not the loop. */
goToNextTask() {
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
// same page to arrive at the same state would only cost a round trip.
if (next === this.task.id) {
this.resetForNewCycle();
return;
}
this.goToTask(next);
return;
}
const taskIds = window.__allTaskIds || [];
const currentIdx = taskIds.indexOf(this.task.id);
if (currentIdx >= 0 && currentIdx < taskIds.length - 1) {
window.location.href = '{{ url_for("measure.task_execute", task_id=0) }}'.replace('/0', '/' + taskIds[currentIdx + 1]);
this.goToTask(taskIds[currentIdx + 1]);
} else {
this.goToSummary();
}
@@ -1073,14 +1448,47 @@ function taskExecute() {
this.supervisorError = '';
},
/* What kind of task this is, as declared in the recipe.
Deducing it from the quotes meant a measurement task whose quotes had not
been entered yet behaved as a note - the screen changed shape depending on
how finished the recipe was. Falls back to the old rule for tasks saved
before the type existed and not yet migrated. */
get isMeasureTask() {
const declared = this.task.task_type;
if (!declared) return this.subtasks.length > 0;
return declared === 'measure' || declared === 'camera_measure';
},
/* A measurement task with no quotes yet: the recipe is unfinished. Said out
loud rather than silently rendering an empty screen. */
get isMeasureTaskWithoutQuotes() {
return this.isMeasureTask && this.subtasks.length === 0;
},
// ---- 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 = '';
@@ -1115,7 +1523,6 @@ function taskExecute() {
this.advanceToNext();
}
}
// fermo_linea and fine_produzione are handled by GAIA integration (future)
} catch (err) {
this.supervisorError = '{{ _("Errore di connessione") }}';
@@ -1123,6 +1530,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;
@@ -4,6 +4,9 @@
{% block content %}
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-5xl">
{# The clock follows the operator here too, and brings them back when it expires. #}
{% include "components/production_clock.html" %}
<!-- Breadcrumb -->
<nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
@@ -164,8 +167,26 @@
{% endif %}
</div>
<!-- Indicators (top-right): measurements + attachment -->
<!-- Indicators (top-right): type + measurements + attachment -->
<div class="shrink-0 self-start flex items-center gap-2.5">
{# The declared type, so the operator sees what a task is before opening
it - and a measurement task still reads as one before its quotes are in. #}
{% set task_type_labels = {
'measure': _('Misura'),
'drawing': _('Disegno'),
'note': _('Nota'),
'xf_compare': _('Confronto profilo'),
'camera_measure': _('Misura camera'),
} %}
{% set is_measure = task.task_type in ('measure', 'camera_measure') %}
{% if task.task_type %}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[11px] font-medium
{% if is_measure %}bg-primary-50 dark:bg-primary-900/20 text-primary
{% else %}bg-steel-100 dark:bg-steel-800 text-steel-600 dark:text-steel-300{% endif %}">
{{ task_type_labels.get(task.task_type, task.task_type) }}
</span>
{% endif %}
{% if task.subtask_count is defined or task.subtasks %}
<span class="inline-flex items-center gap-1 text-xs text-[var(--text-secondary)]"
title="{{ _('misurazioni') }}">
@@ -4,7 +4,7 @@
{% block extra_head %}
<!-- Plotly.js CDN -->
<script src="https://cdn.plot.ly/plotly-2.32.0.min.js" charset="utf-8"></script>
<script src="{{ url_for('static', filename='vendor/plotly-2.32.0.min.js') }}" charset="utf-8"></script>
{% endblock %}
{% block content %}
@@ -132,3 +132,47 @@ def test_non_admin_cannot_access(client, mock_admin_api):
sess["user_id"] = 2
resp = client.get("/admin/stations", follow_redirects=False)
assert resp.status_code in (301, 302)
# ---------------------------------------------------------------------------
# Per-row station reset (point 7)
# ---------------------------------------------------------------------------
def test_reset_station_proxy(logged_in_client, mock_admin_api):
mock_admin_api.delete.return_value = {"station_id": 3, "removed": 2}
resp = logged_in_client.delete("/admin/api/stations/3/recipes")
assert resp.status_code == 200
assert resp.get_json() == {"station_id": 3, "removed": 2}
mock_admin_api.delete.assert_called_once_with("/api/stations/3/recipes")
def test_reset_station_propagates_error(logged_in_client, mock_admin_api):
mock_admin_api.delete.return_value = {
"error": True, "status_code": 404, "detail": "Station not found",
}
resp = logged_in_client.delete("/admin/api/stations/999/recipes")
assert resp.status_code == 404
def test_reset_station_distinct_from_single_unassign(logged_in_client, mock_admin_api):
"""The collection route must not swallow the per-recipe delete."""
mock_admin_api.delete.return_value = {}
logged_in_client.delete("/admin/api/stations/3/recipes/7")
mock_admin_api.delete.assert_called_once_with("/api/stations/3/recipes/7")
def test_station_list_page_renders_assigned_recipes(logged_in_client, mock_admin_api):
"""Recipes assigned to a station reach the page so the row can show them."""
mock_admin_api.get.side_effect = [
[{
"id": 1, "code": "ST-001", "name": "Linea A", "location": "Reparto A",
"notes": None, "active": True, "created_by": 1,
"created_at": "2026-04-25T10:00:00",
"recipes": [{"id": 9, "code": "REC-ABC", "name": "Perno", "active": True}],
}],
[{"id": 9, "code": "REC-ABC", "name": "Perno", "active": True}],
]
resp = logged_in_client.get("/admin/stations")
assert resp.status_code == 200
assert b"REC-ABC" in resp.data
@@ -53,6 +53,23 @@ class TestTaskList:
assert resp.status_code == 200
class TestTaskComplete:
"""GET /measure/complete/<recipe_id> tests."""
def test_task_complete_renders(self, logged_in_client, mock_api_client):
"""The summary carries the measurement clock, so it has to render with it."""
mock_api_client.get.side_effect = [
# The summary serialises the version number, so the recipe carries one.
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": []}],
{"items": []}, # measurements
]
resp = logged_in_client.get("/measure/complete/1?version_id=5")
assert resp.status_code == 200
assert b"productionClock(" in resp.data
class TestSaveMeasurement:
"""POST /measure/save-measurement tests."""
@@ -0,0 +1,282 @@
"""Tests for the production-run proxy routes.
These are what let task_execute.html read its state back from the server instead of
keeping the timer in an Alpine variable that a change of task destroys.
"""
import importlib
from unittest.mock import patch
import pytest
@pytest.fixture(autouse=True)
def _restore_measure_module():
"""Reload config/measure after each test so env tweaks don't leak."""
yield
import config
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
def _with_station(monkeypatch, station_code="ST-PROD"):
if station_code is None:
monkeypatch.delenv("STATION_CODE", raising=False)
else:
monkeypatch.setenv("STATION_CODE", station_code)
monkeypatch.setenv("STATION_SWITCH_ENABLED", "0")
import config
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
from blueprints import measure as measure_mod
return measure_mod
RUN = {
"id": 7,
"station_id": 1,
"recipe_id": 3,
"version_id": 5,
"operator_id": 2,
"lot_number": "LOT-1",
"serial_number": None,
"status": "running",
"measurement_interval_minutes": 30,
"next_measurement_at": "2026-07-28T17:00:00",
"cycle_count": 2,
"started_at": "2026-07-28T16:00:00",
"paused_at": None,
"closed_at": None,
"closed_by": None,
"seconds_to_next_measurement": 1500,
"overdue": False,
"server_time": "2026-07-28T16:35:00",
}
def test_current_production_passes_the_station(logged_in_client, monkeypatch):
measure_mod = _with_station(monkeypatch)
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.get.return_value = RUN
resp = logged_in_client.get("/measure/api/production/current")
assert resp.status_code == 200
assert resp.get_json()["id"] == 7
mock_api.get.assert_called_once_with(
"/api/production-runs/current", params={"station_code": "ST-PROD"},
)
def test_current_production_returns_null_when_idle(logged_in_client, monkeypatch):
"""No production open is a normal answer, not an error."""
measure_mod = _with_station(monkeypatch)
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.get.return_value = None
resp = logged_in_client.get("/measure/api/production/current")
assert resp.status_code == 200
assert resp.get_json() is None
def test_current_production_without_station_is_503(logged_in_client, monkeypatch):
_with_station(monkeypatch, station_code=None)
resp = logged_in_client.get("/measure/api/production/current")
assert resp.status_code == 503
def test_start_production_sends_station_and_recipe(logged_in_client, monkeypatch):
measure_mod = _with_station(monkeypatch)
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.post.return_value = RUN
resp = logged_in_client.post(
"/measure/api/production/start",
json={"recipe_id": 3, "version_id": 5, "lot_number": "LOT-1"},
)
assert resp.status_code == 201
endpoint, kwargs = mock_api.post.call_args
assert endpoint[0] == "/api/production-runs"
sent = kwargs["data"]
assert sent["station_code"] == "ST-PROD"
assert sent["recipe_id"] == 3
assert sent["version_id"] == 5
assert sent["lot_number"] == "LOT-1"
def test_start_production_falls_back_to_session_traceability(logged_in_client, monkeypatch):
"""Lot and serial already captured in the session must reach the run."""
measure_mod = _with_station(monkeypatch)
with logged_in_client.session_transaction() as sess:
sess["lot_number"] = "LOT-SESSION"
sess["serial_number"] = "SN-SESSION"
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.post.return_value = RUN
logged_in_client.post("/measure/api/production/start", json={"recipe_id": 3})
sent = mock_api.post.call_args[1]["data"]
assert sent["lot_number"] == "LOT-SESSION"
assert sent["serial_number"] == "SN-SESSION"
def test_start_production_propagates_conflict(logged_in_client, monkeypatch):
"""A station already running a production must not silently open a second."""
measure_mod = _with_station(monkeypatch)
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.post.return_value = {
"error": True, "status_code": 409,
"detail": "Station 'ST-PROD' already has an open production run",
}
resp = logged_in_client.post(
"/measure/api/production/start", json={"recipe_id": 3},
)
assert resp.status_code == 409
def test_complete_cycle_hits_the_run(logged_in_client, monkeypatch):
measure_mod = _with_station(monkeypatch)
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.post.return_value = {**RUN, "cycle_count": 3}
resp = logged_in_client.post("/measure/api/production/7/cycle", json={})
assert resp.status_code == 200
assert resp.get_json()["cycle_count"] == 3
endpoint, kwargs = mock_api.post.call_args
assert endpoint[0] == "/api/production-runs/7/cycle"
def test_complete_cycle_forwards_the_task(logged_in_client, monkeypatch):
"""Which task was finished is what tells the server whether the cycle closed."""
measure_mod = _with_station(monkeypatch)
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.post.return_value = RUN
logged_in_client.post("/measure/api/production/7/cycle", json={"task_id": 11})
assert mock_api.post.call_args[1]["data"]["task_id"] == 11
def test_remeasure_hits_its_own_endpoint(logged_in_client, monkeypatch):
"""Turning the piece over must not go down the cycle path and restart the timer."""
measure_mod = _with_station(monkeypatch)
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.post.return_value = RUN
resp = logged_in_client.post(
"/measure/api/production/7/remeasure", json={"task_id": 11},
)
assert resp.status_code == 200
endpoint, kwargs = mock_api.post.call_args
assert endpoint[0] == "/api/production-runs/7/remeasure"
assert kwargs["data"]["task_id"] == 11
def test_remeasure_propagates_paused_conflict(logged_in_client, monkeypatch):
measure_mod = _with_station(monkeypatch)
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.post.return_value = {
"error": True, "status_code": 409,
"detail": "Production run is paused: resume it before measuring",
}
resp = logged_in_client.post("/measure/api/production/7/remeasure", json={})
assert resp.status_code == 409
def test_complete_cycle_propagates_paused_conflict(logged_in_client, monkeypatch):
measure_mod = _with_station(monkeypatch)
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.post.return_value = {
"error": True, "status_code": 409,
"detail": "Production run is paused: resume it before measuring",
}
resp = logged_in_client.post("/measure/api/production/7/cycle", json={})
assert resp.status_code == 409
def test_production_routes_require_login(client, monkeypatch):
_with_station(monkeypatch)
for method, url in (
("get", "/measure/api/production/current"),
("post", "/measure/api/production/start"),
("post", "/measure/api/production/7/cycle"),
("post", "/measure/api/production/7/remeasure"),
):
resp = getattr(client, method)(url)
assert resp.status_code in (302, 401), f"{method} {url} -> {resp.status_code}"
# ---------------------------------------------------------------------------
# Fermo linea, ripresa, fine produzione (point 6)
# ---------------------------------------------------------------------------
CREDS = {"supervisor_username": "capoturno", "supervisor_password": "segreta"}
@pytest.mark.parametrize("action", ["pause", "resume", "close"])
def test_supervised_action_forwards_credentials(logged_in_client, monkeypatch, action):
"""Credentials go to the endpoint itself, which records who authorised what."""
measure_mod = _with_station(monkeypatch)
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.post.return_value = {**RUN, "status": "paused"}
resp = logged_in_client.post(f"/measure/api/production/7/{action}", json=CREDS)
assert resp.status_code == 200
endpoint, kwargs = mock_api.post.call_args
assert endpoint[0] == f"/api/production-runs/7/{action}"
assert kwargs["data"]["supervisor_username"] == "capoturno"
assert kwargs["data"]["supervisor_password"] == "segreta"
@pytest.mark.parametrize("action", ["pause", "resume", "close"])
def test_supervised_action_requires_credentials(logged_in_client, monkeypatch, action):
measure_mod = _with_station(monkeypatch)
with patch.object(measure_mod, "api_client") as mock_api:
resp = logged_in_client.post(f"/measure/api/production/7/{action}", json={})
assert resp.status_code == 400
mock_api.post.assert_not_called()
@pytest.mark.parametrize("status_code", [401, 403])
def test_supervised_action_propagates_refusal(logged_in_client, monkeypatch, status_code):
"""A non-supervisor must not be able to stop or close a production."""
measure_mod = _with_station(monkeypatch)
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.post.return_value = {
"error": True, "status_code": status_code, "detail": "no",
}
resp = logged_in_client.post("/measure/api/production/7/pause", json=CREDS)
assert resp.status_code == status_code
def test_close_returns_the_statistics_path(logged_in_client, monkeypatch):
"""Fine produzione emits the file, and the path comes back to the client."""
measure_mod = _with_station(monkeypatch)
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.post.return_value = {
**RUN, "status": "closed",
"statistics_path": "statistics/production_7_20260728_170000.csv",
"seconds_to_next_measurement": None,
}
resp = logged_in_client.post("/measure/api/production/7/close", json=CREDS)
assert resp.status_code == 200
body = resp.get_json()
assert body["status"] == "closed"
assert body["statistics_path"].endswith(".csv")
def test_cycle_route_is_not_shadowed_by_the_supervised_routes(logged_in_client, monkeypatch):
"""/7/cycle must still reach the cycle handler, not a supervised action."""
measure_mod = _with_station(monkeypatch)
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.post.return_value = RUN
logged_in_client.post("/measure/api/production/7/cycle", json={})
assert mock_api.post.call_args[0][0] == "/api/production-runs/7/cycle"
def test_save_measurement_attaches_the_run(logged_in_client, monkeypatch):
"""Every value taken during a production must carry its run id."""
measure_mod = _with_station(monkeypatch)
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.post.return_value = {"id": 1, "pass_fail": "pass"}
logged_in_client.post(
"/measure/save-measurement",
json={
"subtask_id": 21, "version_id": 5, "value": 10.0,
"production_run_id": 7,
},
)
endpoint, kwargs = mock_api.post.call_args
# Trailing slash: without it every measurement pays a 307 redirect.
assert endpoint[0] == "/api/measurements/"
assert kwargs["data"]["production_run_id"] == 7
@@ -2,13 +2,32 @@
import importlib
from unittest.mock import patch, MagicMock
import pytest
def _reload_measure(monkeypatch, station_code=None):
"""Reload config and measure module under the given STATION_CODE env."""
@pytest.fixture(autouse=True)
def _restore_measure_module():
"""Reload config/measure after each test so env tweaks don't leak.
Reloading runs the module body in its existing namespace, so the view
functions already registered on the app see the refreshed globals. Without
this teardown a station code set here would stay visible to later tests.
Autouse fixtures tear down after monkeypatch, so the env is pristine by then.
"""
yield
import config
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
def _reload_measure(monkeypatch, station_code=None, switch_enabled=False):
"""Reload config and measure module under the given station env."""
if station_code is None:
monkeypatch.delenv("STATION_CODE", raising=False)
else:
monkeypatch.setenv("STATION_CODE", station_code)
monkeypatch.setenv("STATION_SWITCH_ENABLED", "1" if switch_enabled else "0")
import config
importlib.reload(config)
import blueprints.measure
@@ -35,3 +54,100 @@ def test_select_recipe_without_station_code_shows_error(logged_in_client, monkey
assert resp.status_code == 503
body = resp.data.lower()
assert b"station_code" in body or b"stazione" in body
# ---------------------------------------------------------------------------
# Switching the current station from the URL (point 7)
# ---------------------------------------------------------------------------
def _station_calls(mock_api):
"""Station codes passed to get_station_recipes, in order."""
return [c.args[0] for c in mock_api.get_station_recipes.call_args_list]
def test_station_switch_ignored_when_disabled(logged_in_client, monkeypatch):
"""Off by default: on the shop floor the station comes from the install."""
_reload_measure(monkeypatch, station_code="ST-CONF", switch_enabled=False)
from blueprints import measure as measure_mod
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.get_station_recipes.return_value = []
resp = logged_in_client.get("/measure/select?station=ST-OTHER")
assert resp.status_code == 200
# The requested station is never even probed.
assert _station_calls(mock_api) == ["ST-CONF"]
def test_station_switch_applies_when_enabled(logged_in_client, monkeypatch):
_reload_measure(monkeypatch, station_code="ST-CONF", switch_enabled=True)
from blueprints import measure as measure_mod
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.get_station_recipes.return_value = []
resp = logged_in_client.get("/measure/select?station=ST-OTHER")
assert resp.status_code == 200
# Probed first to validate, then used to load the recipes.
assert _station_calls(mock_api) == ["ST-OTHER", "ST-OTHER"]
assert b"ST-OTHER" in resp.data
def test_station_switch_survives_next_request(logged_in_client, monkeypatch):
"""The override lives in the session, so it holds without repeating the param."""
_reload_measure(monkeypatch, station_code="ST-CONF", switch_enabled=True)
from blueprints import measure as measure_mod
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.get_station_recipes.return_value = []
logged_in_client.get("/measure/select?station=ST-OTHER")
mock_api.get_station_recipes.reset_mock()
resp = logged_in_client.get("/measure/select")
assert resp.status_code == 200
assert _station_calls(mock_api) == ["ST-OTHER"]
def test_station_switch_rejects_unknown_code(logged_in_client, monkeypatch):
"""A typo must not strand the operator on a station that does not exist."""
_reload_measure(monkeypatch, station_code="ST-CONF", switch_enabled=True)
from blueprints import measure as measure_mod
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.get_station_recipes.side_effect = [
{"error": True, "status_code": 404, "detail": "Station not found"},
[],
]
resp = logged_in_client.get("/measure/select?station=ST-NOPE")
assert resp.status_code == 200
# Probed, refused, then fell back to the configured station.
assert _station_calls(mock_api) == ["ST-NOPE", "ST-CONF"]
assert b"ST-CONF" in resp.data
def test_station_switch_can_be_cleared(logged_in_client, monkeypatch):
_reload_measure(monkeypatch, station_code="ST-CONF", switch_enabled=True)
from blueprints import measure as measure_mod
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.get_station_recipes.return_value = []
logged_in_client.get("/measure/select?station=ST-OTHER")
mock_api.get_station_recipes.reset_mock()
resp = logged_in_client.get("/measure/select?station=")
assert resp.status_code == 200
assert _station_calls(mock_api) == ["ST-CONF"]
def test_station_switch_is_signalled_in_the_page(logged_in_client, monkeypatch):
"""An override must never be silent: wrong-station measurements are untraceable."""
_reload_measure(monkeypatch, station_code="ST-CONF", switch_enabled=True)
from blueprints import measure as measure_mod
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.get_station_recipes.return_value = []
resp = logged_in_client.get("/measure/select?station=ST-OTHER")
body = resp.data.decode()
assert "ST-OTHER" in body
# The configured station is named so the deviation is obvious.
assert "ST-CONF" in body
def test_station_switch_normalises_case(logged_in_client, monkeypatch):
_reload_measure(monkeypatch, station_code="ST-CONF", switch_enabled=True)
from blueprints import measure as measure_mod
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.get_station_recipes.return_value = []
logged_in_client.get("/measure/select?station=st-other")
assert _station_calls(mock_api) == ["ST-OTHER", "ST-OTHER"]
@@ -79,10 +79,8 @@ def _alpine_attribute_expressions(html: str):
yield name, value
def _node_check(source: str, label: str) -> None:
"""Fail the test if `node --check` rejects `source`."""
if not source.strip():
return
def _node_syntax_error(source: str) -> str | None:
"""Return node's complaint about `source`, or None if it parses."""
node = shutil.which("node")
if node is None:
pytest.skip("node binary not found on PATH; cannot validate JS syntax")
@@ -100,31 +98,56 @@ def _node_check(source: str, label: str) -> None:
finally:
os.unlink(path)
if result.returncode != 0:
snippet = source.strip()
if len(snippet) > 600:
snippet = snippet[:600] + "\n…(truncated)…"
pytest.fail(
f"{label}: node rejected this code as invalid JS.\n"
f"--- node stderr ---\n{result.stderr.strip()}\n"
f"--- source (first 600 chars) ---\n{snippet}"
)
return None if result.returncode == 0 else result.stderr.strip()
def _fail(label: str, source: str, stderr: str) -> None:
snippet = source.strip()
if len(snippet) > 600:
snippet = snippet[:600] + "\n…(truncated)…"
pytest.fail(
f"{label}: node rejected this code as invalid JS.\n"
f"--- node stderr ---\n{stderr}\n"
f"--- source (first 600 chars) ---\n{snippet}"
)
def _node_check(source: str, label: str) -> None:
"""Fail the test if `node --check` rejects `source`."""
if not source.strip():
return
stderr = _node_syntax_error(source)
if stderr is not None:
_fail(label, source, stderr)
def _check_alpine_attributes(html: str, page_label: str) -> None:
"""Validate every Alpine expression attribute on the page.
Wraps each value in `void (…)` so node parses it as an expression rather
than a statement. Function-body forms like `async () => { … }` parse fine
inside that wrapper too.
Alpine accepts an attribute either as a single expression or as a sequence of
statements, so the check accepts a value that parses as either and only fails
when both readings are rejected. Neither form alone is enough:
:class="{ 'dark': $store.theme.dark }" only as an expression
@click="showOverlay = false; confirmed = true" only as statements
An unterminated string literal - the bug this file exists for - is a syntax
error under both, so the guard is unchanged.
"""
for name, value in _alpine_attribute_expressions(html):
# Some Alpine attrs accept a function call shorthand (e.g.
# x-data="myComponent(window.__x)"); those parse fine as expressions.
wrapper = f"void ({value});\n"
_node_check(
wrapper,
f"{page_label} attribute {name}=\"\" did not parse as JS",
as_expression = f"void (\n{value}\n);\n"
as_statements = f"(() => {{\n{value}\n}});\n"
expression_error = _node_syntax_error(as_expression)
if expression_error is None:
continue
if _node_syntax_error(as_statements) is None:
continue
_fail(
f"{page_label} attribute {name}=\"\" parsed neither as an expression "
"nor as statements",
as_expression,
expression_error,
)
@@ -192,3 +215,78 @@ def test_admin_users_inline_js_is_valid(logged_in_client, mock_admin_api):
_node_check(body, f"/admin/users script[{i}]")
_check_alpine_attributes(html, "/admin/users")
@pytest.fixture
def mock_measure_api():
"""Patch api_client used inside the measure blueprint."""
mock = MagicMock()
with patch("blueprints.measure.api_client", mock):
yield mock
def test_task_execute_inline_js_is_valid(logged_in_client, mock_measure_api):
"""The measurement screen carries the most inline JS in the app.
It drives the numpad, the caliper burst detection, the supervisor modal and -
since the production run moved server-side - the countdown. A broken literal
here kills every binding on the screen the operator actually works in.
"""
_force_italian(logged_in_client)
task = {
"id": 11,
"recipe_id": 3,
"version_id": 5,
"title": "Quota d'ingresso",
"description": "Misura l'altezza",
"file_path": None,
"file_type": None,
"annotations_json": None,
"subtasks": [{
"id": 21, "marker_number": 1, "name": "Altezza", "order_index": 0,
"nominal": 10.0, "utl": 10.5, "uwl": 10.2, "lwl": 9.8, "ltl": 9.5,
"unit": "mm",
}],
}
mock_measure_api.get.side_effect = [
task, # /api/tasks/11
[{"id": 11, "order_index": 0}], # /api/recipes/3/tasks
{"id": 3, "measurement_interval_minutes": 30}, # /api/recipes/3
]
resp = logged_in_client.get("/measure/execute/11")
assert resp.status_code == 200
html = resp.get_data(as_text=True)
scripts = _INLINE_SCRIPT_RX.findall(html)
assert scripts, "expected at least one inline <script> on /measure/execute"
for i, body in enumerate(scripts):
_node_check(body, f"/measure/execute script[{i}]")
_check_alpine_attributes(html, "/measure/execute")
def test_task_list_inline_js_is_valid(logged_in_client, mock_measure_api):
"""The task list carries the shared measurement clock.
Its bindings are the ones that turn the countdown red and announce the return
to the measurement; a broken expression here would take the whole page's Alpine
with it, and the operator would simply never be called back.
"""
_force_italian(logged_in_client)
mock_measure_api.get.side_effect = [
{"id": 3, "code": "REC-3", "name": "Ricetta", "description": None},
[{
"id": 11, "order_index": 0, "title": "Quota d'ingresso",
"task_type": "measure", "subtasks": [], "file_path": None,
}],
]
resp = logged_in_client.get("/measure/tasks/3")
assert resp.status_code == 200
html = resp.get_data(as_text=True)
for i, body in enumerate(_INLINE_SCRIPT_RX.findall(html)):
_node_check(body, f"/measure/tasks script[{i}]")
_check_alpine_attributes(html, "/measure/tasks")
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff