Skip to main content

Dove siamo, e cosa è già stato deciso

Questa pagina esiste per essere letta all'inizio di una sessione di lavoro, quando il contesto della conversazione precedente non c'è più. Contiene lo stato reale, le decisioni prese con il committente, e le regole concordate ma non ancora implementate.

Aggiornata al 18 agosto 2026.

Non basta da sola. Va letta insieme a AGENTS.md dei due repo — che contiene le regole non negoziabili su migrazioni, rilasci e confini fra microservizi — e, per il dettaglio di cosa è fatto e cosa no, alla checklist e alla roadmap, che sono allineate a questa pagina. Le due forme già costruite si leggono in Storico del costo orario e Ruolo, macro-livello e job level: una tabella nuova segue quelle, non ne inventa un'altra.

Fase 1 — cosa è chiuso

puntostato
1 — riorganizzazione in modulichiuso col rimpatrio
2 — società e sotto-aziendeschema fatto (105), codice da scrivere
3 — catalogo sedi e storicochiuso
4 — ruolo, macro-livello, job levelchiuso
5 — linea di riporto e anti-ciclochiuso
6 — storico costochiuso
7 — dipendente ↔ accountchiuso
8 — freelancernon esiste più: vedi sotto
9 — UI del gestionale HRrimandato volutamente alla fine
import/export dipendentida ripensare, vedi sotto

Le decisioni prese, e perché contano

La sotto-organizzazione è una dimensione, non un confine

Decisa esplicitamente dal committente. È l'unica ragione per cui ADR-001 resta in piedi: l'organizzazione rimane l'unica sorgente di tenancy, e la società è un attributo del dipendente.

Se fosse stata un confine — dati di una società invisibili da un'altra — sarebbe stato da rivedere ADR-001 e con lui ogni chiave composita costruita finora. Chi rimettesse in discussione questa scelta deve sapere che costo ha.

Il freelancer non è un tipo di risorsa

È un flag sulla persona: employees.is_freelance. Un collaboratore è un dipendente con meno vincoli, non un'entità diversa.

Il primo tentativo aveva messo il flag sulla società (105), sul presupposto che i collaboratori stessero in una società dedicata. Il presupposto era sbagliato — stanno dentro le stesse società dei dipendenti — e marcare la società avrebbe reso collaboratori anche i dipendenti che ci lavorano. La 107 lo sposta sulla persona e toglie la colonna dal catalogo: due posti dove leggere la stessa cosa prima o poi divergono.

Non è storicizzato, di proposito. Un passaggio da collaboratore a dipendente è un cambio di contratto, e quando servirà ricostruirlo lo dirà contract_type_id, che è già una colonna a sé. Un quinto storico per un booleano sarebbe una macchina costruita prima di sapere se serve.

Le sue ore sono contrattualizzate e costanti per ogni mese, per tutto il progetto. Il campo esiste già: project_employee_assignments.planned_monthly_hours.

Un collaboratore non ha presenze importate. Ha un contratto che dice «X ore al mese sul progetto A, Y ore al mese sul progetto B», costante per tutti i mesi.

Questo non è «una regola diversa per le ore»: oggi un collaboratore non comparirebbe affatto nel calcolo, perché i candidati nascono da employee_monthly_worked_hours e senza presenze non esiste nessuna riga.

Le tre cose da implementare in allocationEngine, in ordine di rischio:

  1. Entrano dal contratto, cioè da project_employee_assignments, non dalle presenze. Serve un secondo percorso di ingresso in loadCalculationInputs.
  2. Le ore sono quelle, non un peso. Nessuna distribuzione, nessun residuo: planned_monthly_hours è il consuntivo.
  3. Vengono allocati per primi, e consumano budget prima che si calcolino i dipendenti. È la parte più delicata, perché cambia il budget residuo con cui tutti gli altri vengono valorizzati — e quel residuo finisce nelle righe di consuntivo (budgetRemainingBeforeEur / AfterEur).

Le sue ore sono certe, ma vanno comunque nel forecast come per tutti gli altri. Non è una contraddizione: il forecast è ciò che consuma le ore e il budget del progetto, quindi un collaboratore che non ci comparisse lascerebbe credere che ci sia spazio per ore di dipendenti che in realtà non c'è. Il contratto entra nel forecast perché agli altri ne resti di meno.

Come implementarlo

L'ordine sotto è per rischio crescente. Ogni passo va chiuso e verificato prima del successivo.

Passo 0 — caratterizzare. Aggiungere alla fixture allocazione-base.json un collaboratore: una sub_organizations con is_freelance, un dipendente che vi appartiene, e una project_employee_assignments con planned_monthly_hours valorizzate — e nessuna riga in employee_monthly_worked_hours. Oggi quel dipendente semplicemente non compare nel risultato: è quello lo stato di partenza da fissare in un test, prima di cambiare qualsiasi cosa.

Passo 1 — l'ingresso. In loadCalculationInputs (modules/actuals/allocationEngine.js) i candidati nascono da employee_monthly_worked_hours filtrate al mese con worked_hours > 0. Serve un secondo percorso che, per chi ha is_freelance, costruisca la capacità dal contratto invece che dalle presenze. Il catalogo è già letto: conInquadramento espone sub_organization_freelance.

Passo 2 — le ore come consuntivo. In buildCandidates, planned_monthly_hours è oggi il peso con cui si distribuiscono le ore effettive. Per un collaboratore quel numero è il risultato: nessuna distribuzione, nessun residuo, nessun arrotondamento da riconciliare.

Passo 3 — l'ordine, ed è il più delicato. I collaboratori vanno allocati per primi in allocateProjectDemand, e consumano budget prima che si calcolino i dipendenti. Cambia il budget residuo con cui tutti gli altri vengono valorizzati, e quel residuo finisce nelle righe di consuntivo (budgetRemainingBeforeEur e budgetRemainingAfterEur) e nei run consolidati. È qui che i golden esistenti si muoveranno: ogni differenza va guardata una per una e spiegata, non riallineata.

Passo 4 — la prova. Perturbare: togliere l'ordinamento, togliere il percorso dal contratto, trattare le ore come peso. Se un golden non cade per ciascuna di queste, la fixture non contiene ancora il caso.

Attenzione allo split giornaliero. splitRowsDaily spezza il risultato mensile in righe giornaliere usando le presenze. Un collaboratore non ne ha: va deciso se le sue righe restano solo mensili — probabilmente sì — e il codice va reso esplicito su questo invece di dividere per zero o produrre righe vuote.

L'email si genera, e alla collisione ci si ferma

Da nome, cognome e sub_organizations.email_domain.

Al primo omonimo si assegna l'indirizzo; al secondo non si assegna niente e lo gestisce a mano un amministratore. Non c'è una regola di disambiguazione automatica, ed è una scelta: inventare mario.rossi2@ significa che il primo e il secondo hanno indirizzi con logiche diverse.

Un dipendente senza email resta in anagrafica e non accede — è uno stato normale e già gestito.

Il cambio di cognome non si affronta. Deciso esplicitamente: la probabilità non vale il tempo di pensarci. L'email vale alla creazione, poi è sua.

L'import è temporaneo

Sparirà quando lo strumento sarà adottato, o verrà sostituito da un import che legge lo stesso formato dell'export che genereremo. Non vale la pena investirci oltre il necessario, ma oggi assegna solo il job level: sede, ruolo e sotto-organizzazione no. E i codici non riconosciuti finiscono in unknownJobLevels, che nessuna schermata mostra.

La scheda dipendente

Aggiornata al 18 agosto 2026, dopo il lavoro sul punto 9 e sulle ferie.

Sette schede: Overview, Presenze, Ore progetto, Progetti associati, Gerarchia, Storico, Modifica anagrafica. Sotto Storico stanno cinque sotto-schede — i quattro storici più le Ferie — perché in barra erano dieci voci che andavano a capo, e trovare quella giusta costava più che aprirla. Accanto a ogni sotto-scheda c'è il numero di periodi, o di anni.

Le ferie stanno lì pur non essendo un intervallo: la domanda è la stessa — «com'era prima?» — e cercarle in un posto diverso vorrebbe dire cercarle due volte. La collocazione è stata decisa dal committente il 18 agosto 2026, contro l'ipotesi di metterle in Overview.

Scrivere uno storico a una data scelta

Ogni scheda storica ha un form con valore e in vigore dal, e la data può essere passata o futura. inserisciPeriodo in periodi.js applica la regola delle dimensioni a variazione lenta:

casocosa succede
data dentro un periodoquello si chiude il giorno prima, il nuovo eredita la sua fine
data in un bucoil nuovo si ferma dove comincia il successivo
stessa data di un periodoquel periodo viene riscritto, non affiancato da uno di durata zero
data prima di tuttosi ferma prima del primo periodo esistente

L'ereditarietà della fine è il punto che tiene: senza, inserire a metà storia scavalcherebbe il periodo successivo o aprirebbe un buco.

Se il periodo scritto è quello aperto, l'anagrafica viene allineata; se è chiuso nel passato, no — un valore vissuto e finito non è il valore di oggi.

È diverso dal salvataggio dell'anagrafica, che aggiunge sempre in coda e rifiuta una decorrenza anteriore: lì quel caso è quasi sempre un errore.

Due limiti dichiarati

  • Non si può cancellare un periodo né cambiarne la data. Si inserisce, e si riscrive il valore usando la stessa data di inizio. Correggere una data sbagliata oggi si fa in database.
  • Un inserimento retroattivo non ricalcola i consuntivi già fatti. Lo storico cambia, i mesi calcolati restano. È ADR-004 — doppio cursore e correzione retroattiva — rimandato a quando si lavorerà sui progetti.

Gerarchia e mappa

L'organigramma usa react-organizational-chart (MIT, tipi inclusi) e mostra un livello sopra e uno sotto, non l'albero intero.

ApexTree è stato provato e scartato: sia la 1.15 sia la 2.1 chiedono una chiave di licenza ApexCharts e senza disegnano un watermark, e il file LICENSE non è incluso nel pacchetto. Non è una scelta da fare in autonomia dentro un prodotto che si consegna.

Chi non ha responsabile diventa la radice invece di stare sotto un nodo vuoto; un ciclo resta segnalato e l'albero mostra il tratto fino a dove si chiude.

La mappa sotto la sede in corso usa maps.google.com/maps?q=...&output=embed, che non richiede una chiave — l'Embed API ufficiale sì. Non è documentata da Google: se smettesse di funzionare la mappa sparisce e la scheda resta leggibile.

Quello che nessuno può ancora vedere

Risolto il 17 agosto 2026: i quattro storici sono visibili e scrivibili dalla scheda dipendente. Restano invisibili — perché non ci sono ancora in anagrafica — area, titolo di studio, tipo contratto e orario.

Nella stessa situazione: la decorrenza è sempre implicita. Cambiando costo, sede, responsabile o società dall'anagrafica, il nuovo periodo decorre dal giorno del cambio (mai prima dell'assunzione). Nessuna maschera la chiede ancora.

Perché al giorno e non al mese

La prima versione usava il primo del mese in corso, per far ricadere l'intero mese sul valore nuovo. La scelta è stata rovesciata dal committente il 17 agosto 2026, e la ragione è il consuntivo:

Un dipendente costa 10 €/h. Il 12 maggio passa a 12 €/h. A fine maggio arrivano le ore lavorate, divise per giorno. Le ore fino all'11 valgono 10, quelle dal 12 valgono 12.

Il costo si attribuisce giorno per giorno, quindi una decorrenza arrotondata al mese renderebbe sbagliati gli undici giorni precedenti.

Da qui discendono due cose, entrambe implementate il 17 agosto 2026:

  1. Il prezzo si applica dopo lo split giornaliero. Oggi il motore alloca il mese e poi lo spezza in giorni con splitRowsDaily. Il prezzo viene ora applicato dopo lo split — è a quel punto che si sa in quali giorni le ore sono state fatte — e la riga mensile viene ricalcolata dalle giornate. L'allocazione entro budget avviene ancora con il costo di inizio mese: resta un'approssimazione sul vincolo, ma il costo registrato è quello vero, ed è quello che finisce nel consuntivo.
  2. La riga mensile porta una media pesata, non un listino. A maggio per quella persona non esiste un costo orario: la colonna hourly_cost_eur contiene il costo totale diviso le ore, derivato dalle giornate, e il calcolo emette COSTO_MEDIO_DEL_MESE perché chi legge sappia che quel numero è una media. Le righe giornaliere sono la verità, la mensile è il loro riepilogo.

Le tariffe da profilo restano intatte. Nei progetti a costo standard il prezzo viene dal profilo del progetto e non dalla persona, quindi non cambia nel tempo: il costo per giorno le salta, riconoscendole da un flag propagato dal candidato fino alla riga giornaliera.

All'approvazione il costo per giorno non viene riapplicato, ed è voluto. Le righe approvate arrivano dal database, dove quel flag non viene salvato: applicarlo alla cieca sovrascriverebbe le tariffe standard con il costo del dipendente. I totali restano giusti — le righe mensili portano già la media calcolata al consuntivo — e ciò che si perde è il dettaglio dei prezzi diversi sulle righe giornaliere del consolidamento, che sono la ripartizione di un totale già certificato.

Il forecast non cambia: resta mensile e a prezzo unico, perché a maggio non si sa ancora chi lavorerà quando. Consuma budget al prezzo corrente, il consuntivo ne consuma di più, e la differenza si riassorbe dal mese successivo perché il forecast si ricalcola sul residuo aggiornato.

Di conseguenza applicaCostoDelMese — che sceglie un prezzo solo per tutto il mese — resta giusta per il forecast e diventa sbagliata per il consuntivo.

Dove sta il codice

cosadove
forma degli intervalli storicizzatimodules/workforce/periodi.js
i quattro storiciemployeeCost, employeeLocation, employeeReporting, employeeSubOrganization
risoluzione dei cataloghimodules/workforce/cataloghi.js — cinque voci
scritture dall'anagraficamodules/workforce/employeesService.js, funzioni prepara* / applicaPeriodi
il conteggio delle feriemodules/workforce/ferie.js — usato da import e annullamento
le ferie nel monte oreoreDisponibili in modules/forecasting/projectForecast.js
le ferie per la schedacostruisciFerie in ferie.js, dentro la lettura di storico()
la scrittura dei previstiimpostaFeriePreviste in employeesService.js, rotta PUT /employees/:id/ferie/:anno
la sotto-scheda feriepages/dipendenti/components/EmployeeLeaveTab.tsx
la panoramica del forecastmodules/forecasting/panoramica.js — KPI e viste per sede
sede e tetto oremodules/projects/sede.js — regole pure, applicate dai due motori
il self-service del pianoGET/PUT /me/leave* in data-service, components/FeriePersonali.tsx
il motoremodules/actuals/allocationEngine.js
la sede di quel giornocreaSedeStorica in modules/projects/sede.js, applicata dopo lo split giornaliero
il catalogo delle segnalazionimodules/qualita/catalogo.js — 29 codici, livello, entità, rimedio
il banner dei buchi nei datimodules/projects/qualitaDati.js + components/BannerQualita.tsx
il cursore di consegnamodules/projects/consegna.js — regole pure, nessuna lettura
la contiguità dell'importmodules/actuals/contiguitaImport.js
il ricalcolo per progettomodules/actuals/ricalcoloProgetto.js + soloProgetto/progettiCongelati nel motore
la scheda di consegnapages/progetti/components/ConsegnaProgetto.tsx — cursore, ricalcolo e storico
i lettori dei foglimodules/rendicontazione/progettiSheet, tariffariSheet, personaleSheet, tariffeRealiSheet
la riconciliazione fra i due filemodules/data-exchange/riconciliazioneAnagrafica.js
l'import dell'anagraficamodules/rendicontazione/personaleImport.js, rotta POST /personale/excel/*
le look-up dal file consorziomodules/rendicontazione/consorzioLookup.js — referente, titolo, costi
i modelli canonici e il registromodules/data-exchange/modelliCanonici.js, registroAdapter.js
CORDISmodules/data-exchange/cordis.js — propone l'ente, non lo decide
golden e fixturetest/golden/ — nessun GOLDEN_UPDATE, per scelta

Le fixture golden vanno cambiate solo per descrivere meglio il database vero. È già successo una volta con job_level, e quella modifica ha fatto emergere un difetto reale in produzione: le fixture erano più generose dello schema e rendevano verde codice che non lo era.

Lo stato del rilascio

Al 17 agosto TEST ha le migrazioni 094104 applicate e il collaudo è fermo per un problema di risorse del server, non per un difetto applicativo. Dalla 105 in poi sono arrivate dopo: al 20 agosto 2026 sono tutte applicate in DEV, non in TEST.

L'ultima migrazione scritta è la 121. La prossima da scrivere è la 122 — contare le migrazioni applicate per dedurre il numero libero porta a sovrascrivere un file che esiste già.

Due coppie vanno lette insieme, o si crede a un modello che non c'è più:

  • la 116 crea freeze_config e la 117 la rimuove il giorno dopo, sostituita dal cursore di consegna;
  • la 114 crea funding_body_rates e la 118 la elimina, spostando i prezzi sul tariffario.

Chi le applica in sequenza non se ne accorge; chi guarda solo la prima delle due crede che il congelamento periodico e le tariffe sull'ente esistano ancora.

release.json del verticale è fermo a 2.4.0 del 16 agosto: Fase 2, Fase 3, i due cursori e tutta la catena degli import sono fuori dal changelog.

Le 109, 110 e 111 non toccano lo schema, e servono tutte e tre al self-service: la 109 concede al ruolo dipendente i percorsi del verticale, la 110 dà quel ruolo a chi è in anagrafica e non ce l'ha, la 111 concede la scrittura anche sul percorso /data-service/**, che non è il gateway: è data-service a chiamare /auth/validate per autenticare una richiesta che gli arriva senza x-user-id, e quella stessa chiamata autorizza. Senza una qualsiasi delle tre la pagina risponde 403, e sembra un difetto del codice.

Dopo aver applicato la 108 va invocata POST /api/refresh su datahub. Finché lo schema in memoria è quello di prima, ogni lettura delle due tabelle ferie risponde 404 pur essendo la migrazione a posto, ed è un 404 che sembra un difetto applicativo.

La 108 aggiunge tabelle, non colonne, e per le tabelle il refresh basta: /api/table/:nome risolve il router a ogni richiesta dalla mappa che refreshSchema sostituisce per intero. Per le colonne esiste un'osservazione sul campo che dice il contrario — il 16 agosto 2026 servì un riavvio — ed è registrata in Come si lavora. Nel dubbio vale quella.

Il deploy dei microservizi su TEST non è ancora stato fatto: il push sul branch TEST richiede credenziali che l'ambiente di sviluppo non ha.

Le versioni sono state alzate di una minor per gli otto componenti con modifiche vere. Sei servizi comparivano fra i modificati ma erano cambiati solo nel .DS_Storescheduler, analytic-service, docs-service, mail-ingestion-service, message-bot-gateway, storage-connector-service — e non sono stati toccati.

Il deploy su TEST si innesca con un push sul branch TEST. deploy-prod.yml funziona in modo diverso e non legge i release.json.

Limiti dell'ambiente di sviluppo

Cose che non si possono fare da qui, e che è bene sapere prima di provarci:

  • Il typecheck del frontend si può fare, e va fatto. Questa pagina diceva il contrario fino al 18 agosto 2026, e costava un giro di build per errore. npm install fallisce ancora senza il token del registry privato, ma frontend/UserInterface/node_modules esiste già e il tsconfig.app.json dell'app rendicontazione ci punta:

    cd frontend/apps/rendicontazione
    ../../UserInterface/node_modules/.bin/tsc --noEmit -p tsconfig.app.json

    Copre src in modalità strict: verificato piantandoci dentro un errore e guardandolo cadere. Resta vero che la build in container è l'unico posto dove gira il bundler.

  • Niente docker, niente credenziali GitHub. Migrazioni, deploy e verifiche su TEST li esegue l'utente.

  • PostgreSQL 17 c'è, in /usr/lib/postgresql/17/bin: un cluster di prova usa e getta è il modo con cui ogni migrazione di questa fase è stata verificata prima di essere proposta.

⚠️ INCOMPLETO — punto 9, lasciato aperto di proposito

Il punto 9 non è chiuso, e la decisione è del committente: non è bloccante per la Fase 2, che è più urgente. Sono aggiustamenti, non fondamenta — ma vanno ripresi, e finché restano qui il punto 9 non va spuntato.

Quattro campi irraggiungibili dall'interfaccia. area_id, education_level_id, contract_type_id, work_schedule_id esistono su employees e la maschera non li tocca. I rispettivi cataloghi sono vuoti: prima di esporli servono i valori del committente — quali aree, quali tipi di contratto, quali orari con le rispettive weekly_hours. Il titolo di studio è già stato indicato come da mostrare nel dettaglio.

Due cataloghi senza maschera. job_roles e macro_levels: tabelle create dalla 100, vuote, senza scheda in Impostazioni. Sono identiche a Sedi e Società, quindi è lavoro meccanico.

La scheda anagrafica non è organizzata in tab. La roadmap chiede «tab profilo, organizzazione/ruolo, sede, reporting, costi». Oggi Modifica anagrafica è un elenco piatto, che con i quattro campi mancanti diventa lungo. Da fare dopo i campi: si organizza ciò che c'è.

La foto del dipendente non esiste. Discussa il primo giorno, mai iniziata. Serve storage, un endpoint di upload e una decisione su dove finiscono i file: è la voce più costosa delle cinque.

L'audit non esiste. Requisito: chi cambia cosa e quando. Mai dimensionato.

E due limiti degli storici, già in produzione

  • Un periodo non si cancella e non gli si cambia la data. Si inserisce, e si riscrive il valore usando la stessa data di inizio. Correggere una data sbagliata si fa in database.
  • Un inserimento retroattivo non ricalcola i consuntivi già fatti — è ADR-004, rimandato ai progetti.

Fase 2 — ridimensionata dal committente

Non è un modulo HR. Niente workflow di approvazione, stati, calendari di festività, carry-over o viste manager. Lo scopo è uno solo: rendere il forecast più preciso.

Servono due tabelle, ed esistono dal 17 agosto 2026 — migrazione 108:

dove
saldo annualeemployee_leave_balancesemployee_id, leave_year, entitled_days, consumed_days
piano mensileemployee_monthly_leave_planemployee_id, plan_month, planned_days

Il piano è mensile, non giornaliero: «agosto 10 giorni, ottobre 3». Il mese è una data al primo del mese, come work_month e calculation_month altrove — anno e mese in due interi costringerebbero ogni consumatore a ricomporli per confrontarli con le altre tabelle mensili, e il forecast fa esattamente quel confronto.

entitled_days è nullable, e non NOT NULL DEFAULT 0. È la traduzione in schema della regola qui sotto: un dipendente senza il dato per un anno non ha zero giorni, non ha il dato. Un default a zero renderebbe le due cose indistinguibili proprio nella colonna da cui si calcola il residuo.

Da dove arrivano i giorni previsti dell'anno

Sono un attributo del dipendente, per anno, inserito a mano. Il dipendente X nel 2026 ha i suoi giorni; il dipendente Y ne ha altri; lo stesso X nel 2027 può averne un numero diverso ancora.

Da qui discendono tre cose che vincolano la migrazione:

  1. Nessun default di organizzazione. Non esiste un «valore per tutti con le eccezioni» da cui derivare il resto: la riga esiste perché qualcuno l'ha scritta. Un dipendente senza riga per un anno non ha zero giorni — non ha il dato, che è diverso, e chi legge deve poterli distinguere.
  2. Nessuna derivazione dal contratto. Non si calcolano dal livello, dal tipo di rapporto o dall'anzianità. La fonte è l'inserimento, e basta.
  3. Il valore non si eredita da un anno all'altro. Il 2027 non parte dal 2026: è una riga nuova, e finché non viene scritta il dato per il 2027 non c'è.

Li inserisce Admin o HR manager dall'interfaccia, per ciascun dipendente. L'inserimento in self-service dal dipendente stesso è una possibilità aperta, non una decisione presa: se arriverà, passerà dalla capability self già esistente.

Restano fuori dalla fase, per decisione esplicita: workflow di approvazione e stati, carry-over, calendari di festività, unità in ore, viste manager, i permessi <n>PR, part-time e assunzioni infra-annuali. Chi riprende non li aggiunga «già che c'è»: il perimetro stretto è il punto.

Come si riconosce una ferie nell'import presenze

Nel foglio, una giornata piena è 8. Quando è minore (0–7), la riga sotto specifica il motivo:

  • 8FE → un giorno di ferie consumato
  • <n>PR → n ore di permesso

La somma fa sempre 8. Per ora si considera solo 8FE: i permessi non decrementano niente. Nemmeno un 4FE conta, e non è una dimenticanza: la fase conta giorni, e le unità in ore sono fuori perimetro per decisione esplicita.

I giorni goduti si ricalcolano, non si decrementano

È il punto che tiene in piedi il resto, e vale la pena dirlo prima che qualcuno "ottimizzi" il contatore in un incremento.

consumed_days è un valore derivato tenuto in colonna: la verità sta nelle presenze, la colonna ne è il riepilogo per dipendente e anno. Sta in colonna perché la scheda dipendente e il forecast lo chiedono per persona e per anno, e derivarlo a ogni lettura vorrebbe dire rileggere le presenze di tutti ogni volta.

A ogni import — e a ogni annullamento — il contatore viene riscritto dal conteggio delle giornate 8FE che ci sono in quel momento. Un incremento sarebbe sbagliato due volte:

  • si reimporta per correggere, ed è il caso comune: le stesse giornate verrebbero contate due volte;
  • annullare un import ripristina le presenze, e lascerebbe un contatore gonfio senza che nessuna schermata dica di quanto.

Il conteggio legge la tabella dopo che le presenze sono state scritte, invece di prevedere che cosa la scrittura avrebbe prodotto. Costa una lettura in più, e la paga volentieri: un contatore derivato da un modello delle scritture invece che dalle scritture diverge, e diverge in silenzio.

Un saldo già allineato non viene riscritto — un valore identico sposterebbe updated_at e farebbe sembrare cambiato ciò che non lo è.

Il codice di assenza è un codice, e si scrive in una forma sola

absence_code finora veniva salvato come la cella lo scriveva, spazi e maiuscole comprese. Contare le ferie con un filtro di uguaglianza è l'unico modo di leggerle senza scaricare tutte le presenze di tutti gli anni, e perché quel filtro sia affidabile il codice deve esistere in una forma sola.

La 108 normalizza quelli già in tabella e l'import normalizza i nuovi: 8 fe diventa 8FE. Tocca solo ciò che è riconoscibilmente un codice (<n>FE, <n>PR): quella cella a volte contiene una nota scritta a mano, e passarla per upper() la rovinerebbe senza renderla più leggibile.

I giorni goduti passano da un endpoint dedicato

employee_leave_balances tiene due cose con due proprietari diversi, e la capability lo riflette: la tabella richiede hr, perché i giorni previsti li inserisce Admin o HR. Ma i giorni goduti li scrive l'import presenze, che ha actuals.

Dare hr all'import per aggiornare un contatore gli aprirebbe l'anagrafica intera. Quindi c'è POST /rendicontazione/leave/consumed, che accetta actuals e da cui si può scrivere solo consumed_days: i giorni previsti restano intoccabili. È la stessa forma di /forecast/rows, e per la stessa ragione — vedi i contratti di data-service.

La sotto-scheda Ferie, e cosa non fa

Sopra i tre numeri dell'anno in corso e il form; sotto, un anno per riga con previsti, goduti e residui, dal più recente. Un solo campo si scrive: i giorni previsti, un anno per volta.

La lettura non costa una richiesta in più. Le ferie entrano nella leggiInsieme che /employees/:id/storico faceva già, ma accanto a storici e non dentro: le altre quattro dimensioni sono intervalli con una decorrenza, e infilarci un anno solare vorrebbe dire inventargli un valid_from che non ha.

La distinzione fra «non lo so» e «zero» arriva fino allo schermo. Dove mancano i previsti il residuo scrive non calcolabile e la colonna dei previsti scrive non indicati. Uno zero lì direbbe «le ha finite» quando nessuno ha ancora detto quante ne avesse. Il residuo lo calcola il servizio, non il componente, proprio perché quella distinzione non dipenda da come è scritto un ?? dentro una tab.

Tre limiti, dichiarati:

  • Un valore già inserito non si può svuotare dalla maschera. Il bottone è disabilitato a campo vuoto. Il servizio accetta null — è «non lo so ancora», diverso da zero — ma per ora ci si arriva solo via API. È la stessa linea dei periodi storici, che non si cancellano.
  • Si inserisce un dipendente per volta. È ciò che il committente ha chiesto. Popolare il primo anno per tutti significa aprire tutte le schede, e a ogni gennaio si rifà: se diventasse un fastidio, la risposta è una griglia annuale, non un cambio di modello.
  • Il piano mensile non compare. La tabella c'è e nessuno ci scrive: è il passo 3, ed è quello che rende utile la colonna.

Il self-service, e le regole che stanno al cancello

In I miei dati la persona vede quattro numeri — previsti, già fatti, pianificati, ancora disponibili — e i dodici mesi dell'anno, con un campo per quelli futuri.

Le due regole che contano non stanno nel browser, dove disabilitare un campo non è una difesa, ma nella rotta:

  • si pianificano solo i mesi futuri. Il mese in corso è in parte già passato e le sue giornate stanno già arrivando dal foglio presenze: pianificarci sopra le conterebbe due volte nel forecast.
  • non si pianificano più giorni di quanti se ne abbiano, e il messaggio dice quanti se ne possono ancora mettere. Il tetto guarda l'anno del mese pianificato, non quello in corso: i giorni non si ereditano fra anni, e un piano per il 2027 non si misura sul saldo del 2026.

Il tetto vale solo dove i giorni previsti si conoscono. Bloccare chi non li ha renderebbe la pagina inutile proprio il giorno in cui viene aperta, visto che oggi entitled_days è NULL per tutti. Chi non li ha vede scritto perché, e a chi chiederli.

Zero giorni tolgono la riga invece di scriverci uno zero: un mese non pianificato è l'assenza di un piano, non un piano di niente.

Serve la migrazione 109, e senza di lei il 403 non viene dal servizio. Il permesso vive in backbone_identity.role_permissions e lo applica Traefik via /auth/validate: il ruolo dipendente aveva tre percorsi stretti, tutti in GET. La 109 gli aggiunge GET /rendicontazione-service/employees/me/** e il percorso esatto in PUT per il piano — non un pattern, perché la prima cosa che un dipendente può scrivere non deve diventare «tutto ciò che un giorno starà sotto /me». La 094 lo aveva previsto: sola lettura «finché il self-service ferie della Fase 2 non definisce il resto».

Pianificare le proprie ferie resta del solo ruolo dipendente, e non è una restrizione: rendicontazione_employee è il ruolo di base di chiunque sia in anagrafica, non il ruolo di chi non ha altri ruoli. Un HR manager e un amministratore sono dipendenti a loro volta — prendono ferie — quindi hanno quel ruolo in aggiunta al proprio. Le capability e i permessi sono additivi, quindi non restringe nulla.

Il buco non era nei permessi ma nell'assegnazione. Un account creato dall'anagrafica nasce già con quel ruolo; un account che esisteva prima e viene collegato a un dipendente non lo riceveva, perché quel percorso non tocca i ruoli per non declassare chi ne ha di più. Risultato: proprio gli amministratori restavano senza il ruolo di base, e il self-service rispondeva 403 dal gateway, che è il posto dove nessuno pensa di guardare.

Chiuso il 18 agosto 2026 su due fronti:

  • la migrazione 110 dà il ruolo di base a ogni dipendente già collegato a un account. È additiva per costruzione: aggiunge a chi non ce l'ha e non toglie niente a nessuno;
  • /users/resolve ora aggiunge il ruolo di base quando collega un account che esisteva già. Non passa da auth-service di proposito: PUT /auth/admin/user/:id cancella tutti i ruoli e li riscrive, quindi usarlo per aggiungerne uno rischierebbe di portar via gli altri — cioè esattamente il declassamento che si voleva evitare.

È la prima scrittura concessa a self. ADR-003 diceva che il self-service nasceva in sola lettura e descriveva la forma che avrebbe dovuto avere la prima scrittura: un endpoint dedicato che risolve da sé di quale dipendente si tratta. È esattamente quella, e l'ADR è stato aggiornato — lo scope di colonna continua a non servire, ma per una ragione diversa da prima: questa scrittura non tocca employees.

Come entrano nel forecast

Il forecast tiene conto del monte ore massimo che un dipendente può allocare sui progetti — 8 ore per giornata lavorativa. Da lì:

  1. Se il piano mensile è noto, il monte ore di quel mese cala dei giorni pianificati. Fatto il 18 agosto 2026. Cinque giorni ad agosto sono quaranta ore in meno, per quella persona e in quel mese soltanto.
  2. Se non si sa quando verranno prese ma si conosce il residuo a fine anno, quel residuo va tenuto in un calcolo di capacità dipendente/progetti che non è ancora stato sviluppato.

Il secondo caso è il pezzo grosso della fase, e non è "ferie": è un modello di capacità che oggi manca. Resta fuori per decisione esplicita: spalmare sui mesi futuri giorni che nessuno ha collocato sarebbe peggio che ignorarli, perché il numero sembrerebbe un dato.

Cosa è cambiato nel motore

ricalcolaTutti calcolava orePerMese = hoursPerYear / 12una costante, uguale per tutti e per ogni mese. Ora è una funzione di persona e mese: dal monte ore si tolgono giorni pianificati × 8.

Tre cose da sapere prima di rimetterci le mani:

  • Il forecast per progetto non è toccato. ricalcolaProgetto distribuisce il budget residuo sugli slot futuri e non passa da un monte ore: le due strategie divergono, ed è voluto finché nessuno chiede il contrario.
  • hoursPerMonth nel riepilogo resta il valore di base, non la media effettiva. Cambiarlo avrebbe rotto il golden, che è una copia del comportamento di data-service e non si rigenera.
  • I golden non si sono mossi, e non è una svista: nessuna fixture ha un piano ferie, quindi il comportamento di prima è rimasto identico. Il caso nuovo è in test/ferie.test.js, con il confronto fra «con piano» e «senza».

La lettura del piano viaggia nella stessa richiesta delle altre, ma con il secondo tentativo: se employee_monthly_leave_plan non è raggiungibile, il ricalcolo riparte senza ferie invece di fermarsi. È la lezione degli storici, applicata prima di ripeterla — lì una tabella mancante aveva portato giù due schede, qui avrebbe fermato tutte le previsioni.

Fase 3 — le decisioni prese il 18 agosto 2026

La fase è cominciata da qui: i due attributi che servono al progetto esistono giàfunding_body_id e uses_standard_cost — quindi non è schema nuovo, è codice che non c'è.

Il macro-livello si deriva, non si sceglie

Gli enti tariffano per fascia — Dirigente, Quadro, Impiegato — non per livello CCNL. La regola è del committente: Dirigente e Quadro uno a uno, tutti gli altri Impiegato.

Sta sul catalogo (job_levels.macro_level_id, migrazione 112) e non solo sui dipendenti già in anagrafica, perché nessuna maschera espone la fascia: se fosse un riempimento una tantum, ogni assunzione successiva nascerebbe senza, e con la regola del blocco quella persona fermerebbe i progetti a costo standard su cui lavora. Per lo stesso motivo la deriva anche il codice, al salvataggio di un dipendente e alla creazione di un job level nuovo.

Il chiamante non può imporla. Un dirigente che si dichiarasse impiegato pagherebbe la tariffa sbagliata a un ente finanziatore, e sarebbe un numero plausibile. È anche il modo di non avere due sorgenti della stessa cosa — la lezione del flag di collaboratore.

Tariffa mancante: si ferma e lo dice

Confermato dal committente: blocca il calcolo e riporta un warning. Oggi il codice fa il contrario e in due modi diversi — zero nel forecast, costo reale nel consuntivo — entrambi in silenzio.

Il tetto ore è annuo, e vive sulla riga del progetto

max_hours_per_resource: 1720 ore per anno e per risorsa su quel progetto. Non è una costante globale con eccezioni: ogni progetto porta il proprio valore, e un amministratore lo cambia progetto per progetto. 1720 è il valore iniziale, non un ripiego.

Da qui la colonna è NOT NULL con default (migrazione 113) e non nullable con una costante applicativa alle spalle: un progetto senza tetto costringerebbe il motore a inventarne uno, e quel numero inventato non comparirebbe da nessuna parte — né nella scheda, né nell'export, né in una risposta alla domanda «perché questa persona si è fermata a 1720 ore?».

Vale per entrambi i motori, consuntivo e forecast. Non va confuso con le 1840 ore annue con cui il forecast spalma la capacità sui mesi: sono due numeri diversi che convivono.

Le schermate della Fase 3

Fatte il 18 agosto 2026. Una pagina Enti nel menu di sinistra: l'elenco è povero di proposito — codice e nome — perché ciò che conta di un ente sta nella sua scheda. Lì si inseriscono le tariffe per Dirigente, Quadro e Impiegato, e si legge la loro storia: il valore in vigore in evidenza, i periodi chiusi sotto, come per gli storici del dipendente.

Il tetto ore e la sede sono nella maschera di creazione e modifica del progetto, e fra le sue proprietà. Due dettagli che non sono cosmetici:

  • alla sede, «Nessuna sede — chiunque può parteciparvi» è una voce della tendina, non l'assenza di scelta. Fra le proprietà si legge come frase intera e non come trattino: un trattino muto lascerebbe credere che qualcuno abbia dimenticato di compilarla;
  • una fascia senza tariffa lo dice per esteso — «i progetti a costo standard di questo ente non calcolano per questa fascia» — invece di mostrare una casella vuota. È l'unico punto in cui quel buco si vede prima di diventare un consuntivo mancante.

La voce Enti è visibile a chi ha projects o read: vedere con quali tariffe si rendiconta non è un privilegio, e le scritture le ferma data-service.

La sede punta al catalogo, il confronto è per città

Colonna aggiunta dalla 115: projects.work_location_id, nullable con chiave composita verso il catalogo. La verifica della migrazione rifiuta anche l'aggiunta di una colonna testuale per la città — sarebbe la seconda sorgente della stessa cosa, e il confronto comincerebbe a dipendere da quale si legge.

Vale la sede corrente, non quella storica. Confermato dal committente il 18 agosto 2026: chi si sposta da Milano a Torino non può più lavorare su un progetto di Milano, e non conta dove stava quando l'assegnazione è nata. La sede storica servirà al ricalcolo del passato — rifare un mese chiuso con i dati di allora — che è un'altra cosa e arriverà dopo.

La sede del progetto è un riferimento a work_locations, non una città scritta a mano. Il confronto però avviene per città: chi sta a «Milano – Piazzale Trivulzio» può lavorare su un progetto di Milano. E vale nel periodo: conta dove la persona stava quando ha lavorato, non dove sta oggi — lo storico sede esiste dalla 102.

La tariffa standard, e cosa ha sostituito

Fatto il 18 agosto 2026, migrazione 114: funding_body_ratesfunding_body_id, macro_level_id, valid_from, valid_to, hourly_cost_eur, regulation_reference, note.

È la quinta applicazione della forma degli intervalli storicizzati, dopo costo, sede, linea di riporto e società. Cambia solo cosa identifica la serie: non un dipendente, ma la coppia ente + macro-livello. Il vincolo di esclusione garantisce che «la tariffa valida a quella data» abbia una sola risposta — senza, il calcolo ne sceglierebbe una in base all'ordine di lettura.

regulation_reference non è decorazione: fra due anni, a chi chiede perché una persona costava così, la risposta non deve essere «c'era scritto nel database».

Niente colonna valuta, benché 02-to-be la chiedesse. Ogni importo del sistema è in euro per costruzione — tutte le colonne si chiamano *_eur — e aggiungerla qui sola darebbe l'illusione di un multivaluta che nessun calcolo onora. Si aggiunge quando si aggiunge ovunque.

Due cose sono state tolte. standard_cost_rates, che non aveva né ente né macro-livello e non era mai stata letta da nessuna riga di codice, e con lei projects.standard_cost_rate_id. La migrazione le cancella solo se la tabella è vuota: se qualcuno vi avesse messo righe a mano si ferma e lo dice, perché cancellarle in silenzio sarebbe peggio del disordine che si sta togliendo.

projects.standard_cost_profiles è stato tolto dal codice lo stesso giorno: la colonna non esisteva in nessuno schema, quindi non c'era niente da cancellare nel database, solo due motori che la leggevano.

La risoluzione, e cosa fa quando la tariffa manca

Tre passi, uguali nei due motori: fascia del dipendente → ente del progetto → tariffa valida a quella data. Vive in modules/projects/tariffe.js, che è puro e non sa niente di forecast né di consuntivo.

La data è il primo del mese. È un'approssimazione dichiarata: una tariffa che cambiasse a metà mese varrebbe dal successivo. Le tabelle degli enti cambiano a inizio anno, non a metà mese.

Quando la tariffa manca non si ripiega. È il cambio di comportamento vero, e prima i due motori sbagliavano in due modi diversi:

primaora
forecasttariffa 0, nessuna previsione, nessuna spiegazionenessuna previsione e un avviso
consuntivoricadeva sul costo reale della personanessuna riga e un avviso

Il secondo era il peggiore: un progetto dichiarato a costo standard veniva consuntivato al costo vero, in silenzio e con numeri plausibili — cioè nel modo che nessuno ha motivo di controllare.

L'avviso distingue tre cause, perché «tariffa non trovata» da solo manda a cercare senza indizi: SENZA_ENTE (il progetto non ne ha uno), SENZA_FASCIA (il dipendente non ha macro-livello), SENZA_TARIFFA (l'ente non copre quella fascia a quella data). Ognuno porta dentro nome della persona e del progetto.

Sui golden l'effetto è questo: le previsioni sono rimaste identiche, e si sono aggiunti dieci avvisi. La fixture del forecast ha un progetto a costo standard senza ente finanziatore — prima quelle righe sparivano e basta, ora spariscono e si sa perché.

I due vincoli di progetto, e perché i motori li applicano diversamente

Fatti il 18 agosto 2026. Sede e tetto ore ora sono applicati da entrambi i motori — ma non allo stesso modo, ed è una decisione del committente, non un'incoerenza.

forecastconsuntivo
sede — solo chi è nella città del progettonon si prevedono oresi allocano e si segnala
tetto ore per risorsa e per annonon si prevede oltresi allocano e si segnala

La ragione sta nella natura dei due. Il forecast guarda avanti: se una persona non può lavorare lì, non le si assegnano ore, e non c'è niente da dire. Il consuntivo lavora su ore già successe, importate dalle presenze: se un dipendente di Torino ha davvero lavorato su un progetto di Milano, quelle ore sono costate. Rifiutarle non sarebbe una validazione — sarebbe far sparire un costo reale dal rendiconto, e il progetto sembrerebbe costare meno di quanto è costato.

Gli avvisi sono SEDE_INCOMPATIBILE e TETTO_ORE_SUPERATO, e portano dentro persona, progetto e di quanto si è sforato: «tetto superato» da solo non dice a chi guardare.

Tre dettagli che tengono:

  • il confronto è per città, non per riga di catalogo: due sedi diverse a Milano sono la stessa città. Un dipendente senza sede non può stare su un progetto che ne richiede una;
  • il tetto è per progetto e per anno solare: le ore su un progetto non consumano il tetto di un altro, e un anno nuovo riparte pieno;
  • un tetto a zero vale come il default. Uno zero fermerebbe ogni allocazione senza dirlo, e sarebbe indistinguibile da un progetto senza attività.

Nel forecast il tetto non tronca il mese: riempie lo spazio che resta in quell'anno su quel progetto, e quando è finito quella persona non riceve altre ore lì.

L'ordine del forecast: prima chi ha meno scelta

Tre fasi, e l'ordine è la parte che conta. Vale per ogni ricalcolo:

  1. i collaboratori, con le ore del loro contratto, su tutti i progetti;
  2. i progetti con sede, ognuno con i soli dipendenti di quella città;
  3. i progetti senza sede, con le ore che a ciascuno sono rimaste.

I collaboratori vanno sempre per primi perché sono pagati per lavorare su quel progetto e vanno sfruttati a piena capacità: non ha senso impiegare un dipendente al 100% e un collaboratore all'80%, quando il collaboratore lo si paga comunque. Un dipendente con ore residue può lavorare su altro, un collaboratore no. Le loro ore non si distribuiscono: stanno nel contratto, e non si tagliano né per farle stare nel budget né sotto il tetto — tagliarle darebbe un forecast che rispetta il budget e non rispetta il contratto, che è la bugia più comoda delle due.

Un contratto senza ore non produce una riga da zero ore: viene contato come incompleto, perché una previsione muta sparirebbe senza che nessuno sappia perché quella persona non compare.

Un progetto con sede può attingere a una sola città; uno senza sede può attingere a chiunque. Servire prima chi ha meno scelta è il modo di non lasciarlo scoperto: nell'ordine inverso un progetto senza vincoli si prenderebbe la capacità di Milano, e il progetto di Milano resterebbe senza nessuno che possa lavorarci.

Due proprietà da non rompere, entrambe con un test che le protegge:

  • dentro una fase la capacità si fotografa all'inizio, non si scala a ogni riga: le frazioni sommano a uno e si dividono la stessa capacità. Scalarla via via affamerebbe i progetti iterati dopo, e l'ordine di iterazione diventerebbe parte del risultato — è l'errore che è stato fatto scrivendo questo, e il golden l'ha intercettato;
  • fra le fasi la capacità si consuma: la seconda parte da ciò che resta. Se ripartisse dalla capacità piena, le due fasi insieme prometterebbero più ore di quante quella persona ne abbia in un mese.

Il ricalcolo globale ora li tratta come quello per progetto: fino al 18 agosto 2026 non li distingueva dagli altri, e distribuiva loro capacità invece delle ore contrattuali.

I due ricalcoli sono lo stesso motore

Fino al 18 agosto 2026 erano due algoritmi diversi che si somigliavano: il globale partiva dalla capacità delle persone, il per-progetto divideva il budget residuo in parti uguali sugli slot futuri senza chiedersi quante ore qualcuno potesse lavorare in un mese. Sulla fixture ne prevedeva 355 in un mese solo. Due risposte diverse sullo stesso progetto, e nessun modo di sapere quale fosse quella buona.

Ora pianifica è uno, e lo chiamano entrambi. Le tre fasi, la sede, il tetto, le ferie e la tariffa alla data valgono per tutti e due per costruzione, non perché qualcuno si ricordi di aggiornarli in due posti.

Il ricalcolo di un progetto solo ha un vincolo in più. Serve quando si aggiunge o si toglie una risorsa da un progetto: si rifà quello e gli altri restano dove sono. Ma le ore che le persone hanno già su altri progetti non si toccano e occupano capacità: ignorarle farebbe promettere ore che quella persona ha già dato altrove. È il parametro impegnateAltrove, vuoto nel ricalcolo globale — lì tutti i progetti si calcolano insieme e la capacità si consuma strada facendo.

L'effetto sul golden è la chiusura del difetto: le stesse nove righe, e le ore massime in un mese passano da 355,56 a 153,33. Il golden globale non si è mosso.

Il difetto «il forecast non ha vincolo di capacità» in Difetti noti si chiude qui.

La sede filtra anche le tendine di assegnazione

Due punti, entrambi solo interfaccia: il vincolo vero resta nei motori.

  • assegnando un dipendente a un progetto con sede, la tendina mostra solo i dipendenti di quella città, e lo scrive sotto;
  • assegnando un progetto a un dipendente, l'elenco contiene i progetti senza sede e quelli della sua città; gli altri sono contati e spiegati invece che spariti in silenzio.

Serve a non proporre una scelta che al primo calcolo produrrebbe un avviso.

La pagina Forecast, ripensata il 18 agosto 2026

«Ore/anno per FTE» non c'è più. Era un campo della schermata moltiplicato per il numero di teste, e chiamava capacità un numero che nessuno aveva: da quando esistono le ferie pianificate ogni persona ha le proprie ore.

Al suo posto due valori distinti, che rispondono a due domande diverse:

Ore residue al 31/12da oggi a fine anno, al netto delle ferie già collocate
Ore annuali1 gennaio – 31 dicembre dello stesso anno

Ne discende una proprietà che vale la pena conoscere: le ferie di marzo pesano sull'annuale e non sul residuo, se si guarda a luglio. Sono passate.

Gli altri quattro KPI restano quelli: budget personale come somma dei progetti, consuntivo come già coperto, forecast come previsto, e residuo = budget − consuntivo − forecast. In una situazione sana è zero: uno scostamento è ciò che va corretto, e la pagina lo colora di conseguenza invece di lasciarlo leggere.

Le viste per sede

Un menu a tendina cambia fra la globale, una vista per città, e i progetti senza sede. Gli stessi KPI, calcolati solo sui progetti e sui dipendenti di quella sede, ed entrambi gli elenchi sotto seguono la scelta.

Due decisioni che non si vedono ma tengono:

  • le viste per città partizionano: ogni persona sta in una sede sola, e la somma delle viste è la globale. C'è un test che lo verifica, perché è la proprietà che rende confrontabili i numeri;
  • la vista «senza sede» mostra la capacità di tutti, e lo dichiara. Su un progetto senza sede può lavorare chiunque: quelle stesse ore compaiono anche nelle viste per città, e senza la spiegazione sembrerebbero un doppio conteggio da correggere.

Perché il conto sta nel servizio

modules/forecasting/panoramica.js. La capacità netta dipende dal piano ferie, che il browser non può leggere — la capability self apre le proprie ferie, non quelle di tutti — e la stessa aritmetica serve alla vista globale e a ognuna di quelle per sede. Scriverla una volta è il modo di non averne due versioni che divergono.

Le viste arrivano tutte insieme e cambiarle non ricarica: sono partizioni degli stessi dati, e rileggerle a ogni scelta darebbe numeri che si muovono mentre si guarda.

La base annua resta una costante (1840 ore), e non è una svista: dedurla dall'orario contrattuale richiederebbe il catalogo work_schedules, che è vuoto e irraggiungibile dalla maschera — è uno dei cinque punti della sezione INCOMPLETO. Ciò che è cambiato è che non viene più moltiplicata per le teste: si applica a ciascuno, e da lì si tolgono le sue ferie, i mesi prima dell'assunzione e quelli dopo la cessazione.

I buchi nei dati, e perché sono banner e non una pagina

Il committente ha chiesto, il 18 agosto 2026: «possiamo gestire tutti gli avvisi ed errori con dei banner in UI?». Sì — ed è meglio di una pagina di report, per una ragione precisa: l'avviso compare dove si ripara.

Il calcolo sta in modules/projects/qualitaDati.js e risponde a GET /rendicontazione-service/qualita-dati. Guarda i dati come stanno adesso, non l'esito di un ricalcolo: chi non ne lancia uno non saprebbe mai che un progetto a costo standard è senza ente, e quel progetto sparirebbe dai numeri in silenzio. È esattamente il modo in cui il difetto del costo standard è rimasto invisibile finché il golden non l'ha reso visibile.

Le segnalazioni si dividono su due livelli, e la divisione è sostanziale:

livellosignificatoesempi
errorequel progetto non produce numeriprogetto a costo standard senza ente; tariffa mancante per una fascia che qualcuno userebbe
avvisoi numeri ci sono, ma qualcosa non tornaprogetto senza budget; assegnazione fuori sede; ferie senza giorni previsti

Tre scelte meritano di essere scritte:

La tariffa manca solo se qualcuno la userebbe. Un ente senza tariffa per i dirigenti non è un problema finché su quei progetti non lavora un dirigente. Segnalare ogni casella vuota di ogni matrice avrebbe prodotto un muro di avvisi veri ma inutili, e un banner che si impara a ignorare non serve a niente.

Senza macro-livello la causa è distinta, e il rimedio pure. «Manca la tariffa» e «questa persona non ha una fascia» portano a due schermate diverse: la scheda dell'ente e la scheda del dipendente. Un codice unico avrebbe mandato metà delle volte nel posto sbagliato.

Ogni segnalazione porta un rimedio. Sapere che c'è un problema senza sapere da dove cominciare è un cartello, non un aiuto. È il campo che ha giustificato il lavoro.

In interfaccia il banner (components/BannerQualita.tsx) sta in tre punti: sulla scheda progetto filtrato sul progetto aperto, su Overview e su Forecast per intero — lì i buchi spiegano perché un numero può essere più basso del vero. Se non c'è niente da dire non compare, e un guasto della chiamata non rompe la pagina che lo ospita: una pagina senza banner è meglio di una pagina che non si apre.

L'accesso segue i ruoli già in essere: rendicontazione_viewer ha GET su /rendicontazione-service/**, quindi la rotta è coperta senza nuove concessioni. Il ruolo rendicontazione_employee no, e va bene così — la qualità dei dati non è affare suo, e il banner semplicemente non compare.

Il catalogo, e il difetto che chiude

Il committente ha chiesto, subito dopo, se questo dovesse valere come hub per tutti gli avvisi, anche futuri. Non lo era: era il quarto canale accanto a tre che esistevano già, e uno dei tre non arrivava a nessuno — il forecast restituiva i suoi avvisi sulle tariffe e nessuna pagina li leggeva.

Ma il difetto vero stava altrove. L'interpretazione degli avvisi — titolo, gravità, rimedio — viveva in uno switch nel frontend, in AllocazioniPage, con questo fondo:

default: severity "low", title = warning.code, action = "Verifica il dettaglio del run."

Cioè: un codice nuovo emesso dal backend non dava errore. Compariva come stringa grezza con un rimedio inutile, e niente lo segnalava. È esattamente perché gli avvisi sulle tariffe standard non hanno mai avuto un titolo: nessuno li aveva aggiunti a quello switch, e nessuno poteva accorgersene.

modules/qualita/catalogo.js sposta quel catalogo accanto al codice che emette — 29 codici, ognuno con livello, entità e rimedio — e la divisione del lavoro è questa:

chicosa possiedeperché
catalogolivello, entità, titolo generico, rimediosono proprietà del codice: non cambiano da un caso all'altro
chi calcoladettaglio e titolo contestualesono i dati — quali persone, quale mese, quante ore — e solo chi calcola li ha

normalizza() porta alla forma canonica un avviso di qualunque motore, comunque sia fatto: i cinque moduli sono nati in momenti diversi e chiamano le stesse cose in modi diversi (detail/dettaglio, title/titolo, workMonth/month). Riscriverli tutti sarebbe costato cinque moduli e ottanta test; la traduzione sta in un punto solo, ed è coperta. I campi grezzi passano sotto le chiavi canoniche, perché la riconciliazione dei nominativi presenze naviga su importKey e firstName — roba che il catalogo non conosce e non deve conoscere.

Un codice fuori catalogo fa fallire un test, e questo è il punto di tutto il lavoro. test/catalogo.test.js legge i codici emessi dal sorgente — non da un elenco scritto a mano, che avrebbe avuto lo stesso difetto che il test chiude — e li confronta col catalogo. Perturbato rinominando un codice in allocationEngine.js, dice:

not ok 1 - ogni codice emesso dai motori sta nel catalogo
codici senza voce di catalogo: SEDE_MAI_CATALOGATA (actuals/allocationEngine.js)

Nel frontend lo switch è sparito: resta un adattatore di sei righe verso la forma che la pagina già rendeva. Le tre gravità che il consuntivo usava (alta/media/bassa) sono diventate due livelli, per decisione del committente: la distinzione che cambia cosa fai è una sola — ti ferma un numero o no.

Stato ed eventi, che non sono la stessa cosa

Le segnalazioni hanno due nature, e confonderle avrebbe reso l'hub subito sbagliato:

  • stato — ricalcolabile dai dati di adesso. Non si salva: si ricalcola, e sparisce da solo quando qualcuno ripara.
  • eventi — constatazioni di un calcolo passato: ore allocate oltre il tetto, un nome del file presenze mai riconosciuto. Non sono ricalcolabili.

Il committente ha scelto di non creare una tabella: /qualita-dati ricalcola lo stato e pesca gli eventi da dove già stanno, il metadata_json dei run. Di ogni mese vale solo l'ultimo run: i precedenti sono stati sostituiti, e riportarne gli avvisi vorrebbe dire mostrare problemi già riparati.

Il limite di questa scelta va detto: le anomalie d'import non sopravvivono alla pagina che le mostra — non esiste nessuna tabella di anomalie in rendicontazione — e di un run vecchio resta solo ciò che è nel suo metadata_json. Gli avvisi del forecast non sono persistiti affatto, ma la loro causa è coperta dai controlli di stato (PROGETTO_SENZA_TARIFFA e compagni), che sono i loro gemelli osservati sui dati fermi.

I due cursori temporali

Deciso col committente il 18 agosto 2026. I cursori sono due e fanno cose diverse; confonderli è il modo più rapido per sbagliare tutto il resto.

dove vivechi lo muovecosa separa
A — consuntivorendicontazione_operational_state.consolidated_until_month, uno per organizzazionel'import del foglio presenzeconsuntivo da previsione
B — consegnaprojects.delivered_through_month, uno per progettouna persona, dalla scheda del progettociò che è uscito verso l'ente da ciò che si corregge ancora

Fra i due si apre la finestra che rende utile il meccanismo: mesi già consuntivati ma non ancora consegnati, dove persone e ore si possono ancora spostare senza toccare niente di già rendicontato. Prima della migrazione 117 quella finestra esisteva per caso — niente era congelato perché niente sapeva di doverlo essere.

B ≤ A sempre: non si consegna un mese che non è stato consuntivato. Il vincolo non è esprimibile in SQL — A vive in un'altra tabella, in una riga sola — quindi vive nel servizio, e il test lo copre.

Cosa sostituisce, e perché non erano la stessa cosa

freeze_config è stato rimosso: colonna, modulo freezeSchedule.js, freeze_warning, il payload, la maschera progetto, l'indicatore nell'elenco e i suoi tre test. Rispondeva a un'altra domanda:

  • freeze_config diceva quando avresti dovuto congelare — trimestrale, semestrale, più i mesi di tolleranza. Un piano, calcolato da una periodicità.
  • il cursore B dice fino a dove hai consegnato. Un fatto, dichiarato da qualcuno.

Un piano non congela niente, perché cambia da solo quando cambia il calendario.

C'è un'ironia che vale registrare: la migrazione 116 aveva creato freeze_config il 18 agosto, perché il codice leggeva una colonna mai esistita e il salvataggio dei progetti era rotto da mesi. La 117 la toglie il giorno dopo. La 116 diceva: «se un giorno il congelamento non servisse più, si toglie insieme alla sua interfaccia e al suo codice — non lasciando a metà quello che c'è». È quello che è successo.

Cosa si è perso: nessuno avvisa più che una scadenza di rendicontazione sta passando. Se servirà, il posto giusto è l'ente finanziatore — la periodicità di rendicontazione è sua, non del progetto.

Le tre decisioni sul comportamento

Avanti liberamente, indietro con una motivazione. Riaprire un periodo consegnato non è un ripensamento qualunque: quei numeri sono già stati mandati a un ente. La motivazione è obbligatoria e finisce in rendicontazione.project_delivery_events insieme a chi l'ha scritta, al mese di prima e a quello di dopo. Senza quella tabella, l'unica traccia di una consegna disdetta sarebbe la sua assenza.

Lo storico si scrive dopo il cursore. L'ordine opposto sarebbe più comodo e produrrebbe una storia falsa: un evento che racconta una consegna mai avvenuta, se la scrittura del cursore fallisse.

Il permesso è quello del progetto. Scelta del committente: chi può modificare un progetto può anche dichiararne la consegna. Un permesso dedicato resta possibile più avanti, se servirà separare chi rendiconta da chi governa le anagrafiche.

I mesi si importano di fila

Il committente ha posto il vincolo il 18 agosto 2026: «non può capitare che il cursore A vada su maggio e i mesi precedenti non siano stati consuntivati o forecastati».

Prima, l'import non guardava affatto il cursore. Prendeva il mese dal foglio e lo scriveva: importare maggio con il consuntivo a marzo portava il cursore a maggio e faceva sparire aprile — non consuntivato perché mai importato, non previsto perché ormai passato. Il buco non ha righe che lo rappresentino, quindi nessuno se ne accorgeva.

modules/actuals/contiguitaImport.js rifiuta l'import fuori sequenza. Sta nel servizio e non nella maschera, perché una regola che vive solo in interfaccia si aggira con una chiamata diretta, e questa protegge la continuità di tutto il consuntivo.

casoesito
primo import di sempre (nessun cursore)qualunque mese
mese = cursore + 1passa
mese = cursore (correzione di un foglio sbagliato)passa, e il cursore non si muove
mese oltre cursore + 1IMPORT_MESE_SALTATO, e dice quale foglio si aspettava
mese prima del cursoreIMPORT_MESE_ARRETRATO: annullare prima i mesi successivi
mese già consegnato da un progettoIMPORT_MESE_CONSEGNATO, e vince su tutti gli altri

Il buco non si chiudeva con la sola contiguità

La contiguità impedisce al cursore di saltare avanti, ma non basta: con il consuntivo a maggio e oggi 18 agosto, giugno e luglio non sono consuntivati — quindi sono previsione, anche se stanno nel passato.

mesiFuturi partiva dal mese di calendario corrente. Il ricalcolo cancellava tutte le righe previsionali del progetto e le riscriveva da agosto: giugno e luglio sparivano, e nessuna riga lo diceva. Ora parte dal mese successivo al cursore A, e solo in assenza di qualunque import ricade sul mese corrente.

Sono due interventi distinti per un difetto solo, ed è per questo che sono stati fatti insieme.

Rifare il consuntivo di un progetto solo

Fino al 18 agosto 2026 il consuntivo si ricalcolava in un caso solo: importando il foglio presenze di un mese, che ricalcola quel mese e poi rifà il forecast del periodo successivo. Rifare un mese già consuntivato non era mai stato definito — parole del committente. Nella finestra fra i due cursori una modifica non aveva effetto: i numeri restavano quelli di prima, e niente diceva che fossero vecchi.

Ora c'è un bottone nella scheda del progetto, accanto al cursore di consegna. Rifà il consuntivo di quel progetto dal mese dopo la consegna fino al mese consuntivato.

La trappola che ha deciso il disegno

Una sola esecuzione vince per mese (selectLatestAllocationRunByMonth): a parità di stato, la più recente. Quindi non esiste una run «del progetto X» — sarebbe la run del mese, e le righe di tutti gli altri progetti sparirebbero da ogni vista costruita sulle run.

Da qui il disegno, che è lo stesso impegnateAltrove già approvato per il forecast per progetto:

  • la run resta il quadro completo del mese;
  • le righe degli altri progetti si riportano identiche, non si ricalcolano;
  • il progetto lavora con la capacità che avanza, mensile e giornaliera.

La parte giornaliera non è un di più. alignMonthlyRowsToDailyRows riscrive le ore mensili con la somma delle giornaliere: se lo split ridistribuisse le giornate di tutti, cambierebbero anche i totali mensili degli altri progetti — e l'isolamento sarebbe finto. Per questo splitRowsDaily accetta le ore già usate per giorno, e il progetto si divide sulla presenza che resta.

L'ordine dei mesi

Dal più vecchio al più recente, e non è un dettaglio: il budget residuo si consuma mese dopo mese, e rifare maggio prima di marzo lo calcolerebbe su un residuo che a maggio non esisteva.

Cosa lega davvero, scoperto perturbando

Il totale di un progetto ricalcolato è imposto due volte: dalla capacità mensile e dalla presenza residua di ogni giorno. Il primo test scritto per la capacità mensile non falliva quando la si toglieva — perché a tenere il numero giusto bastava la presenza giornaliera. Il test guarda ora anche gli avvisi: partendo dalla capacità piena il motore alloca l'intero fabbisogno e poi dichiara «ore non allocate» che in realtà stanno su un altro progetto — un avviso falso su un mese lavorato per intero. Con quella asserzione la perturbazione fallisce.

Vale registrarlo perché è il secondo caso in due giorni di un test che passava per una ragione diversa da quella per cui era stato scritto.

Le tre porte sul periodo consegnato

Il cursore di consegna nasceva rispettato in due punti soli — l'import e il ricalcolo per progetto. Restavano tre strade per riscrivere numeri già usciti verso un ente, chiuse il 18 agosto 2026 in tre modi diversi, perché i tre casi non sono lo stesso caso.

Il ricalcolo globale di un mese passato: conserva, non rifiuta. Rifiutare il mese intero bloccherebbe l'organizzazione appena un solo progetto consegna. I progetti che hanno consegnato quel mese restano fermi — le loro righe si riportano identiche — e gli altri si ricalcolano. È lo stesso meccanismo del ricalcolo per progetto visto dal lato opposto: là si congela tutto tranne uno, qui si congela un insieme.

L'annullamento di un import: rifiuta. Il rollback cancella presenze e consuntivi di un mese; su un periodo rendicontato vorrebbe dire far sparire i numeri che l'ente ha ricevuto. Non c'è un «parziale» sensato: prima si riapre il periodo, poi si annulla.

Le associazioni: si guarda solo la parte consegnata. La regola non è «l'associazione tocca mesi consegnati» — vieterebbe di prolungare nel futuro un'associazione cominciata due anni fa. È «la parte consegnata cambia»: prima e dopo si confrontano tagliati al cursore. Prolungare passa, spostare l'inizio dentro il consegnato no, cancellare qualcosa che lo copre nemmeno.

Un test che passava per la ragione sbagliata, di nuovo

Il test sul congelamento del ricalcolo globale non falliva quando si toglieva il congelamento: ricalcolare un progetto con gli stessi dati dà lo stesso risultato, quindi «conservato» e «ricalcolato uguale» erano indistinguibili. Ora il test toglie il fabbisogno del progetto congelato prima di ricalcolare: se venisse ricalcolato le sue righe sparirebbero, e la differenza si vede. È il terzo caso in tre giorni, e vale come regola: un test di conservazione deve cambiare l'input, altrimenti verifica solo il determinismo.

La sede storica, per giornata

Il consuntivo è mensile ma calcolato per giornate, e il committente l'ha fatto notare correggendo una proposta sbagliata: avevo proposto «sede valida al primo del mese», sul modello del costo. Il modello giusto era già nel codice, un livello più sotto — applicaCostoGiornaliero risolve il costo con costoOrarioAllaData(righeCosto, riga.workDate, …) e ricalcola la riga mensile dalle giornate. Chi si sposta il 12 marzo ha le ore fino all'11 sulla sede vecchia e dal 12 sulla nuova.

creaSedeStorica risolve la sede alla data; il controllo SEDE_INCOMPATIBILE si è spostato dopo lo split giornaliero, dove le giornate esistono. Guardare la sede corrente faceva due danni opposti, ed entrambi hanno un test:

  • un'incompatibilità inventata su ore che all'epoca erano regolari;
  • una vera che sparisce, perché nel frattempo la persona si è trasferita nella città giusta.

Un avviso per persona-progetto, non per giornata: un trasferimento produce venti giorni incompatibili, e venti avvisi identici sono un muro che si impara a saltare. La riga dice quante giornate e da quando — che è ciò che serve per capire se è un errore o un trasferimento.

Senza storia si ricade sulla sede corrente. employee_location_history è arrivata in Fase 1 e non copre ciò che è successo prima; dichiarare «nessuna sede» produrrebbe un'incompatibilità su ogni progetto con sede, che è peggio.

I tariffari, e un modello rovesciato

Il 19 agosto 2026 il committente ha portato il file vero — FILE PERSONALE CONSORZIO 26.xlsx — e un numero ha ribaltato il modello economico.

Le tariffe standard stavano sull'ente finanziatore. Il file dice che è il contrario, e lo dice con i conti: su 42 progetti, 19 non hanno nessun ente — il 45% — ma tutti e 42 hanno un tariffario, e nove di quei diciannove sono a costo standard. Con le tariffe sull'ente, quei nove non potevano essere valorizzati in nessun modo.

Il modello, dalla migrazione 118:

tariffario   codice, descrizione, ambito, e i prezzi per fascia nel tempo
ente anagrafica. NON ha prezzi.
⟷ molti-a-molti: un ente usa più tariffari, un tariffario serve più enti
progetto ente (opzionale) + tariffario (obbligatorio se a costo standard)

Il legame non decide: dice quali tariffari proporre quando si sceglie un ente. A valorizzare è sempre e solo quello del progetto — che è ciò che il foglio già fa.

REALE non è un tariffario

Nel foglio TARIFFARI la prima riga si chiama REALE, ed è la trappola. Avevo proposto di modellarla come un tariffario con un attributo real_cost; il committente ha corretto: è l'assenza di tariffario. Esiste come riga perché una colonna Excel deve pur contenere qualcosa, ma nel modello corrisponde a ciò che c'era già — il flag «usa costo standard» spento e nessun tariffario.

REALE            uses_standard_cost = false, rate_card_id = NULL
qualunque altro uses_standard_cost = true, rate_card_id = quel tariffario

I due campi non possono contraddirsi, e non perché qualcuno si ricordi di tenerli allineati: c'è un CHECK che rende impossibile la coppia incoerente. È NOT VALID, così non fa fallire la migrazione su dati già discordi — quelli restano visibili nel banner e si riparano uno per volta.

Zero non è un prezzo

Il tariffario Reg Pie ha Quadro e Dirigente a 0, e la sua nota dice che quelle tariffe mancano. Il vincolo è > 0 e il travaso le lascia indietro: importarle avrebbe valorizzato un quadro a zero euro l'ora, in silenzio, con numeri plausibili in ogni schermata.

La catena degli import, e perché ha un ordine

I file sono due e vanno in quest'ordine, deciso dal committente:

cosa porta
1. Personale_ file BUl'anagrafica: 73 persone, azienda, sede, area, contratto, orario, dominio email
2. FILE PERSONALE CONSORZIO 26tariffari, enti, progetti, ore — e aggancia referente, titolo di studio e costi reali a persone che devono già esistere

Invertirli lascerebbe orfani referenti e costi. L'ordine si vede nell'unico posto in cui può vedersi: le schede della pagina Importazioni, dove «Anagrafica personale» sta per prima.

Tre colonne che contengono date, non valori

Il file dell'anagrafica scrive SALERNO DAL 01/07/2026, MILANO DA AGOSTO 2023, PART TIME 30% DAL 01/02/2025 =12 H SETTIMANA. Importate com'erano avrebbero creato una sede chiamata «SALERNO DAL 01/07/2026» — e da lì non si torna indietro.

Sono trasferimenti e cambi d'orario: la sede pulita va nel catalogo, la data nello storico. Cinque persone su 75 hanno un trasferimento datato.

Le due anagrafiche non descrivono le stesse persone

73 nel file BU, 65 nel consorzio, 47 abbinate. La riconciliazione (modules/data-exchange/riconciliazioneAnagrafica.js) segue le regole decise dal committente: per omonimi e quasi vince la versione di BU, gli orfani si creano lo stesso perché un operatore li cancellerà.

Quattro abbinamenti restano incerti e ognuno produce un avviso che dice cosa succede se è sbagliato, non solo che è incerto:

QUARONI SOFIA       → QUADRONI SOFIA        una lettera
MANTEGARI RICCARDO → MENTEGARI RICCARDO una lettera
DELIPERI ENEA → DELIPERI ANDREA nome diverso — 6.185 ore in gioco
Patania Joa → PATANIA JOE (GIOVANNI)

Non si abbina mai oltre una lettera di differenza: a distanza 2 «ROSSI» e «ROSSO» diventerebbero la stessa persona. E un abbinamento sbagliato non si vede guardando i totali — tornano lo stesso.

I costi mensili diventano intervalli

TARIFFE REALI porta dodici numeri per persona e anno; lo storico vuole intervalli. Mesi consecutivi con lo stesso importo si fondono: 229 intervalli invece di 2244 valori.

Due regole che il primo tentativo sbagliava: l'ordinamento è anno-poi-mese (invertirlo produceva 1482 intervalli, uno per mese, cioè il contrario di uno storico), e due importi si confrontano al centesimo, perché il foglio porta valori come 31.293938953488365.

Il file arriva al 2028: sono costi previsti e servono al forecast, ma current_hourly_cost_eur prende l'intervallo che copre oggi, non l'ultimo inserito — altrimenti ogni scheda mostrerebbe il costo del 2028.

Ciò che un import crea, si deve poter cancellare

Regola posta dal committente il 20 agosto 2026: «l'import se non funziona al 100% va bene, un operatore può fissare le eccezioni; l'importante è non creare strutture che poi non possono essere cancellate o modificate».

Ha una conseguenza precisa nello schema, e sono due scelte opposte a proposito:

areeON DELETE SET NULL — un refuso crea l'area «AUDT», e si cancella lasciando i dipendenti scoperti, che è ciò che serve prima di riassegnarla
orariON DELETE RESTRICT — la percentuale entra nei calcoli, e cancellarla mentre uno storico la usa lascerebbe periodi senza capacità

L'area è un'etichetta, l'orario è un numero. La pagina Aree mostra quante persone usano ciascuna: cancellarne una che ne ha trenta dev'essere una decisione informata, non una scoperta.

Un difetto trovato solo provandolo

Su una chiave composita, ON DELETE SET NULL azzera tutte le colonne — compresa organization_id, che è NOT NULL. Il vincolo sembrava giusto (confdeltype valeva 'n', la verifica passava) ma cancellare un'area falliva con un errore che parlava di un'altra colonna. Trovato provando a cancellare un'area, non leggendo il catalogo di sistema. Corretto con ON DELETE SET NULL (area_id).

Una migrazione scritta su uno schema immaginato

La 120 nasceva creando la tabella areas. Ma areas esisteva già dallo schema iniziale, e la 088 le aveva aggiunto organization_id; anche employees.area_id c'era. Un CREATE TABLE IF NOT EXISTS su una tabella esistente non fallisce: non fa niente, e le colonne e i vincoli che dichiarava sarebbero rimasti veri solo nel commento.

È la stessa classe di errore di standard_cost_profiles e freeze_config: codice scritto su uno schema supposto invece che letto. La regola che ne discende, e che vale per chiunque scriva la prossima migrazione: prima si legge init.sql, poi si scrive.

La quarta modalità di import, e la premessa che è caduta

Il committente ha posto la domanda il 20 agosto 2026: ci saranno più file di rendicontazione, con progetti diversi. Una delle tre modalità già aggiunge senza sovrascrivere?

No — e due delle tre sono peggio che inutili per quel caso.

modalitàcosa fa davvero
Revisionecalcola tutte le differenze e le lascia in PENDING; all'applicazione esegue tutto ciò che non è stato rifiutato a mano
Syncstesse differenze, applicate senza approvazione
Resetcancella e reimporta da zero

In importApply l'aggiornamento è un upsert incondizionato. Ma il problema serio è un altro: in Sync e Revisione il confronto per le cancellazioni è contro l'intera tabella, non contro ciò che quel file aveva portato (importDiff.js, missingFromWorkbook). Importando il secondo file, i progetti del primo verrebbero cancellati perché «non presenti nel foglio» — in Sync direttamente, in Revisione come centinaia di proposte da rifiutare.

La premessa delle tre modalità è che il file sia uno solo. Con un file solo regge: il foglio è la fonte, ciò che sparisce dal foglio è sparito. Con più file cade, e nessuna quantità di clic la ripara.

ADD_ONLY — aggiunta

Tre proprietà, decise col committente:

Non cancella, e le proposte di cancellazione non si producono nemmeno: rifiutarle a mano sarebbero centinaia di clic per dire ogni volta la stessa cosa.

Non sovrascrive: dipendenti, progetti e ore già presenti restano com'erano. Le ore seguono la stessa regola delle anagrafiche, per scelta esplicita — protegge i consuntivi già calcolati, e due file che si sovrappongono non raddoppiano le ore in silenzio.

Elenca ciò che non è entrato, campo per campo, con i due valori:

progetto BIOXIS — già presente in archivio
personnel_budget_eur: nel file 240000, nel database 215000

Il rapporto non è troncato. È la parte che rende la modalità usabile: senza, «non sovrascrivo» diventa un silenzio in cui nessuno sa se il file portava qualcosa di nuovo, e scoprirlo mesi dopo guardando un budget vecchio è peggio che non aver importato affatto.

L'invariante che vale più degli altri è il confronto fra le due modalità sulla stessa fixture, con lo stesso progetto estraneo al foglio: in Sync viene proposto in cancellazione, in Aggiunta nemmeno considerato.

Il prossimo passo

La Fase 2 — ferie, nell'ordine deciso dal committente:

  1. Le due tabelle — saldo annuale e piano mensile — con il decremento dei giorni goduti dall'import presenze, riconosciuti da 8FE. Fatto il 17 agosto 2026 (migrazione 108). Il piano mensile ha la tabella ma nessuno ancora ci scrive: quello è il passo 3.
  2. L'interfaccia che mostra ferie prese e residue, e con cui Admin/HR inserisce i giorni previsti. Fatta il 18 agosto 2026: sotto-scheda Ferie dentro Storico, decisa dal committente — il form dei giorni previsti sopra, gli anni sotto con previsti, goduti e residui. entitled_days resta NULL finché qualcuno non lo scrive, un anno per volta.
  3. Il self-service per inserire i giorni pianificati per mese. Fatto il 18 agosto 2026: in I miei dati, i dodici mesi dell'anno con i totali sopra. Sono le prime scritture concesse a self.
  4. Solo dopo, l'uso del dato nel forecast. Fatto il 18 agosto 2026: la capacità del mese cala dei giorni pianificati, e la stessa capacità la usano la panoramica e il motore.

Il modello di capacità dipendente/progetti si sviluppa più avanti, ed è una decisione esplicita: serve al residuo non pianificato, non ai primi tre passi. Questi tre danno il dato e lo rendono visibile senza dipendere da lui.

La Fase 3 è chiusa con i quattro punti aperti il 18 agosto 2026, nell'ordine chiesto dal committente:

  1. Il golden economico che fissa i costi standard. Fatto: la fixture costi-standard attraversa il cambio tariffa del 1º luglio 2025 — 30/30 sul reale, 45→54 e 30→36 sullo standard. È il test che ha reso visibile lo stub del costo standard.
  2. L'avviso sul cambio sede. Fatto: associazioniIncompatibili sulla scheda dipendente, e solo quando la sede è stata davvero scritta.
  3. La qualità dei dati, con i banner. Fatto, ed è la sezione qui sopra.
  4. La sede corrente e non quella storica. Confermato dal committente e già com'era. La storica è arrivata il giorno dopo, insieme al ricalcolo del passato che la rendeva necessaria — vedi la sezione sulla sede per giornata.

La Fase 5 è aperta e i due cursori sono chiusi, con il seguito che ne è disceso:

vocestato
cursore consuntivo (consolidated_until_month)esisteva; ora l'import rifiuta i mesi fuori sequenza e il forecast riparte da A + 1
cursore consegna (delivered_through_month)migrazione 117, per progetto, con storico e riapertura motivata
comando e permesso di consegnaPUT /projects/:id/consegna, permesso del progetto
blocco delle modifiche ai periodi consegnatiquattro punti: import, rollback, associazioni, ricalcolo
ricalcolo del consuntivo per progettobottone nella scheda, da B + 1 ad A, gli altri progetti fermi
sede storica per giornata nel consuntivofatta
snapshot/hash dei periodi consegnatirimandata: immunità ai cambi di tariffa retroattivi
dirty range automaticorimandata: il committente ha scelto il bottone manuale
versioni e confronto prima/doponon aperta
rigenerare i report apertinon aperta

Le due voci rimandate non sono dimenticanze: sono il capitolo delle correzioni retroattive versionate, che ha senso affrontare intero.

La Fase 4 è aperta, e questo è il suo stato al 20 agosto 2026:

vocestato
modelli canonicifatti — tre esistevano già senza nome, il quarto (assenze) mancava
interfaccia e registro adapterscritti e provati, non collegati: le rotte chiamano i lettori dritti
formati attuali come adapter v1i lettori ci sono, il registro no
formati nuovifoglio PROGETTI, foglio TARIFFARI, anagrafica personale
staging, diff, approvazioneprogetti/tariffari/enti sì; anagrafica personale no, è diretta
idempotenzafatta, con un test per ciascuna entità
versionare detection e contrattinon aperta
fixture anonimizzate e contract testnon aperta

Il debito più visibile è il registro adapter: esiste, è provato, e nessuno lo usa. Era il pezzo che doveva rendere possibile «aggiungere un formato senza toccare il dominio», e finché le rotte chiamano i lettori direttamente quell'obiettivo non è raggiunto — funziona, ma per la ragione sbagliata.

Il file vero contro l'import: quattro difetti in fila

L'import del file di rendicontazione reale — ventuno progetti, sessantacinque persone, oltre seimila righe di ore — ha fatto cadere quattro cose che con le fixture non si vedevano. Vale la pena tenerle insieme, perché sono la stessa classe di errore: il codice provato su decine di righe, messo davanti a migliaia.

difettocome si è manifestatorimedio
temporary_key è varchar(20)nome di ente da 29 caratteri: value too long for type character varying(20), senza dire quale colonnamigrazione 122 allarga a text; il test legge i limiti dallo schema, non da numeri copiati
source_sheet_name arrivava nullola rinomina canonica sheetsezione non era stata applicata allo stagingorigineDi(); il test copre tutte le colonne NOT NULL, non quella trovata
oltre 5000 righe in una richiestaTroppe righe in una richiesta: massimo 5000, dopo minuti di lavoro già svolto, sull'ultima scritturail client spezza in gruppi da mille
staging scritto una riga alla voltaseimila richieste in sequenza: i minuti in cui l'import sembra fermo in «Elaborazione dati»scrittura in blocco, gli id tornano indietro con le righe

Sugli ultimi due c'è una decisione da spiegare, perché la via facile era un'altra.

Il limite di cinquemila di data-service non è un difetto: è una guardia, e alzarlo avrebbe spostato il problema a diecimila. A spezzare è il client del verticale, dentro creaInBlocco/aggiornaInBlocco, così ogni chiamante ne beneficia senza saperlo — le differenze, le presenze giornaliere, i risultati di allocazione. La dimensione del gruppo è deliberatamente più bassa del limite, non uguale: due costanti identiche in due servizi diversi divergono al primo che cambia, e allora l'errore torna. Il test legge il limite dal sorgente di data-service; copiarlo avrebbe voluto dire non accorgersene il giorno che viene abbassato.

I gruppi partono in ordine e uno alla volta. In parallelo sarebbe più veloce e renderebbe imprevedibile che cosa è entrato quando uno fallisce: è lo stesso contratto del blocco singolo, dove la prima riga che fallisce interrompe.

Lo staging si scriveva riga per riga per una ragione vera: di ognuna serve l'id generato, che le differenze referenziano. Ma la scrittura in blocco gli id li restituisce, quindi la ragione non c'era più. L'accoppiamento è per posizione — data-service scrive in ordine — e non lo si dà per scontato: ogni riga tornata viene riconosciuta dalla sua chiave naturale. Un id attribuito alla riga sbagliata non darebbe errore, darebbe differenze appese all'entità sbagliata, che è peggio.

Le due perturbazioni che confermano i test: tornare alla scrittura riga per riga fa cadere «anche lo staging si scrive in blocco»; sfasare l'accoppiamento di uno fa cadere «ogni riga in staging tiene il proprio id, non quello del vicino». Nessuna delle due passa inosservata.

Una riga sbagliata non ferma più le altre

Dopo i quattro difetti qui sopra l'import è arrivato in fondo e si è fermato così: «Import non applicato: trovate 1 violazioni dati», con l'unica indicazione «Righe Excel non disponibili» e il dettaglio termination_date precedente a hire_date. Una data invertita su una persona, e ventuno progetti con seimila righe di ore non entravano.

Era una decisione presa apposta, e scritta nel codice: «un import che fallisce a metà lascia il database in uno stato che nessuno ha scelto». Il controllo correva prima di scrivere e fermava tutto. È la regola opposta a quella che il committente ha chiesto: importare il più possibile e lasciare a un operatore l'elenco di ciò che va corretto a mano.

Ora l'incoerenza esclude se stessa:

primaora
una riga incoerente → nessuna riga entrala riga non entra, le altre sì
«Violazione», «Righe Excel non disponibili»nome, foglio e numero di riga
un rapporto per «già presente», niente per le esclusioniun solo rapporto con due categorie
workbook FAILED appena una riga cadeFAILED solo se non è entrato niente

La ragione per cui il vecchio controllo esisteva non è stata buttata via: il risultato parziale adesso c'è per davvero, e per questo ogni esclusione deve arrivare nel rapporto con foglio e riga. Un'esclusione che l'operatore non sa di avere è peggio di un import fallito — e senza il numero di riga, correggerla significa cercare a mano dentro seimila righe di Excel.

Lo stesso vale per i periodi di rapporto: il foglio elenca assunzione, cessazione e riassunzione su righe diverse, e una data sbagliata faceva cadere la persona intera. Ora si salta quel rapporto, la persona entra, e il rapporto dice che il suo storico è incompleto — perché uno storico incompleto e uno completo, senza dirlo, hanno lo stesso aspetto.

FAILED ora vuol dire che non è entrato niente. Finché bastava una riga scartata per marcarlo, un import che aveva scritto quasi tutto e uno che non aveva scritto nulla si presentavano identici, e chi guardava non sapeva se rifarlo. Lo stato intermedio è APPLIED_WITH_ERRORS, in ambra: in verde nessuno leggerebbe il rapporto, in rosso si rifarebbe un import che ha funzionato.

Due note sui test, perché entrambe sono errori già visti qui:

  • L'invariante «date incoerenti fermano l'import prima di scrivere» codificava la decisione vecchia e andava riscritto, non cancellato: ciò che proteggeva — quella riga non deve finire nel database — vale ancora, ed è la prima delle quattro asserzioni che l'hanno sostituito.
  • Il test «un import con righe escluse non si dichiara riuscito» è passato anche perturbando il codice: escludendo il dipendente cadono anche le sue ore, quindi failed > 0 era vero comunque. Settimo caso di test che passa per la ragione sbagliata. Ora guarda quella riga per chiave, non il totale.

L'import racconta a che punto è

Fra «Elaborazione dati» e «Completato» passavano minuti muti. C'erano due soli segnali, entrambi prima della parte lunga: la lettura di seimila righe, lo staging, il confronto e la scrittura non dicevano niente. Un import che lavora e uno morto avevano esattamente lo stesso aspetto — stessa percentuale, stesso messaggio, stesso updatedAt.

Il trasporto esisteva già e non è Redis→WebSocket: createWorkbookImport accettava un callback avanzamento che scrive su updateJob, il quale persiste su Redis e viene letto dal browser in polling. Mancava la granularità, non il canale. La catena Redis→WS esiste (redis-ws-bridge, più il client in admininterface) ma il frontend rendicontazione non ha nessun client WebSocket: resta il passo successivo, se il polling si rivelerà insufficiente.

Ora le fasi le annunciano il diff e l'apply, che sono gli unici a sapere a che punto sono:

Lettura del foglio Excel.
Lettura di quello che c'è già in archivio.
Preparazione delle righe del file. dipendenti: 65, progetti: 21, ore: 6063
Confronto con l'archivio, riga per riga. 3500 di 6149
Scrittura delle differenze. differenze: 6149
Lettura dell'anagrafica per l'applicazione.
Scrittura dei dipendenti. 40 di 65
Scrittura delle ore mensili. 4500 di 6063
Chiusura dell'import. applicate: 6100, fallite: 3

Tre vincoli, e nessuno dei tre è cosmetico:

  • Non si segnala una riga alla volta. Ogni segnale è una scrittura su Redis: seimila righe farebbero seimila scritture, e il rendiconto costerebbe più del lavoro che racconta. Si segnala ogni 500 righe, più sempre l'ultima — «5500 di 6063» seguito da silenzio farebbe sembrare piantato proprio il momento in cui ha finito.
  • Un guasto qui non ferma l'import. Raccontare non è il lavoro: se Redis non risponde si perde il rendiconto, non l'importazione.
  • Non si annunciano fasi senza lavoro da fare. «Scrittura di tariffari ed enti» su zero e zero allunga l'elenco senza dire niente.

La barra avanza a ogni fase nuova, non a ogni segnale, e non arriva mai a 100 da lì: dentro una fase lunga si muovono i conteggi nel messaggio, e far rincorrere anche la percentuale la renderebbe una previsione che non siamo in grado di fare.

avanzamento viaggia nelle stesse opzioni che portano req, e viene tolto prima di parlare con data-service: un invariante lo verifica con un proxy sul client, perché altrimenti finirebbe spruzzato in ogni chiamata HTTP come se fosse un'opzione di rete.

Anche qui un test è passato per la ragione sbagliata: «non si annunciano fasi senza lavoro da fare» guardava solo totale, ma la fase che davvero annunciava lavoro inesistente — tariffari ed enti a zero — porta {tariffari, enti}. Guardava l'unico campo che quella fase non ha.

La 121 non era mai stata applicata

La migrazione 121_rendicontazione_orario_e_titolo.sql falliva, e con lei si annullava per intero:

ERROR: there is no unique constraint matching given keys
for referenced table "work_schedules"

Lo storico degli orari punta al catalogo con una chiave esterna composta (organization_id, work_schedule_id). Perché sia creabile, work_schedules deve avere un indice unico su (organization_id, id) — e non ce l'aveva. La migrazione 088 ha creato gli indici ux_<tabella>_org_id solo per le tabelle che erano già genitrici di una relazione, e gli orari non lo erano. La stessa riga l'hanno dovuta aggiungere 114 per gli enti, 118 per i tariffari e 120 per le aree; la 121 se l'era dimenticata.

La conseguenza è più larga del previsto: la migrazione è in BEGIN/COMMIT, quindi anche work_schedules.percentage e employees.education_title non sono mai stati creati, pur non c'entrando niente con la chiave esterna. Chi ha applicato le migrazioni ha visto un errore e uno schema senza tre cose.

Il controllo in coda ora guarda anche l'indice. Non è ridondante, e la perturbazione mostra perché: nel caso normale la migrazione fallisce prima, sul CREATE TABLE; ma se la tabella dello storico esiste già, CREATE TABLE IF NOT EXISTS è un no-op silenzioso — la stessa trappola della 120 — e senza il controllo l'indice mancante passerebbe inosservato. Provocato quello stato esatto, il controllo grida con il nome dell'indice invece del messaggio di Postgres, che non nomina né l'indice né la tabella che dovrebbe averlo.

Verificato sul cluster usa e getta: init.sql da zero senza errori, 121 applicata tre volte di fila, e i quattro vincoli che mordono davvero — RESTRICT sul catalogo, non sovrapposizione degli intervalli, percentuale a zero rifiutata, progetto a costo REALE inserito senza tariffario.

La 123, e un database divergente dal repository

L'import dei progetti veniva rifiutato:

[POST /projects] null value in column "rate_card_id" of relation "projects"
violates not-null constraint

Ed era il database ad avere torto. Il vincolo che la 118 ha scritto codifica già la regola «REALE = assenza di tariffario»:

CHECK (COALESCE(uses_standard_cost, false) = (rate_card_id IS NOT NULL))

Con rate_card_id obbligatoria quel CHECK diventa impossibile da soddisfare per ogni progetto a costo reale: obbligherebbe uses_standard_cost a essere vero sempre. I due vincoli insieme non descrivono nessuno schema valido. Il verticale faceva già la cosa giusta, e apposta — rate_card_id: … ? rateCardId : null, con il commento che spiega che se il tariffario non si risolve il costo standard non si accende.

Da dove venga il NOT NULL non è noto, ed è il fatto più importante qui. La 118 aggiunge la colonna come bigint senza vincolo, init.sql non la nomina, nessuna migrazione la rende obbligatoria, datahub non altera le colonne, e un cluster costruito da zero con init.sql la produce nullable — verificato. Il database che ha dato l'errore è quindi divergente dal repository: qualcosa l'ha modificato fuori dalle migrazioni. La 123 lo riallinea, ma non spiega come ci sia arrivato, e finché non si sa può succedere di nuovo.

Per questo la riparazione è una migrazione e non un ALTER a mano: su un database sano non fa niente, su uno divergente lo riallinea, e il prossimo ambiente ricostruito non riparte con il difetto.

La verifica in coda tiene insieme tre cose, non una: la colonna è facoltativa, il CHECK che lega le due colonne c'è ancora — senza, un progetto potrebbe dire di usare il costo standard senza avere con cosa calcolarlo — e la chiave esterna al tariffario pure, perché facoltativo non vuol dire libero. Le tre perturbazioni corrispondenti fanno cadere ciascuna il proprio controllo.

Riprodotto il difetto sul cluster (SET NOT NULL, import del progetto REALE rifiutato con lo stesso messaggio), applicata la 123, il progetto entra e il CHECK continua a rifiutare «standard senza tariffario».

Le tariffe sono intervalli, non righe sciolte

[POST /rate_card_rates] conflicting key value violates
exclusion constraint "rate_card_rates_no_overlap"

L'import inseriva ogni tariffa con valid_to: null, e lasciava al vincolo di esclusione del database il compito di dire di no. Poi riconosceva quel no dal testo del messaggio:

.catch((err) => {
if (!/exclusion|overlap|duplicat/i.test(String(err?.message || ""))) throw err;
});

Due difetti in uno, e il secondo peggiore del primo.

Reimportare lo stesso file faceva fallire tutto — che è il caso normale quando un import si interrompe a metà e lo si rilancia. E un cambio di tariffa era impossibile: stesso tariffario, stessa fascia, decorrenza nuova sono esattamente ciò che il vincolo di esclusione esiste per modellare, ma due intervalli aperti si sovrappongono sempre, qualunque siano le loro date di inizio. Il catch lo ingoiava, e la tariffa nuova spariva senza una parola.

Il riconoscimento per stringa è la parte fragile: quel messaggio attraversa datahub e data-service prima di arrivare al verticale, e basta che una delle due sponde lo riformuli — o non sia stata ricostruita — perché la rete si apra e l'import cada. È successo.

Ora la collisione non si verifica, invece di verificarsi e venire perdonata. Le tariffe esistenti si leggono insieme al resto, e la decisione è la stessa dei cinque storici del dipendente: stessa decorrenza → si corregge; decorrenza successiva → si chiude la precedente il giorno prima; decorrenza anteriore a una già registrata → si segnala nel rapporto, perché rifare l'ordine dello storico non è lavoro da import.

Il modulo è quello, non una copia. creaPeriodi era legato a employee_id; ora accetta un soggetto, che per le tariffe è la coppia tariffario-fascia. Riscrivere la logica degli intervalli sarebbe stato il secondo posto dove sbagliare a chiuderli.

Il client finto non applica il vincolo di esclusione, quindi i test non possono aspettarsi che sia lui a fermare l'errore: guardano le righe che restano e calcolano le sovrapposizioni, che è ciò che il database vedrebbe.

E un test che non discriminava niente — il nono caso. «Due fasce dello stesso tariffario non collidono fra loro» passava anche togliendo l'aggiornamento dell'indice in memoria: fasce diverse hanno chiavi diverse e non collidono comunque. Il caso vero — lo stesso tariffario due volte nel file — non arriva mai alla scrittura, perché lo staging rifiuta i codici duplicati. Il test ora fissa quella garanzia, ed è la ragione per cui l'indice non ha bisogno di essere riletto durante un import.

Seimila fallimenti senza un posto dove dire quali

L'import ha chiuso COMPLETED_WITH_ERRORS con 22 righe applicate su 6149, e la domanda giusta — «dove trovo cosa non è entrato e perché» — non aveva risposta.

Il rapporto a schermo mostrava solo le righe già presenti: le righe fallite finivano in saltate, con un motivo in inglese sul modello dei dati («Invalid monthly allocation payload»), nessun riferimento alla riga di Excel, e nessuna schermata che le mostrasse. Un import poteva dichiarare seimila fallimenti e non avere un posto dove dire quali.

Quattro cose, e ognuna toglie un pezzo di quel silenzio:

primaora
«Invalid monthly allocation payload»«riga ore non importabile: manca dipendente, mese»
motivo generico anche quando la causa è nota«il progetto «ALFA» non è stato importato»
righe fallite fuori dal rapportostesso rapporto delle altre, categoria non_applicata
elenco piattoraggruppate per motivo, dal più frequente

Il raggruppamento è la parte che risponde davvero. Con seimila righe un elenco piatto non si legge, e quasi sempre la causa è una sola che si è propagata: un progetto che non entra porta con sé tutte le sue ore. Il conteggio per motivo lo mostra in una riga invece che in seimila. I motivi portano nomi propri, quindi si raggruppa sulla forma con i nomi tolti — altrimenti seimila motivi diversi sarebbero seimila gruppi da uno, che è di nuovo nessuna risposta.

Il conteggio attraversa tutte le righe, non le prime cento. Nel lotto se ne registrano cento — ed è un campione: con seimila fallimenti quelle cento possono raccontare un motivo solo e nasconderne altri, e chi legge crederebbe di avere il quadro. Il conteggio per motivo si calcola sull'intero elenco e costa poche righe di JSON. Per gli import fatti prima di questa modifica il riepilogo non c'è, e allora la schermata raggruppa il campione dicendo che è un campione.

Lo stesso elenco sta ora anche nello storico degli import, sotto «perché»: il rapporto a schermo vive nello stato della pagina e sparisce con un ricaricamento, proprio quando servirebbe di più.

E il decimo caso di test che passa per la ragione sbagliata, in questo stesso lavoro: la prova che il raggruppamento toglie i nomi propri usava nomi finti P1, P2, P3 — che si collassano già per la regola sui numeri. Passava anche togliendo la regola sui nomi. I nomi devono differire per lettere.

Due schemi di chiave per la stessa entità

4 righe applicate su 6149. Il motivo dominante era «il progetto «…» non è stato importato», 888 volte — e non era un problema di dati.

Il foglio PROGETTI identifica un progetto con il codice: FLASH, EXIRIS, IMPACT. I fogli delle ore scrivono solo il nome, e da quello si ricava l'hash P-6117E. Due schemi per la stessa entità, introdotti in momenti diversi: finché l'indice conosceva solo il codice, nessuna riga di ore trovava il suo progetto, nemmeno quando era lo stesso identico nome. Sul file vero: 6063 righe su 6063.

Un progetto ora si registra sotto tutti i nomi con cui lo si può chiamare — chiave naturale, codice, hash del nome — e la stessa cosa vale per i progetti già in archivio, perché i fogli delle ore citano anche progetti chiusi che questo file non porta più.

Con due omonimi vince quello del file. Le righe di ore arrivano dallo stesso foglio dei progetti: se il file porta un «FLASH», le sue ore sono di quel FLASH, non di un omonimo in archivio con un altro codice. Fra pari vince il primo, così la scelta è stabile invece che dipendente dall'ordine di lettura.

Progetti fantasma nati da una conversione

String(valore) su un oggetto qualunque dà [object Object]; su una data invalida dà Invalid Date. Il lettore delle celle ci cadeva, e quei due diventavano nomi di progetto: Invalid Date si è preso settanta righe di ore vere.

Le cause sono due, e la seconda era nascosta dentro la prima:

  • celle con una formula senza valore calcolato in cache — il file è stato salvato da un programma che non ricalcola, e il nome del progetto è spesso +A1383, «lo stesso della riga sopra»;
  • una data invalida dentro il risultato della formula, non nella cella: corretto solo il primo caso, il fantasma restava.

Un nome vuoto fa saltare la riga, ed è giusto — la riga si segnala. Un nome inventato non fa saltare niente e sporca l'archivio in silenzio.

Le formule non calcolate sono 1815 righe su quattro fogli, e ora si dicono con il rimedio: aprire il file in Excel e risalvarlo registra i valori. L'avviso non dice «non lette» ma «lette a metà o non lette», perché alcune hanno il progetto e non il dipendente: mandare a cercare righe scomparse quando invece compaiono con un altro sintomo è un secondo giro perso.

Risultato sul file vero, a parità di dati: da 4 a 4297 righe applicate.

Il rapporto diceva quanti, non quali

Il raggruppamento per motivo toglie i nomi propri — è ciò che permette di contare — ma «il progetto «…» non è stato importato», ripetuto 1417 volte, non si può usare per fare niente. E l'unico esempio mostrato era la chiave interna E-239BA|P-59520|2024-01-01, che nel foglio non compare da nessuna parte.

Due correzioni, entrambe sul punto in cui la diagnosi diventa azione:

  • una riga di ore ora si nomina con persona, progetto e meseBALOSSA · DIGESTIMULUS · 2024-01 — invece che con la chiave di correlazione;
  • ogni motivo porta con sé i casi concreti, dal più frequente, con quante righe ciascuno trascina.

Il risultato è la differenza fra un numero e un elenco di cose da fare:

1417  il progetto «…» non è stato importato        (19 casi distinti)
474 × ALTRE ATTIVITA' ORDINARIE/CORSI
188 × EPINOMA- ACCORDI ITALFARMACO
160 × FUNCHARS
52 × Reinfoce ← refuso: nei progetti è REINFORCING

I casi distinti tenuti sono dieci: uno solo farebbe scoprire un progetto per volta rifacendo l'import a ogni giro, tutti sarebbero l'elenco piatto da cui si era partiti. Il numero dei casi distinti resta però quello vero, così il taglio si vede invece di essere silenzioso.

E due prove che non discriminavano, trovate perturbando:

  • l'ordinamento degli esempi per frequenza passava anche senza ordinare, perché nella fixture l'ordine di arrivo coincideva con quello di frequenza. Ora è deliberatamente il contrario.
  • (già annotato) i nomi finti che differiscono solo per cifre.

Due decisioni del committente, e cosa cambiano

I progetti chiusi restano fuori, e le loro ore con loro. I fogli delle ore coprono anni passati e citano diciannove progetti che il foglio PROGETTI non elenca più. Non sono un difetto: sono la conseguenza di importare solo i progetti attivi.

Ma finché contavano come fallimenti, ogni import da qui in avanti avrebbe chiuso in ambra per una condizione che non cambierà mai — e in sei mesi nessuno legge più un rapporto che dice sempre la stessa cosa. Il giorno di un problema vero sarebbe stato sepolto sotto ALTRE ATTIVITA' ORDINARIE/CORSI × 494.

Quelle righe sono ora una categoria a sé, attesa: elencata come tutto il resto, ma fuori dal conteggio dei fallimenti. Lo stato dell'import torna a dire «è successo qualcosa di nuovo».

Il rischio di questa scelta è reale e sta scritto nel codice: un refuso nel nome di un progetto è indistinguibile da un progetto chiuso. Reinfoce invece di REINFORCING finisce lì dentro senza fare rumore. Per questo le righe attese si elencano lo stesso, con il nome del progetto e quante righe si porta dietro: guardare i nomi è l'unico modo di accorgersene, e la schermata lo dice.

Senza data di assunzione si mette il 1º gennaio 1980. Undici persone del file non ce l'hanno, e senza quella colonna non entrano — e non entrando loro non entrano le loro ore: oltre trecento righe perse per una cella vuota.

La data di ripiego è deliberatamente implausibile. Una verosimile — l'inizio dell'anno, la data del primo progetto — si mimetizzerebbe fra quelle vere e nessuno la correggerebbe più; il 1980 si riconosce a colpo d'occhio in qualunque elenco. Ogni caso emette DIPENDENTE_SENZA_ASSUNZIONE con nome e riga del foglio.

Il ripiego si applica dopo la fusione dei periodi di rapporto, non prima su ogni riga: applicato prima, una riga senza data diventerebbe un rapporto che comincia nel 1980 accanto a quello vero. La prova di questo ha richiesto due tentativi — guardare hireDate non discrimina, perché il 1980 è più vecchio di qualunque data reale e non vince mai la scelta del rapporto più recente. Quello che discrimina è il periodo fantasma.

Sul file vero, a parità di dati:

primaora
applicate42974621
fallite178811
attese1453

Le undici che restano sono dati veri da correggere a mano: nove righe di un dipendente che il foglio non elenca, una cessazione precedente all'assunzione di un giorno, una persona senza nome.

Il foglio che nessuno leggeva

Il lookup dei dati dipendente «non funzionava» per una ragione semplice e scomoda: il codice c'era tutto e non lo chiamava nessuno.

tariffeRealiSheet.js sapeva leggere il foglio TARIFFE REALI e raggruppare i mesi in intervalli. consorzioLookup.js sapeva abbinare per cognome e scrivere lo storico dei costi. Entrambi con i loro test, entrambi verdi. Ma l'analizzatore non apriva quel foglio, e l'apply non chiamava quella funzione: un modulo provato che non è sul percorso di nessuna richiesta produce esattamente zero. È lo stesso debito del registro adapter, con la differenza che qui si è visto perché qualcuno ha chiesto perché una funzione documentata non produce niente.

Tre collegamenti mancanti, e due difetti trovati collegandoli:

l'analizzatore non leggeva TARIFFE REALIora sì, e i costi entrano nel modello
i costi non arrivavano all'applyviaggiano nel summary_json del workbook
l'apply non li applicavascrive lo storico dopo i dipendenti, prima delle ore
il titolo di studio finiva solo nelle note come JSONora nella sua colonna
gli intervalli si scrivevano alla ciecachi arriva dopo chiude chi c'era prima

Perché il summary_json e non un parametro. L'apply può avvenire molto dopo il diff — in Revisione lo lancia un operatore dalla UI, senza il file sotto mano — e a quel punto il modello non esiste più. I costi non sono un'entità da approvare riga per riga: arricchiscono persone che devono già esistere, quindi non stanno nello staging. Se non si scrivono col workbook, all'apply non esistono.

Perché dopo i dipendenti e prima delle ore. Dopo, perché arricchiscono persone che devono esistere. Prima, perché la scrittura aggiorna anche current_hourly_cost_eur, che le righe di ore leggono come ripiego.

Lo scrittore aveva lo stesso difetto già corretto sulle tariffe d'ente: inseriva alla cieca e riconosceva il rifiuto del vincolo di esclusione dal testo del messaggio, che attraversa datahub e data-service. Ora usa operazioniCambioCosto, la stessa logica dei cinque storici del dipendente.

Il match è sul solo cognome, perché nel foglio c'è quello, e si accetta solo se univoco: due «ROSSI» renderebbero arbitrario a chi attribuire il costo, e sbagliarlo non si vedrebbe in nessun totale. Gli ambigui e i senza-anagrafica escono come COSTO_SENZA_DIPENDENTE.

Sul file vero: 221 intervalli per 54 persone, 50 con il costo corrente, 36 con il titolo di studio, 2 cognomi orfani (GIUDICI, IMGEGNERI — quest'ultimo sembra un refuso per INGEGNERI).

Una prova che non discriminava, l'ennesima: «l'intervallo porta la sua data di chiusura» passava anche togliendo la chiusura, perché l'intervallo successivo chiude comunque il precedente. A distinguere è l'ultimo, che nessuno chiude: lasciato aperto, un costo del 2025 varrebbe ancora nel 2030.

Quattro cose che c'erano e non si vedevano

Una segnalazione su una sola scheda dipendente — ACERBI GABRIELE — ha fatto cadere quattro difetti diversi, tre dei quali facevano sembrare assente un dato che c'era.

«In vigore» non era mai in vigore. Il confronto era !valid_to: corrente voleva dire «che non finisce mai». I costi importati dal foglio TARIFFE REALI hanno tutti una fine — il foglio dice mese per mese fin dove arriva — quindi la scheda mostrava «In vigore: —» per una persona con un costo valido oggi, e chi guardava concludeva che il costo non c'era. Ora corrente vuol dire che contiene oggi: già cominciato, non ancora finito. Un intervallo aperto e già cominciato resta corrente, che è il caso di chi cambia valore senza sapere fino a quando.

Le date erano spostate di un giorno. new Date("2026-01-01") è mezzanotte UTC, e Intl la formatta nel fuso del browser: in un fuso dietro Greenwich diventa 31/12/2025. Un costo valido dal 1º gennaio si leggeva «dal 31/12». valid_from e valid_to sono colonne dategiorni di calendario, non istanti — e non hanno un fuso: convertirli in uno è già l'errore. Ora si legge la parte data e si riordina, senza costruire nessun istante. La prova gira con TZ=America/New_York e riproduce esattamente il sintomo quando la si perturba.

Il referente non lo scriveva nessuno. La colonna REFERENTE del foglio finiva solo dentro le note come JSON. Si risolve ora in una seconda passata, dopo che tutti i dipendenti sono stati scritti: il capo di chi sta in cima può comparire venti righe più sotto, e risolverlo nel primo giro non lo troverebbe. Un referente che non è in anagrafica non si crea — comparirebbe negli organigrammi una persona che non esiste — si segnala. Sul file vero: 42 referenti risolti, 15 avvisi.

La società finiva solo sulla scheda, non nello storico. L'import BU scriveva sub_organization_id, e sede e orario avevano già il loro intervallo; la società no. La dimenticanza non si vedeva proprio perché il valore corrente c'era: la scheda mostrava la società giusta e lo Storico diceva «In vigore: —».

Due note sul metodo, entrambe emerse perturbando:

  • La prova sul referente «il capo può stare più in basso nell'elenco» non discriminava con una perturbazione meccanica. Quella fedele è risolvere i nomi solo fra chi c'era prima dell'import, e con quella cade.
  • La guardia «nessuno è capo di se stesso» che avevo scritto era un duplicato silenzioso: la regola sta già in operazioniCambioResponsabile, e ripeterla significava saltare la riga senza dire niente. Tolta: ora il caso — un refuso, o due omonimi collassati — produce un avviso, che è ciò che serve a chi deve correggere il file.

Riusare un modulo provato non basta, se le premesse sono diverse

conflicting key value violates exclusion constraint
"employee_cost_history_no_sovrapposizioni"

Per scrivere i costi reali avevo riusato operazioniCambioCosto — la stessa logica dei cinque storici del dipendente, provata e perturbata. Sembrava la scelta giusta proprio perché evitava di riscrivere la logica degli intervalli.

Ma quella logica chiude il periodo precedente solo se è aperto, perché nasce per uno storico che costruisce lei, dove l'ultimo intervallo è sempre aperto. Gli intervalli del foglio TARIFFE REALI arrivano già chiusi — il foglio dice mese per mese fin dove arriva — quindi non veniva chiuso niente e il nuovo ci finiva sopra:

in archivio   2025-09-01 → 2025-12-31
dal foglio 2025-11-01 → 2025-12-31 ← si sovrappone

Il modulo non era sbagliato: era fuori contesto. È una modalità di errore diversa dal copiare la logica, e meno visibile — il codice riusato è corretto, e a essere sbagliata è l'assunzione che ci sta sotto.

La regola giusta qui è più semplice, perché il foglio è un elenco completo e ordinato: si scrive ciò che non tocca niente, ciò che è già identico si salta in silenzio, e ciò che si sovrappone si segnala con entrambi i periodi. Non si sovrascrive uno storico già in archivio: può essere stato corretto a mano, e un import non lo ribalta senza dirlo.

Il doppio che non imponeva ciò che il database impone

La ragione per cui questo è arrivato in produzione con la suite verde: il client finto accettava qualunque intervallo. Un doppio che non impone ciò che il database impone non prova che il codice funziona — prova che il doppio è permissivo.

Ora clientDaFixture rifiuta le sovrapposizioni sulle sei tabelle che hanno un vincolo EXCLUDE, con lo stesso messaggio di PostgreSQL. Non è ciò che fa passare i test nuovi — quelli guardano le righe che restano — ma è la rete che farà cadere il prossimo errore di questa famiglia prima di arrivare in DEV.

Trovato di passaggio, perturbando: errori is not defined nel ciclo dei tariffari. Una fascia fuori catalogo faceva morire l'import con un errore di programmazione travestito da guasto dei dati. Non si era mai visto perché i tre macro-livelli li semina la migrazione 112, e in un database completo quella riga non si raggiunge mai.

Inquadramento e titolo di studio: tre cause per un sintomo

«Non vengono recuperati» era vero, e le ragioni erano tre indipendenti.

Il livello si leggeva dal posto sbagliato. employeePayload mette job_level dentro source, e l'apply leggeva payload.job_level — livello superiore, sempre undefined. Non si è mai risolto nessun inquadramento da quando esiste quella riga. E non si vedeva: il contatore dei livelli non riconosciuti restava a zero, perché la riga che lo incrementa non si raggiungeva mai. Un contatore a zero sembra una buona notizia.

I codici non si incontravano. Il foglio scrive LIVELLO 2, il catalogo ha il codice livello_2 e il nome «2° livello»: la stessa cosa detta in tre modi, confrontata per uguaglianza esatta. Passavano solo QUADRO e DIRIGENTE.

Il riconoscimento ora normalizza le tre forme, ma solo quelle: nel file c'è anche «Liv. da acquisire 3», che significa «il livello va ancora deciso». Pescarne il 3 e chiamarlo livello 3 sarebbe inventare un inquadramento, e nessuno se ne accorgerebbe più — un dato mancante si vede, uno inventato no. Restano fuori anche «QUADRO - LIV. 1», che è ambiguo, e «CO.CO.CO», che è un tipo di contratto.

In modalità Aggiunta non si scrivevano mai. Chi esiste già non si tocca — è la regola della modalità — ma inquadramento e titolo di studio il file del personale non li ha: se non si scrivono lì, restano vuoti per sempre, e «non sovrascrivere» diventa «non scrivere mai». Ora si riempiono i campi vuoti e si lasciano stare quelli valorizzati: la stessa regola applicata al singolo campo invece che alla riga intera.

I valori fuori catalogo escono ora come INQUADRAMENTO_SCONOSCIUTO, raggruppati per valore e con il conteggio: «23 livelli non riconosciuti» non dice quali, e chi deve correggere il foglio o allargare il catalogo non sa da dove partire.

Sul file vero, a parità di dati: da 0 a 24 persone con inquadramento, 36 con il titolo di studio, 23 valori segnalati con nome.

Filtrare i dipendenti per sede e società

Due tendine nell'elenco dipendenti, e nelle Impostazioni una colonna che dice quante persone lavorano in ogni sede — cliccabile, porta all'elenco già filtrato.

Tre decisioni che valgono la pena di essere scritte:

Filtrare sta nel servizio, non nella schermata. L'elenco esce paginato: filtrare le sole righe già arrivate direbbe «tre dipendenti a Milano» quando sono trenta e le altre ventisette stanno oltre la pagina. I due filtri vivono accanto a ricerca e stato, dove già si filtra.

Il conteggio si calcola dove stanno i dati. La schermata delle Impostazioni riceve il catalogo delle sedi, non l'anagrafica: per mostrare un numero dovrebbe scaricarsi tutti i dipendenti. Lo calcola il servizio, e conta chi è attivo — una sede chiusa da anni con dieci cessati non ha dieci persone.

Zero non è un collegamento. Aprire un elenco vuoto non aggiunge niente a «nessuno», e un pulsante che non porta da nessuna parte si prova comunque: le sedi vuote mostrano la parola, non un link.

Il parametro viaggia dentro l'hash#/dipendenti?sede=3 — perché il router lavora lì: getRouteId taglia su [/?], quindi la query non disturba la rotta. Leggerla da window.location.search tornerebbe sempre vuota, il click aprirebbe la pagina senza filtro, e il difetto non darebbe nessun errore — si noterebbe solo contando le righe. È l'unica parte di questo lavoro che ha un test dedicato in frontend, ed è quella che poteva rompersi in silenzio.

I filtri partono da ciò che dice l'indirizzo e poi comandano le tendine: riallinearli a ogni cambio di hash farebbe saltare indietro la scelta appena fatta.

La gerarchia si percorre

Dai riquadri della linea manageriale si apre la scheda di quella persona, e da lì si continua a risalire o a scendere: l'organigramma si percorre un passo alla volta invece di essere disegnato tutto. Era già previsto — «la navigazione fra le schede arriverà dopo» stava scritto nel commento del componente.

Due decisioni piccole e una trappola.

Il riquadro della persona corrente non è un collegamento: è dove si è già, e un link che non porta da nessuna parte si prova comunque.

È un collegamento vero, non un onClick su un riquadro. Un <a href> si apre in una scheda nuova col tasto centrale, si copia, e si raggiunge da tastiera; un div che reagisce al click non fa niente di tutto questo.

La trappola: il router dell'applicazione tiene in stato la rotta, non l'hash intero. Passare da #/dipendenti/336 a #/dipendenti/412 produce la stessa rotta, setRoute riceve lo stesso valore e React non ridisegna niente: l'indirizzo cambia e la scheda resta quella di prima, senza nessun errore e col nome sbagliato in cima. Finché si navigava da altre pagine il caso non si presentava — la rotta cambiava davvero. Con la gerarchia navigabile diventa il caso normale.

La scheda ora ascolta hashchange e rilegge l'id. Non tocco il router condiviso per una cosa che riguarda una pagina sola.

Ciò che non è coperto da un test, e perché. Il ri-render al cambio di hash è comportamento di un componente React, e questo frontend non ha né jsdom né testing-library: aggiungerli è una decisione sulle dipendenze del progetto, non un dettaglio di questo lavoro. È provato invece getEmployeeIdFromHash, il perno da cui dipende il riallineamento — perturbandone la regex, due prove cadono. Il resto è verificabile solo a mano: aprire una scheda, cliccare il responsabile, controllare che il nome in cima cambi.

Lo stato dedotto in un posto, letto nell'altro

Sintomo: dopo l'import, Allocato 29.510 € / 6.206.472 €, consuntivo e forecast a zero, un solo progetto con ore. Il budget coincideva al centesimo — i progetti c'erano tutti — e l'ultimo import dichiarava 11 record su 6058.

L'import non si era bloccato: aveva trovato tutto già presente. Le circa 4500 righe di ore erano in archivio da un import precedente, scritte con data_status = BASELINE_COVERED perché quel giorno il cursore era troppo avanti. In modalità Aggiunta una riga esistente non si tocca, quindi lo stato scritto non veniva più rivisto, e reimportare non poteva cambiarlo.

Riprodotto:

giro 1  cursore 2028-12 → 4581 applicate, 4519 righe, tutte BASELINE_COVERED, forecast 0 €
giro 2 cursore 2026-07 → 4 applicate, le stesse righe, ancora BASELINE_COVERED, forecast 0 €

E il cursore corretto non bastava. Su una riga salvata BASELINE_COVERED, con il cursore operativo a giugno 2026:

pagine progetti  →  1 riga utile   ← si riprende da sola
pagina Forecast → 0 EUR ← resta a zero

Due lettori, due regole. projectHours deduce lo stato ogni volta, e il commento dice perché: «lo stato non è scritto nella riga ma dedotto ogni volta, così spostare il confine di consolidamento non richiede di riscrivere le righe già salvate». panoramica — che alimenta Overview e Forecast — leggeva il valore scritto. La stessa nozione implementata due volte, e una delle due sbagliata: la conseguenza è che quelle due pagine non si riprendevano mai più, qualunque cosa si facesse col cursore.

Ora la panoramica usa withDynamicDataStatus, la funzione delle pagine progetti. Non una copia: la stessa.

Perché non l'avevo visto. Nelle mie simulazioni partivo sempre da un database vuoto, dove il valore scritto e quello dedotto coincidono. La divergenza si vede solo su un archivio che ha già una storia — e la storia era fatta di import precedenti con un cursore diverso.

Il test che mancava era il cablaggio, non il calcolo. La funzione pura sapeva già dedurre lo stato; a mancare era chi le passasse il cursore. Perturbando la lettura dello stato operativo, all'inizio non cadeva nessun test: provare il calcolo e non la lettura lascia scoperto proprio il punto in cui i due si incontrano. C'è ora una prova che attraversa panoramicaForecast con un client finto, e cade sia togliendo la lettura sia scollegando il cursore.

Un invariante in più tiene allineati i due lettori: sulla stessa riga e con lo stesso cursore, panoramica e pagine progetti devono dire la stessa cosa.

Datahub troncava a mille righe, in silenzio

La causa di tutto: 640 righe di ore importate su 6058, forecast quasi nullo, un solo progetto con ore, e tre import di fila che dichiaravano di aver lavorato senza scrivere quasi niente.

function normalizeLimit(value, fallback = 100) {
return Math.min(Math.max(parsed, 1), 1000); // ← tetto rigido
}

Il verticale chiedeva limit: 10000, data-service lo lasciava passare — il suo tetto per la lettura multipla è 10000, alzato apposta con un commento che dice «abbassare il tetto sotto quella soglia la troncherebbe, producendo totali sbagliati senza alcun errore» — e datahub ne restituiva 1000. L'intenzione era giusta e veniva sconfitta un livello più sotto.

L'apply leggeva 1000 delle 6058 righe di staging e finiva, convinto di aver finito. Le altre 5058 non erano né applicate, né fallite, né attese: per lui non esistevano. Nessun conteggio lo rivelava — le somme tornavano, sui dati sbagliati.

Verificato sul database di DEV: import_staged_entities?limit=100000 → esattamente 1000 righe; project_employee_monthly_hours → 606 righe su 12 progetti invece di 4519 su 21.

Due correzioni, per decisione del committente.

Il verticale sfoglia. elenca e leggiInsieme leggono a pagine di mille finché la pagina torna piena — una pagina corta è la fine, ed è l'unico segnale deducibile senza fidarsi di chi risponde. Il primo giro della lettura multipla resta una richiesta sola, e si torna a chiedere soltanto per le tabelle tornate piene: poche righe è il caso normale e non deve costare una richiesta per tabella. C'è un tetto di 50 pagine, che non è prestazione ma sicurezza: un filtro sbagliato che aprisse una tabella intera non deve far sfogliare per sempre.

Datahub dichiara. Il tetto resta — è una difesa sensata — ma smette di tacere: X-Datahub-Limit-Applied, X-Datahub-Total, X-Datahub-Truncated, e un avviso nei log quando la richiesta supera il massimo. total c'era già nel corpo, ma chi legge tiene solo data: gli header attraversano anche chi scarta il resto.

Il doppio povero ha fermato un difetto vero. Il logger del progetto espone warning, non warn: this.logger.warn(...) sarebbe stato un TypeError in produzione. Il doppio del test non aveva quel metodo — né res.set — e ha fatto fallire tutto invece di lasciar passare. Ora i due doppi portano gli stessi metodi degli originali, per la stessa ragione per cui il client finto ha imparato il vincolo di esclusione.

Le due diagnosi sbagliate che l'hanno preceduta partivano dallo stesso errore: leggere i conteggi dell'import come se fossero completi. «11 su 6058 con 5747 non contate» ammetteva due letture opposte — già presenti oppure mai viste — e ho scelto la sbagliata due volte. A chiuderla è stato interrogare il database invece di simulare: 606 righe reali contro le 4519 attese.

Il consuntivo storico dei progetti

Sintomo: progetti senza speso, budget interamente disponibile, risorse tutte libere. Tre difetti distinti, e il committente aveva ragione a dire che «prima funzionava».

Il numero storico si era perso. Il foglio CRUSCOTTO porta lo speso per progetto; finiva in projects.baseline_actual_cost_eur, e il motore di allocazione lo usa come tetto: `actual_cost_before_month = ore già consuntivate

  • baseline. Quando il foglio PROGETTI è diventato la sorgente dei progetti, progettoDaFogliolo ha messo anull`. In archivio: 22 progetti, tutti a zero. Regressione mia, e nessun test la copriva.

Il consuntivo non aveva una strada per arrivare a schermo. Cercata baseline_actual_cost_eur in projectQueries, panoramica, metriche, projectHours: non compare in nessuna. Serve solo al motore.

BASELINE_COVERED lo decideva il cursore. Ogni riga importata del passato ci finiva dentro e spariva da ogni totale. Ma quelle righe sono il consuntivo: a non contarsi sono solo gli anni che il foglio dichiara «SPESO STORICO», che hanno un confine diverso.

Ora i tre stati rispondono a tre domande diverse:

quandoperché
BASELINE_COVEREDmese ≤ ultimo anno «SPESO STORICO»quel denaro è già in baseline_actual_cost_eur
ACTUALdopo la baseline, fino al cursore inclusoè il consuntivo a grana fine
FORECASTdopo il cursoreprevisione

Il confine si legge dal file, non si scrive nel codice. Il foglio raggruppa le colonne sotto «SPESO STORICO — inserito a mano da vecchi file» e «SPESO CALCOLATO — fogli ore personale»: l'anno in cui il consorzio è passato dai vecchi file ai fogli ore cambierà, e una costante invecchierebbe in silenzio. Il confine viaggia poi nello stato operativo accanto al cursore, perché chi legge mesi dopo non ha il file sotto mano.

Sul file vero: 952.889 € di storico per i 21 progetti aperti, e le righe di ore si dividono in 1071 coperte dalla baseline, 1855 consuntivo, 1593 previsione.

La quadratura contro il cruscotto

Dopo la scrittura si confronta quello che abbiamo messo in archivio con quello che il foglio calcola. Chi non torna si segnala e si importa lo stesso: una differenza è un'informazione, non un motivo per fermarsi.

Il confronto è anno per anno, e solo sugli anni chiusi. Il primo tentativo usava TOTALE SPESO, che somma tutti gli anni compresi quelli futuri: dava una differenza per ogni progetto, che è come non segnalare niente. L'anno del cursore resta fuori — la colonna del foglio copre dodici mesi, il nostro consuntivo si ferma a metà — e gli anni della baseline pure, perché confrontarli con se stessi non prova niente.

Sul file vero: 12 anni-progetto non quadrano, tutti sul 2025, e i nostri numeri sono sistematicamente più bassi. Coerente con le 1815 righe dei fogli ore che contengono formule senza valore calcolato: la quadratura sta indicando un difetto del file, che è esattamente il suo mestiere.

Due prove che non discriminavano, trovate perturbando: «una formula senza valore non diventa zero» usava celle vuote, che non raggiungono nemmeno la conversione; e fineAnno(null) restituiva "0-12-01", perché Number(null) è zero e zero è finito.

Quadratura del consuntivo — congelata in attesa del committente

Il metodo è verificato: ore dai fogli × costo storico nostro dà lo stesso numero del foglio, riga per riga. Su ARCOE 2025, dove il costo storico esiste, i valori coincidono al centesimo:

ROMANO     160 h | foglio 3.501 | nostro 3.501
RIPAMONTI 126 h | foglio 3.316 | nostro 3.316
LIBERATO 33 h | foglio 953 | nostro 953
DELIPERI 26 h | foglio 845 | nostro 0 ← costo storico assente

Verificato anche che il riferimento a cella singola si risolve: la riga di CAPOZZI, il cui progetto è una formula con la cache persa, si attribuisce correttamente leggendo EP2025!$A$11.

Oggi il costo lo prendiamo dalla colonna tariffa del foglio, non dallo storico: costo = ore × payload.hourly_cost_eur. Lo storico dei costi entra nell'import solo per esservi scritto, mai per valorizzare le ore.

Cosa resta aperto, in ordine di peso:

  1. Metà anagrafica senza costo storico — 201 intervalli per 48 dipendenti su 97. Le loro ore valgono zero. Cause: quattro doppioni da unificare (DELIPERI ANDREA/ENEA, MATTIOCCO LUCA/LUCA MARIA, ROSSATO LETIZIA/LETIZIA ANNA MARIA, Patania JOE/Joa) e i cognomi orfani di TARIFFE REALI (CARDOZO, BALOSSA, GIUDICI, IMGEGNERI).
  2. Attribuzione per anno — differenze che quasi si compensano fra anni contigui (EXIRIS −47k nel 2026 e +51k nel 2027; IGEA-MUR 42.900 mai importati; BIOSMARTPU 2027 −148.769). È un difetto nostro, non del file, e non è ancora spiegato.
  3. Risolutore dei riferimenti a cella singola — provato che funziona, non ancora scritto nel lettore.
  4. Passare al costo storico invece della tariffa del foglio, con la decisione su cosa fare dove lo storico manca.

Fatto e attivo: baseline dal cruscotto (12 progetti, 1.033.365 €), confine baseline_until_month dedotto dal file, consuntivo dalle righe mensili, quadratura anno per anno, e il costo storico sommato anche nella panoramica — prima le pagine dei progetti lo contavano e Overview no.

Restano fuori dalla Fase 1, da riprendere più avanti:

  1. I cinque punti della sezione INCOMPLETO qui sopra.
  2. Il generatore di email da nome, cognome e dominio della società. Fatto il 20 agosto 2026 dentro l'import dell'anagrafica: il dominio è una colonna del file BU, coerente con la società — italbiotech.it, itb.it, lgca.it — e l'indirizzo si costruisce come nome.cognome@dominio, senza accenti né apostrofi, con i nomi doppi uniti dal trattino.
  3. Import/export dipendenti: l'import è fatto, l'export no.

I moduli di rendicontazione, e cosa si poteva fare senza le risposte

Progetti diversi rendicontano su moduli diversi. Fino al 21 agosto 2026 il report ore usava un modello solo per tutti; i sette moduli veri del committente — MIMIT, Regione Piemonte, Regione Campania, Regione Lombardia, MUR/PRIMA, EU Grants, timesheet europeo giornaliero — sono formati diversi, non varianti grafiche dello stesso.

E il report è per dipendente, non per progetto: chi in un mese ha lavorato su cinque progetti di cui tre su un modulo e due su un altro produce due file, e il totale si calcola per modulo.

Cosa dipendeva dalle risposte del committente, e cosa no

La mail del 21 agosto chiede le decisioni che il file non contiene: come si classificano le ore fra ricerca e sviluppo, dove prendere codice fiscale, matricola, monte ore annuo, CUP, decreto e SAL. Senza quelle, la mappatura dei campi non si può scrivere: non si sa cosa va nelle celle.

Ma tre cose non ne dipendono, e sono state fatte mentre si aspetta:

  1. Il catalogo nel database — migrazione 124: report_templates con codice, nome, impronta del file e struttura letta; report_template_files con il contenuto; projects.report_template_id per l'associazione.
  2. Il caricamento — una pagina, #/moduli, che accetta il file, ne legge la struttura e lo archivia.
  3. L'associazione ai progetti — una tendina nella scheda del progetto, accanto alla sede e al tariffario.

Tre decisioni che vale la pena ricordare

Il contenuto sta in una tabella a parte. Le rotte generiche di datahub fanno SELECT *: con il file nella stessa riga dei metadati, riempire una tendina avrebbe spedito ogni volta tutti i moduli per intero. Separandolo, l'elenco resta leggero e il file si chiede solo quando si genera un timesheet.

base64 nel database, non sul disco del servizio. I container si ricostruiscono di continuo: un modulo scritto sul filesystem del servizio sparirebbe al primo rilascio. Nello schema non esiste un solo byteabackbone_documents.documents tiene un file_path — quindi non c'era una convenzione da seguire. I moduli misurano fra 16 e 104 KB, tranne Piemonte a 825 KB.

La scelta del modulo è un attributo del progetto, non un'operazione sul modulo. Era nata come un elenco dei progetti dentro la pagina dei moduli, ed era il posto sbagliato: il committente l'ha fatto notare il 22 agosto 2026. Un progetto ha una sede, un tariffario e un modulo — si scelgono tutti nella sua scheda, e si salvano insieme al resto. La rotta dedicata che scriveva quel solo campo è stata tolta: due percorsi di scrittura sullo stesso campo prima o poi divergono (ADR-002). Della pagina dei moduli resta il verso utile a chi governa il catalogo — quanti progetti usano ogni modulo, che è ciò che serve sapere prima di cancellarne uno.

Il legame dal progetto è ON DELETE RESTRICT. Cancellare un modulo usato da trenta progetti li lascerebbe senza, e lo si scoprirebbe al primo export. Il rifiuto arriva comunque dal database, ma con il nome di un vincolo: il servizio lo anticipa e nomina i progetti, e suggerisce di disattivare invece che cancellare.

Cosa la lettura del file dice, e cosa non dice

Al caricamento il modulo viene aperto e se ne registra la struttura: fogli, dimensioni, dove corre la sequenza dei giorni, le etichette testuali. Non è la mappatura — dice dove sono i giorni, non quale campo ci va scritto — ma permette di proporla invece di farla scrivere a mano da zero.

Due cose vengono dichiarate invece che nascoste:

  • Le formule senza valore in cache. Un file salvato da un programma che non ricalcola porta formule il cui risultato non è mai stato scritto. Non sono celle vuote e non sono zeri: contarle come vuote presenterebbe come completa una lettura che è parziale. Sui moduli veri sono parecchie — 71 celle su MIMIT, 40 su Piemonte.
  • L'assenza di un asse dei giorni. Regione Lombardia ed EU Grants sono mensili: non hanno giorni, e va detto invece che lasciar credere di non averli riconosciuti.

La sequenza si accetta a partire da dieci giorni, non da trentuno: i moduli hanno spesso una colonna di totali subito dopo, e cercare una corsa perfetta avrebbe trovato l'asse solo nei file già a posto — cioè quelli in cui serve meno.

Cosa manca ancora

La mappatura dei campi, che aspetta le risposte. E la generazione vera e propria: oggi l'esportatore usa ancora il modello unico, e leggerà il modulo del progetto solo quando ci sarà una mappatura da applicare.

Il colore verde, e perché era una seconda verità

Fino al 22 agosto 2026 un mese dei fogli ore progetti YYYY contava solo se la cella corrispondente nella riga «TOTALE PROGETTI» del blocco era colorata di verde. Letteralmente: green > red && green > blue sul riempimento.

Il verde marca i mesi consolidati. Ma quale mese sia consolidato lo dichiara già il cursore «Dati ore consolidati fino a» che si compila all'import — mese compreso — e da cui discende ACTUAL o FORECAST. Erano due verità per lo stesso fatto, e dove divergevano vinceva il colore.

Divergevano in tre modi, tutti nella stessa direzione:

  • un mese consolidato ma senza il verde → ore buttate;
  • una riga sotto l'ultima riga «TOTALE PROGETTI» → nessun blocco la copriva, e tutte le sue ore uscivano dall'import;
  • un foglio senza nessuna riga di totale → l'anno intero perso, con un avviso in inglese che nessuno leggeva.

Nessun conteggio nominava quelle ore: non erano né applicate, né fallite, né attese. Sul file vero un solo progetto su ventidue quadrava.

Ora il colore non si guarda più. Le ore scritte nella cella sono il dato; il cursore decide, in un punto solo, cosa è consolidato e cosa previsto. Le righe di totale restano fuori perché sono somme, e quello è l'unico controllo rimasto.

Dopo il cursore la riga entra a zero ore, come prima. Non è una svista: il motore di previsione conta come impegnate tutte le righe non automatiche, e importare le ore previste dal file gli farebbe credere quel budget già speso, spegnendo la previsione. Se un giorno si vorrà che il file detti anche il forecast, è una decisione a sé e tocca il motore.

La quadratura, e il punto cieco che aveva

Il confronto col cruscotto era anno per anno contro le colonne dei singoli anni. Più fine, ma cieco dove serviva: saltava gli anni in cui il nostro calcolato era zero — cioè esattamente i progetti le cui ore avevamo perso del tutto — e quelli dal cursore in avanti. Un progetto sparito per intero non produceva nessuna segnalazione, ed è così che il difetto è rimasto invisibile per settimane.

Ora si confronta il totale, contro la colonna M «TOTALE SPESO»:

baseline (anni «SPESO STORICO») + righe dei fogli ore  ⟷  colonna M

Il totale non ha quel punto cieco: se manca qualcosa, manca dal totale. La segnalazione porta le due metà separate — quanto viene dallo storico e quanto dalle righe — perché senza, chi legge sa che manca qualcosa ma non se guardare il cruscotto o i fogli ore. Chi non quadra si importa lo stesso: una differenza è un'informazione, non un motivo per rifiutare i dati.

Il diagnostico

diagnostica/consuntivoVsCruscotto.js apre il file senza toccare il database e mette in colonna, progetto per progetto, il nostro consuntivo fino al cursore, la colonna M, lo scarto, e quanto la correzione recupera rispetto al vecchio lettore a colore. Si lancia con il file e il cursore:

node diagnostica/consuntivoVsCruscotto.js <file.xlsx> <AAAA-MM>

Serve a verificare il file prima di reimportarlo, e a distinguere ciò che recuperiamo da ciò che resta da spiegare.

Il consuntivo quadra, e cosa mancava

Il 22 agosto 2026 un solo progetto su ventidue coincideva con la colonna «TOTALE SPESO» del cruscotto. Ora coincidono tutti e diciannove i progetti aperti, al centesimo. Mancavano due cose, e nessuna delle due era il colore verde.

La formula del cruscotto le nomina entrambe:

=SUMIFS('ore progetti 2025'!$AB:$AB, 'ore progetti 2025'!$B:$B, $B8)
+SUMIFS('In-house 2025'!$C:$C, 'In-house 2025'!$B:$B, $B8)

1. Il nome del progetto è una formula, e non sempre si legge

Nei fogli ore la colonna del progetto contiene +'EP2025'!A11, non testo. Excel la ricalcola e mostra «ARCOE»; noi leggevamo il valore che il file si era portato dietro dall'ultimo salvataggio, e per 297 righe quel valore non c'era. Le celle hanno un formato data, quindi exceljs restituiva il risultato in cache come Date non valida — e cellText la trasformava in stringa vuota, per una ragione giusta: String(new Date(NaN)) è "Invalid Date", e settanta righe di ore vere erano già finite su un progetto chiamato così.

Il risultato era che la riga aveva ore e costo leggibili e nessun progetto a cui appartenere: 161.002 €.

La stessa cella, riferita in forma relativa in una riga e assoluta in un'altra, in un punto si leggeva e nell'altro no. Che è la prova che il dato non manca — manca solo la sua copia in cache. Quindi si segue il rimando: +'EP2025'!$A$11 → foglio EP2025, cella A11 → «ARCOE».

Non una lista progetto-cella scritta a mano, che andrebbe aggiornata a ogni riga nuova: il riferimento sta già nel file ed è aggiornato per costruzione. Solo il rimando secco, però — A11&" "&B11 no, e nemmeno SUM(...): quella sarebbe una macchina per calcolare i fogli Excel, e sbagliare un calcolo in silenzio è peggio che non farlo.

eFormulaSenzaValore non se ne accorgeva nemmeno, perché pretendeva result === undefined mentre lì result è un oggetto Date. Quelle righe sparivano senza comparire neanche fra quelle segnalate. Ora la riconosce, e segnala solo quando il rimando non l'ha risolta: altrimenti sarebbero trecento righe di rumore sopra i casi veri.

2. I fogli In-house non li leggevamo

Sono l'altro addendo della formula: 833.970 € in quattro fogli, mai aperti. Le colonne sono rovesciate rispetto a «ore progetti» — qui i costi stanno in D-O e le ore in AB-AM — e l'intestazione si controlla prima di leggere: un foglio che cambia forma deve fallire dicendolo, non leggere la colonna sbagliata.

Le nove persone che ci compaiono non sono dipendenti: dieci nomi su undici non stanno in elenco dipendenti. Entrano come collaboratori (employees.is_freelance), senza sotto-società, senza responsabile e senza inquadramento — per un fornitore non sono dati mancanti, non esistono.

Sul nome la prima versione sbagliava, e in modo istruttivo. Nel foglio l'ordine non è costante — «DAVIDE MOSSERI» è nome-cognome, «LIMENTA MATTEO» è cognome-nome — quindi per non inventare niente il nome restava tutto nel cognome, e la data di assunzione nulla: un fornitore non ha un'assunzione. Ma isUsableEntity pretende nome, cognome e data, e le nove persone venivano scartate in silenzio insieme a tutte le loro righe. Il modello era giusto; era il gradino dopo a respingerle, e su ARCOE mancavano ancora 10.320 €.

Ora il nome si divide sulla prima parola e si dichiara che metà potrebbero essere al contrario, con l'elenco: un ordine sbagliato si corregge dalla scheda in un minuto, una persona che non entra non si vede nemmeno. La data è il primo mese in cui compare nei fogli In-house: non è un'assunzione, ma è vero e non va corretto — al contrario del 1° gennaio 1980 che si mette a chi la data ce l'ha davvero mancante.

Il loro costo è dato, non calcolato: il foglio ha mesi con un costo e nessuna ora — una prestazione a corpo — e ricalcolarlo da ore per tariffa li azzererebbe. È l'unico caso in cui il payload di una riga di ore porta cost_amount_eur.

La quadratura verifica la lettura, non l'archivio

La colonna del cruscotto somma anche gli anni futuri: ARCOE ha 28.759 € di 2027 dentro la sua colonna M. In archivio, dopo il cursore, le ore stanno a zero. Confrontare i due darebbe una differenza per ogni progetto, che è come non segnalare niente.

Quindi si confronta baseline + quello che i fogli dichiarano, accumulato mentre si legge — prima che l'azzeramento dopo il cursore lo renda impossibile. La domanda a cui risponde è «abbiamo letto il file per intero?», ed è quella che conta: ciò che finisce in archivio discende da lì.

La barra che non arrivava mai in fondo

L'import finiva — si vedeva ricaricando l'elenco delle esecuzioni — ma la barra restava a metà. Due difetti indipendenti, e ognuno da solo bastava.

Il ciclo di attesa non tollerava niente. La schermata chiede lo stato ogni secondo e mezzo per tutta la durata dell'import, che su un file vero sono minuti. Il ciclo non aveva try: una sola richiesta andata storta — un 502 del gateway, un timeout, un 404 mentre Redis riparte — lo faceva uscire con un'eccezione, e la barra restava ferma dov'era mentre l'import continuava.

Ora un errore isolato non conta e una lettura riuscita azzera il conto: contano solo gli errori di fila, cinque, e a quel punto si smette dicendolo — «l'import potrebbe essere ancora in corso, controllare l'elenco delle esecuzioni» — invece di girare a vuoto per sempre.

La scrittura finale poteva non riuscire, e nessuno lo sapeva. Lo stato del job vive su Redis e la schermata lo legge solo da lì. Il rendiconto non è piccolo: nonImportate non è troncato di proposito — chi importa deve poter leggere tutto ciò che non è entrato — e su un file vero sono migliaia di righe. Se quella scrittura falliva, lo stato restava all'ultimo riuscito: 95%, «in corso», per sempre. Il fallimento spariva dentro un .catch(() => {}).

Ora si ritenta senza il rendiconto: sapere che è finito vale più del dettaglio, e il dettaglio resta comunque nel lotto di import. La schermata lo dice invece di mostrare una pagina vuota, e i due fallimenti finiscono nei log.

La risposta che non era quella che la schermata aspettava

Associare un progetto a un dipendente salvava correttamente e poi mostrava una pagina bianca, con Cannot read properties of undefined (reading 'forEach') in console.

Le tre rotte delle associazioni restituivano la riga appena scritta di project_employee_assignments — la creazione e la modifica — oppure il solo { closedAt } per la chiusura. La schermata metteva quella risposta dove teneva la scheda del dipendente, e da lì in poi detail.associatedProjects era undefined. Il tipo dichiarato lato client diceva già RendicontazioneEmployeeDetail per tutte e tre: il servizio non stava onorando il contratto che lui stesso dichiarava.

Ora si rilegge la scheda, come già facevano crea e aggiorna dei progetti per la stessa ragione: chi riceve la risposta mostra metriche e associazioni, che la riga da sola non contiene. La chiusura restituisce la scheda più closedAt, perché servono entrambi — la prima per ridisegnare l'elenco, il secondo per dire cosa è appena successo.

Perché è diventata una pagina bianca invece di un campo vuoto

detail?.associatedProjects.forEach(...): l'optional chaining si ferma su detail, non sul campo dentro. Con detail presente e il campo assente, l'accesso solleva — e sollevare dentro un useMemo porta giù l'intero albero React, non il solo componente.

Lo stesso schema era in altri quattro punti: la scheda progetto, due volte la pagina Forecast, e le presenze nella scheda dipendente. Un campo mancante ora dà un elenco vuoto. Non sostituisce il contratto giusto — lo rende leggibile invece che fatale.

Il forecast che «non funziona»

Su Talent 4 BBI il ricalcolo non allocava nessuno, e aggiungere risorse non cambiava niente. Il motore aveva ragione, e lo diceva male.

Nel foglio PROGETTI quel progetto aveva budget personale zero, e il motore si ferma alla seconda riga — if (!budget) return { reason: "no_budget" } — prima ancora di guardare chi è associato. Subito dietro c'era un secondo ostacolo: la fine era il 31 agosto 2026, e con il cursore ad agosto non restava nessun mese da prevedere.

Dalla schermata si leggeva solo:

Nessuna previsione scritta (no_budget).

Un codice inglese fra parentesi. Il motore il motivo lo sa sempre: si ferma con un reason, e quando arriva in fondo porta un contatore per ogni ragione di scarto — costo orario mancante, sede incompatibile, periodo fuori, mese già consuntivato, tetto ore, capacità esaurita. Tutto questo c'era e non si vedeva.

Ora il servizio compone la frase, perché è lui a conoscere i contatori:

Nessuna previsione scritta: 3 assegnazioni saltate perché il dipendente non ha un costo orario, o il tariffario del progetto non ha una tariffa per il suo livello; 1 assegnazione saltata per la sede.

Un test scandaglia il sorgente del motore, raccoglie ogni reason: che può emettere e pretende che ognuno abbia la sua frase. Ha funzionato subito: due motivi del ricalcolo globale erano già sfuggiti a chi scriveva le frasi.

E un difetto vero, trovato per strada

consuntivoDelProgetto — quanto un progetto ha già speso, da cui discende il residuo su cui il forecast lavora — contava una sola metà, e sbagliata: sommava tutte le righe mensili, comprese quelle degli anni della baseline che quel denaro lo rappresentano già, e ignorava baseline_actual_cost_eur.

Su Talent 4 BBI, che ha 59.510 € di storico e nessuna riga, il motore vedeva zero speso: con 100.000 di budget ne avrebbe promessi 100.000, cioè 59.510 oltre il residuo vero. La panoramica del Forecast, nella stessa schermata, contava già nel modo giusto: la stessa nozione scritta due volte, e una delle due sbagliata. Ora usa la regola di sempre — storico più righe dagli anni calcolati in poi — che è quella della quadratura col cruscotto.

La traccia del forecast, e il livello giusto

Il motore ora registra chi ha escluso e perché, non solo quanti: la differenza fra «3 assegnazioni saltate per il costo orario» e «manca il costo a Russo, Greco e Bruno». Il verdetto si raccoglie dove i motivi vengono già calcolati, quindi non c'è una seconda copia delle regole; una persona esclusa più volte per la stessa ragione compare una volta sola, e l'elenco ha un tetto di cinquanta oltre il quale si torna ai conteggi.

Nei log sta su due livelli, che rispondono a due domande e costano diversamente:

  • debug — l'esito, il residuo, lo speso e i contatori. Una riga per ricalcolo: si accende quando qualcuno chiede «perché non alloca nessuno».
  • trace — il verdetto persona per persona. Su un ricalcolo globale sono decine: si accende per un caso specifico e si spegne dopo.

Si alzano e si abbassano dall'admin interface senza toccare il codice. Non è un warning: un ricalcolo che non alloca nessuno è spesso la risposta giusta — budget finito, progetto concluso — e segnalarlo come anomalia riempirebbe i log di allarmi falsi.

Il logger condiviso non espone warn. Espone trace, debug, log, info, warning, error. Scritto con l'optional call, logger?.warn?.(...) non solleva: restituisce undefined e non scrive niente, e il difetto si scopre non trovando i log — cioè nel momento in cui servono. È già successo due volte, una in datahub e una qui, dove quattro chiamate sono rimaste mute. Ora un test legge le chiamate al logger nel sorgente e pretende che il metodo esista.

L'analisi delle allocazioni di un progetto

La domanda: da qui alla fine del progetto, le ore previste su questa persona stanno nelle ore che le restano?

Tre numeri per riga, non due — previste qui, previste altrove, capacità residua. Senza il secondo la pagina direbbe «sotto-allocata» a chi non ha un'ora libera: la sovra-allocazione è una proprietà della persona, e un progetto solo non la può vedere.

Il consuntivo c'è ma sta fuori dal giudizio. È già stato speso, e confrontarlo con una capacità che non esiste più non significa niente: si mostra perché dice cosa è costata finora quella persona.

La capacità, che prima non esisteva

capacità del mese = 8h × giorni lavorativi(mese, città) × percentuale d'orario
− ferie del mese × 8h × percentuale d'orario

Prima era 1840 / 12: centocinquantatré ore per ogni mese, agosto compreso, uguali per un tempo pieno e per un part time al 30%, e al netto delle sole ferie pianificate — chi non pianificava risultava sempre libero.

Quattro fonti, nessuna obbligatoria, e dove una manca si prosegue con un'assunzione dichiarata: orario non noto → tempo pieno; anno senza saldo ferie → i giorni dell'ultimo anno valorizzato; nessun saldo → capacità al lordo; sede assente → festività nazionali senza patrono. Gli avvisi escono per persona, e la riga li scrive accanto al nome invece di presentare una percentuale come se fosse un fatto.

Il calendario non c'era: la migrazione 108 elencava «calendari di festività» fra le cose lasciate fuori. Ora ci sono le dieci festività nazionali fisse, la Pasquetta calcolata, e il patrono per venti città — Milano 7 dicembre, Salerno 21 settembre, Caserta 20 gennaio. Nel 2026 nove festività cadono in giorni feriali: settantadue ore, il quattro per cento dell'anno. Una città che non conosciamo non ha patrono e la sua capacità è sovrastimata di otto ore all'anno: la pagina lo scrive.

Il piano ferie sposta le ferie, non le cambia di numero: dieci giorni collocati ad agosto valgono dieci ad agosto, e i restanti si dividono sui mesi senza piano. Il totale dell'anno resta quello che spetta.

Le scelte della schermata

Uno switch dentro la scheda Dipendenti del progetto, non una pagina che ne sostituisce un'altra: l'elenco resta dov'è — è da lì che si associa una persona — e l'analisi si raggiunge senza cambiare pagina.

Compare anche chi ha ore senza essere associato: l'import scrive le ore dal foglio, che non conosce le assegnazioni, e nasconderle lascerebbe fuori proprio le righe che nessuno ha deciso. E chi è associato senza un'ora, che è la categoria oggi invisibile.

Un progetto finito entro il mese consolidato non ha una percentuale: la pagina lo dice invece di mostrare una colonna di trattini che sembra un guasto.

La barra è sorella di quella del budget — stessa forma, stessa regola sullo sforo, un'altra grandezza. Arancione le ore di questo progetto (sono forecast), ardesia quelle altrove, grigio ciò che resta, rosso lo sforo. Il verde non compare: è il colore del consuntivo, e il consuntivo qui non si giudica.

I buchi dell'anagrafica, segnalati come quelli dei progetti

Un progetto senza tariffario aveva l'icona nell'elenco e il banner nella scheda. Una persona senza costo orario non aveva niente — e il costo orario mancante è esattamente ciò che aveva fatto sembrare rotto il forecast di TALENT4BBI: il motore la saltava, senza dirlo, e il progetto restava a zero ore.

Sei controlli, con la stessa regola dei progetti: l'assenza in astratto non è un problema, lo diventa quando qualcuno la userebbe.

segnalazionequando
costo orario mancanteerrore, solo se è assegnata a progetti a costo reale
inquadramento mancanteerrore, solo se è assegnata a progetti a costo standard
sede mancanteavviso: resta fuori dai progetti vincolati a una città
orario non dichiaratoavviso: la capacità la conta a tempo pieno
assunzione 1980-01-01avviso: è la data di ripiego che mette l'import
ore dopo la cessazioneerrore: ore che nessuno lavorerà

Due limiti tenuti stretti perché l'elenco resti leggibile: un cessato senza ore future non produce nulla, anche se gli manca tutto — non c'è più niente da riparare; e le ore già consuntivate dopo la cessazione non si contano, sono un fatto e non una previsione da cancellare.

L'orario di lavoro, l'unico storico in sola lettura

Cinque dimensioni storicizzate — costo, sede, società, riporto, orario — e la quinta si poteva solo leggere: employee_work_schedule_history si riempiva dall'import e da nessun'altra parte. Chi passava al part time dopo il caricamento del file non aveva dove scriverlo.

È il difetto peggiore della famiglia, perché non si vede: il forecast continua a produrre numeri plausibili, calcolati su una capacità doppia di quella vera. Un errore che si nota solo quando la persona non riesce a consegnare.

Ora l'orario si sceglie in tre punti, e sono tre cose diverse:

  • nella scheda anagrafica, e vale da oggi — chi sceglie in una maschera dice cosa vale adesso;
  • nella scheda Storico → Orario, con la decorrenza, per datare un cambiamento nel passato;
  • in Impostazioni → Orari, dove si crea la riga di catalogo che ancora non c'è: «PART TIME 60%» con la sua percentuale.

Il catalogo non si cancella — la chiave esterna dello storico è ON DELETE RESTRICT, e un orario a cui punta un periodo del 2024 non è più cancellabile senza riscrivere la storia di chi lo aveva. Il codice si deduce dal nome con la stessa regola dell'import, così l'orario scritto a mano e quello letto dal file restano una riga sola; e non si modifica più, perché è la chiave con cui il file successivo lo riconoscerà.

La percentuale resta facoltativa: nel foglio del personale c'è «PART TIME» senza numero, e inventare il 50% darebbe una capacità plausibile e sbagliata. Dove manca, le tendine scrivono «percentuale non indicata» invece di lasciarla dedurre dal nome.

Il forecast semi-manuale, fase uno

Il ricalcolo distribuisce, e distribuisce bene in media: riempie fino al budget, rispetta la capacità, non supera il tetto per risorsa. Ciò che non può fare è scegliere fra due impegni entrambi sostenibili — quale progetto tenga CATAPANO a marzo è una decisione, non un calcolo.

Questa fase è il gesto opposto: una riga per volta, con un nome e un mese.

Le tre regole

Il consuntivo non si tocca. È speso. Il rifiuto vive nel cancello di data-service, non solo nel codice che lo attraversa — e vale anche per una riga marcata FORECAST in un mese già consolidato, che è consuntivo lo stesso: lo stato si deduce dal cursore, non dalla colonna.

Ciò che si scrive a mano sopravvive al ricalcolo. La riga nasce MANUAL, e la cancellazione in blocco del motore porta via le sole AUTOMATIC. Non serve altro perché il resto torni: consuntivoDelProgetto considera consuntivo tutto ciò che non è AUTOMATIC, quindi una riga manuale scala già il budget residuo e occupa il suo mese. Lo sfoltimento invece andava corretto: riduceva qualunque riga FORECAST. Ora le manuali le conta nell'impegno della persona — quelle ore sono promesse — ma non le tocca. Un automatismo che le limasse renderebbe la modifica a mano una proposta, e nessuno si fiderebbe più di ciò che ha appena salvato.

Zero ore non è una riga a zero. Una riga a zero resta in ogni elenco e in ogni somma, e chi la rilegge non sa se qualcuno l'ha azzerata o dimenticata. Azzerare significa togliere la riga.

Il salvataggio, e perché non è automatico

La prima versione salvava ogni casella appena perdeva il fuoco. È stata cambiata su indicazione del committente, e la ragione regge da sola: distribuire ore fra i mesi è un ragionamento a somma quasi costante — si tolgono di qui per metterle di là — e salvare a ogni casella scrive stati intermedi che nessuno ha deciso. Il mese svuotato salvato, quello da riempire ancora no, e nel mezzo un progetto che sembra sotto budget: se qualcuno guarda la barra in quel momento, legge un numero che non è mai stato una decisione.

Il tasto in fondo dice quando la decisione è finita. La barra che lo contiene resta appiccicata al bordo — con dodici mesi su tre righe, un tasto che scorre via è un tasto che non si trova — e accanto porta quanto cambia, in ore e in euro, col segno: «60 h» non dice se sono in più o in meno, ed è l'unica cosa che conta quando si redistribuisce.

Le scritture partono in ordine di mese, una alla volta. Non è una transazione, e non si finge che lo sia: se una viene rifiutata — un mese consolidato, un costo orario che manca — si dice quante erano già passate, e la griglia si rilegge per mostrare ciò che è stato scritto davvero. Chiudere la griglia con delle caselle in sospeso chiede conferma, e chiudere la scheda del browser fa avvisare il browser.

Cosa si vede

Nella scheda Analisi allocazioni, tre cose nuove:

  • la barra del budget in euro, in cima. Spostare ore da una persona a un'altra non cambia il costo solo se hanno la stessa tariffa, cioè quasi mai: senza la barra la domanda «e adesso il progetto ci sta dentro?» richiede di cambiare scheda, e chi modifica smette di controllarla. È la stessa somma della panoramica, e ora è scritta una volta sola;
  • Modifica mesi su ogni riga, che apre la griglia dei mesi di quella persona. Ogni casella porta accanto quanto ha altrove e quanto le resta: senza, la griglia direbbe che c'è spazio a chi non ha un'ora libera. Si compila tutta e si salva insieme, con il tasto in fondo;
  • Elimina le ore dopo la cessazione, accanto alla frase che descrive il problema invece che in una barra di azioni in cima: chi legge quella riga è chi vuole premerlo. Il mese della cessazione resta — chi cessa il 18 ottobre, a ottobre ha lavorato.

Il tasto sta sulla riga ma non ne eredita il click: la riga porta alla scheda della persona, il tasto apre i mesi. Due destinazioni sulla stessa riga vanno tenute separate, o una delle due sorprende.

L'audit non è stato scritto

Ogni modifica lascia una traccia in audit_log senza una riga di codice in più: in data-service l'audit è un middleware su tutte le rotte non-GET, «so a new mutating route cannot silently ship without a trail». Le tre nuove ci passano sotto come le altre.

Cosa resta fuori

Le azioni massive — selezionare più persone e ridurre tutte in proporzione — per decisione presa: prima la fase uno, e le massive quando si sarà visto come si usa questa.

La prima azione massiva: le ore dopo la cessazione

Due tasti, e fanno cose diverse di proposito.

Sulla scheda di una persona, sotto il banner dei problemi: elenca le ore che ha oltre la sua uscita, progetto per progetto, e le toglie tutte. Chi guarda una persona sta chiudendo una posizione, e lasciarne metà su un progetto che non stava guardando sarebbe un lavoro finito senza dirlo. È anche l'unico posto da cui si può fare in un gesto: quelle ore stanno su progetti diversi, e aprirli uno per uno è esattamente il lavoro che il tasto evita.

Sulla scheda di un progetto, in cima all'analisi delle allocazioni: elenca chi ha ore oltre la propria cessazione su quel progetto, e toglie solo quelle. Chi guarda un progetto ripulisce il proprio conto, e non ha titolo per toccare quelli altrui.

Entrambi mostrano i numeri prima: la conferma elenca i progetti e le ore, perché una conferma generica fa premere Ok senza leggere, e questa cancella righe. La lettura e la cancellazione stanno sullo stesso indirizzo (GET e DELETE su /employees/:id/ore-dopo-cessazione) così il numero che il tasto mostra e quello che poi cancella vengono dallo stesso calcolo.

Quattro cose che non si toccano

Il consuntivo, i mesi già consolidati, il mese della cessazione — chi cessa il 18 ottobre, a ottobre ha lavorato — e le righe senza id, che non sono cancellabili e per cui non si finge di poterlo fare.

Il controllo sul consuntivo guarda due cose, non una: lo stato dedotto dal cursore e quello scritto nella colonna. Il primo è la verità del dominio; il secondo è ciò che guarda il cancello di data-service, che rifiuterebbe la cancellazione — e una cancellazione in blocco che muore a metà lascia una situazione che nessuno ha deciso.

La pagina della qualità dati, e perché non è una campanella

La proposta era una campanella accanto all'icona utente, con il numero delle notifiche. La forma è stata cambiata dopo averla discussa, e la ragione vale più della schermata: quello che abbiamo è stato, non eventi.

Le 75 voci del catalogo sono condizioni ricalcolate a ogni lettura — «questo dipendente non ha costo orario», «questo progetto non ha tariffario» — e restano vere finché qualcuno non ripara il dato. Una campanella è una metafora a eventi: non letto/letto, «3 nuove», scarta. Applicarla allo stato produce tre guasti prevedibili:

  • il numero non scende mai. Con 25 codici di avviso sui dipendenti e un'anagrafica da sessantacinque persone, il badge parte da qualche centinaio. È lo stesso «un banner che compare sempre si impara a ignorare» che governa già il banner e l'icona;
  • «segna come letto» sarebbe una bugia. L'unico modo onesto di far sparire una segnalazione è riparare il dato;
  • le notifiche sono per utente, lo stato è per organizzazione. La prima cosa vuole una tabella di letture, la seconda non vuole niente.

Quindi due pezzi, non uno.

La pagina

Terzo posto in cui le segnalazioni compaiono, e risponde a una domanda che gli altri due non toccano. L'icona nell'elenco dice quali righe guardare; il banner nella scheda dice cosa manca lì; la pagina dice quanto è messa male la base dati, e da dove si comincia.

Raggruppata per tipo, non per entità. Diciassette dipendenti senza costo orario sono diciassette righe uguali, un rimedio solo e un pomeriggio di lavoro solo: elencarle una per una obbliga chi guarda a scoprirlo contando. Il rimedio sta sul gruppo — è lo stesso per tutte — e si legge una volta.

L'ordine è gravità, poi numerosità, poi alfabetico. Chi apre la pagina chiede «da dove comincio», e la risposta è «da ciò che ferma un calcolo, e ne ferma tanti». L'ultimo criterio non è decorazione: senza, due letture della stessa pagina mostrerebbero due ordini diversi.

Con la pagina, il banner che elencava tutte le segnalazioni in testa a Overview e Forecast non aveva più senso: erano centinaia di righe, e un muro in testa alla pagina si impara a saltare — nascondendo proprio il posto dove quelle righe sono raggruppate e hanno un rimedio scritto una volta sola.

Ora la regola è una sola, e discende da cosa si sta guardando: con un'entità elenca, senza un'entità riassume. Sulla scheda di un progetto o di una persona il banner resta quello di prima — sono i suoi problemi, e si riparano lì. Sulle pagine d'insieme diventa una riga: quanti errori, quanti avvisi, il gruppo più numeroso, e un collegamento alla pagina.

La pastiglia

Sul menu, sulla voce «Qualità dati», e conta i soli errori — 29 codici su 75. Un errore significa che un calcolo non produce numeri; un avviso che i numeri ci sono ma qualcosa non torna. Solo il primo merita un contatore permanente.

È una forma da stato anche visivamente: un numero accanto a una voce non promette che sia nuovo, dice che è così. La campanella resta libera per gli eventi veri — import finiti o falliti, ricalcoli, run di consuntivo — che oggi si vedono solo restando sulla pagina che li ha lanciati, e che una tabella delle letture la meritano davvero.

La campanella, e la tabella che non c'è

Eventi, non stato: import finiti o falliti, ricalcoli del forecast, consuntivi consolidati. Hanno un istante, un esito e un autore, e si leggono una volta — per questo il loro contatore scende, mentre quello della qualità dati non scenderebbe mai.

Non c'è una tabella di notifiche

L'elenco si ricava da ciò che quelle operazioni già scrivono: import_batches, import_apply_runs, allocation_consolidation_runs. Una tabella di notifiche a parte può mancare un evento in silenzio — basta che chi aggiunge un percorso nuovo si dimentichi di scriverci — mentre un elenco derivato da ciò che è successo è vero per costruzione.

Restavano fuori i ricalcoli del forecast, che non lasciavano traccia da nessuna parte. Non era solo un buco per la campanella: era l'impossibilità di rispondere a «quando è stato ricalcolato l'ultima volta, da chi, e cosa ha scritto», e il resoconto del motore — esclusi, sfoltimento, avvisi sulle tariffe — spariva chiudendo la pagina. rendicontazione.forecast_runs è quel registro; la campanella è il suo primo lettore, non la sua ragione.

Del resoconto si conserva il perché, non le righe: le righe scritte stanno già nella tabella delle ore e su un globale sono migliaia, mentre il motivo dell'esito non lo può ricostruire nessuno dopo. E registrare non può far fallire un ricalcolo: il lavoro è già stato fatto e le righe sono già scritte, quindi una scrittura di servizio che va storta si annota nel log e si prosegue — restituire un errore su un'operazione riuscita la farebbe rilanciare.

Un segnalibro, non uno stato per notifica

user_notification_state tiene una riga per persona con «visto fino a». Le notifiche qui non si scartano una per una: si scorrono, e da un certo punto in poi sono nuove. Con un segnalibro «segna tutte come lette» è una scrittura sola, e non ci sono righe che crescono per sempre a ogni evento moltiplicato per ogni utente.

Il segnalibro non passa dal CRUD generico. Gli si sarebbe dovuta concedere la capability self, che ce l'hanno tutti, e il CRUD generico non guarda di chi è la riga: chiunque avrebbe potuto segnare come lette le notifiche di un altro. Passa da /notifications/state, che l'utente lo prende dal token — stessa forma di /forecast/rows e /leave/consumed, e per la stessa ragione. Anche l'istante lo decide il servizio: accettarlo dal chiamante permetterebbe di dirsi «visto fino al 2030» e non vedere più niente.

Due decisioni sulla schermata

Il conto si azzera aprendo, non leggendo voce per voce, ed è coerente col segnalibro: da un certo punto in poi sono nuove. Il numero sparisce subito nella schermata e la scrittura parte dietro — aspettare la risposta per spegnere un pallino farebbe sembrare inceppato un gesto già capito; se la scrittura fallisce, al ricaricamento il conto torna, ed è giusto, perché il segnalibro non si è spostato davvero.

L'ordine è cronologico e basta. Una campanella che riordina per gravità fa perdere il filo di cosa è appena successo, che è l'unica domanda a cui deve rispondere: il livello serve al colore, non alla posizione. E la finestra è di due mesi — una campanella non è un archivio, e il registro completo resta nelle pagine che lo mostrano.

La mappa delle allocazioni

La domanda non è «come sono collegati dipendenti e progetti»: è «questa persona è oltre la sua capacità — dove sono le sue ore, e dove conviene toglierle?». È quantitativa, e questo decide la forma.

Un grafo a forza sarebbe stata la risposta d'istinto e la scelta sbagliata: la quantità finirebbe nello spessore degli archi, che è il canale quantitativo più debole — da due linee non si legge «510 contro 480» — e con settanta persone su cinquanta progetti diventa un gomitolo che si dispone diversamente a ogni apertura.

La forma scelta è un ego: un centro e i suoi vicini, un salto alla volta. Con due livelli la stessa persona ricomparirebbe più avanti nel disegno, e non si capirebbe più se è la stessa o un'altra — che è esattamente l'obiezione per cui il Sankey è stato scartato.

React Flow come tela, non come motore di layout

@xyflow/react era già in casa: la usa la Pipeline di admininterface, e la usa bene — nodi che sono componenti React normali, posizioni esplicite, draggable: false. Qui vale lo stesso: le coordinate le calcoliamo noi, il centro a sinistra e i vicini incolonnati, aritmetica pura. La stessa mappa si dispone allo stesso modo a ogni apertura, e due visite si possono confrontare.

Ciò che si guadagna rispetto a un SVG scritto a mano: pan, zoom, fitView, archi con spessore, e la strada aperta se un domani servisse la profondità due. Ciò che non si perde: i nodi restano HTML, quindi le barre già esistenti ci stanno dentro e il testo resta leggibile da tastiera e da screen reader.

L'attribuzione della libreria resta visibile. Toglierla — come fa la Pipeline con proOptions — richiede l'abbonamento Pro di xyflow: il codice è MIT, quella riga no.

Le regole della lettura

Lo spessore degli archi è proporzionale alle ore, ma il numero è scritto in ogni scheda: con lo zoom lo spessore percepito cambia, e un dato che vivesse solo lì cambierebbe con la vista. Lo spessore ha un minimo e un massimo, non è una proporzione pura: un vicino da otto ore deve restare visibile, e uno da novecento non deve coprire gli altri.

Il secondo numero di ogni vicino è quello che decide dove intervenire: da una persona è l'avanzamento del budget del progetto — togliere ore da un progetto già oltre budget avvicina due conti insieme — e da un progetto è l'impegno complessivo della persona, perché la sovra-allocazione è una proprietà sua e un progetto solo non la può vedere.

Uscendo si arriva dove si era arrivati

Le briciole di pane mostrano il percorso, e lasciando la mappa si va sulla scheda dell'ultimo nodo guardato, non su quella da cui si era partiti. Girare per tre salti e ritrovarsi al punto di partenza vanificherebbe il giro.

Il fuoco si confronta per valore, non per identità

Il secondo difetto è costato una raffica di richieste in cerchio, fino a net::ERR_INSUFFICIENT_RESOURCES. Chi ospita la mappa le passa il punto di partenza come oggetto scritto lì — { tipo: "dipendente", id } — e quell'oggetto è nuovo a ogni suo render. L'effetto che rimetteva lo stato dipendeva dall'identità dell'oggetto, quindi si riattivava a vuoto, rimetteva lo stato, faceva ridisegnare, e ricominciava: ogni giro una lettura in più.

Non si presenta come un errore ma come lentezza, e poi come un browser che finisce le connessioni — la causa è lontana dal sintomo, come nel caso dei pezzi del bundle. Ora le dipendenze degli effetti sono valori (tipo, id), e una funzione stessoFuoco confronta due fuochi per valore: regge anche se un domani qualcuno rimettesse l'oggetto fra le dipendenze, e ha i suoi test.

L'endpoint speculare

GET /employees/:id/allocazioni è lo specchio di quello di progetto e riusa gli stessi moduli di capacità. Cambia la finestra: quella di un progetto finisce col progetto, quella di una persona arriva fino all'ultimo progetto che la impegna. I due numeri non coincidono per costruzione, e la risposta dichiara la finestra invece di lasciarla dedurre da un confronto fra due schermate.

Una nota di build

Il primo tentativo ha lasciato la pagina bianca con Cannot read properties of undefined (reading 'createContext'), un errore che con la causa non c'entrava niente. La regola di suddivisione dei pezzi diceva id.includes("/react/"), che combacia anche con @xyflow/react/dist/…: React Flow finiva nel pezzo di React mentre le sue dipendenze — zustand, classcat, @xyflow/system — restavano nell'altro, i due si importavano a vicenda, e in un ciclo fra moduli ES uno viene eseguito prima che l'altro sia inizializzato. Ora il confronto è sul percorso completo del pacchetto, la regola sta in vite.chunks.mjs e ha i suoi test — è il genere di difetto che il typecheck non vede e che si manifesta solo a build fatta.

@xyflow/react è pinnata a 12.11.2, non ^12.11.2: la 12.11.4 importa da @xyflow/system un simbolo che la versione a cui si aggancia non esporta, e la compilazione si ferma. È anche la versione che gira in admininterface, quindi la piattaforma ne ha una sola. In vite.config.mjs due alias mandano zustand/traditional e zustand/shallow sui file ESM: la mappa exports di zustand tiene il CJS come ultimo ripiego, in build quel ripiego vinceva, e da un file CJS non si tracciano le esportazioni con nome.

Il freno, e perché non basta correggere il difetto

Il ciclo di render della mappa non ha prodotto una schermata rotta: ha prodotto un guasto del server di DEV. Una raffica di letture verso una rotta che legge tutte le ore dell'organizzazione, risposte da 750 KB, timeout a dieci secondi e nessuna attesa prima del tentativo successivo. Le richieste si accumulavano in volo, RAM e swap si sono esauriti, il carico è salito a 13.

Il difetto è stato corretto. Ma correggerlo non è una precauzione: è la riparazione di quel difetto. La precauzione è rendere impossibile che il prossimo arrivi al server, e sta in due posti che non sanno niente delle schermate.

Lato client: una sola in volo, e una pausa che cresce

api/freno.ts avvolge tutte le letture. Due regole:

  • una sola richiesta in volo per indirizzo. Chiedere di nuovo mentre la prima non è tornata restituisce la prima promessa. È la regola che da sola avrebbe contenuto l'incidente: le richieste non hanno esaurito la memoria perché erano tante, ma perché erano tante insieme;
  • dopo un errore si aspetta, e l'attesa raddoppia fino a mezzo minuto. Nella pausa si ridà l'errore precedente, non un silenzio: la schermata deve mostrare un guasto, non una pagina vuota.

Vale per le letture e non per le scritture: due GET identiche chiedono la stessa cosa e una risposta le accontenta entrambe, due POST identiche sono due gesti e fonderli perderebbe il secondo.

Lato servizio: un solo volo per credenziale

unSoloVolo è lo specchio, e serve perché il prossimo client che martella potrebbe non essere il nostro. Le due letture di allocazioni si uniscono quando arrivano insieme dalla stessa credenziale.

La chiave comprende chi chiede, con l'impronta della credenziale e non la credenziale: due persone che guardano lo stesso progetto possono appartenere a organizzazioni diverse, e riusare la risposta dell'una per l'altra sarebbe una fuga di dati, non un'ottimizzazione. E non è una cache — niente sopravvive alla richiesta — così chi arriva dopo legge i dati di quel momento.

E un tetto, per quando il client non si può correggere

Le prime due precauzioni presuppongono un frontend aggiornato. Il giorno stesso si è visto perché non basta: il servizio era stato ricostruito, il frontend no, e il ciclo continuava dal bundle vecchio. unSoloVolo aveva tolto l'accumulo in volo — che è ciò che aveva esaurito la memoria — ma non il flusso: un ciclo che aspetta ogni risposta prima di rilanciare produce traffico costante, e il servizio continuava a rifare un lavoro pesante per nessuno.

Il tetto è per credenziale e per rotta, su finestra scorrevole di un minuto, ed è largo abbastanza che navigando non ci si arrivi mai. Oltre, 429 con Retry-After e un messaggio che dice cosa sta succedendo — «se succede senza che tu stia facendo niente, la scheda sta ricaricando da sola» — perché un rifiuto senza un «quando» fa riprovare subito, che è esattamente ciò che si sta cercando di fermare.

Due dettagli che i test tengono fermi: i rifiuti non contano nel tetto, altrimenti un client rotto — che non smette di provare — resterebbe fuori per sempre; e il tetto è per chiave, così il client rotto di uno non chiude fuori gli altri.

Cosa resta da fare

Il freno contiene il danno; non rende leggera una lettura pesante. Le allocazioni leggono project_employee_monthly_hours per intera organizzazione a ogni chiamata, e la risposta da 750 KB dice che il dataset è quello che è. Due strade, da misurare prima di scegliere: aggregare lato servizio ciò che serve a «quanto ha altrove» invece di portarsi indietro tutte le righe, e alleggerire la risposta togliendo ciò che la schermata non usa.

Due percentuali, e perché stanno insieme

La stessa persona compariva al 418% nella pagina Forecast e al 103% nella mappa delle allocazioni. Nessuno dei due numeri era sbagliato: rispondevano a due domande diverse, e si chiamavano tutti e due «impegno %».

ForecastMappa
periodol'anno solaredal mese dopo il cursore all'ultimo progetto che lo impegna
numeratoreconsuntivo + previstosolo il previsto
denominatore1840/12 al mese, uguale per tuttigiorni lavorativi della città, part time, ferie

Il periodo è la differenza che pesa di più: sedici mesi diluiscono ciò che dodici concentrano. Il numeratore viene dopo: chi ha molto consuntivo quest'anno gonfia il primo numero e non tocca il secondo. Il denominatore, paradossalmente, spinge nella direzione opposta — la capacità vera di un part time è minore, quindi la percentuale della mappa dovrebbe essere più alta, non più bassa.

La scelta: mostrarli entrambi, non sceglierne uno

Unificarli avrebbe voluto dire cancellare una delle due domande. «Quanto è carico quest'anno» e «quanto di ciò che è ancora promesso sta nelle ore che gli restano» servono a due decisioni diverse, e la seconda è quella su cui si interviene.

Il numero dell'anno si calcola con la funzione della pagina Forecast, importata invece che riscritta. È la parte che conta: due numeri che rispondono a due domande vanno bene, due numeri che rispondono alla stessa domanda con due formule no — e riscrivendola avremmo avuto tre numeri invece di due. L'anno è quello del cursore, non quello del calendario: è l'anno di cui parla la pagina Forecast quando la si apre.

E poi il 418% si è rivelato sbagliato

Mostrando i due numeri accanto, il secondo è diventato 132% invece di 418% — e la discordanza, questa volta, era un difetto vero.

Nel Forecast un solo ciclo riempiva due mappe: le ore per progetto e le ore per persona. Per il progetto sommava tutti gli anni, ed è giusto — un budget copre tutta la durata. Per la persona faceva lo stesso, ma poi divideva per la capacità di un anno solo. Chi lavorava dal 2025 al 2027 compariva al 418%: un numero che non voleva dire niente, e che la stessa persona smentiva in ogni altra schermata.

La correzione è una riga — le ore della persona si contano solo per l'anno guardato — ma la lezione è nella forma: due domande diverse che passano dallo stesso ciclo finiscono per rispondere entrambe con la formula di una sola. Adesso ci sono due test, uno per verso: la persona conta un anno, il progetto li somma tutti.

Ciò che la legenda dice, e ciò che ammette

La legenda spiega le due colonne e porta i numeri da cui escono — «1600 h su 1840 h» — perché una percentuale senza il suo rapporto è una parola contro un'altra. E dichiara un limite che resta: nessuno dei due dice quando. Un impegno medio sostenibile su sedici mesi può nascondere un mese pieno, e finché la mappa non mostrerà il profilo mensile quella domanda va fatta altrove.

«Ha ore dopo la cessazione» e nella mappa zero ovunque

ZENI ALESSANDRO, cessato: la piattaforma segnalava ore previste dopo la sua uscita, e la mappa delle allocazioni lo mostrava con 0 h su ogni progetto. Due schermate che si contraddicono sono peggio di una schermata sbagliata, e qui il difetto era in tutte e due.

Lo stato dedotto, ancora una volta

Il controllo di qualità guardava lo stato scritto nella riga ed escludeva le sole ACTUAL. Ma una riga marcata FORECAST in un mese già consolidato è consuntivo: lo stato si deduce dal cursore, come in ogni altra parte del dominio. Le ore di ZENI stavano in mesi chiusi, quindi erano fatti — e chiamarle «promesse a chi non ci sarà» era falso.

È la terza volta che questa distinzione morde, e ogni volta in un posto nuovo: la cancellazione delle ore dopo la cessazione, il conteggio del pannello di progetto, e ora la segnalazione di qualità. Chi legge data_status senza il cursore sta leggendo una fotografia vecchia.

Ma quelle ore vogliono dire qualcosa

Non si potevano semplicemente far sparire. Se qualcuno risulta aver lavorato dopo la propria cessazione, o la data di cessazione è sbagliata o le ore sono finite nel mese sbagliato — ed è esattamente il genere di cosa che il catalogo qualità esiste per dire. Da qui la voce nuova DIPENDENTE_CONSUNTIVO_DOPO_CESSAZIONE, avviso e non errore: quelle ore sono nel bilancio dei progetti e non si tolgono, quindi il rimedio non è cancellarle ma verificare la data.

E lo zero che sembrava un guasto

La mappa mostrava le sole ore previste, che per un cessato sono zero. Il numero era vero — non c'è più niente da spostare — ma illeggibile da solo. Ora accanto compare ciò che invece c'è stato: «niente da prevedere · 640 h già lavorate». Uno zero che si spiega smette di sembrare un guasto.

Archiviare una segnalazione, e i progetti che non ci sono più

Due richieste che condividono un principio: un elenco che non si vuota si impara a saltare.

Ciò che non si ripara si archivia

Alcune segnalazioni non hanno un rimedio. Le ore consuntivate dopo una cessazione sono nel bilancio di un progetto e non si tolgono; la data di assunzione di ripiego su una persona entrata nel 2009 nessuno la ricostruirà. Restavano lì per sempre, e trascinavano giù anche le altre.

Ora si archiviano — e archiviare non è correggere, cosa che il disegno tiene visibile: le archiviate si contano a parte, si guardano con un filtro, e si riportano indietro con un click. Il riepilogo e la pastiglia sul menu contano le sole vive, che è tutto il senso dell'operazione.

L'impronta è la parte che rende onesto il meccanismo. Si archivia una segnalazione com'era — «120 ore consuntivate dopo la cessazione» — non il suo codice in astratto. Se il numero cambia, l'impronta non combacia più e la segnalazione torna: senza, una decisione presa su un fatto piccolo coprirebbe per sempre lo stesso fatto diventato grande. La riga lo dichiara a chi guarda: «tornerà se i numeri cambiano».

L'archiviazione vale per quella entità, non per il codice: decidere di convivere con la data di ripiego di ZENI non dice niente su quella di ROSSI.

Sul permesso: scrivere quality_dismissals ricade su admin di proposito — silenziare una segnalazione è una decisione di governo del dato — mentre leggerla basta read, perché vedere cosa è stato archiviato non è un privilegio e nasconderlo renderebbe incomprensibile un elenco più corto del previsto. È un'eccezione dichiarata nel test di contratto con data-service, non una dimenticanza.

Gli elenchi mostrano i progetti in corso

Stessa forma dei dipendenti — in corso, chiusi, tutti — e stessa ragione: ciò che è finito non sparisce, si chiede. La pagina Progetti lo faceva già; Overview partiva dagli attivi ma senza modo di vedere gli altri; Forecast non filtrava affatto, e un progetto chiuso due anni fa gonfiava ogni totale con denaro non più in gioco.

Nel Forecast il filtro sceglie un insieme di progetti e tutta la vista si calcola su quello: KPI, righe dei progetti e ore delle persone. Se le ore restassero tutte, la pagina mostrerebbe il carico su progetti che non elenca — due numeri della stessa schermata che parlano di insiemi diversi. La riga sotto il filtro lo dice a parole, perché una vista filtrata che non lo dichiara fa credere che i totali siano quelli di tutto.

«Attivo» si misura sulla fine effettiva, la stessa dell'elenco progetti: una seconda definizione avrebbe prodotto due elenchi diversi. E «chiusi» è il complemento esatto di «attivi» — insieme danno tutti — perché un progetto che non compare in nessuna delle due viste sarebbe invisibile ovunque.

Quanto manca al cento, e un'etichetta che diceva il contrario

Due numeri che mancavano nella scheda di un progetto, e uno che c'era ma mentiva.

Il residuo, in euro

La panoramica mostrava budget, costo allocato, consuntivo, forecast e una percentuale. Da «65% di 100.000 €» il residuo si ricava a mente, e nessuno lo fa. Sopra il 100% era peggio: la barra si ferma al pieno, quindi lo sforo non si vedeva affatto.

Ora c'è una scheda che cambia nome insieme al segno — «Residuo budget» oppure «Oltre budget», in rosso — e il residuo resta negativo quando si sfora invece di essere schiacciato a zero: zero direbbe «in pari», che è il contrario di ciò che sta succedendo. Senza budget non è zero ma nulla: non c'è un cento a cui arrivare.

Quanto aggiungere o togliere

Sotto la barra, in tutte e due le schermate: «Per arrivare al 100%: mettere a forecast 32.400 € — circa 620 h alla tariffa media del progetto (52 €/h)».

In euro e in ore, perché sono due grandezze diverse e servono a due momenti diversi: il budget è in euro, ma ciò su cui si interviene sono le ore. La tariffa media è quella che il progetto ha praticato finora, e non è la tariffa di nessuno in particolare — la frase lo dice: «spostarle fra persone con costi diversi cambia il conto». Senza ore allocate non c'è una tariffa da cui partire e restano i soli euro: un numero di ore ricavato da una tariffa immaginata sarebbe plausibile e falso, che è il modo peggiore di sbagliare.

«Da prevedere» erano le ore già previste

La scheda nell'analisi allocazioni si chiamava «Da prevedere» e mostrava le ore già messe a forecast: l'etichetta diceva il contrario del numero. Ora si chiama «Ore già previste», e la nota dice su cosa si misurano — la capacità residua delle persone, non il budget. Ciò che manca per arrivare al budget è un'altra grandezza, e sta accanto alla barra del budget di cui parla.

Le ore tolte a mano tornavano al primo ricalcolo

Su BIOSMART PU sette risorse sovra-allocate erano state svuotate a mano, la copertura era scesa dal 96% all'88%, e un ricalcolo le ha rimesse tutte.

Una scelta precedente, sbagliata

«Zero ore non è una riga a zero» era una regola scritta qui poche settimane fa, con l'argomento che una riga a zero resta in ogni elenco e in ogni somma senza dire se qualcuno l'ha azzerata o dimenticata. L'argomento è vero e irrilevante rispetto a ciò che costava: cancellando la riga non resta più niente che dica che qualcuno ha deciso zero, e un mese azzerato diventa indistinguibile da un mese mai riempito. Al ricalcolo successivo il motore lo riempie di nuovo.

Ora azzerare lascia una riga MANUAL a zero. Quella riga occupa il mese — il motore salta i mesi già occupati da righe non automatiche, è la stessa regola con cui rispetta il consuntivo — e la decisione sopravvive. Costa zero euro e zero ore in ogni somma, quindi il timore originale non si materializza.

Due dettagli che i test tengono fermi: uno zero digitato su una casella mai riempita non scrive niente — sarebbe vietare al motore un mese che nessuno gli ha vietato — e azzerare non richiede un costo orario, perché pretenderlo impedirebbe di togliere le ore proprio a chi non ce l'ha, cioè a chi le ha per sbaglio.

Ciò che questo non risolve

Nello stesso progetto una persona con l'89% di capacità libera resta all'11% anche dopo il ricalcolo, e non è lo stesso difetto. Il motore, mese per mese, scorre i candidati nell'ordine delle assegnazioni e dà a ciascuno il minimo fra la sua capacità, il budget residuo del progetto e il tetto per risorsa: chi viene prima consuma il budget, chi viene dopo trova poco o niente. Non c'è nessuna preferenza per chi ha più capacità libera.

Distribuire preferendo chi è più libero è un cambiamento del criterio, non una correzione: sposta ore fra persone su ogni progetto e va deciso, non fatto di nascosto mentre si ripara un'altra cosa.

Due azioni massive nella griglia dei mesi

Accanto al tasto Salva, e propongono: riempiono la bozza, la griglia mostra le celle toccate come «da salvare», e chi guarda decide. Un'azione massiva che scrive da sola sarebbe la cosa più veloce da rimpiangere — e con il salvataggio esplicito già lì, non c'era ragione di fare diversamente.

Spalma con un tetto

Si scrive un numero di ore per mese e le ore si ridistribuiscono su tutti i mesi, al massimo quelle. Il totale non cambia: è una redistribuzione, non un aumento.

Ciò che non ci sta — troppi pochi mesi per quel tetto — non si colloca e si dichiara: «40 h non collocate, restano dov'erano». Spingerle dentro vanificherebbe il tetto, toglierle in silenzio perderebbe ore. Le ore restano intere: mezze ore spalmate su dodici mesi producono numeri che nessuno ha deciso e che non tornano con niente.

Sposta dai mesi in sforo a quelli liberi

Toglie ore dai mesi in cui la persona è oltre la propria capacità e le mette dove ne ha ancora. Anche qui le ore non si creano e non si distruggono, e se lo spazio non basta ciò che avanza resta dov'era: meglio uno sforo visibile di ore sparite.

Due limiti che i test tengono fermi, e che a occhio non si vedono. Da un mese non si tolgono più ore di quante ce ne siano su questo progetto: lo sforo può venire da ciò che la persona ha altrove, e togliere quelle da qui la porterebbe a un numero negativo. E non si riempie un mese oltre il suo spazio: con cinquanta ore da ricollocare e due mesi da quaranta e trenta, il primo non deve prendersele tutte.

Nessuna delle due tocca i mesi oltre la cessazione: sono ore che non lavorerebbe nessuno, e spalmarcele sopra sarebbe creare il problema che un'altra schermata segnala.

Entrambe partono da ciò che è scritto nella bozza, non da ciò che è salvato: due azioni di fila devono comporsi, non ripartire dai numeri vecchi.

Il budget si divide, non si dà a chi capita prima

Progetto nuovo, cinque risorse assegnate, ricalcolo: tutte le ore alla prima. Il motore scorreva le persone nell'ordine delle assegnazioni e riempiva ciascuna fino alla capacità piena finché il budget non finiva — e quell'ordine era quello delle righe nel database: non una scelta, un caso.

Su una fixture con cinque risorse uguali e 60.000 € su quattro mesi:

prima:  352 h · 320 h · 176 h · 176 h · 176 h      7 righe su 20
dopo: 240 h · 240 h · 240 h · 240 h · 240 h 20 righe su 20

Non era un difetto ma un criterio mancante, e cambiarlo è una decisione del committente: sposta ore fra persone e fra mesi su ogni progetto. La scelta è stata quota proporzionale alla capacità libera, spalmata sui mesi.

Due dimensioni, due divisioni

Sulle persone: ognuna riceve una quota proporzionale alla capacità che ha libera in quel mese. Chi è più libero prende più ore, chi è già carico altrove ne prende meno, e nessuno viene riempito al cento per cento mentre un altro resta a zero. Tre vincoli restano al di sopra della quota — capacità, tetto per risorsa, budget — perché la quota decide come si divide ciò che si può dare, non quanto.

Sui mesi: a ogni mese spetta la parte del residuo proporzionale a quanto quel mese può assorbire, non residuo / mesi rimanenti. La divisione in parti uguali sembra giusta e lascia soldi per strada: gli ultimi mesi di un progetto hanno meno capacità, la loro parte non è collocabile, e nessuno la riprende. Il primo tentativo lasciava non speso il 18% del budget sulla fixture globale; con i pesi sulla capacità la differenza è scesa allo 0,8%, che è ciò che i tetti per risorsa impediscono di collocare comunque.

La divisione si autocorregge in tutte e due i versi: il residuo è quello vivo, quindi ciò che un mese non riesce a spendere torna nel piatto dei successivi.

Cosa dicono i golden

Gli snapshot sono stati rigenerati e il confronto prima-dopo mostra esattamente ciò che ci si aspetta e nient'altro:

  • forecast-progetto: 9 righe, esito identico — stessi contatori, stessi esclusi, stesse diagnostiche. Solo ore e costo redistribuiti.
  • forecast-globale: da 21 a 26 righe, e l'unica differenza nell'esito è inserted: 21 → 26.
  • costi-standard-forecast: 12 righe, nessuna riga nuova o sparita, nessuna tariffa cambiata.

Un test dedicato riproduce ora il caso che ha aperto il difetto — cinque risorse uguali, budget su quattro mesi — e verifica le quattro cose che devono valere insieme: nessuno a zero, ore uguali a capacità uguale, tutti i mesi coperti, e il budget speso tutto.

Un surrogato che ha smesso di funzionare

Un test sulle due fasi (progetti con sede, poi senza) verificava che la somma delle due non superasse la capacità confrontando i totali di due scenari. Da quando il budget si spalma sui mesi, due scenari possono chiudere con totali diversi senza che nessuno dei due abbia sforato un mese: il totale era un surrogato dell'invariante, e ha smesso di reggere appena è cambiato ciò che gli stava intorno. Ora il confronto è con la capacità vera, mese per mese, calcolata con il modulo del motore — non una seconda implementazione, la stessa funzione sulla stessa fixture.

Quattro persone oltre il proprio limite, e nessuna riga che lo sforava

Su WAVE, quattro risorse attive risultavano allocate oltre la propria capacità dopo un ricalcolo. Il limite nel codice c'era — nessuna singola scrittura supera la capacità del mese — e il difetto stava in cosa la capacità non contava.

Ciò che il ricalcolo non riscrive, occupa comunque

Il ricalcolo globale cancella e rifà le sole righe AUTOMATIC. Tutto il resto — previsioni importate dal foglio, ore decise a mano — resta dov'è. Ma la capacità di ciascuno ripartiva da zero come se quelle righe non esistessero, e ogni progetto poteva prendersi la stessa persona per intero.

mesiOccupati non bastava, e vale la pena capire perché: esclude quella persona da quel progetto nei mesi occupati, non dagli altri. Una persona con centocinquanta ore importate su ARCOE a novembre restava «libera» per tutti gli altri progetti di novembre.

Ora anche il globale riceve le ore che non riscriverà — le non automatiche, ovunque — e le toglie dalla capacità. È la stessa cosa che il ricalcolo di un singolo progetto già faceva con le ore degli altri progetti: cambia cosa ci finisce dentro, non a cosa serve.

E quando le persone non bastano, lo si dice prima

Il motore si ferma alla capacità, e fa bene. La conseguenza è che il budget resta scoperto — la copertura al 50% che si vedeva su WAVE — e finora quel fatto non lo diceva nessuno: si scopriva guardando una barra.

Due posti, e sono due momenti diversi:

  • prima di ricalcolare, il tasto chiede conferma con i numeri: «da coprire 40.000 €, le risorse assegnate possono fare circa 25.000 € (500 h libere alla tariffa media): ne mancano 15.000. Procedere lo stesso?». Chi legge decide se aggiungere persone o accettare una copertura più bassa — che è esattamente la scelta che prima non veniva offerta;
  • dopo, il messaggio del ricalcolo porta il budget non collocato e i progetti che restano scoperti, in ordine di importo.

Due dettagli che i test tengono fermi. Un progetto già coperto o oltre budget non fa domande: chiedere «vuoi procedere?» a chi sta al 100% è una domanda senza risposta utile, e «da coprire −20.000 €» non vuol dire niente. E senza una tariffa media non si afferma che non basta: la capacità è in ore, il budget in euro, e senza il cambio non si confrontano — meglio non chiedere che chiedere sulla base di un numero inventato.

Il salvataggio che a volte non salvava

«Elimino ore, salvo, nessun feedback, e la situazione non cambia. Se insisto, dopo un po' i dati vengono scritti.» I log di datahub dicono cosa stava succedendo davvero:

[cachingTableManager] Error getting caching config for
rendicontazione.project_employee_monthly_hours: timeout exceeded when trying to connect
[GET /employees] Error: timeout exceeded when trying to connect

Non una query lenta: il pool di connessioni esaurito. E la causa era nostra.

Centoquarantaquattro letture per salvare dodici mesi

La rotta di scrittura di un mese ricostruiva il contesto: dodici tabelle, tre delle quali lette per intera organizzazione — tutte le ore di tutti, tutti i dipendenti, tutti i saldi ferie. Il frontend chiamava quella rotta una volta per mese. Salvare una griglia di dodici mesi voleva dire dodici volte quelle dodici letture, ognuna con la sua paginazione.

Sotto quel carico il pool si svuotava, ogni query andava in timeout, e la scrittura falliva. Con il pool di nuovo libero passava: da qui l'intermittenza e l'«insistendo funziona».

Tre correzioni, in ordine di peso

Una richiesta sola per tutti i mesi. Il contesto si legge una volta e si applicano tutte le celle. Le scritture restano una per riga — sono aggiornamenti distinti — ma erano le letture a pesare. Un test conta le letture e cade se tornano a essere una per mese.

Letture strette. Le ore si leggono in due tagli — quelle del progetto e quelle della persona — invece che tutte. La seconda serve a sapere quanto uno ha altrove, ed era l'unica ragione per cui si leggeva tutto. Un test verifica che nessuna lettura di project_employee_monthly_hours parta senza filtro.

Un mese rifiutato non ferma gli altri. Il servizio prosegue e riporta quanti mesi ha scritto, quanti erano già a posto e quanti no, col primo errore per esteso. Fermarsi al primo lascerebbe metà lavoro fatto senza dire quale metà.

E il feedback, che non c'era

Il messaggio di esito era una riga di testo in fondo a una barra: un salvataggio fallito si leggeva come niente. Ora esito ed errore sono due pastiglie, verde e rossa, con il segno di spunta o la croce. Un salvataggio che fallisce in silenzio fa credere che sia andato bene, e il difetto si scopre guardando i numeri il giorno dopo.

Una lettura dopo una scrittura non può venire da prima

Il freno del client unisce le letture identiche in volo. Se una lettura parte, poi arriva una scrittura, e poi qualcuno chiede di nuovo, la seconda si attaccherebbe alla prima — e riceverebbe lo stato di com'era: chi ha appena salvato vedrebbe i numeri vecchi e crederebbe che il salvataggio non sia passato. Ora ogni scrittura azzera il freno.

La cache di datahub, e perché non si poteva accendere dove serviva

Il pool di connessioni di datahub è max: 10. Il /query di data-service esegue le letture tutte in parallelo, e una chiamata alle allocazioni ne manda dieci. Una sola richiesta poteva quindi saturare il pool da sola: due schede aperte insieme e chi arrivava dopo aspettava dieci secondi e riceveva timeout exceeded when trying to connect.

La cache Redis c'era già, per tabella e configurabile dall'admin. Ma non si poteva accendere dove sarebbe servita, per tre ragioni che ora non ci sono più.

La chiave univa i valori, non i nomi

Object.values(params).join(":"): una lettura con project_id=7 e una con employee_id=7 producevano la stessa chiave e si scambiavano i risultati. Finché è stato così, la cache era sicura solo sulle tabelle lette sempre nella stessa forma — cioè non su ore e assegnazioni, che sono le più costose e si leggono con due filtri diversi.

Ora la chiave è nome=valore in ordine alfabetico. L'ordine conta: due richieste identiche scritte in ordine diverso sono la stessa richiesta, e chiavi diverse dimezzerebbero i colpi a segno.

Invalidare scansionava l'intero keyspace

invalidateTable faceva KEYS prefix:tabella:* seguito da DEL. KEYS scorre tutte le chiavi di Redis e lo blocca mentre lo fa: con un import che scrive seimila righe sarebbero state seimila scansioni complete, e con Redis fermo si fermano anche gli altri applicativi. È la ragione per cui la cache era impraticabile proprio sulle tabelle scritte spesso.

Ora ogni tabella ha un contatore di versione e la chiave lo porta dentro: invalidare è un INCR. Le chiavi vecchie non si cancellano — diventano irraggiungibili e scadono da sole con il loro TTL. L'invalidazione passa da O(keyspace) a O(1).

Se la versione non si legge — Redis irraggiungibile — non si serve niente dalla cache e si va al database: mostrare un dato di cui non si conosce l'età è peggio che pagare una query.

Chiedere «sei cachata?» costava una query

getCachingConfig interrogava il database per tabella per richiesta: dieci letture, dieci query solo per la configurazione, ognuna con la sua presa di connessione. Il livello di cache era diventato una delle cause del carico che avrebbe dovuto ridurre — e per una tabella con cache spenta era puro sovrapprezzo.

Ora si tiene in memoria per trenta secondi, e cambiarla dall'admin la fa rileggere subito su quell'istanza. È configurazione, non un dato di dominio.

Quali tabelle accendere, e in che ordine

gruppotabelleTTL
A — cataloghirendicontazione_operational_state, work_locations, work_schedules, rate_card_rates, job_levels, macro_levels, areas, sub_organizations, funding_bodies3600 s
B — anagraficheemployees, projects, employee_work_schedule_history, employee_leave_balances, employee_monthly_leave_plan600 s
C — operativeproject_employee_assignments, project_employee_monthly_hours120 s

Quante letture per richiesta smettono di arrivare al database:

rottaletturecon Acon A+Bcon A+B+C
/projects/:id/allocazioni103810
/employees/:id/allocazioni103810
/qualita-dati11379
/forecast/panoramica6256
griglia mesi e salvataggio1251012

Accenderei A per primo, misurerei, poi B. Il gruppo C ora è tecnicamente sicuro, ma su quelle tabelle la cache è fredda ogni volta che qualcuno lavora — ogni scrittura sposta la versione — quindi aiuta chi legge e non chi modifica. E soprattutto non toglie il costo del trasporto: i 750 KB delle ore di tutta l'organizzazione restano da serializzare, spedire e interpretare a ogni richiesta. Quella lettura si aggredisce restringendola, come è stato fatto nel percorso di salvataggio, non cachandola.

«Salvo, si chiudono le ore, non succede niente, e non c'è un log»

Tre difetti diversi che davano lo stesso sintomo, e uno di essi rendeva impossibile diagnosticare gli altri due.

Un salvataggio riuscito non lasciava traccia

Le rotte del verticale scrivono nel log solo in caso di errore. Quindi «nessun log in rendicontazione-service, data-service, datahub» non voleva dire «la richiesta non è arrivata»: voleva dire che nessuno aveva sollevato un'eccezione. Un salvataggio andato a vuoto e un salvataggio mai partito erano indistinguibili dall'esterno.

Ora ogni salvataggio manuale lascia una riga: progetto, dipendente, mesi chiesti, scritti, invariati, falliti e il primo errore. Una riga per salvataggio non è rumore — è la differenza fra un'ipotesi e una prova.

La pausa del freno rispondeva al posto della rete

Il freno del client mette in pausa un indirizzo dopo un errore e, durante la pausa, ridà l'errore precedente senza toccare la rete. L'azzeramento dopo una scrittura c'era, ma solo sul percorso riuscito: se la scrittura falliva, la pausa sulla lettura restava.

Il seguito è meccanico: il salvataggio fallisce, la rilettura che lo segue non parte nemmeno, e nessun servizio riceve niente. Guardando i log si conclude che il browser non abbia mandato nulla, mentre la prima richiesta era partita eccome.

Ora il freno si azzera in un finally: anche una scrittura fallita libera le letture.

Una rilettura fallita svuotava la griglia

Il fallimento della rilettura chiamava setErrore, e quello sostituisce l'intera griglia con una riga rossa. Da fuori sembra che il salvataggio abbia chiuso le ore — che è esattamente come è stato descritto.

La rilettura è un di più: le celle a schermo restano quelle di prima. Ora il messaggio si mette accanto agli altri, dice che i numeri possono essere vecchi, e la griglia resta dov'è.