Passa al contenuto principale

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 effettivil'insieme dei rischi a cui il lavoratore è esposto, con la loro provenienzavista SQL job.vw_workerEffectiveRisks
Livello di rischiola singola colonna workers.riskLevelId, materializzataworker.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:

  1. job.vw_workerEffectiveRisks + job.fn_getWorkerRisks — l'insieme dei rischi e la loro provenienza
  2. worker.UpdateRiskLevel (TrainingHub.Shared/DataLayer/job/workers.cs) — materializzazione di workers.riskLevelId
  3. i QueryModifier di job e reg — rilanciano l'UPDATE e le stored procedure di refresh delle cache
  4. WorkersQueryModifier.PreExecutionQuery — veto e cascata sulla cancellazione di un lavoratore
  5. 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 chiama reg.company.UpdateRiskLevel;
  • non considera workersRisks né le esclusioni: quelle valgono per i rischi effettivi, non per questa colonna;
  • il predicato finale rende l'UPDATE un 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»):

ChiamanteScope
job/WorkersQueryModifier.cs:72,74workerId, o tutti su UpdateWhere
job/WorkersJobsQueryModifier.cs:62il lavoratore
job/JobsQueryModifier.cs:34,40jobId, o tutti
job/JobsRisksQueryModifier.cs:28jobId
job/CompaniesRisksQueryModifier.cs:28companyId
reg/CompaniesQueryModifier.cs:78,82companyId, o tutti
Shared/Services/TrainingImportService.Processing.cs:884import 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: la BusinessRuleException porta 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 in oss.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):

RamooriginCondizione di ingresso
mansionejobjobsRisks.onlyIfCompanyHasRisk = 0, oppure l'azienda dichiara lo stesso rischio (righe 18-20)
aziendacompanycompaniesRisks.onlyForExposedJobs = 0 e almeno una mansione del lavoratore ha jobs.inheritsCompanyRisks = 1 (righe 36-43)
personaleworkerriga 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:

companiesRisksmansionechi prende il rischio
assenteonlyIfCompanyHasRisk = 1nessuno
onlyForExposedJobs = 0onlyIfCompanyHasRisk = 1chi ha la mansione + chi eredita
onlyForExposedJobs = 1onlyIfCompanyHasRisk = 1solo chi ha la mansione
onlyForExposedJobs = 1nessuna la dichiaranessuno — 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 solo UPDATE, grazie al predicato WHERE ISNULL(...) <> ISNULL(...). È invocato così da job/WorkersQueryModifier.cs:74, job/JobsQueryModifier.cs:40 e reg/CompaniesQueryModifier.cs:82 ogni volta che l'operazione è una UpdateWhere senza entità.

📦 Dipendenze

  • Brighela.SimpleCRUD — CRUD base e hook
  • TrainingHub.Shared.Services.IRiskInheritanceService + TrainingHub.Shared.Services.ITrainingExpirationService (quest'ultimo consuma output job)
  • Cross-dominio:
    • reg.companies, reg.companyLocations, reg.academicQualifications — FK in uscita da workers
    • reg.companySubcategories — FK in uscita da jobSubcategories
    • edu.* — consumatori di workers.riskLevelId (via ITrainingExpirationService)

📁 File chiave

  • TrainingHub.Database/job/Views/vw_workerEffectiveRisks.sqll'autorità sul cascade
  • TrainingHub.Database/job/Functions/fn_getWorkerRisks.sql — vista + rischi di catalogo non assegnati
  • TrainingHub.Shared/DataLayer/job/workers.csUpdateRiskLevel e SyncSiblingsByFiscalCode
  • TrainingHub.Shared/Services/IRiskInheritanceService.cs + RiskInheritanceService.cs — sola lettura
  • TrainingHub.BackOffice/Services/QueryModifiers/job/ — i sette modifier del dominio
  • TrainingHub.BackOffice/Services/QueryModifiers/EntityDeletionGuard.cs
  • TrainingHub.BackOffice/Components/CRUD/job/Forms/WorkerForm.razor.cs
  • TrainingHub.BackOffice/Components/CRUD/job/WorkerEffectiveRisk.razor
    • .razor.cs

⚠️ Debito tecnico

  • Nessuna validazione del checksum del codice fiscale. WorkerForm.razor.cs:10 controlla solo Length >= 16: sedici caratteri qualsiasi passano, e import da Excel o API non passano nemmeno di lì. L'algoritmo esiste già nel pacchetto Mascher (repo sorella dev3sd/Mascher, Anonymization/Italian/FiscalCode.cs) ma non è cablato. Valutare un ValidationAttribute in TrainingHub.Shared/Validations/ più il check nell'import.
  • Lo spareggio dei rischi effettivi non è deterministico sul livello. vw_workerEffectiveRisks.sql:77-80 ordina solo per origin: due mansioni che dichiarano lo stesso rischio con riskLevelId diverso non hanno un vincitore stabile. Aggiungere un secondo criterio (riskLevelId DESC) renderebbe la vista coerente con il «massimo» che UpdateRiskLevel applica alla colonna.
  • equipmentCount è un contatore morto. WorkerEffectiveRisk.razor.cs:20 conta origin = 'equipment', ma nessun ramo di vw_workerEffectiveRisks produce quell'origine (esiste solo nelle viste edu dei fabbisogni formativi). Il contatore vale sempre 0 e concorre ad activeRiskCount.
  • Cascade combobox in WorkerForm. Logica cascade FK è duplicata tra form (qui, e in companies reg). Helper condiviso farebbe bene.
  • workerJobHistory copre solo la mansione. WorkersJobsQueryModifier apre il periodo sull'Insert di workersJobs e lo chiude sul DeleteSingle; i cambi di ruolo e reparto, che passano da job.workers, non lasciano traccia (WorkersQueryModifier.cs:64-93). Se lo storico deve coprirli, va esteso.

🔗 Vedi anche