Logica applicativa — dominio job
🎯 Cosa fa
La logica applicativa del dominio job gira intorno ai rischi del
lavoratore. Attenzione: sotto lo stesso nome convivono due
meccanismi distinti, che non vanno confusi.
| Che cos'è | Dove vive | |
|---|---|---|
| Rischi effettivi | l'insieme dei rischi a cui il lavoratore è esposto, con la loro provenienza | vista SQL job.vw_workerEffectiveRisks |
| Livello di rischio | la singola colonna workers.riskLevelId, materializzata | worker.UpdateRiskLevel (T-SQL in TrainingHub.Shared) |
Il primo non è calcolato in C#: è tutto nella vista. Il secondo è un
UPDATE che i QueryModifier rilanciano quando qualcosa cambia.
Punti di ancoraggio:
job.vw_workerEffectiveRisks+job.fn_getWorkerRisks— l'insieme dei rischi e la loro provenienzaworker.UpdateRiskLevel(TrainingHub.Shared/DataLayer/job/workers.cs) — materializzazione diworkers.riskLevelId- i QueryModifier di
jobereg— rilanciano l'UPDATEe le stored procedure di refresh delle cache WorkersQueryModifier.PreExecutionQuery— veto e cascata sulla cancellazione di un lavoratore- code-behind
WorkerForm.razor.cs— cascade FK azienda → sede / reparto, e derivazione del sesso dal codice fiscale
🔧 IRiskInheritanceService è di sola lettura
Da non usare come motore di propagazione: l'interfaccia espone due sole query e non scrive nulla.
// TrainingHub.Shared/Services/IRiskInheritanceService.cs — namespace TrainingHub.Services
public interface IRiskInheritanceService
{
Task<IEnumerable<workerEffectiveRisk>> GetEffectiveRisksAsync(Guid workerId);
Task<IEnumerable<workerEffectiveRisk>> GetEffectiveRisksByCompanyAsync(Guid companyId);
}
RiskInheritanceService sono due SELECT su job.vw_workerEffectiveRisks.
È iniettato solo da Worker.razor.cs, Company.razor.cs e Program.cs:
nessun QueryModifier lo referenzia.
🔧 worker.UpdateRiskLevel — la materializzazione
Questo è il metodo che scrive. Sta in
TrainingHub.Shared/DataLayer/job/workers.cs:7:
public static async Task<int> UpdateRiskLevel(
ISimpleCRUDService simpleCRUD,
Guid? jobId = null,
Guid? companyId = null,
Guid? workerId = null);
Emette un solo UPDATE:
UPDATE W
SET riskLevelId = ISNULL(MaxRisk.maxRiskLevelId, C.riskLevelId)
FROM job.workers W
INNER JOIN reg.companies C ON W.companyId = C.id
OUTER APPLY (SELECT MAX(J.riskLevelId) AS maxRiskLevelId
FROM job.workersJobs WJ JOIN job.jobs J ON J.id = WJ.jobId
WHERE WJ.workerId = W.id) MaxRisk
WHERE ISNULL(W.riskLevelId, -1) <> ISNULL(ISNULL(MaxRisk.maxRiskLevelId, C.riskLevelId), -1)
Da leggere così:
- il livello è il massimo fra le mansioni del lavoratore, con fallback sul livello dell'azienda quando non ha mansioni;
- non legge l'ATECO: la propagazione ATECO → azienda è un altro
passo, in
job/AtecoCodesQueryModifier.cs:29, che chiamareg.company.UpdateRiskLevel; - non considera
workersRisksné le esclusioni: quelle valgono per i rischi effettivi, non per questa colonna; - il predicato finale rende l'
UPDATEun no-op quando il valore non cambia, quindi chiamarlo di troppo costa una scansione, non una scrittura; - i tre parametri sono filtri cumulativi: senza nessuno di essi ricostruisce tutti i lavoratori fuori sync.
Chi lo chiama (tutti i percorsi, non «potenzialmente»):
| Chiamante | Scope |
|---|---|
job/WorkersQueryModifier.cs:72,74 | workerId, o tutti su UpdateWhere |
job/WorkersJobsQueryModifier.cs:62 | il lavoratore |
job/JobsQueryModifier.cs:34,40 | jobId, o tutti |
job/JobsRisksQueryModifier.cs:28 | jobId |
job/CompaniesRisksQueryModifier.cs:28 | companyId |
reg/CompaniesQueryModifier.cs:78,82 | companyId, o tutti |
Shared/Services/TrainingImportService.Processing.cs:884 | import formazioni |
job/WorkersRisksQueryModifier.cs:29-30 è l'eccezione: un rischio
personale non cambia il livello (che guarda solo le mansioni), quindi il
modifier si limita alle stored procedure di refresh delle cache.
🧩 CompaniesRisksQueryModifier
Path: TrainingHub.BackOffice/Services/QueryModifiers/job/CompaniesRisksQueryModifier.cs.
Hook su Insert / UpdateSingle / UpdateWhere / DeleteSingle / DeleteWhere
di companiesRisks. In PostExecutionQuery (righe 28-31) fa tre
cose, non una:
await DataLayer.job.worker.UpdateRiskLevel(db, companyId: args.Entity.companyId);
await db.ExecuteAsync("EXEC edu.sp_refreshEffectiveVariantForScope @companyId=@cid", new { cid });
await db.ExecuteAsync("EXEC job.sp_refreshEffectiveRisksForScope @companyId=@cid", new { cid });
Le due stored procedure ripopolano le cache
(edu.workerEffectiveVariantCache, job.workerEffectiveRisksCache):
saltarle lascia le griglie ferme al valore precedente.
Effetto: modificare i rischi di un'azienda propaga a tutti i lavoratori di quell'azienda, senza intervento esplicito dell'utente.
🧩 Cancellazione di un lavoratore — veto e cascata
WorkersQueryModifier.PreExecutionQuery (righe 95-104) intercetta il
DeleteSingle e delega a
Services/QueryModifiers/EntityDeletionGuard.cs, che applica due
liste:
blocking(righe 18-27) — fatti già avvenuti, la cui presenza vieta l'eliminazione:edu.attendances,edu.workersTrainings,edu.certificates,edu.priorTrainings,edu.convocationsSent,edu.trainingRequests,edu.workersNominations. L'errore che risale non è localizzato dal guard: laBusinessRuleExceptionporta lo slug grezzo (worker_delete_blocked) e l'elenco delle etichette come unico argomento ({0}). A risolverlo nella culture dell'utente è il consumer. Il motivo è preciso: una frase già tradotta qui rientrerebbe nel localizer del consumer come chiave mancante, che Oss stampa__così__e auto-inserisce inoss.localizations— una riga per ogni combinazione di bloccanti. Le etichette nominano la categoria, non la tabella.ownChildren(righe 32-46) — pezzi propri dell'anagrafica, cancellati in cascata dalle foglie verso l'alto:edu.workerCredentials,edu.workerEquipments,job.workerDocuments,fin.workerDocuments,job.workerJobHistory,job.workersRisks,job.workersJobs, più le cinque cache (edu.workerCompletionsCacheStaging,edu.workerCompletionsCache,edu.workerEffectiveVariantCache,job.workerEffectiveRisksCache,edu.workersRequiredTrainings).
Lo stesso meccanismo regge la cancellazione dell'azienda
(reg/CompaniesQueryModifier.cs:95-107): vedi
dominio reg.
La guardia pretende una transazione: se args.Service è null o non
transazionale, fallisce lei.
🧩 Sincronizzazione anagrafica per codice fiscale
Un lavoratore può esistere più volte, una per azienda. worker.SyncSiblingsByFiscalCode
(TrainingHub.Shared/DataLayer/job/workers.cs:40) tiene allineati i
gemelli: su Insert e UpdateSingle, WorkersQueryModifier.cs:82-83
propaga firstName, lastName, birthDate, birthPlace, email,
phone, gender, academicQualificationId e fiscalCode a tutti
i worker con lo stesso CF, cessati compresi.
Restano fuori dal sync perché specifici dell'azienda: companyId,
companyLocationId, departmentId, roleId, riskLevelId,
startDateWork, endDateWork, notes.
UpdateWhere non ha Entity, quindi non fa scattare il sync — scelta
accettata perché non lo si usa per dati anagrafici. Togliere il CF
(NULL) scollega il worker dai gemelli.
🧩 Code-behind WorkerForm.razor.cs
Contiene la sola logica UI, in tre metodi
(Components/CRUD/job/Forms/WorkerForm.razor.cs):
Cascade FK filter — OnCompanyChanged() (righe 14-31)
Al cambio companyId filtra fk_companyLocationId e fk_departmentId
sull'azienda selezionata, azzera la selezione se non è più valida e —
se resta una sola opzione — la preseleziona.
Derivazione del sesso dal CF — OnFiscalCodeChanged() (righe 8-12)
if (dataItem.fiscalCode is { Length: >= 16 } && int.TryParse(dataItem.fiscalCode.AsSpan(9, 2), out int dayPart))
dataItem.gender = dayPart > 40 ? nameof(Gender.F) : nameof(Gender.M);
È tutto qui: solo il sesso, dai caratteri 9-10. Nessuna data di
nascita, nessun luogo di nascita, nessun helper condiviso e — soprattutto
— nessuna validazione del checksum: Length >= 16 è l'unico
controllo, quindi sedici caratteri qualsiasi passano. Non esiste un
pulsante «calcola»: entrambi i metodi sono invocati da Validate()
(righe 36-43), che gira al salvataggio.
🧩 Vista WorkerEffectiveRisk
Il cascade non è nel componente: è tutto in
TrainingHub.Database/job/Views/vw_workerEffectiveRisks.sql. Il
code-behind si limita a
SELECT * FROM job.fn_getWorkerRisks(@workerId)
(WorkerEffectiveRisk.razor.cs:40). L'autorità è la vista.
Tre rami di provenienza, in UNION ALL, tutti con il filtro
W.endDateWork IS NULL (righe 15, 35, 55):
| Ramo | origin | Condizione di ingresso |
|---|---|---|
| mansione | job | jobsRisks.onlyIfCompanyHasRisk = 0, oppure l'azienda dichiara lo stesso rischio (righe 18-20) |
| azienda | company | companiesRisks.onlyForExposedJobs = 0 e almeno una mansione del lavoratore ha jobs.inheritsCompanyRisks = 1 (righe 36-43) |
| personale | worker | riga in workersRisks con excluded = 0 (riga 54) |
Da leggere con attenzione: inheritsCompanyRisks governa il ramo
azienda, non somma i rischi azienda a quelli mansione;
onlyIfCompanyHasRisk governa il ramo mansione. Sono due condizioni
diverse su due rami diversi.
onlyForExposedJobs distingue due tipi di rischio aziendale. Un
rischio diffuso (rumore, microclima) tocca chiunque lavori lì: flag a
0, si propaga dal ramo azienda come sempre. Un rischio d'attività
(manipolazione alimenti, agenti chimici di reparto) è presente in
azienda ma riguarda le sole mansioni che ci lavorano: flag a 1, resta
condizione per il ramo mansione — dove onlyIfCompanyHasRisk = 1 lo
cerca — ma non si propaga a chi eredita. Senza questa distinzione,
mettere il rischio in azienda per abilitare l'esposizione condizionata
dell'autista lo consegnava anche agli amministrativi.
La matrice completa:
companiesRisks | mansione | chi prende il rischio |
|---|---|---|
| assente | onlyIfCompanyHasRisk = 1 | nessuno |
onlyForExposedJobs = 0 | onlyIfCompanyHasRisk = 1 | chi ha la mansione + chi eredita |
onlyForExposedJobs = 1 | onlyIfCompanyHasRisk = 1 | solo chi ha la mansione |
onlyForExposedJobs = 1 | nessuna la dichiara | nessuno — riga inerte |
Le esclusioni non spariscono. workersRisks.excluded = 1 toglie il
rischio dall'insieme attivo (WHERE NOT EXISTS, righe 82-85), ma la
riga torna in una UNION separata con origin = 'excluded' e
excluded = 1 (righe 101-112) — è quella che la UI conta come
excludedCount.
Chi legge la vista deve filtrare excluded = 0. Le due procedure
che riempiono job.workerEffectiveRisksCache
(job.sp_refreshEffectiveRisksForScope,
edu.sp_refreshWorkerCompletions) lo fanno, e
edu.sp_reconcileWorkerCaches confronta con lo stesso criterio su
entrambi i lati. Senza quel filtro l'esclusione manuale non toglieva
l'obbligo formativo, e su un rischio che nessuna fonte assegnava lo
creava dal nulla: la riga di esclusione era l'unica sorgente di
(workerId, riskId) e finiva in cache come se fosse attiva.
Spareggio (righe 77-80): a parità di (workerId, riskId) vince
worker (1) → job (2) → company (3)
ROW_NUMBER() con WHERE rn = 1: la mansione batte l'azienda, e il
rischio personale batte entrambe.
Livello (riga 91): COALESCE(RK.riskLevelId, C.riskLevelId) — se la
riga vincente non porta un livello, si eredita quello dell'azienda.
Catalogo completo. job.fn_getWorkerRisks(@workerId) avvolge la
vista e aggiunge i rischi di catalogo non assegnati, con
origin = 'none' — sono quelli che la griglia mostra solo con
«mostra tutti».
Le origini prodotte dal SQL sono dunque cinque: job, company,
worker, excluded, none.
🧩 Staticizzazione workers.riskLevelId
Il campo riskLevelId su workers è materializzato: viene
calcolato e scritto al posto di essere derivato a ogni query. Trade-off:
Pro:
- Query su "lavoratori con rischio alto" sono immediate (filtro su colonna, index-friendly).
- Join con formazione/compliance più efficienti.
Contro:
- Coerenza dipende da puntualità dei refresh.
- Se un refresh viene saltato (errore, path non coperto dal QueryModifier), il dato è fuori sync.
Mitigazioni:
- QueryModifier invocati da otto percorsi diversi (tabella sopra).
- Il rebuild massivo esiste già:
UpdateRiskLevel(db)senza filtri ricostruisce tutti i lavoratori fuori sync in un soloUPDATE, grazie al predicatoWHERE ISNULL(...) <> ISNULL(...). È invocato così dajob/WorkersQueryModifier.cs:74,job/JobsQueryModifier.cs:40ereg/CompaniesQueryModifier.cs:82ogni volta che l'operazione è unaUpdateWheresenza entità.
📦 Dipendenze
Brighela.SimpleCRUD— CRUD base e hookTrainingHub.Shared.Services.IRiskInheritanceService+TrainingHub.Shared.Services.ITrainingExpirationService(quest'ultimo consuma output job)- Cross-dominio:
reg.companies,reg.companyLocations,reg.academicQualifications— FK in uscita daworkersreg.companySubcategories— FK in uscita dajobSubcategoriesedu.*— consumatori diworkers.riskLevelId(viaITrainingExpirationService)
📁 File chiave
TrainingHub.Database/job/Views/vw_workerEffectiveRisks.sql— l'autorità sul cascadeTrainingHub.Database/job/Functions/fn_getWorkerRisks.sql— vista + rischi di catalogo non assegnatiTrainingHub.Shared/DataLayer/job/workers.cs—UpdateRiskLeveleSyncSiblingsByFiscalCodeTrainingHub.Shared/Services/IRiskInheritanceService.cs+RiskInheritanceService.cs— sola letturaTrainingHub.BackOffice/Services/QueryModifiers/job/— i sette modifier del dominioTrainingHub.BackOffice/Services/QueryModifiers/EntityDeletionGuard.csTrainingHub.BackOffice/Components/CRUD/job/Forms/WorkerForm.razor.csTrainingHub.BackOffice/Components/CRUD/job/WorkerEffectiveRisk.razor.razor.cs
⚠️ Debito tecnico
- Nessuna validazione del checksum del codice fiscale.
WorkerForm.razor.cs:10controlla soloLength >= 16: sedici caratteri qualsiasi passano, e import da Excel o API non passano nemmeno di lì. L'algoritmo esiste già nel pacchettoMascher(repo sorelladev3sd/Mascher,Anonymization/Italian/FiscalCode.cs) ma non è cablato. Valutare unValidationAttributeinTrainingHub.Shared/Validations/più il check nell'import. - Lo spareggio dei rischi effettivi non è deterministico sul
livello.
vw_workerEffectiveRisks.sql:77-80ordina solo perorigin: due mansioni che dichiarano lo stesso rischio conriskLevelIddiverso non hanno un vincitore stabile. Aggiungere un secondo criterio (riskLevelId DESC) renderebbe la vista coerente con il «massimo» cheUpdateRiskLevelapplica alla colonna. -
equipmentCountè un contatore morto.WorkerEffectiveRisk.razor.cs:20contaorigin = 'equipment', ma nessun ramo divw_workerEffectiveRisksproduce quell'origine (esiste solo nelle visteedudei fabbisogni formativi). Il contatore vale sempre 0 e concorre adactiveRiskCount. - Cascade combobox in
WorkerForm. Logica cascade FK è duplicata tra form (qui, e incompaniesreg). Helper condiviso farebbe bene. -
workerJobHistorycopre solo la mansione.WorkersJobsQueryModifierapre il periodo sull'InsertdiworkersJobse lo chiude sulDeleteSingle; i cambi di ruolo e reparto, che passano dajob.workers, non lasciano traccia (WorkersQueryModifier.cs:64-93). Se lo storico deve coprirli, va esteso.
🔗 Vedi anche
- Panoramica dominio
- Schema DB
- Componenti UI — vista
WorkerEffectiveRisk - Dominio
reg: logica applicativa — cascade chiamata daCompaniesQueryModifier - Dominio
edu: logica applicativa —ITrainingExpirationServiceconsuma rischi job