Difetti trovati durante il rimpatrio
Trovati mentre si spostava il codice, e lasciati com'erano fino alla fine. Il motivo era sempre lo stesso: un'onda sposta codice senza cambiarne il comportamento, e correggere durante lo spostamento rende impossibile distinguere una differenza dovuta al trasloco da una dovuta alla correzione.
A rimpatrio finito quella ragione è scaduta, e i due difetti attivi sono stati chiusi. Restano elencati qui sotto, sotto Corretti, perché la spiegazione di perché i numeri sono cambiati vale più della loro sparizione.
Corretti
La cancellazione di ore importate puntava all'id sbagliato
Dove: modules/data-exchange/importDiff.js, caricaOreMensiliPerImport
La riga delle ore veniva arricchita con i campi del dipendente e del progetto
tramite spread, e quegli oggetti hanno un proprio id che sovrascriveva quello
della riga. La proposta di cancellazione puntava all'id del progetto: in
modalità SYNC una riga non più presente nel foglio non veniva cancellata, e nel
caso peggiore veniva cancellata la riga sbagliata.
Era presente anche nell'implementazione originale — verificato eseguendola sulla stessa fixture. Non introdotto dal rimpatrio.
Corretto ripristinando id, employee_id e project_id dopo gli spread. Lo
snapshot del diff è cambiato in un solo valore: target_record_id da 20
(l'id del progetto) a 900 (l'id della riga).
Elenco e dettaglio progetti non concordavano
Dove: modules/projects/projectQueries.js
L'elenco aggregava solo ore mensili e run giornaliere; il dettaglio includeva
anche allocation_consolidated_results. Sulla fixture l'elenco dichiarava
181 ore dove il dettaglio ne diceva 314.
Non era ancora attivo — nessun codice scriveva quella tabella — ma lo sarebbe diventato con la consolidazione.
Corretto estraendo la regola di prevalenza fra le sorgenti in
oreEffettive, usata ora da entrambi: due copie della stessa regola avrebbero
riprodotto la divergenza al primo cambiamento. Lo snapshot dell'elenco è
cambiato in sei valori sulla riga del progetto 10 e quattro nel riepilogo,
tutti verificati uno per uno prima di aggiornarlo a mano.
Ancora aperti
Il dettaglio dipendente non riclassifica le ore
Dove: rendicontazione-service/modules/workforce/employeeQueries.js
Il dettaglio progetto applica withDynamicDataStatus alle ore — quindi le
righe coperte da baseline diventano BASELINE_COVERED e non entrano nei
totali. Il dettaglio dipendente non lo fa.
Conseguenza: le stesse ore contano nel totale del dipendente e non in quello del progetto. Due numeri diversi per lo stesso fatto.
Non ha un test dedicato perché è comportamento registrato negli snapshot: si vedrà come differenza quando qualcuno lo correggerà, che è il momento giusto per accorgersene.
Il forecast non è riproducibile
Dove: rendicontazione-service/modules/forecasting/projectForecast.js
Test: data-service/test/golden/forecast.golden.test.js, marcato todo
Il ricalcolo legge new Date() per stabilire da quale mese partire. Lo stesso
input a marzo genera 9 previsioni, a maggio 6.
Non è un difetto di calcolo: è che il calcolo dipende da quando lo esegui. Finché è così, un forecast non può essere riprodotto per verificarlo, e la domanda «perché questo numero era diverso il mese scorso» non ha risposta.
Si chiude iniettando l'orologio invece di leggerlo. È previsto nella riscrittura del forecasting.
Il forecast non ha vincolo di capacità — chiuso il 18 agosto 2026
Dove: come sopra
Test: stesso file, marcato todo
Il ricalcolo per progetto divide il budget residuo in parti uguali fra gli slot futuri, senza chiedersi quante ore una persona possa realmente lavorare in un mese. Sulla fixture prevede 355 ore in un mese per una persona sola.
Il ricalcolo globale invece un tetto ce l'ha — il monte ore annuo diviso dodici — quindi i due percorsi non solo danno risultati diversi, danno risultati diversi in modo strutturale.
Chiuso il 18 agosto 2026: i due percorsi sono diventati lo stesso motore. Il per-progetto non divide più il budget sugli slot, parte dalla capacità come il globale, e in più tiene conto delle ore che ciascuno ha già su altri progetti. Sulla fixture le ore massime in un mese passano da 355,56 a 153,33, e sede, tetto per risorsa e ferie pianificate valgono per entrambi.
Il costo standard di progetto è uno stub — chiuso il 18 agosto 2026
Dove: forecastRules.js (profiliCostoStandardDi) e allocationEngine.js
(resolveHourlyCostEur, tariffaDaProfilo)
Trovato: 18 agosto 2026, preparando la Fase 3
Entrambi i motori leggono projects.standard_cost_profiles, un JSON che mappa
il job level sulla tariffa. Quella colonna non esiste in nessuna parte dello
schema: non in init.sql, non in db/postgres/init/, non in nessuna
migrazione. Esiste solo nelle fixture golden, che quindi sono più generose del
database — è esattamente ciò che era già successo con job_level, e la ragione
per cui quella lezione era stata scritta.
Chiuso il 18 agosto 2026 con le migrazioni 112–114 e la sostituzione
della lettura nei due motori: la tariffa ora viene da funding_body_rates,
risolta per fascia, ente e data. Quanto segue resta perché spiega perché i
numeri sono cambiati.
Non era una funzione rotta: era uno stub di una funzione mai costruita, ed è stato buttato invece che riparato. Il costo standard, come è scritto in To-be funzionale, non è una tariffa del progetto per job level: è una tariffa dell'ente finanziatore per macro-livello, storicizzata. Il progetto sceglie soltanto se usarla.
Ne discende che anche la pagina Progetti descrive una griglia di tariffe per job level che non può salvare nulla: è documentazione di qualcosa che non esiste.
Le conseguenze sono diverse nei due motori, e nessuna delle due si vede:
- nel forecast un progetto a costo standard ottiene tariffa
0, quindi non produce nessuna previsione. Il conteggio finisce inskippedNoRate, che nessuna schermata mostra; - nel consuntivo si ricade su
current_hourly_cost_eur, cioè il costo reale della persona. Un progetto dichiarato a costo standard viene quindi valorizzato al costo reale, in silenzio e con numeri plausibili.
I golden sono verdi perché le fixture contengono la colonna. Sono verdi su codice che in produzione non lo è.
Si chiude in Fase 3, ed è il motivo per cui i punti 1, 2 e 6 vanno affrontati insieme: la tariffa standard va spostata dal JSON di progetto a una tabella storicizzata per ente e macro-livello, e risolta alla data.
Le fixture sono state allineate il 18 agosto 2026, prima di toccare il codice, ed è quella modifica ad aver reso visibile il difetto. Sulla fixture del forecast globale il conto è questo:
| prima | dopo | |
|---|---|---|
| previsioni scritte | 26 | 21 |
| assegnazioni scartate per tariffa mancante | 2 | 3 |
| progetti che ricevono previsioni | 10, 11, 15, 16 | 10, 11, 16 |
Il progetto 15 — ZETA, l'unico a costo standard, sparisce dal forecast: le sue cinque previsioni non esistono, e le ore delle due persone che vi erano assegnate si ridistribuiscono sugli altri progetti, cambiando anche numeri che sembravano non c'entrare. Gli snapshot sono stati riscritti a mano su questi valori: non sono un miglioramento, sono la fotografia di cosa succede oggi in produzione, ed è da lì che la Fase 3 riparte.
Anche freeze_config è una colonna che non esiste — chiusa il 18 agosto 2026
freeze_config è una colonna che non esisteDove: projectQueries.js, freezeSchedule.js, projectPayload.js
Trovato: 18 agosto 2026, allineando le fixture per la Fase 3
Stessa classe di standard_cost_profiles, trovata con lo stesso metodo:
projects.freeze_config non compare in nessuno dei 118 file di migrazione né in
init.sql. Il codice la legge, il payload la scrive, e datahub la scarta in
silenzio.
Ne discende che il freeze warning — «questo progetto è in ritardo con il
consolidamento» — in produzione non si accende mai: freeze_config è
sempre null, quindi nessuna scadenza è configurata per nessun progetto. I test
che lo verificano sono verdi perché la fixture contiene la colonna.
C'era però una terza conseguenza, e non era innocua: il salvataggio di un
progetto era rotto. Il payload scriveva freeze_config a ogni PUT,
datahub la rifiutava con __strict_columns e l'intera scrittura falliva.
Arrivava al browser come «Servizio dati non raggiungibile» — un guasto
inesistente — e nessuno collegava le due cose.
Chiusa dalla migrazione 116, che crea la colonna invece di togliere la
funzione. Il congelamento è costruito per intero: maschera, payload, query,
freezeSchedule, invarianti golden. Mancava un pezzo, non il disegno. Se un
giorno non servisse più, si toglie insieme alla sua interfaccia — non lasciando
a metà quello che c'è.
La verifica della 116 controlla tutte e tre le colonne che il payload scrive
— freeze_config, max_hours_per_resource, work_location_id — e rifiuta il
ritorno di standard_cost_profiles: è il modo di non riaprire lo stesso buco da
un'altra parte.
Il self-service del dipendente è dichiarato e non implementato
Dove: ADR-003, migrazione 095
Il ruolo rendicontazione_employee esiste, ha la capability self, ed è
descritto come «accesso self-service del dipendente». Ma self non è
implementata a nessuno dei tre livelli necessari, e soprattutto
rendicontazione.employees non ha alcuna colonna che colleghi una riga a un
account.
La migrazione 095 ha tolto al ruolo il permesso di gateway: finché il filtro
non esiste, un permesso di lettura su tutto il verticale è una promessa che il
codice non può mantenere. Il ruolo non va assegnato.
Il prerequisito è un dato, non un permesso: vedi ADR-003.
Contatori diagnostici nel contratto dell'API
Dove: projectForecast.js, campo debug della risposta del ricalcolo
globale
Undici contatori di debug fanno parte della risposta, e la pagina Forecast li mostra. Nel rimpatrio sono stati ricostruiti — le regole restituiscono il motivo dell'esclusione e i conteggi si ricavano da lì — invece di reintrodurre le impalcature che li producevano.
Due campi non sono più riproducibili e lo dichiarano nel codice:
failedInserts e firstInsertError descrivevano una riga che fallisce mentre
le altre proseguono. Con la scrittura in blocco quel caso non esiste: o passa
tutto o solleva.
skippedDateRange conta due cose diverse — progetti scartati in blocco e
singole assegnazioni fuori periodo. È così anche nell'implementazione
precedente, quindi è rimasto così.
Una scrittura che risponde 2xx e non salva — chiuso
Il difetto e costato tre giri: l'email del dipendente, il job level, la citta di una sede. Ogni volta lo stesso sintomo — la richiesta riesce, il dato non c'e — e ogni volta mezz'ora per capirlo.
La causa sta in dynamicRouterGenerator.js: il router di ogni tabella
cattura la columnMap in una closure quando viene generato, e le scritture
filtrano il corpo su quella mappa. Dopo una migrazione che aggiunge colonne, la
mappa in memoria e vecchia — e POST /api/refresh ricarica lo schema ma non
rigenera i router gia montati. Lo dice la documentazione di datahub stessa, in
fondo alla pagina delle limitazioni. Finche il processo non riparte, la colonna
nuova non esiste per chi scrive.
Il rimedio operativo resta lo stesso: dopo una migrazione che tocca le
colonne, docker restart del container di datahub, non solo il refresh.
Il rimedio vero e __strict_columns, la controparte in scrittura di
__strict_filters. Chi lo dichiara riceve un 400 che nomina le colonne
rifiutate, elenca quelle note e dice di riavviare il servizio, invece di un
2xx silenzioso. data-service lo dichiara su ogni create e update.
Chi non lo dichiara non cambia comportamento: i chiamanti esistenti continuano a funzionare come prima.
Costruendolo e emerso un secondo difetto, piu vecchio: il catch di POST e PUT
rispondeva 500 a qualunque errore, quindi anche un errore di richiesta
sembrava un guasto del server e mandava a cercare nel posto sbagliato. Ora
entrambi rispettano lo stato dichiarato dall'errore.