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.
🧩 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.
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.
| 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à |
|---|---|
| variant | edu.vw_workerEffectiveVariant (vista live) |
| risks | job.vw_workerEffectiveRisks (vista live) |
| completions | edu.sp_refreshWorkerCompletions @apply = 0 → edu.workerCompletionsCacheStaging |
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 contiene un campione delle righe in drift
(FOR JSON, per indagare).
@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)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.)
🔗 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