Passa al contenuto principale

Logica applicativa — dominio edu

🎯 Cosa fa

La logica applicativa del dominio edu si distribuisce in tre punti:

  1. 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).
  2. QueryModifiers in BackOffice/Services/QueryModifiers/edu/ — hook pre/post sui CRUD di entità edu specifiche.
  3. Code-behind .razor.cs dei 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.
  • ConflictInfo con ConflictKind enum, sette valori: teacher_double_booked, location_double_booked, location_unavailable (aula dichiarata indisponibile in edu.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.cs chiama DetectConflictsAsync dopo 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 su oss.documents) o caricamento del file già firmato fuori dall'app. Concorrenza ottimistica via fingerprintExpected (StaleEngagementException). SignUploadAsync ha due overload: uno prende un documentId già archiviato, l'altro il file del docente (.pdf/.p7m, tetto da TeacherArea:SignatureUpload:MaxSizeMb) e lo archivia sotto la categoria teacher-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 il signatureBlock del dataset Servel fa ramificando su @signatureMode.
  • CancelEngagementAsync — revoca incarico / sessione annullata; la version corrente, se signed, passa a superseded.

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

FileScopeCosa fa
AppointmentsQueryModifier.csappointmentsHook su CRUD appuntamenti
AppointmentsTeachersQueryModifier.csappointmentsTeachersHook M:N appuntamento↔docente, propaga aggiornamenti su teacherEngagements
TrainingSessionsQueryModifier.cstrainingSessionsHook sessione (validazione cross-corso, eventi notifica)
TrainingSessionsCoursesQueryModifier.cstrainingSessionsCoursesPulizia preventiva su DELETE: rimuove gli appointmentsCourses figli (FK NO ACTION per multi-path cascade) prima di cancellare il corso erogato
TeachersQueryModifier.csteachersHook anagrafica docente
WorkerTrainingDetailsQueryModifier.csworkerTrainingDetailsRefresh di workerCompletionsCache dopo modifiche; invocazione TrainingCompletionService
PriorTrainingsQueryModifier.cspriorTrainingsHook su formazione pregressa
TrainingVariantsQueryModifier.cstrainingVariantsHook su varianti normative
TrainingVariantsOverlapsQueryModifier.cstrainingVariantsOverlapsHook 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 / ICompanyTrainingMatrixService per 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 raggiungono minimumHours, dove L scorre 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.workerCompletionsCache materializza effectiveHours (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) e requiredHours (il minimo della variante richiesta oggi).
  • edu.vw_workerTrainingStatus le espone e ne deriva hoursShortfall (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 note hoursShortfall è 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 1 hoursShortfall non si calcola: la sovrapposizione è equivalenza, non riclassificazione, e il confronto fra le ore di due formazioni diverse non ha significato.
  • edu.vw_companyComplianceStatus: okCount e compliancePercent richiedono status = 'ok' AND hoursShortfall = 0; si aggiunge insufficientCount.

La presenza della riga in cache non significa "formazione valida". Lo SP scrive sempre una riga anche sotto il minimo, con effectiveCompletionDate NULL: 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×topic
  • job.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:

SPCacheScope accettatiNote
edu.sp_refreshEffectiveVariantForScopevariant@workerId, @companyId, @jobId, @trainingTopicIdcascata precisa a completions: ricalcola i completamenti solo per i worker la cui variante è effettivamente cambiata (snapshot prima/dopo via EXCEPT)
job.sp_refreshEffectiveRisksForScoperisks@workerId, @companyId, @jobId
edu.sp_refreshCompletionsForScopecompletions@workerId, @companyId, @trainingTopicId, @workerTrainingIdespande 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)ChiamaScopeNote
edu.workerTrainingDetailC@workerTrainingIdsolo completamenti
edu.workerTrainingDetail (batch)C@workerIds (lista)percorso bulk, WorkerTrainingDetailsQueryModifier.cs:168: se non risolve alcun lavoratore logga un warning e salta
edu.priorTrainingC@workerIdsolo completamenti
job.worker (company/CF/endDate/risk)V + R + C@workerIdC esplicita per CF/attività; V cascata copre company/risk
reg.company (ccnl)V@companyIdC via cascata
reg.companyTagV@companyIdC via cascata
reg.companyLocationTagV@companyIdla SP non ha uno scope «sede»: risolve l'azienda dalla sede e usa quello, più largo del necessario (CompanyLocationTagsQueryModifier.cs:29-36)
edu.trainingVariantM + V@trainingTopicId (dalla variante)TrainingVariantsQueryModifier.cs:26-27: la mappa va rifatta prima di V; C via cascata
edu.trainingVariantsOverlapM + C@trainingTopicId del topic alsoValidForVariantIdnon 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.workersJobV + R@workerIdV cascata a C solo se il riskLevel sposta la variante
job.jobsRisk / job.jobV + R@jobIdidem
job.companiesRiskV + R@companyIdidem
job.workersRiskV + R@workerIdidem

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:

CacheSorgente di verità
variantedu.vw_workerEffectiveVariant (vista live)
risksjob.vw_workerEffectiveRisks (vista live)
completionsedu.sp_refreshWorkerCompletions @apply = 0edu.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 da trainingTopics.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 .razor con SessionDocumentsMatrix).

Forms con cascade

  • CourseForm.razor.cs — cascade su trainingVariantId → 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 matrice appointmentsCourses (quali corsi nello slot) e dai relativi appointmentsCoursesArguments (argomenti per slot × corso).

📦 Dipendenze

Runtime:

  • ISimpleCRUDService — CRUD base
  • Service Shared elencati sopra
  • Librerie 3SD Mola / Ploc / TiraPloc per notifiche e trigger
  • Oss.Documents per allegati (engagement PDF, document upload)

Cross-dominio interni:

  • edu.locationsreg.companies (gestore aula opzionale)
  • edu.courses, edu.teacherCostsreg.organizers
  • workerCompletionsCachejob.worker (dominio lavoratori)
  • edu.trainingSessions.responsibleGroupIdcore.recipientGroups

📁 File chiave

  • Shared/Services/SessionPlanner/ — 17 file (ISessionPlannerService.cs e i partial di SessionPlannerService.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 + implementazioni
  • Shared/Services/Attendance/IAttendanceService.cs
  • Shared/Services/TrainingCompletion/ITrainingCompletionService.cs
  • Shared/Services/Engagement/{ITeacherEngagementService.cs, EngagementFingerprint.cs}
  • Shared/Services/TeacherArea/ITeacherAreaService.cs
  • Shared/Services/PriorTraining/IPriorTrainingService.cs
  • Shared/Services/Pricing/PricingHelpers.cs
  • Shared/Services/BusinessRules/{IBusinessRule.cs, BusinessRuleResult.cs, Rules/}
  • Shared/Services/AppointmentsCalendarService.cs (impl — sta in Shared, non in BackOffice)
  • BackOffice/Services/QueryModifiers/edu/*.cs — 10 modifier

⚠️ Debito tecnico

  • Cache coerenza. Risolto con refresh scoped (matrice di invalidazione per QueryModifier) + sp_reconcileWorkerCaches giornaliero che rileva e alerta su drift senza mascherarlo.
  • Service in Shared ma logica edu-centrica. Il bound fra TrainingHub.Shared e TrainingHub.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 in reg/, 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 — vedi TrainingHub.UnitTests/Services/QueryModifiers/JobsRisksQueryModifierTests.cs:32-41 (Arg.Is<string>(q => q.Contains(...)) + ArgParam.Check(p, "jid", jobId)). It.IsAny era l'API di Moq e non compare più in TrainingHub.UnitTests.
  • Race condition SuggestTeachersAsync BulkInsert. Click concorrente su due tab può causare PK violation su appointmentsTeachers. Wrap in transaction o INSERT WHERE NOT EXISTS per riga. (Vedi BACKLOG.md.)
  • ITrainingExpirationService con parametri string. Il parametro status è stringa invece di enum WorkerTrainingStatusValue: meno type-safe.
  • Business Rules Engine aggregator. Le regole WorkerFinalRiskLevelRule e TrainingExemptionRule sono in BusinessRules/Rules/ ma l'aggregator IBusinessRuleEngine e la sostituzione delle query SQL inline restano fuori scope. (Vedi BACKLOG.md.)

🔗 Vedi anche