Skip to main content

Il costo di un mese è quello che valeva allora

Prima di questa modifica il costo orario di una persona era un valore solo, employees.current_hourly_cost_eur. Bastava a mostrare un numero in una lista, e non bastava a nient'altro: rifare il consuntivo di marzo dopo un aumento di giugno lo calcolava con il costo di giugno.

Il difetto è del tipo peggiore. Non dà errore, non si vede, e non si scopre guardando il mese sbagliato: si scopre solo confrontando due stampe dello stesso mese fatte in momenti diversi. Un mese chiuso e consegnato cambiava da solo, senza che nessuno avesse toccato quel mese.

Come funziona adesso

rendicontazione.employee_cost_history esisteva già — con la forma giusta — ma era vuota e non la leggeva nessuno. Ora è la fonte di verità per il passato.

Ogni riga dice: da quando, fino a quando, quanto.

valid_fromvalid_tohourly_cost_eur
2024-03-012026-05-3141,50
2026-06-01(aperto)45,00

Gli estremi sono inclusivi da entrambi i lati: valid_to è l'ultimo giorno in cui quel costo vale, non il primo in cui non vale più. Chi chiude un intervallo deve quindi chiuderlo al giorno prima della decorrenza del successivo — chiudere allo stesso giorno li farebbe toccare, e la scrittura verrebbe rifiutata.

Un valid_to nullo significa "finché non arriva il prossimo".

Cosa impedisce il database

Il vincolo di esclusione employee_cost_history_no_sovrapposizioni rende impossibile che due intervalli dello stesso dipendente si sovrappongano. Non è un controllo applicativo che si può dimenticare di chiamare: è il database che rifiuta la riga.

EXCLUDE USING gist (
organization_id WITH =,
employee_id WITH =,
daterange(valid_from, valid_to, '[]') WITH &&
)

Da questo discende gratis anche un'altra garanzia: due intervalli aperti hanno entrambi estremo superiore infinito, quindi si sovrappongono sempre. Il costo corrente è quindi uno solo, senza bisogno di un indice unico in più.

Serve l'estensione btree_gist — è quella che permette di mettere l'uguaglianza su organization_id e employee_id dentro un vincolo che da solo saprebbe confrontare solo intervalli. È un'estensione trusted: non richiede un ruolo superutente.

Le scelte che valgono la pena di ricordare

Il ripiego sulla colonna vecchia resta. Un dipendente senza righe di storico continua a costare quello che dice current_hourly_cost_eur. Toglierlo significherebbe azzerare i costi di chi non è ancora stato migrato — di nuovo un danno silenzioso. Il ripiego sparirà quando lo storico coprirà tutti, non prima.

Un cambio a metà mese viene dichiarato, non deciso in silenzio. La riga di consuntivo ha un costo orario solo, quindi il mese usa il costo valido al primo del mese. Quando lo storico cambia dentro il mese, il calcolo emette un avviso COSTO_CAMBIATO_NEL_MESE insieme agli altri: metà mese è costata diversamente, e chi legge il risultato deve saperlo.

Chi modifica il costo dall'anagrafica non indica una decorrenza, perché la maschera non la chiede ancora. Il valore predefinito è il primo del mese in corso, non oggi. Decorrere da oggi lascerebbe il mese in corso al costo vecchio: chi ha appena alzato uno stipendio non vedrebbe cambiare niente, e concluderebbe che il salvataggio non ha funzionato. Se la persona è stata assunta dentro il mese in corso, la decorrenza è la data di assunzione — un intervallo che copre giorni in cui non era in azienda non ha senso.

Correggere un refuso non lascia scorie. Due modifiche nello stesso mese non producono due intervalli, di cui uno di durata zero: la seconda riscrive la prima.

Il conflitto si scopre prima di scrivere. Se esiste già un costo con decorrenza successiva, il salvataggio viene rifiutato prima di toccare l'anagrafica. Fallire a metà lascerebbe l'anagrafica cambiata e lo storico no — esattamente il disallineamento che tutto questo serve a evitare.

Dove sta il codice

la regolamodules/workforce/employeeCost.js nel verticale
la lettura del meseloadCalculationInputs, in modules/actuals/allocationEngine.js
la scritturapreparaCosto / applicaCosto, in modules/workforce/employeesService.js
lo schemamigrazione 099, rispecchiata in db/postgres/init.sql
i controllisezione 8 di db/postgres/checks/rendicontazione_tenant_invariants.sql

Il costo del mese viene sostituito una volta sola, all'ingresso del calcolo, prima che l'anagrafica venga sparsa dentro forecast, assegnazioni e capacità. Farlo più avanti significherebbe ricordarsi di farlo in tre punti — e il punto dimenticato non darebbe un errore, darebbe un numero plausibile.

Il costo è giornaliero, e questo ha fatto scuola

applicaCostoGiornaliero risolve il costo con la data della giornata (costoOrarioAllaData(righeCosto, riga.workDate, …)) e poi ricalcola la riga mensile dalle giornate: si sommano i costi veri e si divide per le ore, altrimenti i due numeri non tornano. COSTO_MEDIO_DEL_MESE è l'avviso che lo dice.

Il 18 agosto 2026 questo è diventato il precedente per la sede storica. La prima proposta era «sede valida al primo del mese», sul modello sbagliato — il committente ha fatto notare che il consuntivo è mensile ma calcolato per giornate: chi si sposta il 12 marzo ha le ore fino all'11 sulla sede vecchia e dal 12 sulla nuova, esattamente come il costo.

Vale come criterio generale: quando una grandezza storicizzata deve entrare nel consuntivo, si risolve per giornata e il mese si aggrega da lì. Due regole diverse per la stessa ambiguità sono il modo sicuro per non riuscire a spiegare un numero fra due anni.

Il vincolo che il database non può garantire

Lo storico e la colonna current_hourly_cost_eur devono dire lo stesso numero. Se divergono, le liste mostrano un costo e i consuntivi ne usano un altro: due schermate della stessa applicazione si contraddicono, e nessuna delle due sembra sbagliata.

Questo il database non lo sa, quindi lo controllano tre query negli invarianti:

controllocosa trova
costo_corrente_disallineatol'intervallo aperto dice un numero, la colonna un altro
costo_senza_intervallo_apertoun dipendente ha un costo e nessun intervallo che lo copre oggi
costo_con_buchiun vuoto fra due intervalli: i mesi che ci cadono usano il ripiego

Cosa resta da fare

  • La maschera non chiede la decorrenza. Fa parte del punto 9 della Fase 1 (UI del mini gestionale HR), insieme alla visualizzazione dello storico.
  • Il riempimento iniziale non è la storia vera. La migrazione 099 crea un intervallo aperto per dipendente con il costo di oggi, valido dalla data di assunzione. È l'unica ricostruzione che non cambia nessun numero — prima di oggi il costo di ogni mese era già quello — ma non racconta gli aumenti passati, che non sono registrati da nessuna parte.