Logica applicativa - dominio edu
🎯 Cosa fa
La logica applicativa del dominio edu si distribuisce in tre
punti:
- Service condivisi in
TrainingHub.Shared/Services/- un roster di service per orchestrazione, lettura analitica, import, compliance, fatturazione. Vivono in Shared perché usati da più applicazioni (BackOffice, Import). - QueryModifiers in
BackOffice/Services/QueryModifiers/edu/- hook pre/post sui CRUD di entità edu specifiche.
- Code-behind
.razor.csdei CRUD e delle pagine custom - logica UI specifica.
🔧 Servizi condivisi
Orchestrazione sessioni
ISessionPlannerService
Cuore del wizard di pianificazione: crea sessione + corsi erogati + appuntamenti + iscritti + docenti + stima costi, con rilevamento conflitti.
Tipi principali (in Shared/Services/SessionPlanner/):
SessionPlannerState- stato persistito del wizard (id session, data, topic, corsi pianificati, appointment IDs, iscrizioni, docenti).SessionCoursePlan- il "draft" di un corso erogato dentro la session (orari per-corso, iscritti previsti).ComplianceEnrollmentRequest/ComplianceEnrollmentResult- input/output per l'iscrizione bulk da scadenziario / coda richieste.ConflictInfoconConflictKindenum, sette valori:teacher_double_booked,location_double_booked,location_unavailable(aula dichiarata indisponibile inedu.locationUnavailabilities),enrollment_overflow,teacher_threshold,worker_double_enrolled,lesson_skipped. SeveritàWarning/Info.CostBreakdown- stima costi (Iscrizioni + Docenze + Aule) per lo step Riepilogo.SessionPlannerSeedKind- origine del seed iniziale:FromScratch,FromTrainingSession,FromTrainingSessionClone,FromRequests,FromWorkers.
Uso tipico:
SessionPlannerPopup(wizard UI) chiama draft + step methods.AppointmentsCalendar.razor.cschiamaDetectConflictsAsyncdopo il salvataggio di un appuntamento e mostra toast warning.
IAppointmentsCalendarService
Data provider della vista calendario /appointments-calendar.
public interface IAppointmentsCalendarService
{
Task<IEnumerable<appointmentsCalendar>> GetAppointmentsAsync(
DateTime periodStart, DateTime periodEnd,
Guid? courseId = null, Guid? locationId = null,
Guid? teacherId = null, Guid? trainingSessionId = null);
Task<SessionContextInfo?> GetSessionContextAsync(Guid trainingSessionId);
Task<IEnumerable<appointmentAttendee>> GetAttendeesAsync(Guid appointmentId);
}
SessionContextInfo aggrega per il pannello dettaglio: label
sessione, corsi erogati con topic+color, stato, conteggi iscritti
vs appuntamenti completati.
Compliance e requisiti formativi
ITrainingExpirationService
Calcola la compliance formativa (read prevalentemente da
workerCompletionsCache):
public interface ITrainingExpirationService
{
Task<IEnumerable<trainingExpiration>> GetExpiringTrainingsAsync(
Guid? companyId = null, string? status = null);
Task<ComplianceStats> GetComplianceStatsAsync(Guid? companyId = null);
}
public record ComplianceStats(
int TotalWorkers, int CompliantWorkers,
int ExpiredCount, int ExpiringCount, int MissingCount,
int Expiring30Count, int Expiring60Count,
int RequiresFullRetrainingCount, int Insufficient = 0);
ITrainingRequirementsService
Espone gli status formativi (workerTrainingStatus) per singolo
lavoratore o per intera azienda. Cita la vista
edu.vw_workerTrainingStatus.
edu.vw_workerTrainingStatus mostra una sola variante attiva per
coppia (lavoratore × topic):
- Se il regime di aggiornamento è dovuto (base completata,
requireUpdates = 1, base non da rifare) → espone solo la variante aggiornamento (isUpdate = 1). - Altrimenti → espone solo la variante base (
isUpdate = 0). - La base si ri-espone se mancante o se
fullRetrainingYearsè superato (il lavoratore deve rifare tutto dall'inizio).
La materializzazione di queste informazioni avviene tramite
edu.sp_refreshWorkerCompletions (vedi sotto).
ICompanyTrainingMatrixService
Costruisce la matrice formativa azienda: righe = lavoratori
(con role + jobs), colonne = topic richiesti dall'azienda, celle
= status + giorni residui + flag aggiornamento. Restituisce
TrainingMatrixData con percentuale compliance aggregata.
IRiskInheritanceService
È di sola lettura. L'interfaccia
(TrainingHub.Shared/Services/IRiskInheritanceService.cs:5-9) espone
GetEffectiveRisksAsync(workerId) e
GetEffectiveRisksByCompanyAsync(companyId): due SELECT su
job.vw_workerEffectiveRisks, nessuna scrittura.
La propagazione la fa worker.UpdateRiskLevel
(TrainingHub.Shared/DataLayer/job/workers.cs:7), chiamata direttamente
dai QueryModifier - reg/CompaniesQueryModifier.cs:78 e
job/JobsRisksQueryModifier.cs:28 fra gli altri. Nessuno dei due
referenzia il service.
Dettaglio in dominio job → logica applicativa.
Erogazione e iscrizione
IEnrollmentService
Iscrive un lavoratore a un corso erogato dentro una sessione, gestendo i casi di duplicato/coda d'attesa/capienza:
Task<EnrollmentOutcome> EnrollWorkerAsync(
Guid workerId, Guid trainingSessionId, Guid courseId,
bool acceptWaitlist = false, string? notes = null,
CancellationToken cancellationToken = default);
public enum EnrollmentResult
{ Enrolled, Waitlisted, Duplicate, Full, CourseNotInSession }
Usato dallo step "Iscritti" del session planner e dalla gestione coda richieste.
IAttendanceService
Gestione presenze per appuntamento: inizializza, salva draft,
upload documento firmato, chiusura digitale. Espone
AttendanceViewModel per la UI.
ITrainingCompletionService
EvaluateForAppointmentAsync(appointmentId): alla chiusura di un
appuntamento valuta i workerTrainingDetails correlati e, se
tutti gli appuntamenti del trainingSession sono chiusi, calcola
percentuale presenza cumulativa per worker e imposta lo status
(Completed/Failed) + completeDate. Idempotente.
Docenti e firma
ITeacherEngagementService
Engagement (incarico docente) per coppia (trainingSession, teacher):
CreateOrRecalcAsync- crea o ricalcola fingerprint dell'engagement; idempotente se il fingerprint non cambia.SignClickThroughAsync/SignUploadAsync- firma click-through (genera PDF "timbrato" e lo salva suoss.documents) o caricamento del file già firmato fuori dall'app. Concorrenza ottimistica viafingerprintExpected(StaleEngagementException).SignUploadAsyncha due overload: uno prende undocumentIdgià archiviato, l'altro il file del docente (.pdf/.p7m, tetto daTeacherArea:SignatureUpload:MaxSizeMb) e lo archivia sotto la categoriateacher-engagement, compensando il documento se la transizione di stato fallisce. Sul percorso upload non si renderizza nulla: il file caricato è il documento firmato, quindi nessun artefatto rivendica l'impronta che solo il click-through possiede - lo stesso taglio che ilsignatureBlockdel dataset Servel fa ramificando su@signatureMode.CancelEngagementAsync- revoca incarico / sessione annullata; la version corrente, se signed, passa asuperseded.
ITeacherAreaService
Area docente self-service: visibilità sessioni assegnate, download lettere, conferma engagement.
Import e dati pregressi
IPriorTrainingService
Import Excel di crediti formativi pregressi: parse + validate +
bulk insert in priorTrainings. Espone LoadSystemDataAsync
per caching reference data.
ITrainingImportService
Import generale di registri formativi (storico). Vedi
TrainingImportService.{cs,Models.cs,Parser.cs,Processing.cs}
per la struttura a sub-file.
Notifiche e alert
IAlertService
Generazione alert applicativi (banner / counter UI). Lavora di
concerto con le librerie 3SD Mola/Ploc per la persistenza dei
trigger.
Convocazioni
IConvocationService (TrainingHub.Shared/Services/Convocation/) risolve destinatari, rende l'anteprima e
invia. Non c'è più un trigger Mola schedulato: l'invio è sempre esplicito, dal popup
ConvocationSendPopup o dalla rotta HTTP POST /system/convocations/run.
public interface IConvocationService
{
Task<IReadOnlyList<ConvocationPreviewItem>> PreviewAsync(
Guid trainingSessionId, ConvocationMode mode, DateOnly? day, bool force,
Guid? onlyWorkerId = null, CancellationToken ct = default);
Task<ConvocationSendResult> SendAsync(
Guid trainingSessionId, ConvocationMode mode, DateOnly? day, bool force,
string channel, Guid? onlyWorkerId = null, CancellationToken ct = default);
Task<ConvocationScanResult> RunScanAsync(
int daysAhead, ConvocationMode mode, bool dryRun, CancellationToken ct = default);
}
ConvocationPlannerdecide quante mail partono e a chi: un lavoratore con un proprio indirizzo riceve la sua, tutti gli altri vengono raggruppati per (azienda × corso × giornata) in un'unica mail; un lavoratore senza indirizzo proprio e senza azienda raggiungibile produce un envelopeUnreachable- un esito visibile, mai un invio silenziosamente saltato.ConvocationMessageBuildercalcola i dati del messaggio (orario unico, sedi distinte in ordine, elenco giornate/lavoratori): la formulazione vive nel template Handlebars (Ploc), non in C#.ConvocationPreviewRendererrende il template vero (stesso motore dell'invio reale): l'anteprima nel popup e la rispostadryRundell'endpoint mostrano esattamente quello che partirebbe. La rigaploc.awsSesla sceglie con la stessa regola del canale (PlocAwsSesTemplateSelector: culture esatta, poi il ripiego del motore), non con la prima che torna dalla query - o dal primo duplicato aggiunto dal pannello admin anteprima e invio userebbero due template diversi, in silenzio.- Ploc guard: prima di inviare, il service verifica che i quattro ploc (giornata/riepilogo ×
lavoratore/azienda, GUID
…0E0001/…0E0003/…0E0005/…0E0007) esistano, sianoactive = 1e abbiano la rigaploc.awsSesche il motore renderebbe - un template mancante fallisce esattamente come un ploc disattivo:TriggerPlocAsyncè async void, logga e ritorna. È il bug che teneva la convocazione a zero destinatari prima di questo rework. Sulla stessa linea, ogni envelope viene chiesto aIPlocRecipientsOverrideEngine(Ploc:RecipientsOverride, registrato inProgram.cs): un invio che l'override bloccherebbe diventa un errore visibile invece di una mail contata e messa a audit. I messaggi diConvocationSendResult.Errorsarrivano interi nel toast del popup, non il loro numero. - Audit: una riga in
edu.convocationsSentper (worker × sessione × corso × giornata - NULL = riepilogo), conrecipientKind(worker/company) echannel(on-demand-session,on-demand-worker,api-scan). Il dedupe di "già inviata" (salvoforce = true) usa questa stessa chiave. L'audit dice che l'invio è stato chiesto, non che sia avvenuto: il solo registro di ciò che il canale ha davvero spedito èploc.history, eTrainingHub.Database/Queries/diag-convocation-delivery.sqlriconcilia le due (da lanciare a mano, non c'è nessun controllo automatico).
POST /system/convocations/run
Endpoints/ConvocationEndpoints.cs, gate SCARNAS_POLICY_APPLICATION_ONLY (token macchina, non il token di
un utente browser - vedi gruppo scarnas-auth): la rotta manda mail a persone vere e sostituisce il vecchio
trigger Mola schedulato (mai acceso).
Corpo (ConvocationRunRequest), tutti i campi opzionali:
public sealed record ConvocationRunRequest(int? DaysAhead, string? Mode, bool DryRun);
daysAhead(default7, range0..365) - il target è sempreoggi + daysAhead; una sessione entra nella scansione solo se una sua giornata cade esattamente su quella data. L'ancoraggio cambia conmode: indayil confronto è su ciascuna giornata della sessione, quindi un corso da 5 giornate su 5 chiamate successive (una al giorno,daysAheadinvariato) matcha una giornata diversa a ogni chiamata - una mail per giornata, con l'orario e le sedi di quel giorno; inrecapil confronto è solo sulla prima giornata della sessione, quindi la sessione matcha una volta sola.mode("day"default, o"recap", case-insensitive) - stessa distinzione delle due modalità del popup.dryRun(defaultfalse) - calcola e restituisce l'anteprima senza inviare alcuna mail e senza scrivere alcuna riga di audit: nessun ploc viene attivato, nessunaconvocationsSentviene inserita. È il modo in cui un chiamante esterno (o un cliente) vede il testo senza toccare il BackOffice.
Risposta (200):
{
"sessionsScanned": 3, // sessioni trovate per il target date calcolato da daysAhead
"recipients": 5, // dryRun: conta gli envelope di preview (raggiungibili + non raggiungibili);
// invio reale: workersConvoked, i lavoratori davvero raggiunti
"dryRun": false,
"sent": { // null se dryRun = true
"mailsSent": 4, "workersConvoked": 5, "skippedAlreadySent": 0,
"unreachable": 1, "errors": []
},
"preview": null // popolato solo se dryRun = true: array di { to, kind, subject, body } -
// un item "Unreachable" ha to/subject/body a null
}
400 su daysAhead fuori range o mode non riconosciuto (Enum.IsDefined, quindi anche un valore numerico
come "99" non passa): { "reason": "..." }. La validazione (TryParseRequest) è pura e testata in
isolamento, senza host ASP.NET - vedi TrainingHub.UnitTests/Endpoints/ConvocationEndpointsTests.cs.
🧩 QueryModifiers edu
| File | Scope | Cosa fa |
|---|---|---|
AppointmentsQueryModifier.cs | appointments | Hook su CRUD appuntamenti |
AppointmentsTeachersQueryModifier.cs | appointmentsTeachers | Hook M:N appuntamento↔docente, propaga aggiornamenti su teacherEngagements |
TrainingSessionsQueryModifier.cs | trainingSessions | Hook sessione (validazione cross-corso, eventi notifica) |
TrainingSessionsCoursesQueryModifier.cs | trainingSessionsCourses | Pulizia preventiva su DELETE: rimuove gli appointmentsCourses figli (FK NO ACTION per multi-path cascade) prima di cancellare il corso erogato |
TeachersQueryModifier.cs | teachers | Hook anagrafica docente |
WorkerTrainingDetailsQueryModifier.cs | workerTrainingDetails | Refresh di workerCompletionsCache dopo modifiche; invocazione TrainingCompletionService |
PriorTrainingsQueryModifier.cs | priorTrainings | Hook su formazione pregressa |
TrainingVariantsQueryModifier.cs | trainingVariants | Hook su varianti normative |
TrainingVariantsOverlapsQueryModifier.cs | trainingVariantsOverlaps | Hook su sovrapposizioni varianti |
Registrati in Program.cs come IQueryModifier<T> con
Brighela.SimpleCRUD.
🧩 Cache workerCompletionsCache e SP di refresh
Tabella materializzata che aggrega i completamenti formativi per
lavoratore con stato (ok, expiring, expired, missing).
- Aggiornata da QueryModifiers dopo modifiche a
workerTrainingDetails,priorTrainings,trainingVariants, e analoghi. - Letta da
ITrainingExpirationService/ITrainingRequirementsService/ICompanyTrainingMatrixServiceper performance: evita join pesanti a runtime.
edu.sp_refreshCompletionsForScope - logica calcolo scadenze
La logica vive nello SP scoped; edu.sp_refreshWorkerCompletions è il
wrapper che lo invoca in modalità all (vedi sotto). La logica per la
data efficace differisce tra base e aggiornamento:
Base (isUpdate = 0): coperta se un singolo completamento ha ore
frequentate ≥ minimo, oppure se le sole formazioni pregresse
nella finestra (L − validitySpan, L] raggiungono il minimo sommate
(L = data di una pregressa). La data efficace è la più recente fra le
date coperte. I completamenti formali non cumulano: registrano la
presenza a una sessione, non un modulo concluso.
Aggiornamento (isUpdate = 1) - rolling window:
La data efficace non è più calcolata su cicli fissi consecutivi ancorati alla base (un buco passato inchiodava la scadenza alla base di partenza). La nuova semantica è:
La data efficace è l'ultima finestra
(L − validitySpan, L]in cui le ore di aggiornamento frequentate raggiungonominimumHours, doveLscorre all'indietro ancorato all'ultimo completamento valido.
In pratica: si cerca la finestra più recente in cui il lavoratore ha totalizzato le ore minime richieste, indipendentemente da buchi precedenti. Un gap storico non degrada la scadenza corrente - conta solo l'ultimo blocco di ore valide.
Il cancello ore si giudica sulla variante svolta
Il minimo con cui si confrontano le ore è quello della variante svolta
(takenMin), non quello della variante richiesta oggi. Le ore efficaci sono
COALESCE(ore reali, takenMin, minimo richiesto): il terzo anello copre la
variante svolta senza minimumHours dichiarato, dove non c'è evidenza e si
resta sul comportamento storico. Il gate confronta con
COALESCE(takenMin, m) e passa incondizionato solo se nessuno dei due
minimi è dichiarato.
Vale su entrambi i rami (base e aggiornamento) e in entrambe le direzioni:
- Chi ricade oggi sotto una variante più onerosa di quella svolta mantiene data di completamento e scadenza. L'attestato non si invalida: il passaggio a un rischio superiore obbliga alla formazione di integrazione, non annulla quella fatta.
- Dove le ore reali esistono il metro si stringe anche al contrario: 8 ore registrate su un corso da 16 non coprono più un requisito da 4. Un corso frequentato a metà non certifica nemmeno un obbligo più leggero.
Il caso della quasi totalità dei dati è invariante per costruzione: con
attendanceHours NULL e variante svolta uguale alla richiesta il confronto era
m >= m ed è takenMin >= takenMin.
hoursShortfall - l'insufficienza è una dimensione separata
status continua a valere ok/expiring/expired/missing e a significare
solo dove sei rispetto alla scadenza. L'insufficienza è una colonna a parte,
così i 22 consumer che leggono solo status restano invariati.
edu.workerCompletionsCachematerializzaeffectiveHours(le ore che il cancello ha valutato per la data risultata efficace: il singolo evento se ha coperto da solo, il totale della finestra se ha coperto il cumulo) erequiredHours(il minimo della variante richiesta oggi).edu.vw_workerTrainingStatusle espone e ne derivahoursShortfall(BIT). Ore e data vengono sempre dalla stessa riga (CROSS APPLY EFF): se in regime aggiornamento la data arriva dal fallback sulla base, il confronto è fra ore e minimo della base. Senza requisito dichiarato o senza ore notehoursShortfallè 0 - non si accusa nessuno.edu.workerCompletionsCache.dischargedByOverlap(BIT) è l'ulteriore eccezione: vale 1 quando la copertura vincente contiene almeno un evento svolto sotto una variante dichiarata valida per un'altra (edu.trainingVariantsOverlaps) e regolare rispetto al proprio minimo. Quando vale 1hoursShortfallnon si calcola: la sovrapposizione è equivalenza, non riclassificazione, e il confronto fra le ore di due formazioni diverse non ha significato.edu.vw_companyComplianceStatus:okCountecompliancePercentrichiedonostatus = 'ok' AND hoursShortfall = 0; si aggiungeinsufficientCount.
La presenza della riga in cache non significa "formazione valida". Lo SP scrive sempre una riga anche sotto il minimo, con
effectiveCompletionDateNULL: l'unico predicato corretto èeffectiveCompletionDate IS NOT NULL.
La gerarchia «alto vale anche per basso/medio» non richiede
edu.trainingVariantsOverlapsquando i livelli sono varianti di un unico topic: nasce già dal cancello sulle ore dello Step 6 disp_refreshCompletionsForScope- un evento copre la data se raggiunge il minimo della variante svolta, e un minimo più alto soddisfa per costruzione quelli più bassi. Gli overlap servirebbero solo modellando i livelli come topic distinti. Da non ri-derivare ogni volta.
edu.vw_configurationGaps - i buchi di configurazione
Il fail-silent (un lavoratore con obbligo ma senza variante applicabile
sparisce dal prospetto, per l'INNER JOIN su workerEffectiveVariantCache)
non è riparato: la vista di stato non si tocca, per non toccare la forma di
un join già al limite dell'optimizer. È tamponato da una vista diagnostica con
tre controlli (checkKey) - missingVariant, anti-join sulla cache varianti
per regime, non "esiste una riga qualsiasi";
variantWithoutMinimumHours, il cancello resta senza metro su quella variante;
variantWithoutValiditySpan, status = 'ok' e scadenza NULL per sempre - dietro la
griglia generata /edu/vw_configurationGaps e un badge nella KPI bar della dashboard
compliance quando il totale è > 0. I checkKey si leggono localizzati via
l'enum ConfigurationCheckKey, non li traduce la vista.
Cache di performance: workerEffectiveVariantCache / workerEffectiveRisksCache
edu.vw_workerTrainingStatus era lenta su filtro companyId (~2 s per 35 righe):
l'optimizer andava in timeout e non propagava il predicato attraverso le viste annidate
edu.vw_workerEffectiveVariant e job.vw_workerEffectiveRisks (window function,
~215 k righe, spill su tempdb).
Fix (read path): le due viste sono materializzate in:
edu.workerEffectiveVariantCache- variante efficace per worker×topicjob.workerEffectiveRisksCache- rischi efficaci per worker
edu.vw_workerTrainingStatus legge dalle cache (3 sostituzioni). Le viste live
(vw_workerEffectiveVariant, vw_workerEffectiveRisks) restano sorgente di verità
e per gli altri consumer (pagine CRUD, fn_getWorkerRisks, ecc.).
Refresh scoped - architettura write path
Il refresh è proporzionale a ciò che è cambiato, non al totale dei dati. Tre SP:
| SP | Cache | Scope accettati | Note |
|---|---|---|---|
edu.sp_refreshEffectiveVariantForScope | variant | @workerId, @companyId, @jobId, @trainingTopicId | cascata precisa a completions: ricalcola i completamenti solo per i worker la cui variante è effettivamente cambiata (snapshot prima/dopo via EXCEPT) |
job.sp_refreshEffectiveRisksForScope | risks | @workerId, @companyId, @jobId | - |
edu.sp_refreshCompletionsForScope | completions | @workerId, @companyId, @trainingTopicId, @workerTrainingId | espande ai siblings (stesso CF) internamente; modalità all/topic via parametri |
edu.sp_refreshWorkerCompletions rimane come full rebuild (ricostruisce tutte e 3
le cache da zero): usato solo per seed iniziale post-deploy e dal reconcile via
@apply = 0 (scrive in edu.workerCompletionsCacheStaging invece che in cache - stessa
logica, niente duplicazione). Non è più chiamato dai QueryModifier.
Cleanup pattern: ogni SP scoped cancella le righe dello scope in entrata, poi le reinserisce calcolate. Così la cache resta allineata con i worker che escono dalla visibilità (terminati, aziende disattivate) senza scansioni globali.
Matrice di invalidazione
Ogni QueryModifier, in PostExecutionQuery, chiama le SP scoped secondo questa tabella.
V = sp_refreshEffectiveVariantForScope, R = sp_refreshEffectiveRisksForScope,
C = sp_refreshCompletionsForScope. La C via cascata è interna a V (solo per i
worker la cui variante cambia davvero) e non va richiamata dal trigger.
M = sp_refreshVariantTopicMap, senza scope: ricostruisce l'intera mappa variante → topic.
Questi due modifier restano l'unico percorso incrementale che la rifà, ma non più l'unico in
assoluto: sp_refreshWorkerCompletions la esegue come prima istruzione del full rebuild, ed è
questo che rende riparabile la deriva che il reconcile rileva.
| Trigger (QueryModifier) | Chiama | Scope | Note |
|---|---|---|---|
edu.workerTrainingDetail | C | @workerTrainingId | solo completamenti |
edu.workerTrainingDetail (batch) | C | @workerIds (lista) | percorso bulk, WorkerTrainingDetailsQueryModifier.cs:168: se non risolve alcun lavoratore logga un warning e salta |
edu.priorTraining | C | @workerId | solo completamenti |
job.worker (company/CF/endDate/risk) | V + R + C | @workerId | C esplicita per CF/attività; V cascata copre company/risk |
reg.company (ccnl) | V | @companyId | C via cascata |
reg.companyTag | V | @companyId | C via cascata |
reg.companyLocationTag | V | @companyId | la SP non ha uno scope «sede»: risolve l'azienda dalla sede e usa quello, più largo del necessario (CompanyLocationTagsQueryModifier.cs:29-36) |
edu.trainingVariant | M + V | @trainingTopicId (dalla variante) | TrainingVariantsQueryModifier.cs:26-27: la mappa va rifatta prima di V; C via cascata |
edu.trainingVariantsOverlap | M + C | @trainingTopicId del topic alsoValidForVariantId | non chiama V: sp_refreshEffectiveVariantForScope non legge le overlap, quindi sarebbe un no-op. L'overlap cambia il topic coperto, perciò rinfresca direttamente i completamenti di quel topic (TrainingVariantsOverlapsQueryModifier.cs:26-37) |
job.workersJob | V + R | @workerId | V cascata a C solo se il riskLevel sposta la variante |
job.jobsRisk / job.job | V + R | @jobId | idem |
job.companiesRisk | V + R | @companyId | idem |
job.workersRisk | V + R | @workerId | idem |
Reconcile giornaliero - edu.sp_reconcileWorkerCaches
Rilevatore di drift: non ripara di default (mascherare i buchi nella matrice con un
full rebuild notturno impedirebbe di trovarne la causa). Schedulato via Mola
(mola.timetable → trigger system.cache.drift).
Per ogni cache calcola la verità e confronta via EXCEPT bidirezionale:
| Cache | Sorgente di verità |
|---|---|
effectiveVariant | edu.vw_workerEffectiveVariant (vista live) |
effectiveRisks | job.vw_workerEffectiveRisks (vista live, excluded = 0) |
completions | edu.sp_refreshCompletionsForScope @apply = 0, @variantFromView = 1 → edu.workerCompletionsCacheStaging |
variantTopicMap | edu.vw_variantTopicMap (vista live) |
variantTopicMap non è una cache di lavoratori come le altre tre, ma è derivata allo stesso
modo e la ricostruiscono solo i query modifier su trainingVariants e
trainingVariantsOverlaps: ogni scrittura che salta SimpleCRUD (uno script in
Scripts/tools/, un bulk) la lascia derivata. Per questo è nel confronto.
Ordine: la riga
variantTopicMapsi legge prima della rigacompletions. La verità dei completamenti è calcolata in staging dasp_refreshCompletionsForScope, che leggeedu.variantTopicMap: se la mappa è derivata, quella verità nasce dalla mappa sbagliata e il confronto sui completamenti può risultare falsamente pulito. È una conseguenza deliberata del contratto read-only - il reconcile rileva, non ripara. Con@autoRepair = 1il rebuild ricostruisce la mappa per prima e il run successivo è attendibile.
Scrive sempre una riga su
edu.cacheReconciliationLog(id, runAt, cacheName, missingInCache, extraInCache, sampleJson)
- anche a drift zero (prova che il check gira). Per leggere l'ultimo run:
SELECT * FROM edu.cacheReconciliationLog
WHERE runAt = (SELECT MAX(runAt) FROM edu.cacheReconciliationLog);
Retention 90 giorni. sampleJson è previsto per un campione delle righe in drift
(FOR JSON, per indagare) ma la procedura lo scrive NULL su tutte e quattro le righe:
oggi il log dice quanto si è derivato, non cosa.
@autoRepair = 0(default): compute + log + alert Ploc (ploc.awsSes) - read-only sulle cache di produzione. Nessuna scrittura.@autoRepair = 1(opt-in manuale): riallinea le cache in drift via le SP scoped/full dopo aver capito la causa. Mai automatico.
🧩 Code-behind pattern
Pagine page-level scritte a mano
Pages/AppointmentsCalendar/*- vista calendario page-level (filtri, viste mese/settimana/giorno, colori datrainingTopics.color, conflict detection post-save).Components/edu/SessionPlanner/SessionPlannerPopup*- wizard a 8 step (Step1Session, Step2Courses, Step3Schedule, Step4Program, Step4Teachers, Step5Workers, Step6Review come partial classes, più.razor.Delete.cs; l'ottavo step, Documenti, è markup nel.razorconSessionDocumentsMatrix).
Forms con cascade
CourseForm.razor.cs- cascade sutrainingVariantId→ pre-popola campi del corso.LocationForm.razor.cs- combobox headquarters (reg) per associazione aula → sede.AppointmentForm.razor.cs- solo dati appuntamento (data, aula, link). Il "contenuto" viene dalla matriceappointmentsCourses(quali corsi nello slot) e dai relativiappointmentsCoursesArguments(argomenti per slot × corso).
📦 Dipendenze
Runtime:
ISimpleCRUDService- CRUD base- Service Shared elencati sopra
- Librerie 3SD
Mola/Ploc/TiraPlocper notifiche e trigger Oss.Documentsper allegati (engagement PDF, document upload)
Cross-dominio interni:
edu.locations↔reg.companies(gestore aula opzionale)edu.courses,edu.teacherCosts↔reg.organizersworkerCompletionsCache↔job.worker(dominio lavoratori)edu.trainingSessions.responsibleGroupId↔core.recipientGroups
📁 File chiave
Shared/Services/SessionPlanner/- 17 file (ISessionPlannerService.cse i partial diSessionPlannerService.Delete.cs,.SlotArguments.cs,.SyncSessionHeader.cs- più i tipi di stato/piano/risultato/conflitto/costo:AppointmentCourseEntry.cs,ProgramCoverageInfo.cs,QueueEnrollmentResult.cs,SlotArgumentDtos.cs,WorkerAppointmentResolution.cs,SessionPlannerSeedKind.cs)
Shared/Services/{IAppointmentsCalendarService, ITrainingExpirationService, IRiskInheritanceService, ITrainingRequirementsService, IEnrollmentService, ICompanyTrainingMatrixService}.cs+ implementazioniShared/Services/Attendance/IAttendanceService.csShared/Services/TrainingCompletion/ITrainingCompletionService.csShared/Services/Engagement/{ITeacherEngagementService.cs, EngagementFingerprint.cs}Shared/Services/TeacherArea/ITeacherAreaService.csShared/Services/PriorTraining/IPriorTrainingService.csShared/Services/Pricing/PricingHelpers.csShared/Services/BusinessRules/{IBusinessRule.cs, BusinessRuleResult.cs, Rules/}Shared/Services/AppointmentsCalendarService.cs(impl - sta inShared, non inBackOffice)Shared/Services/Convocation/{IConvocationService, ConvocationService, ConvocationModels, ConvocationPlanner, ConvocationMessageBuilder, ConvocationPreviewRenderer}.csBackOffice/Endpoints/ConvocationEndpoints.cs-POST /system/convocations/runBackOffice/Components/edu/Convocation/ConvocationSendPopup.razor(.cs)BackOffice/Services/QueryModifiers/edu/*.cs- 10 modifier
⚠️ Debito tecnico
- Cache coerenza. Risolto con refresh scoped (matrice di
invalidazione per QueryModifier) +
sp_reconcileWorkerCachesgiornaliero che rileva e alerta su drift senza mascherarlo. - Service in Shared ma logica edu-centrica. Il bound fra
TrainingHub.SharedeTrainingHub.DataLayer.eduè stretto: Shared dipende dal DataLayer di dominio. Accettabile per dimensione attuale ma genera coupling. - QueryModifiers sparsi senza visione d'insieme. 10 modifier
in
edu/+ alcuni inreg/,job/,inv/. La matrice di invalidazione (sezione sopra) copre il refresh delle cache; manca documentazione analoga per gli altri effetti collaterali. -
Test contract drift sui mock SQL.Chiuso dalla migrazione a NSubstitute. I test asseriscono il testo SQL e il valore dei parametri - vediTrainingHub.UnitTests/Services/QueryModifiers/JobsRisksQueryModifierTests.cs:32-41(Arg.Is<string>(q => q.Contains(...))+ArgParam.Check(p, "jid", jobId)).It.IsAnyera l'API di Moq e non compare più inTrainingHub.UnitTests. - Race condition
SuggestTeachersAsyncBulkInsert. Click concorrente su due tab può causare PK violation suappointmentsTeachers. Wrap in transaction o INSERT WHERE NOT EXISTS per riga. (VediBACKLOG.md.) -
ITrainingExpirationServicecon parametri string. Il parametrostatusè stringa invece di enumWorkerTrainingStatusValue: meno type-safe. - Business Rules Engine aggregator. Le regole
WorkerFinalRiskLevelRuleeTrainingExemptionRulesono inBusinessRules/Rules/ma l'aggregatorIBusinessRuleEnginee la sostituzione delle query SQL inline restano fuori scope. (VediBACKLOG.md.)
Attestati anteriori all'Accordo 2025
Un attestato da datore-RSPP rilasciato entro il 24/05/2026 - la fine del regime transitorio, in cui
i corsi col vecchio schema si sono ancora potuti erogare - si registra sulle varianti storiche
C-RSPP BR/MR/AR (16/32/48 ore, Accordo 2011), non sulle varianti dell'integrazione. Le storiche
portano il tag training_asr2011, che le tiene fuori dalla scelta della variante richiesta: servono
solo come metro con cui giudicare le ore dell'attestato, ed è da lì che parte l'equivalenza che esonera
dal corso datore da 16 ore. Registrarlo altrove significa farlo misurare con un requisito che al momento
del rilascio non esisteva, e vederlo sparire dal prospetto.
L'integrazione RSPP è propedeutica al corso datore (edu.trainingTopicDependencies, 12 mesi): finché
il corso datore non risulta completato, la riga dell'integrazione non compare affatto - non compare in
rosso, non compare. Per chi ha l'attestato storico l'esonero copre il prerequisito e la riga resta; per
chi non ce l'ha la sospensione dura finché non fa il corso. Fa eccezione chi è nominato RSPP senza
avere il ruolo "Datore di lavoro": l'obbligo del corso datore nasce dal ruolo, quindi a lui il
prerequisito non verrà mai chiesto e la riga resta sospesa per sempre. È un dato da correggere, non una
configurazione da cambiare; li elenca Queries/diag-rspp-integrazione-senza-prerequisito.sql.
🔗 Vedi anche
- Panoramica dominio
- Schema DB - viste e tabelle materializzate
- Componenti UI - calendario e session planner
- Dominio
reg: logica applicativa - cascade reg → edu via RiskInheritance