Passa al contenuto principale

Schema DB edu

🎯 Cosa fa

Definisce il persistence layer della formazione: 54 tabelle nel dominio edu. Questa pagina documenta in dettaglio le core del catalogo + erogazione + docenti, allineate al redesign edu-sessions-redesign del 2026-05-08 (co-erogazione multi-corso, drop lessons/lessonsCourses, rename courseSessionstrainingSessions).

🗺️ Tabelle core

TabellaRuolo
edu.categoriesTassonomia piatta dei corsi
edu.coursesDefinizione corso template organizer-agnostic (FK trainingVariants)

Le tabelle edu.lessons e edu.lessonsCourses sono state droppate dal redesign 2026-05-08. La struttura didattica vive ora su trainingVariants / trainingArguments (fuori scope di questa pagina) + appointmentsCoursesArguments per gli argomenti effettivamente trattati nello slot (granularità slot × corso × argomento dal redesign 2026-05-18 "arguments-per-slot", che ha droppato la precedente trainingSessionsCoursesArguments).

Erogazione

TabellaRuolo
edu.trainingSessionsSessione formativa: blocco fisico (giornata/giornate). FK NOT NULL organizers (ente che eroga). FK opzionali trainingTopics (vincola i corsi), core.recipientGroups (notifiche). locationId, startDateTime ed endDateTime non sono input: sono cache derivata dagli appointment (vedi sotto). Stati: planned/open/inProgress/completed/cancelled
edu.trainingSessionsCoursesM:N session ↔ corso (co-erogazione). Ogni riga è una "vista contrattuale" del corso erogato nella session: solo id, trainingSessionId, courseId + UQ_trainingSessionsCourses. Nessuna finestra oraria — quella vive su appointmentsCourses
edu.appointmentsOccorrenza fisica (finestra oraria) all'interno di una session. FK trainingSessions NOT NULL, locations override opzionale
edu.appointmentsCoursesM:N appointment ↔ trainingSessionsCourse: cella della matrice "programma" che dichiara quali corsi sono erogati in ogni slot della session. Finestra oraria opzionale per slot+corso (NULL = copre intero appointment)
edu.appointmentsCoursesArgumentsRegistro effettivo degli argomenti trattati per ciascuna cella (appointment, corso). Granularità slot × corso × argomento (redesign 2026-05-18). Sostituisce la precedente trainingSessionsCoursesArguments (granularità sessione+corso)
edu.trainingVariantsArgumentsCatalogo degli argomenti tipici di una variante formativa: pre-popola il popup "Inserisci da catalogo" nello Step 4 del wizard sessioni. Pattern coerente con variantTopicMap
edu.locationsAule (indirizzo inline; FK opzionale reg.companies come gestore)
edu.locationsTrainingVariantsM:N aula ↔ variant per filtrare aule abilitate alle prove pratiche (variant con requiresEquippedRoom = 1)

Docenti

TabellaRuolo
edu.teachersAnagrafica docenti (label = computed lastName + ' ' + firstName — cognome prima)
edu.appointmentsTeachersM:N appuntamento ↔ docente: unica fonte dei docenti di una session, che si ricavano risalendo dagli appointment (con unitAmount + costMode opzionali)
edu.teacherCostsCosto per docente per organizzatore: unitAmount + costMode (hourly default, oppure daily)
edu.teacherSkillsSkill matrix docente ↔ variante normativa (trainingVariantId, score). Granularità a variant (non più a lesson, ridenominata dal redesign 2026-05-08)

🪟 Viste

VistaRuolo
edu.vw_appointmentsCalendarBacking della vista calendario /appointments-calendar: appuntamenti arricchiti con info session, sede, docenti aggregati, primo trainingTopic (per il colore). Colonne aggiuntive: isFunded, fundedProjectId, fundedProjectCode, fundedProjectShortLabel
edu.vw_appointmentsDataDati appuntamenti aggregati (totale ore, partecipanti, ecc.). Colonne aggiuntive: isFunded, fundedProjectId, fundedProjectCode, fundedProjectShortLabel
edu.vw_appointmentAttendeesPartecipanti per appuntamento
edu.vw_workerTrainingStatusStato formativo per coppia (lavoratore × formazione richiesta): status (ok/expiring/expired/missing), daysRemaining, ultima formazione erogata, fonte del requisito (ruolo/mansione/qualifica/attrezzatura/rischio/esenzione), più effectiveHours/requiredHours/hoursShortfall (cancello ore: attestato valido ma sotto il minimo richiesto oggi — dimensione separata da status). Esposta come CRUD read-only WorkerTrainingStatus. Aggregata da job.vw_workerComplianceSummary
edu.vw_trainingSessionAppointmentsTeachersVista filtrata di appointmentsTeachers per trainingSessionId (usata negli step del session planner)
edu.vw_workersTrainingsDataDati formazione per lavoratore
edu.vw_companyComplianceStatusStato compliance per azienda. okCount e compliancePercent contano solo status = 'ok' AND hoursShortfall = 0; insufficientCount è il contatore delle integrazioni dovute. Lo scarto ore non viene addebitato quando il completamento è assolto da una formazione dichiarata equivalente in edu.trainingVariantsOverlaps (colonna dischargedByOverlap di edu.workerCompletionsCache)
edu.vw_configurationGapsControlli di configurazione che fanno tacere il prospetto (checkKey: missingVariant, variantWithoutMinimumHours, variantWithoutValiditySpan). Backing della griglia generata /edu/vw_configurationGaps e del badge nella KPI bar della dashboard compliance
edu.vw_trainingExpirationsFormazioni in scadenza
edu.vw_availableTrainingSessionsCombinazioni (session, course) iscrivibili: planned/open, future, con il conteggio iscritti per corso
edu.vw_workerRequiredTopicsClassificazione dell'obbligo per lavoratore attivo × argomento candidato: requirementState = required | forbidden | exempted | prerequisiteMissing. Una riga per worker×topic. Consumata da vw_workerTrainingStatus (solo required) e da vw_workerOptionalTrainings (tutto il resto)
edu.vw_workerOptionalTrainingsCompletamenti il cui argomento non è dovuto. Stessa semantica di scadenza degli obbligatori, regime aggiornamento incluso; notRequiredReason distingue facoltativa vera, vietata, esentata, prerequisito mancante. Esclusa dalla compliance. Una riga esce solo se il regime risolto ha evidenza
edu.vw_workerEffectiveVariantVariante risolta per lavoratore attivo × formazione × regime (match per CCNL, tag azienda/sede, livello di rischio). Espone minimumHours e validitySpan; nessun filtro di obbligatorietà o completamento — lo applicano i consumer. Materializzata in edu.workerEffectiveVariantCache
edu.vw_workerTrainingDetailsDataStorico formativo denormalizzato per lavoratore: ore totali/frequentate, deadline per riga, flag isOptional
edu.vw_workersRequiredTrainingsDataDichiarazioni manuali di obbligo con la diagnosi del loro effetto: active, oppure la causa per cui non entrano nel piano (forbidden, exempt, waitingPrerequisite, noVariant, inactiveWorker)
edu.vw_trainingVariantsDataVarianti con topic, categoria, rischio, livello, CCNL e tag azienda
edu.vw_trainingRequestsDataCoda richieste denormalizzata per filtri e sort della griglia
edu.vw_convocationDataSorgente del template mail di convocazione: una riga per (worker × appointment del corso), con orario effettivo (sotto-finestra da appointmentsCourses, fallback sull'appointment), sede e docenti dell'appointment
edu.vw_sessionCoursesSlotsSlot (appointment × corso di sessione) del session planner
edu.vw_sessionEnrolledWorkersIscrizioni arricchite con azienda del lavoratore per lo step Iscritti. Write-back su edu.workerTrainingDetails via OriginalTable

La fundedness in vw_appointmentsCalendar e vw_appointmentsData non è denormalizzata: si deriva via join su fin.editions (shared PK con edu.trainingSessions). Vedi docs/superpowers/specs/_archive/2026-05-18-edu-fin-bridge-design.md.

vw_workerEffectiveRisks e vw_workersData non sono viste edu: vivono nello schema job (TrainingHub.Database/job/Views/). Vedi schema DB job.

🔗 Relazioni

Referenze esterne:

  • edu.trainingSessions.organizerSlugreg.organizers(slug)
  • edu.courses.trainingVariantIdedu.trainingVariants(id) (subsystem normative)
  • edu.trainingSessions.responsibleGroupIdcore.recipientGroups(id) (notifiche operatore)
  • edu.trainingSessions.trainingTopicIdedu.trainingTopics(id)
  • edu.locations.companyIdreg.companies(id) (opzionale, gestore aula)
  • edu.teacherCosts.organizerSlugreg.organizers(slug)

🗂️ Dettaglio tabelle

edu.courses

Template formativo organizer-agnostic: definisce il contenuto didattico (variante normativa, etichetta, durata) indipendentemente dall'ente erogatore. L'organizer è agganciato alla sessione (trainingSessions.organizerSlug), così lo stesso corso può essere erogato da enti diversi senza duplicazione anagrafica.

  • PK: id
  • FK: trainingVariantId → edu.trainingVariants(id)
  • Campi: code, label, description, active (default 1), elearning (default 0), inCatalog (default 1), maxPeople, extraInstructorThreshold, listPrice DECIMAL(15,2) NOT NULL default 0 (prezzo di listino per iscritto; lo sconto convenzione si applica a valle, in stima costi e fattura)
  • Check: maxPeople > 0, extraInstructorThreshold > 0, listPrice >= 0

edu.trainingSessions

Giornata (o giornate consecutive) di erogazione formativa, può ospitare uno o più corsi in co-erogazione. L'organizer è agganciato alla sessione: stesso courses template può essere erogato da enti diversi in sessioni distinte.

  • PK: id
  • FK:
    • organizerSlug → reg.organizers(slug) (NOT NULL, ente organizzatore che eroga la sessione formativa; valorizzato al seed dal trainingRequest o manualmente al primo salvataggio)
    • locationId → edu.locations(id)cache, non input: è la location condivisa da tutti gli appointment della sessione. Il campo è nascosto nella form CRUD e viene sincronizzato server-side dal trigger tr_appointments_aft_iud_syncSessionHeader; scriverlo direttamente viene sovrascritto al primo salvataggio di un appointment (extended property su trainingSessions.sql:58-62). Stesso trattamento per startDateTime (:46-50) ed endDateTime (:52-56): «NON modificare direttamente: usa gli appointments»
    • responsibleGroupId → core.recipientGroups(id) (opzionale, gruppo destinatari notifiche operatore)
    • trainingTopicId → edu.trainingTopics(id) (opzionale; se valorizzato, vincola i corsi della sessione a quel topic)
  • Campi: organizerSlug (NVARCHAR(128), NOT NULL), startDateTime (required, DATETIME), endDateTime (nullable: NULL = single-block), maxPeople (capienza pratica della giornata), minPeople (soglia minima di iscritti), status, notes (1024 char), shortLabel (NVARCHAR(512), etichetta compatta calcolata da edu.fn_sessionShortLabel), documentsManagedAt (DATETIME nullable — timestamp con cui lo step Documenti del wizard marca la sessione «gestita»: valorizzato = tutto il wizard passa in sola lettura finché non si annulla la gestione)
  • Check:
    • endDateTime IS NULL OR endDateTime >= startDateTime
    • maxPeople > 0
    • minPeople > 0
    • status IN ('planned','open','inProgress','completed','cancelled')
  • Indici: IX_trainingSessions_startDateTime, IX_trainingSessions_organizerSlug, IX_trainingSessions_locationId (filtered), IX_trainingSessions_trainingTopicId (filtered), IX_trainingSessions_responsibleGroupId (filtered)

edu.trainingSessionsCourses

M:N session ↔ corso: ogni riga rappresenta un corso erogato dentro la session (co-erogazione di livelli diversi nello stesso giorno).

  • PK: id
  • FK: trainingSessionId → edu.trainingSessions(id), courseId → edu.courses(id)
  • Unique: UQ_trainingSessionsCourses su (trainingSessionId, courseId) — un corso compare al più una volta per session
  • Campi: nessuno oltre chiave e FK. ⚠️ La tabella non ha finestra oraria: la co-erogazione a orari diversi si esprime sugli slot, cioè su appointmentsCourses.startDateTime/endDateTime.
  • Indice: IX_trainingSessionsCourses_courseId

edu.appointmentsCourses

Cella della matrice "programma" (appointment × corso) che dichiara quali corsi sono effettivamente erogati in ogni slot della session. Riga della matrice di Step 4 del wizard SessionPlannerPopup.

  • PK: id (surrogato, dal redesign 2026-05-18; in precedenza la PK era composita (appointmentId, trainingSessionsCourseId))
  • FK:
    • appointmentId → edu.appointments(id) (ON DELETE CASCADE)
    • trainingSessionsCourseId → edu.trainingSessionsCourses(id) (NO ACTION per evitare multi-path cascade conflict)
  • Unique: UX_appointmentsCourses_appCourse su (appointmentId, trainingSessionsCourseId) (la cella della matrice resta unica nonostante la PK surrogata)
  • Campi: startDateTime, endDateTime (entrambi nullable: NULL = copre l'intero appointment; valorizzati = sotto-finestra che deve ricadere dentro l'appointment)
  • Check: CK_appointmentsCourses_windowPair forza la coppia start/end consistente (entrambi NULL oppure entrambi valorizzati con endDateTime > startDateTime)
  • Indici: UX_appointmentsCourses_appCourse (unique), IX_appointmentsCourses_trainingSessionsCourseId

edu.appointmentsCoursesArguments

Registro effettivo degli argomenti trattati per ciascuna coppia (appointment, corso). Granularità slot × corso × argomento. Sostituisce la vecchia trainingSessionsCoursesArguments (granularità sessione + corso) per supportare il registro lezioni per giornata (redesign 2026-05-18 "arguments-per-slot"). La cancellazione cascade dalla parent appointmentsCourses garantisce coerenza quando la cella (slot, corso) viene rimossa dalla matrice del programma.

  • PK: id
  • FK:
    • appointmentsCourseId → edu.appointmentsCourses(id) (ON DELETE CASCADE: la riga vive solo finché esiste la cella nella matrice)
    • trainingArgumentId → edu.trainingArguments(id) (opzionale: NULL = riga ad hoc con solo label/description come override)
    • riskId → job.risks(id) (opzionale, override del rischio associato)
  • Campi: label (NVARCHAR(256), override / ad hoc), description (NVARCHAR(1024))
  • Indice: IX_appointmentsCoursesArguments_appointmentsCourseId

edu.trainingVariantsArguments

Catalogo degli argomenti tipici associati a una variante formativa. Pre-popola il popup "Inserisci da catalogo" nello Step 4 del wizard sessioni (multi-select sugli argomenti suggeriti per la variante del corso della cella). Pattern coerente con edu.variantTopicMap (variante ↔ topic).

  • PK composita: (trainingVariantId, trainingArgumentId)
  • FK:
    • trainingVariantId → edu.trainingVariants(id) (ON DELETE CASCADE)
    • trainingArgumentId → edu.trainingArguments(id)
  • Campi: sortOrder (INT, nullable: NULL = ordine alfabetico della label dell'argomento)
  • Indice: IX_trainingVariantsArguments_trainingArgumentId

edu.appointments

Occorrenza fisica (finestra oraria) all'interno di una session.

  • PK: id
  • FK:
    • trainingSessionId → edu.trainingSessions(id) (NOT NULL dal redesign 2026-05-08)
    • locationId → edu.locations(id) (nullable, override dell'aula default della session)
  • Campi: startDateTime, endDateTime (entrambi required), link (NVARCHAR MAX), notes (NVARCHAR MAX), locationUnitCost DECIMAL(15,2) e locationCostMode NVARCHAR(16) — override del costo aula per questo appuntamento; se NULL si usa quello dell'anagrafica aula. È l'unico punto in cui la modalità può essere fixed: edu.locations ammette solo hourly/daily
  • Check:
    • endDateTime > startDateTime
    • locationCostMode IS NULL OR locationCostMode IN ('hourly','daily','fixed')
    • coppia costo/modalità coerente: o entrambi NULL o entrambi valorizzati
    • locationUnitCost IS NULL OR locationUnitCost >= 0
  • Indici: IX_appointments_trainingSessionId, IX_appointments_locationId (filtered), IX_appointments_startDateTime

lessonId è stato droppato. L'appuntamento non riferisce più una lezione specifica: il "contenuto" è derivato dalla matrice appointmentsCourses (quali corsi in questo slot) e dai relativi appointmentsCoursesArguments (registro lezioni per slot × corso).

edu.locations

Aule / spazi di erogazione. Entità autosufficiente: l'indirizzo è inline sull'aula stessa, non rimandato a una sede.

  • PK: id
  • FK: companyId → reg.companies(id) (opzionale: gestore aula esterno; NULL = aula 3SD)
  • Campi anagrafici: shortName, label (required), description, instructions, active, digital, capacity
  • Tariffa: unitCost, costMode ('hourly' | 'daily'), coppia consistente (entrambi NULL = aula gratuita) garantita da CK_locations_costPair
  • Contatti / fatturazione: email, vatCode (P.IVA gestore), invoicingLabel
  • Indirizzo geocoded: formattedAddress, country, province, city, zipCode, address, streetNumber, latitude, longitude

edu.locationsTrainingVariants

M:N aula ↔ variante normativa: utilizzata per filtrare le aule abilitate alle prove pratiche di una variante (requiresEquippedRoom = 1).

edu.teachers

  • PK: id
  • FK:
    • userId → kset.users(id) (nullable) — il collegamento che abilita l'Area Docente: senza, il docente esiste come anagrafica ma non accede. È anche ciò che delimita cosa vede.
    • companyId → reg.companies(id) (nullable) — azienda di riferimento del collaboratore/fornitore
  • Campi: firstName, lastName, label (computed column: lastName + ' ' + firstName, in quest'ordine), active, notes, fiscalCode, vatCode, isInternal (default 0), delegatesTraining (default 0 — il docente affida l'erogazione ad altri), più il blocco indirizzo geocodificato (country, province, city, zipCode, address, streetNumber, formattedAddress, latitude, longitude)

edu.appointmentsTeachers

  • PK composita: (appointmentId, teacherId)
  • FK: a appointments e teachers
  • Campi: unitAmount DECIMAL(15,2) e costMode NVARCHAR(16) (fixed | daily | hourly) — override sul costo standard da teacherCosts. CK_appointmentsTeachers_costMode limita i valori, CK_appointmentsTeachers_costPair impone che i due campi siano entrambi valorizzati o entrambi nulli. fixed esiste solo qui: è l'importo a forfait per il singolo appuntamento.
  • Indice: IX_appointmentsTeachers_teacherId

edu.teacherCosts

  • PK composita: (teacherId, organizerSlug)
  • FK: teacherId → edu.teachers(id), organizerSlug → reg.organizers(slug)
  • Campi: unitAmount DECIMAL NOT NULL e costMode NOT NULL con default 'hourly' (CK_teacherCosts_costMode: daily | hourly). La tariffa anagrafica può quindi essere giornaliera, non solo oraria; il fixed è ammesso solo come override per appuntamento.

edu.teacherSkills

Skill matrix docente ↔ variante normativa: abilitazione del formatore per quella variante (granularità a variant dal redesign 2026-05-08, non più a lesson).

  • PK composita: (teacherId, trainingVariantId)
  • FK: teacherId → edu.teachers(id), trainingVariantId → edu.trainingVariants(id)
  • Campi: score (nullable), active (default 1)
  • Indice: IX_teacherSkills_trainingVariantId (filtered su active = 1)

edu.trainingTopics

Argomento didattico di alto livello (es. "Antincendio", "Primo soccorso"). Usato come vincolo opzionale su trainingSessions e come fonte del colore mostrato nel calendario.

  • PK: id
  • FK: categoryId → edu.categories(id), riskId → job.risks(id) (opzionale)
  • Campi: code, label, description, requireUpdates, regulated (default 1), fullRetrainingYears, ats (default 0), color (NVARCHAR(16), nullable; hex es. #e53935, usato dal calendario per il colore degli appuntamenti)
  • Check: fullRetrainingYears > 0
  • Indice: IX_trainingTopics_riskId (filtered)

edu.categories

  • PK: id
  • Campi: label, position (INT nullable — ordinamento manuale delle colonne del prospetto formativo; NULL va in fondo, non in cima)

edu.locationUnavailabilities

Periodi in cui un'aula non è utilizzabile. Il default è l'assenza di righe: aula sempre disponibile.

  • PK: id
  • FK: locationId → edu.locations(id)
  • Campi: startDateTime, endDateTime (entrambi NOT NULL), allDay (default 0), reason, active (default 1)
  • Check: endDateTime > startDateTime
  • È la sorgente del conflitto location_unavailable (SessionPlannerService.cs:784-790): un appointment che si sovrappone a una riga attiva produce il warning, con motivo e finestra nel messaggio. Non blocca il salvataggio, come tutti gli altri conflitti.

edu.workersRequiredTrainings

Obbligo formativo dichiarato a mano su un singolo lavoratore, quando non deriva da mansione, nomina, qualifica, attrezzatura o rischio. È la sesta fonte di vw_workerTrainingStatus (origin = 'manual').

  • FK: al lavoratore e all'argomento formativo
  • Nell'ordinamento delle origini manual viene per prima: se il topic è dovuto anche da un'altra fonte, l'etichetta resta stabile il giorno in cui quella fonte viene tolta. Non è una precedenza di appartenenza: il topic entra nel piano comunque, da qualunque fonte arrivi.
  • La diagnosi di perché una dichiarazione non produce effetto sta in vw_workersRequiredTrainingsData (forbidden, exempt, waitingPrerequisite, noVariant, inactiveWorker): senza quella vista una riga dichiarata e inefficace sarebbe indistinguibile da una attiva.

📚 Tabelle fuori scope

Worker training journey (5)

workersTrainings, workerTrainingDetails, trainingDetailAttempts, attendances, workerCompletionsCache — registrazione iscrizioni, presenze, esiti, materializzazione compliance.

Argomenti formativi (8)

trainingArguments, trainingTopics, trainingVariants, variantTopicMap, trainingTopicDependencies, trainingArgumentChecks, trainingVariantsOverlaps, appointmentChecks — macchina normativa degli argomenti formativi, varianti, sovrapposizioni.

trainingVariants.tagSlug (NVARCHAR(128) NULL) è confrontato con due sorgenti, non una: i tag dell'azienda (reg.companyTags, il default) e quelli della sede del lavoratore (reg.companyLocationTags, l'override). Vocabolario unico: entrambe pescano dal gruppo company, quindi lo stesso slug può stare su tutti e due i livelli e basta una variante per valore, non una per asse. Stesso campo, due sorgenti — non una seconda colonna. NULL = applicabile ovunque. Filtro duro in edu.vw_workerEffectiveVariant: la variante si applica solo se una delle due sorgenti porta lo stesso tag, e in caso di match da entrambe prevale la sede. Il tier è deciso da quale sorgente contiene lo slug, mai dal gruppo del tag — che infatti ora è lo stesso per entrambe. Precedenza fra varianti candidate: ccnl → tag della sede → tag dell'azienda → variante universale (senza tag) → livello di rischio.

Nomine (3)

nominations, workersNominations, nominationsTrainingTopics — incarichi di ruolo (es. RSPP, ASPP, preposto) con vincoli formativi.

Certificati & qualifiche (5)

certificates, enrollmentCosts, priorTrainings, qualificationsExemptions, qualificationsRequiredTrainings — attestati, costi iscrizione, formazione pregressa, esenzioni e requisiti.

enrollmentCosts ha 2 FK opzionali: invoiceLineId (→ inv.invoiceLines per la fatturazione cliente) e expenseId (→ fin.expenses per la rendicontazione FondItalia). I due possono coesistere (stesso costo fatturato a cliente E rendicontato a fondo). Il costType ammette 6 valori: tuition, material, travel, other, staff_internal, staff_external.

Ruoli vs corsi (2)

rolesRequiredTrainings, rolesForbiddenTrainings — matrice ruoli e corsi richiesti/vietati.

Attrezzature (3)

equipments, workerEquipments, workerCredentials — attrezzature e credenziali necessarie.

📁 File chiave

  • TrainingHub.Database/edu/Tables/*.sql — 54 tabelle
  • TrainingHub.Database/edu/Views/*.sql — 21 viste (vedi «Viste»)
  • TrainingHub.Database/edu/Functions/ — due TVF inline:
    • fn_workerTrainingDeadline — scadenza, stato, giorni rimanenti e scadenza di riformazione completa. Senza accesso a tabelle: è l'unica definizione, condivisa da vw_workerTrainingStatus e vw_workerOptionalTrainings, così le due non possono divergere.
    • fn_sessionShortLabel — etichetta compatta di una sessione (prefisso comune dei corsi + shortLabel dei livelli di rischio, con fallback a STRING_AGG).
  • TrainingHub.Database/edu/Stored Procedures/ — il refresh delle tre cache (sp_refreshWorkerCompletions, sp_refreshCompletionsForScope, sp_refreshEffectiveVariantForScope, sp_refreshVariantTopicMap), il reconcile (sp_reconcileWorkerCaches) e sp_companyComplianceMatrix.
  • TrainingHub.Database/edu/Triggers/tr_appointments_aft_iud_syncSessionHeader.sql — AFTER INSERT/UPDATE/DELETE su appointments: ricalcola le colonne cache trainingSessions.{startDateTime,endDateTime,locationId} come MIN/MAX/COUNT-DISTINCT degli appointment. Tiene il DB coerente con SessionPlannerService.SyncSessionHeaderAsync: se tocchi una delle due, tocca anche l'altra.
  • Indici espliciti: vedi sezione "Dettaglio tabelle"

⚠️ Debito tecnico

  • appointments.courseSessionId nullable. Risolto in edu-sessions-redesign 2026-05-08: ora è trainingSessionId NOT NULL.
  • Drop appointments.lessonId. Risolto dallo stesso redesign: il contenuto deriva da trainingSessionsCourses + appointmentsCourses + appointmentsCoursesArguments (granularità slot×corso×argomento dal redesign 2026-05-18 "arguments-per-slot", che ha droppato la precedente trainingSessionsCoursesArguments).
  • teacherSkills.lessonId. Sostituito da trainingVariantId per allinearsi alla granularità varianti.
  • Nessun check su sovrapposizione appuntamenti. A livello DB i constraint restano informativi, ma a livello applicativo ISessionPlannerService.DetectConflictsAsync rileva sette casi: docente sovrapposto, sede sovrapposta, aula non disponibile (edu.locationUnavailabilities), iscritti oltre capienza, soglia docente, lavoratore in più sessioni, lezione mancante. Esposto come toast warning dal calendario e dal wizard.
  • edu.categories senza FK visibili da courses. La correlazione categoria ↔ corso passa per trainingVariants: da documentare chiaramente nel subsystem varianti.
  • courses.maxPeople vs trainingSessions.maxPeople. Due limiti distinti — validazione di coerenza non applicata a DB, solo in service SessionPlanner (conflict enrollment_overflow).
  • teachers.label computed. Colonna calcolata da lastName + ' ' + firstName: efficiente ma non indicizzabile. Se serve ricerca, aggiungere indice full-text o persisted computed column.
  • Cache workerCompletionsCache non atomica. Aggiornata da trigger/hook vari: coerenza in caso di failure non chiara.

🔗 Vedi anche