Proposta di sviluppo — Modulo Germinabilità TopSeed

Analisi dettagliata dello schema DB esteso, motivazioni per ogni scelta, roadmap operativa

1. Principi di progettazione

1.1 Convivenza con modulo TopSeed esistente, non Big Bang

Il file germinabilita.html già esiste in TopSeed e mostra una struttura con due macro-tab: "🌱 Nuovo modulo" (scheletro vuoto creato da me come preparazione a questa integrazione) e "📜 Germinabilità OLD" (la consultazione legacy che abbiamo già costruito). Il lavoro consiste nel riempire di contenuto reale il tab "Nuovo modulo", non creare un'altra pagina da zero.

Motivazione: evitiamo che gli utenti TopSeed attuali debbano imparare una nuova URL o una nuova navbar. Il modulo appare dove si aspettano. La transizione è invisibile: l'utente che apriva germinabilita.html per vedere i placeholder, oggi trova le analisi reali.

1.2 Data model first, UI second

Tentare di costruire UI sopra uno schema incompleto porta a doppi giri di lavoro. Prima definiamo TUTTI i campi DB (anche quelli che compaiono solo in 1-2 schermate), poi sopra costruiamo. Questo è il Sprint 0 (fondamenta).

Motivazione: ogni ALTER TABLE post-UI rischia rotture nelle validazioni JS, nei filtri query, nelle serializzazioni. Meglio 3 giorni iniziali di "solo DB" che riscritture UI ricorrenti.

1.3 Feature parity prima, estensioni dopo

Sprint 0-7 mirano a replicare esattamente Semiorto (stesse pagine, stessi campi, stesse regole). Niente innovazioni. Solo dopo il go-live aggiungiamo cose che Semiorto non ha: email/WhatsApp, audit log, multi-azienda, esportazione API REST pubblica.

Motivazione: il laboratorio usa Semiorto da anni. Stravolgere la UX durante la migrazione = utenti che non trovano le cose = fallimento percepito. Replicare 1:1 è noioso ma riduce l'attrito al minimo.

1.4 Zero interruzioni lab durante la migrazione

Fino al giorno del taglio (fine Sprint 7), il laboratorio Semiorto continua a usare l'app di Diego su AWS. Il nostro modulo è "beta in costruzione" e non riceve dati reali.

Motivazione: se trasferiamo utenti progressivamente avremmo bisogno di sync bidirezionale (TopSeed → AWS e viceversa), che è molto complesso da mantenere. Meglio un taglio netto: Semiorto è fonte di verità fino al giorno X, TopSeed dal giorno X+1.

1.5 Sprint da 3-5 giorni con deliverable dimostrabile

Ogni sprint produce qualcosa che si può aprire nel browser e toccare. Non "backend 50%, frontend 50%". Si segue il principio "verticale": una feature completa end-to-end alla volta.

2. Stato attuale del modulo TopSeed esistente

Prima di scrivere lo schema esteso, bisogna capire cosa c'è GIÀ nel DB TopSeed. Ho verificato quanto segue interrogando il DB di produzione.

2.1 Tabella test_germinabilita attuale

Nel commit iniziale TopSeed questa tabella ha solo uno scheletro (creata come placeholder nella fase "Sprint E — Germinabilità standalone" di qualche mese fa). Campi attuali:

CREATE TABLE test_germinabilita (
  id VARCHAR(50) NOT NULL PRIMARY KEY,   -- ex UUID, stile TopSeed
  lotId VARCHAR(50),                     -- FK → lots.id
  tipo ENUM('contratto','commerciale') DEFAULT 'contratto',
  specieRichiesta VARCHAR(255),
  varietaRichiesta VARCHAR(255),
  stato ENUM('in_coda','in_corso','completato','annullato') DEFAULT 'in_coda',
  germinabilitaPercentuale INT,
  esito ENUM('idoneo','non_idoneo','non_valutabile'),
  sogliaMin INT DEFAULT 85,
  dataRichiesta DATETIME,
  dataCompletamento DATETIME,
  richiestoDa VARCHAR(50),               -- FK → users.id
  assegnatoA VARCHAR(50),                -- FK → users.id (ruolo GENETISTA)
  note TEXT,
  refertoImg LONGTEXT                    -- base64 del PDF/foto caricato
);

Questo schema è molto semplificato rispetto a Semiorto: non c'è l'FT, non c'è la tipologia seme, non c'è prova (PETRI/TORBA), non ci sono le conte giornaliere, non c'è scostamento, rif, semePuro, ecc.. È pensato come "richiesta test commerciale" più che come processo di laboratorio strutturato.

2.2 Record attuali in produzione

La tabella contiene pochissimi record di test (demo). Non c'è storico produttivo. Questo è un vantaggio: possiamo allargarla senza timore di rompere dati reali.

2.3 Tabella lots attuale

CREATE TABLE lots (
  id VARCHAR(50) PRIMARY KEY,
  num VARCHAR(50),                  -- codice lotto testuale
  sId VARCHAR(50),                  -- FK → seeds.id
  ctId VARCHAR(50),                 -- FK → contracts.id (contratto produzione)
  qty DOUBLE,                       -- quantità in unità TopSeed
  qtyUnit VARCHAR(10),              -- 'kg' | 'g'
  stato ENUM('in_stock','riservato','scalato','esaurito','scartato'),
  note TEXT
);

Campi rilevanti: c'è già num (codice lotto), qty (quantità), stato. Mancano: dataArrivo, provenienza, dataEsaurito, dataProssimaAnalisi, id_produttore (lotti TopSeed hanno ctId non produttore: diversa logica).

Scoperta importante

In TopSeed i lotti sono legati a contratti di produzione (modello agricolo: faccio un contratto con un produttore per ricevere X kg di seme). In Semiorto i lotti sono legati direttamente a un produttore (modello laboratoriale: ricevo seme da un produttore, lo analizzo). Sono due modelli diversi.

Soluzione: il lotto TopSeed avrà sia ctId (opzionale, per lotti da contratto) sia produttore_id (opzionale, per lotti legacy Semiorto o lotti senza contratto). Uno dei due deve essere valorizzato.

2.4 Tabella categories attuale

CREATE TABLE categories (
  id VARCHAR(50) PRIMARY KEY,
  name VARCHAR(255) NOT NULL         -- es. "Pomodoro"
);

Scarsa: solo nome. Semiorto ha 160 specie con tanti metadati (italiano/inglese/latino/codice/giorni1Conta/giorniTConta/UR). Tutti da aggiungere.

2.5 Tabella produttori attuale

CREATE TABLE produttori (
  id VARCHAR(50) PRIMARY KEY,
  name VARCHAR(255),
  -- + campi anagrafica base (indirizzo, partitaIva, email, ecc.)
  aliquotaIvaAutofattura DECIMAL(5,2)
);

Manca codice (Semiorto ha un codice breve tipo "SF" per Schiavone Fernando, usato in generaLotto e nelle stampe).

2.6 Tabella users attuale

Ha già: id, username, password (bcrypt), nome, cognome, email, role, attivo. Manca: legacy_sha_hash per preservare gli hash Semiorto durante la migrazione (così al primo login Semiorto post-migrazione l'utente può loggare con la vecchia password).

3. Schema esteso — campo per campo con motivazioni

3.1 Estensione tabella categories

ALTER TABLE categories
  ADD COLUMN italiano VARCHAR(60),
  ADD COLUMN inglese VARCHAR(60),
  ADD COLUMN latino VARCHAR(60),
  ADD COLUMN codice_specie VARCHAR(10),
  ADD COLUMN giorni_1_conta INT,
  ADD COLUMN giorni_tot_conta INT,
  ADD COLUMN ur_target INT,
  ADD COLUMN note_specie VARCHAR(512),
  ADD COLUMN legacy_source VARCHAR(32),
  ADD COLUMN legacy_id INT;

Motivazioni campo-per-campo:

italiano VARCHAR(60)
Perché: in Semiorto le specie hanno 3 nomi paralleli (italiano, inglese, latino) perché i certificati DOCX per clienti esteri usano il nome inglese e latino. Dove viene usato: AnalisiController.certificato() chiama bo.getLotto().getSpecie().getNome("en") e getNome("lat"). Perché non fonderlo con name: TopSeed già usa name genericamente. Tenere separato italiano permette di distinguere "il nome primario visualizzato" (name, spesso = italiano) dai tre linguistici. In fase di migrazione, se name coincide con italiano sappiamo che la specie è pulita.
inglese, latino VARCHAR(60)
Perché: solo per stampe multilingue. Nessun altro uso.
codice_specie VARCHAR(10)
Perché: codice breve (es. "POM" per Pomodoro) usato in Semiorto dentro generaLotto() (che è dead code, ma il concetto di codice breve resta comodo per UI compatta). Lo manteniamo per permettere (in futuro) un'eventuale feature di auto-generazione codice lotto, o per avere un riferimento più leggibile dell'ID numerico.
giorni_1_conta INT
Perché CRUCIALE: definisce quando il tecnico deve fare la PRIMA lettura dopo l'inizio dell'analisi. Dove usato: AnalisiController.nuovaAnalisi() pre-compila la conta A con questo valore. AnalisiServiceImpl.inCorso() calcola la "prossima operazione" aggiungendo questo numero a dataInizio. Esempio: Pomodoro = 5 giorni (prima conta), Peperone = 7 giorni.
giorni_tot_conta INT
Perché CRUCIALE: definisce quando deve finire la prova (giorno della lettura finale). Dove usato: pre-compilazione conta A, calcolo "seconda operazione", e soprattutto dentro la query daAnalizzare() come filtro: DATE_ADD(dataChiusura, INTERVAL (dayOfMonth - giorniTConta) DAY) < firstOfMonth. Senza questo campo la query "Da Analizzare" non funziona.
ur_target INT
Perché: umidità relativa % consigliata per la germinazione di quella specie. Pre-compilato nel form analisi come default per il campo ur. Non è obbligatorio, serve solo a ridurre errori di inserimento.
note_specie VARCHAR(512)
Perché: osservazioni particolari della specie (es. "germinazione lenta, spesso serve scarificazione"). Mostrato come help-text nel form nuova analisi. Dove usato: in AnalisiController.nuovaAnalisi() viene passato al model con chiave note.
legacy_source VARCHAR(32)
Perché: marcatore di provenienza. Valori: 'diego' per record importati da Semiorto, NULL per specie create in TopSeed nativamente, 'shared' se una specie esiste in entrambi ed è stata mergeata. Ci permette dopo la migrazione di capire l'origine di ogni record.
legacy_id INT
Perché: traccia l'ID originale in semiorto_specie. Serve per audit trail e per riconciliare problemi nella migrazione (se qualcosa non torna, possiamo re-interrogare il DB legacy con quell'ID).

3.2 Estensione tabella lots

ALTER TABLE lots
  ADD COLUMN produttore_id VARCHAR(50) NULL,
  ADD COLUMN kg DOUBLE,
  ADD COLUMN provenienza VARCHAR(60),
  ADD COLUMN data_arrivo DATE,
  ADD COLUMN data_prossima_analisi DATE,
  ADD COLUMN data_esaurito DATE,
  ADD COLUMN esaurito TINYINT(1) DEFAULT 0,
  ADD COLUMN legacy_id INT,
  ADD COLUMN legacy_source VARCHAR(32),
  ADD INDEX idx_esaurito (esaurito),
  ADD INDEX idx_data_arrivo (data_arrivo),
  ADD INDEX idx_legacy_id (legacy_id),
  ADD CONSTRAINT fk_lots_produttore FOREIGN KEY (produttore_id) REFERENCES produttori(id) ON DELETE SET NULL;
produttore_id VARCHAR(50)
Perché NULLABLE: i lotti TopSeed esistenti sono legati a ctId (contratto), non a produttore diretto. I lotti Semiorto importati hanno produttore_id valorizzato e ctId NULL. La regola business: uno dei due deve essere valorizzato. ON DELETE SET NULL: se un produttore viene cancellato, i lotti non spariscono, si orfanano.
kg DOUBLE
Perché non usare qty esistente: qty in TopSeed è in unità generiche (qtyUnit='g' o 'kg'). Semiorto usa sempre kg come unità primaria. Manteniamo kg separato per evitare conversioni al volo durante import. In visualizzazione UI decidiamo: se c'è kg valorizzato lo mostriamo, altrimenti formattiamo qty + qtyUnit.
provenienza VARCHAR(60)
Perché: testo libero che identifica il sito di origine del seme (es. "Piana del Sele", "Campo 3 Calabria"). Separato dal nome del produttore perché un produttore può avere più siti. Dove usato: stampe certificato, ricerche.
data_arrivo DATE
Perché: giorno in cui il lotto è stato ricevuto dal magazzino. Usato per: ordinamento liste lotti (default: più recenti prima), filtri ricerca, calcolo "validità" del lotto (se dopo N giorni senza analisi diventa "da analizzare").
data_prossima_analisi DATE
Perché: data suggerita per la prossima analisi periodica. Viene popolata automaticamente alla chiusura di un'analisi: data_prossima_analisi = data_chiusura + giorni_tot_conta + 30 (indicativo). L'operatore la può modificare.
data_esaurito DATE
Perché: quando si imposta esaurito=1, il sistema valorizza automaticamente data_esaurito=CURDATE(). Riportando il lotto ad attivo (raro) data_esaurito=NULL.
esaurito TINYINT(1)
Perché non usare lo stato ENUM esistente: lots.stato ha già un valore 'esaurito'. Ma il campo esaurito è un FILTRO QUERY usato moltissimo ("solo lotti non esauriti per nuove analisi", "solo esauriti per archivio"). Avere un tinyint indicizzato è più veloce di un filtro sul VARCHAR stato. Quando stato='esaurito' ↔ esaurito=1. Trade-off: denormalizzazione controllata.
legacy_id, legacy_source
Come per categories: audit trail migrazione.

3.3 Estensione tabella produttori

ALTER TABLE produttori
  ADD COLUMN codice VARCHAR(45),
  ADD COLUMN legacy_id INT,
  ADD COLUMN legacy_source VARCHAR(32);
codice VARCHAR(45)
Perché: codice identificativo breve (es. "SF", "SR", "VSF"). Usato in Semiorto per stampe compatte e (teoricamente) nel generaLotto. In TopSeed lo usiamo per: pulsanti nel dropdown produttori più leggibili ("[SF] Schiavone Fernando"), future feature di auto-code lotto.

3.4 Estensione tabella users

ALTER TABLE users
  ADD COLUMN legacy_sha_hash VARCHAR(255),
  ADD COLUMN legacy_source VARCHAR(32),
  ADD COLUMN legacy_id INT,
  ADD COLUMN cambia_password TINYINT(1) DEFAULT 0;
legacy_sha_hash VARCHAR(255)
Perché: Semiorto usa SHA-1 (ShaPasswordEncoder). TopSeed usa bcrypt. Durante la migrazione importiamo l'hash SHA-1 in legacy_sha_hash e lasciamo password (bcrypt) vuoto. Al primo login post-migrazione:
  1. L'utente digita password
  2. Se legacy_sha_hash valorizzato e match SHA-1 → ok, RIGENERA bcrypt, salva in password, svuota legacy_sha_hash
  3. Se no match → errore credenziali
Questo evita di forzare reset password agli utenti (avrebbero esperienza "ho perso l'account").
cambia_password TINYINT(1)
Perché: replica il flusso Semiorto dove al primo login (post-creazione admin o post-reset) si forza cambio password. Flag booleano: se 1, al login prossimo l'utente viene redirezionato a /cambio-password forzato.

3.5 Estensione tabella test_germinabilita (il pezzo grosso)

Qui c'è la parte più massiccia. Partiamo dallo schema TopSeed attuale (vedi §2.1) e aggiungiamo 30+ campi per catturare tutto quello che Semiorto registra.

ALTER TABLE test_germinabilita
  -- Identificativo laboratorio
  ADD COLUMN ft VARCHAR(45),

  -- FK tecnico
  ADD COLUMN tecnico_id VARCHAR(50),

  -- Tipologia prova e seme (FK verso nuove lookup)
  ADD COLUMN tipologia_prova_id INT,
  ADD COLUMN tipologia_seme_id INT,
  ADD COLUMN replica_analisi INT,
  ADD COLUMN calibratura VARCHAR(45),

  -- Date workflow
  ADD COLUMN data_arrivo_laboratorio DATE,
  ADD COLUMN data_inizio DATE,
  ADD COLUMN data_chiusura DATE,

  -- Parametri ambientali/prova
  ADD COLUMN scostamento ENUM('REGOLARE','IRREGOLARE'),
  ADD COLUMN ur INT,
  ADD COLUMN totale_giorni_prova INT,

  -- Riferimento analisi esterna
  ADD COLUMN rif_numero VARCHAR(45),
  ADD COLUMN rif_data DATE,
  ADD COLUMN rif_germinabilita INT,
  ADD COLUMN rif_prova_laboratorio VARCHAR(45),
  ADD COLUMN germinabilita_prova_laboratorio INT,

  -- Esito
  ADD COLUMN risultato ENUM('POSITIVO','NEGATIVO'),
  ADD COLUMN vigore ENUM('SCARSO','MEDIO','OTTIMO'),
  ADD COLUMN pillole INT,

  -- Purezza
  ADD COLUMN seme_puro DOUBLE DEFAULT 100,
  ADD COLUMN materiale_inerte DOUBLE DEFAULT 0,
  ADD COLUMN altri_semi DOUBLE,
  ADD COLUMN altri_semi_string VARCHAR(512),
  ADD COLUMN osservazioni_purezza VARCHAR(512),

  -- Misc
  ADD COLUMN categoria_analisi ENUM('STANDARD','COMMERCIALE') DEFAULT 'STANDARD',
  ADD COLUMN osservazioni VARCHAR(512),
  ADD COLUMN germinabilita INT,
  ADD COLUMN cancella_note VARCHAR(128),

  -- Audit
  ADD COLUMN legacy_id INT,
  ADD COLUMN source VARCHAR(32) DEFAULT 'topseed',

  ADD INDEX idx_legacy_id (legacy_id),
  ADD INDEX idx_source (source),
  ADD INDEX idx_data_chiusura (data_chiusura),
  ADD INDEX idx_ft (ft),
  ADD INDEX idx_tecnico (tecnico_id),
  ADD CONSTRAINT fk_tg_tecnico FOREIGN KEY (tecnico_id) REFERENCES users(id) ON DELETE SET NULL,
  ADD CONSTRAINT fk_tg_tp FOREIGN KEY (tipologia_prova_id) REFERENCES tipologie_prova(id),
  ADD CONSTRAINT fk_tg_ts FOREIGN KEY (tipologia_seme_id) REFERENCES tipologie_seme(id);

Motivazioni (raggruppate per aree logiche):

3.5.1 Identificativo laboratorio — ft

ft VARCHAR(45) — "Fattura tecnica"
Perché: numero progressivo annuale che identifica univocamente l'analisi agli occhi del cliente e del laboratorio. Formato NNN/YY (es. "3562/26"). Come viene generato: max FT dell'anno + 1. Vedi calcoli §2. Impatto: stampato sul certificato, cercabile in tabella cerca, mostrato ovunque. Perché tipo VARCHAR: il separatore "/" rende inadatto un tipo numerico. 45 char = margine di sicurezza (in realtà max 7 char).

3.5.2 Tecnico responsabile — tecnico_id

tecnico_id VARCHAR(50)
Perché: ogni analisi è eseguita da un tecnico (GENETISTA). Il nome del tecnico appare sul certificato. Motivazione semantica: nello schema TopSeed già esistente c'è richiestoDa e assegnatoA. tecnico_id è quello che chiude l'analisi e firma. Potrebbe coincidere con assegnatoA, ma li teniamo separati perché: chi prende in carico il test (assegnatoA) non sempre è chi lo chiude (tecnico_id). ON DELETE SET NULL: se un tecnico esce dall'azienda e viene cancellato, lo storico analisi non sparisce.

3.5.3 Tipologia prova, seme, replica — campi prova

tipologia_prova_id INT → tipologie_prova
Perché FK invece di ENUM: ENUM richiederebbe ALTER TABLE per ogni nuovo tipo. FK permette all'admin di aggiungere "TEST RAPIDO" o "SUBSTRATO ALTERNATIVO" senza migrazione DB. Valori iniziali: PETRI, TORBA, CONTENITORI ALVEOLATI.
tipologia_seme_id INT → tipologie_seme
Perché: stesso principio. Lookup table con 9 valori iniziali (Portaseme, Natura, Selezionato, Calibrato, Film coated, Pillolato, Trattato, Multiseme, Vigorizzato).
replica_analisi INT
Perché INT e non FK: in Semiorto i valori ammessi sono solo 1, 2, 3 (numero di ripetizioni programmate). Un ENUM o una lookup sarebbero over-engineering. Un INT con check constraint > 0 AND < 4 basta.
calibratura VARCHAR(45)
Perché: testo libero sulla calibratura del seme (es. "3.25-3.5 mm"). Dato tecnico che va sul certificato.

3.5.4 Date del workflow

data_arrivo_laboratorio DATE
Perché: giorno in cui il campione è arrivato in laboratorio dal magazzino. Può essere diverso da lots.data_arrivo (il lotto è arrivato in magazzino il giorno X, il campione per l'analisi è stato prelevato e portato al lab il giorno Y > X). Impatto: stampato sul certificato come "Data di arrivo".
data_inizio DATE
Perché: giorno di inizio effettivo della prova (semina nelle Petri). Spesso uguale a data_arrivo_laboratorio, ma non sempre. Usato per calcolare: la "prossima operazione" nella lista In Corso (data_inizio + giorni_1_conta = data prima conta).
data_chiusura DATE (NULL = in corso)
Perché un campo che può essere NULL: è il discriminante tra analisi "In Corso" (data_chiusura IS NULL) e "Chiusa" (IS NOT NULL). Impostato a CURDATE() al momento della chiusura. Perché DATE e non DATETIME: Semiorto usa solo la data (non l'ora). Coerenza.

3.5.5 Scostamento, UR, giorni prova

scostamento ENUM('REGOLARE','IRREGOLARE')
Perché: durante le letture il tecnico valuta se la prova sta andando REGOLARMENTE o si rilevano IRREGOLARITÀ (es. contaminazione, UR non mantenuta, temperatura anomala). Valore impostato alla chiusura. Se IRREGOLARE, il certificato riporta "prova con scostamento, interpretare con cautela".
ur INT
Perché: UR (Umidità Relativa) effettiva mantenuta durante la prova, misurata al termine. Pre-compilato con categories.ur_target, modificabile dal tecnico. Stampato sul certificato: sì.
totale_giorni_prova INT
Perché: numero totale di giorni effettivi della prova. Pre-compilato con categories.giorni_tot_conta. Viene registrato fisso all'avvio perché se la specie viene modificata dopo (nuova giorniTConta) non cambia lo storico.

3.5.6 Riferimento analisi esterna

Questi 5 campi servono se il cliente fornisce un'analisi eseguita da un altro laboratorio, per confronto. Il tecnico Semiorto riceve il seme già con un "certificato del fornitore" e può annotarlo.

rif_numero VARCHAR(45)
Numero del certificato esterno.
rif_data DATE
Data del certificato esterno.
rif_germinabilita INT
% germinabilità dichiarata dal fornitore.
rif_prova_laboratorio VARCHAR(45)
Nome del laboratorio esterno.
germinabilita_prova_laboratorio INT
Quasi sempre uguale a rif_germinabilita ma in alcuni casi il tecnico annota il valore finale che risulta dalla loro stessa prova di laboratorio (diversa da quella del fornitore). Bug nel codice Semiorto: update() imposta entrambi i campi allo stesso valore (vo.setGerminabilitaProvaLaboratorio(bo.getAnalisiEsterna().getGerminabilita())). Replichiamo il bug ma lo segnaliamo all'admin — potrebbe essere un'ottimizzazione richiesta.

3.5.7 Esito

risultato ENUM('POSITIVO','NEGATIVO')
Perché: giudizio finale del tecnico. NON automatico da % germinabilità (un 84% potrebbe essere POS per una specie con soglia 80 e NEG per una con soglia 85). Dove usato: stampa certificato, filtri ricerca.
vigore ENUM('SCARSO','MEDIO','OTTIMO')
Perché: valutazione di vigore globale. Pre-compilato come media dei vigori delle singole conte (vedi calcoli §11), modificabile manualmente dal tecnico.
pillole INT
Perché: per semi pillolati, numero totale di pillole. Dato tecnico, non sempre valorizzato.

3.5.8 Purezza

seme_puro DOUBLE DEFAULT 100
Perché DEFAULT 100: se non specificato, assumiamo seme al 100% puro. Rappresenta la % di semi della varietà dichiarata sul totale del campione.
materiale_inerte DOUBLE DEFAULT 0
Frammenti non-seme (terra, polvere, foglie secche).
altri_semi DOUBLE
% di semi di altre specie contaminanti.
altri_semi_string VARCHAR(512)
Testo libero dettagliando quali semi contaminanti (es. "Amaranthus retroflexus, Chenopodium album").
osservazioni_purezza VARCHAR(512)
Note aggiuntive sul test di purezza.

3.5.9 Metadati finali

categoria_analisi ENUM('STANDARD','COMMERCIALE')
Perché: STANDARD = test di routine interno. COMMERCIALE = test con validità per certificazione ufficiale (soglie più alte, procedure più stringenti). Impatto su stampa e su eventuali controlli automatizzati futuri.
osservazioni VARCHAR(512)
Note libere generali sull'analisi.
germinabilita INT
Perché STORED e non CALCULATED: la germinabilità viene calcolata server-side alla chiusura dell'analisi (media delle conte, normalizzata). Salvarla evita di ricalcolare ogni volta che si visualizza. In Semiorto è sempre stored, lo replichiamo.
cancella_note VARCHAR(128)
Perché stringa e non bool: in Semiorto il campo cancella contiene il motivo della cancellazione (non solo un flag). NULL = analisi attiva, stringa valorizzata = analisi cancellata. Permette soft-delete con audit trail.

3.5.10 Audit trail

legacy_id INT, source VARCHAR(32) DEFAULT 'topseed'
Come per altre tabelle. Fondamentale per distinguere record importati da record nuovi post-migrazione.

3.6 Nuova tabella test_germinabilita_conte

CREATE TABLE test_germinabilita_conte (
  id INT AUTO_INCREMENT PRIMARY KEY,
  analisi_id VARCHAR(50) NOT NULL,
  lettera CHAR(1) NOT NULL DEFAULT 'A',

  giorni_1 INT,
  germinati_1 INT, duri_1 INT, freschi_1 INT, anormali_1 INT, morti_1 INT,
  vigore_1 ENUM('SCARSO','MEDIO','OTTIMO'),

  giorni_2 INT,
  germinati_2 INT, duri_2 INT, freschi_2 INT, anormali_2 INT, morti_2 INT,
  vigore_2 ENUM('SCARSO','MEDIO','OTTIMO'),

  FOREIGN KEY (analisi_id) REFERENCES test_germinabilita(id) ON DELETE CASCADE,
  INDEX idx_analisi (analisi_id),
  INDEX idx_lettera (lettera)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Motivazioni tabella

Relazione 1:N con test_germinabilita
Ogni analisi ha tipicamente 4 conte (una per ripetizione A, B, C, D). A volte più (E, F, G) se si aggiungono repliche. La tabella conte ha una riga per ciascuna ripetizione.
Perché ON DELETE CASCADE
Se si elimina definitivamente un'analisi, tutte le sue conte vanno cancellate insieme. Non ha senso mantenere conte orfane. La modalità soft-delete (cancella_note) non tocca le conte, le mantiene.
lettera CHAR(1)
Ripetizione identificata da lettera A, B, C, D, ... Seguendo il pattern Semiorto. L'alternativa sarebbe un numero progressivo ma le lettere sono più immediatamente distinguibili nei grafici stampe.

Motivazioni campi 2 letture parallele

Per ogni ripetizione ci sono sempre 2 letture temporali: la prima parziale e la finale. Quindi ogni campo è duplicato: germinati_1 (prima lettura) e germinati_2 (seconda lettura).

giorni_1, giorni_2
Numero di giorni dall'inizio della prova al momento della rispettiva lettura. Es. giorni_1=5, giorni_2=10 significa "prima lettura il giorno 5, seconda al giorno 10".
germinati_1, germinati_2
N° semi germinati (su 100 seminati) rilevati alla rispettiva lettura. Per la germinabilità finale si sommano le due letture (vedi calcoli).
duri_1/2
Semi che non si imbibiscono (tegumento impermeabile). Frequenti in leguminose.
freschi_1/2
Semi vivi ma non ancora germinati. In dormienza.
anormali_1/2
Plantule germinate ma con difetti (radici malformate). NON contate come "germinate" ai fini commerciali.
morti_1/2
Semi morti, decomposti.
vigore_1/2
Valutazione soggettiva del tecnico sulla vitalità delle plantule. ENUM 3 valori.

Invariante biologico: la somma di (germinati + duri + freschi + anormali + morti) in giorni_1 e giorni_2 deve essere 100 (= 100 semi seminati per ogni Petri). Se non torna, il sistema normalizza riassegnando a germinati_2.

3.7 Nuova tabella tipologie_prova

CREATE TABLE tipologie_prova (
  id INT AUTO_INCREMENT PRIMARY KEY,
  codice VARCHAR(30) UNIQUE NOT NULL,
  nome VARCHAR(60) NOT NULL,
  descrizione TEXT,
  ordinamento INT DEFAULT 0,
  attivo TINYINT(1) DEFAULT 1
);

INSERT INTO tipologie_prova (codice, nome, descrizione, ordinamento) VALUES
  ('PETRI', 'PETRI', 'Germinazione in capsula Petri su carta assorbente. La più comune.', 1),
  ('TORBA', 'TORBA', 'Germinazione in pane di torba. Per specie che richiedono substrato.', 2),
  ('CONT_ALV', 'CONTENITORI ALVEOLATI', 'Germinazione in vassoi con alveoli separati. Per sementi grosse o a sviluppo radicale importante.', 3);
Perché tabella dedicata
In Semiorto è una lista iniettata via Spring XML. Lista statica. Noi la mettiamo in DB per permettere all'admin di aggiungere nuovi tipi (es. se in futuro si introduce "Substrato idroponico") senza deploy. Il campo attivo permette di disabilitare un tipo obsoleto senza cancellarlo (preserva i record storici).
descrizione TEXT
Testo help-text mostrato nella UI sotto il dropdown "Tipo prova", spiega all'operatore cosa significa ciascuna opzione.
ordinamento INT
Permette all'admin di controllare l'ordine nel dropdown. Il default mette PETRI in cima (il più usato).

3.8 Nuova tabella tipologie_seme

CREATE TABLE tipologie_seme (
  id INT AUTO_INCREMENT PRIMARY KEY,
  codice VARCHAR(30) UNIQUE NOT NULL,
  nome VARCHAR(60) NOT NULL,
  descrizione TEXT,
  ordinamento INT DEFAULT 0,
  attivo TINYINT(1) DEFAULT 1
);

INSERT INTO tipologie_seme (codice, nome, descrizione, ordinamento) VALUES
  ('PORTASEME',   'Portaseme',   'Seme destinato a riproduzione.', 0),
  ('NATURA',      'Natura',      'Seme grezzo, non lavorato.', 1),
  ('SELEZIONATO', 'Selezionato', 'Seme pulito dai materiali inerti.', 2),
  ('CALIBRATO',   'Calibrato',   'Seme calibrato per dimensione uniforme.', 3),
  ('FILM_COATED', 'Film coated', 'Seme con rivestimento sottile protettivo.', 4),
  ('PILLOLATO',   'Pillolato',   'Seme incapsulato in pellet (più grosso, più facile semina meccanica).', 5),
  ('TRATTATO',    'Trattato',    'Seme trattato con fungicidi o insetticidi.', 6),
  ('MULTISEME',   'Multiseme',   'Pellet contenente più semi (es. lattughe).', 7),
  ('VIGORIZZATO', 'Vigorizzato', 'Seme pre-imbibito per accelerare germinazione.', 9);

Stessa logica di tipologie_prova. I 9 valori iniziali vengono direttamente dalla tabella semiorto_tipologia_seme letta con SQL live sul DB AWS.

3.9 Nuova tabella specie_calendario_analisi

CREATE TABLE specie_calendario_analisi (
  id INT AUTO_INCREMENT PRIMARY KEY,
  categoria_id VARCHAR(50) NOT NULL,
  mese INT NOT NULL,
  tipologia_seme_id INT NOT NULL,

  UNIQUE KEY uq (categoria_id, mese, tipologia_seme_id),
  INDEX idx_categoria (categoria_id),
  INDEX idx_mese (mese),

  FOREIGN KEY (categoria_id) REFERENCES categories(id) ON DELETE CASCADE,
  FOREIGN KEY (tipologia_seme_id) REFERENCES tipologie_seme(id) ON DELETE CASCADE,

  CHECK (mese BETWEEN 1 AND 12)
);

Motivazioni

Equivalente a cosa Semiorto
Replica la tabella legacy semiorto_map_specie_analisi_ripetizione_tipologia_seme. Nome legacy fuorviante (perché "analisi_ripetizione" in realtà sono i 12 mesi dell'anno). Noi usiamo nomi espliciti: mese.
Semantica: "calendario di ripetizione analisi"
Per ogni specie, l'admin spunta in quali mesi dell'anno è previsto un test di routine, e per quale tipologia seme. Esempio: "Pomodoro, a Maggio, per seme Selezionato" significa "ogni maggio ripetiamo il test su tutti i lotti di pomodoro selezionato attivi".
Perché INT per mese invece di FK
In Semiorto i mesi sono una tabella (semiorto_analisi_ripetizione, 12 righe). Per noi è over-engineering: un INT 1-12 con check constraint è più leggibile nelle query e non richiede JOIN.
UNIQUE KEY su 3 campi
Previene duplicati della stessa configurazione (stessa specie, stesso mese, stessa tipologia).
ON DELETE CASCADE su entrambe le FK
Se si cancella una specie → spariscono le sue configurazioni calendario. Idem tipologia seme. Il calendario è configurazione, non dato storico.

Dove viene usato: alimenta la query "Da Analizzare" (vedi flussi). Senza questa tabella popolata, la lista "Da Analizzare" è sempre vuota, anche se ci sono lotti attivi.

4. API endpoints custom

I CRUD standard sono già coperti da api.php esistente (basta aggiungere le nuove entità in $ENTITY_MAP). Per le logiche custom creiamo un nuovo file api_germinabilita.php.

4.1 ?action=in_corso — lista analisi in corso

SELECT tg.*,
       l.num lotto_num, l.provenienza,
       s.name seme_name,
       c.italiano specie_name, c.giorni_1_conta, c.giorni_tot_conta,
       u.nome tecnico_nome, u.cognome tecnico_cognome
FROM test_germinabilita tg
LEFT JOIN lots l ON l.id = tg.lotId
LEFT JOIN seeds s ON s.id = l.sId
LEFT JOIN categories c ON c.id = s.categoryId
LEFT JOIN users u ON u.id = tg.tecnico_id
WHERE tg.data_chiusura IS NULL
  AND tg.cancella_note IS NULL
  AND (l.esaurito IS NULL OR l.esaurito = 0)
ORDER BY tg.data_inizio DESC;

Ritorna JSON array. Usato nel Dashboard "In Corso" + badge navbar (con count).

4.2 ?action=da_analizzare — lotti che scadono questo mese

SELECT tg.* FROM test_germinabilita tg
JOIN (
  SELECT MAX(a.id) id, l.id lot_id, c.giorni_tot_conta
  FROM test_germinabilita a
  JOIN lots l ON a.lotId = l.id
  JOIN seeds s ON l.sId = s.id
  JOIN categories c ON s.categoryId = c.id
  JOIN specie_calendario_analisi cal ON cal.categoria_id = c.id
  WHERE l.esaurito = 0
    AND cal.mese = MONTH(CURDATE())
  GROUP BY l.id
) q ON tg.id = q.id
WHERE tg.data_chiusura IS NOT NULL
  AND DATE_ADD(tg.data_chiusura,
      INTERVAL (DAY(LAST_DAY(CURDATE() - INTERVAL 1 MONTH)) - q.giorni_tot_conta) DAY)
      < DATE_FORMAT(CURDATE(), '%Y-%m-01')
ORDER BY tg.id;

Replica esatta della query daAnalizzare() Semiorto, adattata allo schema TopSeed. Vedi spiegazione decodificata.

4.3 ?action=nuova_analisi&lot_id=X — crea analisi su lotto

Transazione:

  1. Verifica che il lotto esista e non sia esaurito
  2. Genera FT prossimo (vedi §4.4)
  3. INSERT test_germinabilita con: ft, lot_id=X, data_arrivo_laboratorio=CURDATE(), tutti gli altri NULL
  4. Pre-popola conta A via INSERT test_germinabilita_conte con giorni da specie
  5. Ritorna l'id nuovo dell'analisi

4.4 ?action=genera_ft — prossimo FT disponibile

-- Usa FOR UPDATE per evitare race conditions
BEGIN;
SELECT MAX(CAST(SUBSTRING_INDEX(ft, '/', 1) AS UNSIGNED)) + 1 AS prossimo
FROM test_germinabilita
WHERE ft LIKE CONCAT('%/', YEAR(CURDATE()) % 100)
FOR UPDATE;
-- Ritorna: "3562/26"

4.5 ?action=chiudi_analisi&id=X

  1. Verifica tutte le conte completate (giorni_2 valorizzato per tutte)
  2. Chiama ricalcola_germinabilita(X) che esegue la formula della media (vedi calcoli)
  3. Legge dal payload: risultato, scostamento, vigore, tecnico_id
  4. UPDATE analisi con data_chiusura=CURDATE() + valori
  5. Ritorna analisi aggiornata

4.6 ?action=ricalcola_germinabilita&id=X

Chiamabile anche prima della chiusura per anteprima. Riproduzione esatta dell'algoritmo generaConta() di Semiorto. Vedi calcoli.

4.7 ?action=cancella_analisi

POST { id: "X", motivo: "Errore nel lotto" }
-- UPDATE test_germinabilita SET cancella_note = :motivo WHERE id = :id

4.8 ?action=ripristina_analisi

POST { id: "X" }
-- UPDATE test_germinabilita SET cancella_note = NULL WHERE id = :id

Solo ADMIN (controllo lato PHP su session).

4.9 ?action=certificato&id=X&azienda=SEMIORTO

GET. Genera PDF con template engine. Vedi Sprint 5.

4.10 ?action=scheda&id=X

GET. Genera PDF scheda di controllo.

4.11 ?action=stats

SELECT
  (SELECT COUNT(*) FROM test_germinabilita WHERE data_chiusura IS NULL AND cancella_note IS NULL) inCorso,
  (SELECT COUNT(*) FROM ... [query da_analizzare]) daAnalizzare,
  (SELECT COUNT(*) FROM test_germinabilita WHERE cancella_note IS NOT NULL) cancellate,
  (SELECT COUNT(*) FROM test_germinabilita WHERE data_chiusura IS NOT NULL AND cancella_note IS NULL) chiuse;

Usato per badge navbar + dashboard.

5. Roadmap 8 sprint — dettagli operativi

Ogni sprint è descritto con: scope, giorni, files toccati, deliverable verificabile.

Sprint 0 — Fondamenta DB (3 giorni)

Giorno 1 — Migration SQL

Giorno 2 — Registrazione entità in api.php

Giorno 3 — Skeleton api_germinabilita.php

Deliverable Sprint 0
  • Schema DB esteso in produzione
  • 15 entità disponibili via CRUD in api.php
  • 9 azioni custom disponibili in api_germinabilita.php
  • Documento changelog su cosa è stato fatto

Sprint 1 — Anagrafiche (4 giorni)

Giorno 1 — Form edit Specie con matrice calendario

Giorno 2 — Lista e edit Varietà

Giorno 3 — Lista e edit Produttori

Giorno 4 — Test end-to-end anagrafiche

Sprint 2 — Lotti (3 giorni)

Giorno 1 — Form crea lotto

Giorno 2 — Lista lotti con filtri

Giorno 3 — Bulk "Setta come esaurito"

Sprint 3 — Analisi core (5 giorni)

Il cuore del modulo. Sprint più lungo perché replica l'update.jsp 739 righe di Semiorto.

Giorno 1 — Pagina "Nuova analisi"

Giorno 2 — Form analisi: sezione dati prova

Giorno 3 — Form analisi: sezione conte A/B/C/D (il difficile)

Giorno 4 — Form analisi: chiusura

Giorno 5 — Endpoint backend + test end-to-end

Sprint 4 — Workflow & dashboard (3 giorni)

Giorno 1 — Lista "In Corso"

Giorno 2 — Lista "Da Analizzare"

Giorno 3 — Cerca + Cancellate

Sprint 5 — Stampe PDF (4 giorni)

Giorno 1 — Setup engine PDF

Giorno 2 — Template certificato HTML

Giorno 3 — Endpoint certificato

Giorno 4 — Scheda controllo + editor template

Sprint 6 — Migrazione dati (3 giorni)

Giorno 1 — Script migrate_semiorto.php

Giorno 2 — Dry-run su staging

Giorno 3 — Fix + secondo dry-run

Sprint 7 — Go-live (2 giorni)

Giorno 1 — Switch utenti

Giorno 2 — Safety net + monitoring

6. Scelte tecniche con motivazioni puntuali

6.1 Mesi come INT invece di FK a tabella

Semiorto ha semiorto_analisi_ripetizione con 12 righe per i mesi. È over-engineering. Noi usiamo INT 1-12 direttamente in specie_calendario_analisi.mese. Vantaggi:

6.2 Mappatura ruoli: ADMIN / GENETISTA / MAGAZZINIERE

Semiorto ha Amministrazione / Laboratorio / Magazzino. TopSeed ha già ADMIN / GENETISTA / MAGAZZINIERE. Usiamo i nomi TopSeed. Conversione:

6.3 Stampe: PDF invece di DOCX

Motivazioni:

  1. PDF è immutabile: il cliente non può modificare un certificato. DOCX sì (con Word aperto).
  2. Template HTML editabile: con HTML + CSS, l'admin può modificare il layout direttamente da browser via pagina config. Con DOCX serve MS Word, competenze grafiche, risorse tecniche.
  3. Niente dipendenze MS: docx4j richiede java + licenze MS Office nel flusso editing. dompdf è zero-dep PHP.
  4. Mobile-friendly: PDF si apre nativamente su qualsiasi dispositivo. DOCX richiede Word o app compatibili.

Perdita: perdiamo la possibilità di editare il certificato prima di inviarlo. Mitigazione: se il cliente chiede modifiche, il tecnico può cambiare i dati in TopSeed e rigenerare.

6.4 FT generation: SELECT FOR UPDATE invece di max+1 lazy

Semiorto usa:

-- Race condition possibile
max_ft = SELECT MAX(ft) FROM ... WHERE year = Y
new_ft = max_ft + 1

Se due tecnici creano analisi contemporaneamente, potrebbero ottenere lo stesso FT. Improbabile con 4 tecnici ma non impossibile.

Soluzione nostra:

BEGIN;
SELECT MAX(...) FOR UPDATE;  -- lock pessimistico
INSERT ... FT = max+1;
COMMIT;

Penalità prestazionale trascurabile con 4 concurrent users. Robustezza totale.

6.5 cancella_note VARCHAR invece di is_deleted BOOL

Soft-delete con motivo = audit trail migliore. L'admin può capire perché un'analisi è stata cancellata senza query log separati.

6.6 Non implementiamo generaLotto

La funzione LottoServiceImpl.generaLotto() di Semiorto genera un codice auto-calcolato per il lotto. Ma nessun controller la invoca — l'operatore inserisce il codice manualmente. È dead code. La saltiamo.

Se in futuro serve: aggiungeremo un bottone "Suggerisci codice" nel form lotto che chiama endpoint dedicato con logica nostra (migliorata rispetto a quella originale bug-prone).

7. Rischi con mitigazioni puntuali

R1 — Schema test_germinabilita già ha dati di test

Probabilità: bassa (ho controllato, tabella quasi vuota)
Impatto: potenziali conflitti durante ALTER se ci sono dati con valori strani
Mitigazione: backup pre-ALTER, rollback pronto. Se ci sono < 10 record di test, cancellarli prima.

R2 — Template DOCX certificato non ottenibile

Probabilità: media (Diego potrebbe non averlo conservato o non volerlo condividere)
Impatto: non abbiamo il layout originale da replicare
Mitigazione: ricostruire da zero. Chiedi al cliente un certificato Semiorto stampato in PDF. Riproduci il layout in HTML. Mostralo al cliente per validazione. Itera.

R3 — Formula calcolo germinabilità differisce

Probabilità: bassa (abbiamo il codice Java di riferimento)
Impatto: dopo migrazione, i nuovi calcoli potrebbero dare valori diversi dai legacy
Mitigazione: test su 100 analisi legacy. Usando le conte come input, ricalcolare con formula nostra, confrontare con germinabilita stored. Tolleranza: differenza ≤ 1 (arrotondamenti diversi).

R4 — Utenti SHA-1 non riescono a loggarsi

Probabilità: media (ShaPasswordEncoder ha configurazioni specifiche, un detail diverso rompe tutto)
Impatto: 5 utenti bloccati al giorno del go-live
Mitigazione: testare il flusso legacy_sha_hash con utente di staging PRIMA del go-live. Se fallisce → forzare reset password per tutti via email automatica al day-1.

R5 — Query "Da Analizzare" troppo pesante su 62K analisi

Probabilità: bassa (la query Semiorto gira in produzione da anni con 62K record senza problemi)
Impatto: badge navbar lento, lista impiega > 2 secondi
Mitigazione: indici su test_germinabilita.data_chiusura, lots.esaurito, specie_calendario_analisi.mese. Se ancora lenta → cache il risultato per 1 ora (la query dipende da MONTH(NOW()) che cambia ~1 volta al mese).

R6 — Race condition FT

Vedi §6.4. Mitigazione: SELECT FOR UPDATE.

R7 — Utenti confusi dalla UI nuova

Probabilità: media (la UI Bootstrap 5 di TopSeed è molto diversa da Bootstrap 3 di Diego)
Impatto: resistenza iniziale, produttività bassa nei primi giorni
Mitigazione: sessione di training di 1 ora via screen-share con ogni utente. Manuale utente come pagina del dossier. Safety net 30 giorni dove possono consultare Semiorto se serve conferma.

8. Decisioni da prendere prima di partire

  1. OK alla roadmap generale (8 sprint, ~7 settimane)? Se hai vincoli di timeline comprimere/allentare?
  2. PDF engine: dompdf (semplice, 100% PHP) o mpdf (più feature, più CSS support)? → io propongo dompdf perché è più leggero, ma se TopSeed ne usa già uno è meglio adottarlo.
  3. Ruoli: uso ADMIN/GENETISTA/MAGAZZINIERE TopSeed esistenti? → , è la mia raccomandazione per coerenza.
  4. Migrazione Big-Bang o progressiva? → Big-Bang con dry-run. Scegliamo un sabato, backup pre-migrazione, eseguiamo script, validiamo, notifichiamo utenti lunedì mattina.
  5. Contattiamo Diego per template DOCX e SMTP? Decisione commerciale: se il rapporto con lui è ancora buono, chiedere. Se no, ricostruiamo tutto.
  6. Azienda single vs multi-azienda? Semiorto gestisce 1 azienda. TopSeed lo supporta multi. Nel Sprint 1-7 → SINGOLA (semplicità). In fase 2 post-go-live → aggiungiamo multi-azienda se serve.
  7. Ripristino analisi cancellate: chi può farlo? Solo ADMIN? O anche GENETISTA che l'ha cancellata? → io propongo ADMIN only (più sicuro).
  8. Eliminazione definitiva: in Semiorto l'ADMIN può farlo. In TopSeed aggiungiamo una finestra di conferma tipo "Scrivi DELETE per confermare". O togliamo proprio la feature (chi la usa?). → io propongo di mantenerla ma richiedendo doppia conferma.
  9. Template certificato: 1 solo template o multi-azienda già nel v1? → nel v1 uno solo, chiamato SEMIORTO. Se poi serve multi, aggiungiamo un azienda_id in config.
  10. Login post-migrazione: forzare reset pwd a tutti (più sicuro ma più attrito) o provare prima SHA-1 match (più smooth ma più rischioso)? → io propongo SHA-1 match con fallback reset.
Se dici ok

Parto con Sprint 0 giorno 1: deploy della migration SQL in produzione, popolamento lookup tables, registrazione entità in api.php, skeleton di api_germinabilita.php. Tempo stimato: 3-4 ore (parte di oggi).

Fine Sprint 0: entro fine settimana.