From 26ebd320e57aa38da20591187ac4e66a5f95a830 Mon Sep 17 00:00:00 2001 From: SmartRootsSrl Date: Sat, 21 Mar 2026 10:27:51 +0100 Subject: [PATCH] docs: add expired certificate report implementation plan --- .../2026-03-21-expired-certificate-report.md | 642 ++++++++++++++++++ 1 file changed, 642 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-21-expired-certificate-report.md diff --git a/docs/superpowers/plans/2026-03-21-expired-certificate-report.md b/docs/superpowers/plans/2026-03-21-expired-certificate-report.md new file mode 100644 index 0000000..23a9f2e --- /dev/null +++ b/docs/superpowers/plans/2026-03-21-expired-certificate-report.md @@ -0,0 +1,642 @@ +# Expired Certificate Report — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Aggiungere un secondo template PDF per certificati non in quotazione (Scaduto/Rimborsato/Revocato), auto-rilevato dal campo `Stato` della SP esistente, senza modificare endpoint né renderer esistenti. + +**Architecture:** Il campo `Stato` viene aggiunto al modello e mappato dal DB. L'orchestratore legge `Stato` dopo il caricamento dati e sceglie il flusso: attivo (flusso esistente invariato) oppure expired (nuovo `ExpiredAnagraficaSectionRenderer` + `EventiSectionRenderer` riusato + Chart). Il nuovo renderer è iniettato direttamente nell'orchestratore, non via `IEnumerable`, per evitare inclusione nel ciclo normale. + +**Tech Stack:** ASP.NET Core 8, Syncfusion PDF (Community), SkiaSharp, Microsoft.Data.SqlClient, `dotnet build` per verifica compilazione. + +> **Nota:** Non esistono test automatici nel progetto. La verifica avviene tramite `dotnet build` (compilazione) e richiesta HTTP manuale con un ISIN expired reale. + +--- + +## File Map + +| File | Operazione | +|------|-----------| +| `CertReports.Syncfusion/Models/CertificateModels.cs` | Modifica — aggiunge campo `Stato` | +| `CertReports.Syncfusion/Services/Implementations/CertificateDataService.cs` | Modifica — mappa `Stato` dal reader | +| `CertReports.Syncfusion/Services/Implementations/ExpiredAnagraficaSectionRenderer.cs` | **Nuovo file** | +| `CertReports.Syncfusion/Services/Implementations/ReportOrchestrator.cs` | Modifica — branching + cache keys + inject ExpiredRenderer | +| `CertReports.Syncfusion/Program.cs` | Modifica — registra `ExpiredAnagraficaSectionRenderer` | + +--- + +## Task 1: Aggiungere campo `Stato` al modello + +**Files:** +- Modify: `CertReports.Syncfusion/Models/CertificateModels.cs` + +- [ ] **Step 1: Aggiungere la proprietà `Stato` a `CertificateInfo`** + +Aprire `CertificateModels.cs`. Alla fine della sezione `// Vari` (dopo `TriggerOneStar` e prima di `Note`), aggiungere: + +```csharp +// Stato quotazione (da SP rpt_Master_CFT_ISIN) +// Valori: "Quotazione" | "Revocato" | "Scaduto" | "Rimborsato" +public string Stato { get; set; } = string.Empty; +``` + +La sezione interessata è intorno a riga 73-76: +```csharp + // Vari + public string Leva { get; set; } = string.Empty; + public string FattoreAirbag { get; set; } = string.Empty; + public string TriggerOneStar { get; set; } = string.Empty; + public string Note { get; set; } = string.Empty; +``` + +Diventa: +```csharp + // Vari + public string Leva { get; set; } = string.Empty; + public string FattoreAirbag { get; set; } = string.Empty; + public string TriggerOneStar { get; set; } = string.Empty; + public string Note { get; set; } = string.Empty; + + // Stato quotazione (da SP rpt_Master_CFT_ISIN) + // Valori: "Quotazione" | "Revocato" | "Scaduto" | "Rimborsato" + public string Stato { get; set; } = string.Empty; +``` + +- [ ] **Step 2: Verificare compilazione** + +```bash +cd "CertReports.Syncfusion" && dotnet build +``` +Atteso: `Build succeeded` (0 errori). + +- [ ] **Step 3: Commit** + +```bash +git add CertReports.Syncfusion/Models/CertificateModels.cs +git commit -m "feat: add Stato field to CertificateInfo model" +``` + +--- + +## Task 2: Mappare `Stato` nel data service + +**Files:** +- Modify: `CertReports.Syncfusion/Services/Implementations/CertificateDataService.cs` + +- [ ] **Step 1: Aggiungere la mappatura nel reader di `rpt_Master_CFT_ISIN`** + +In `GetCertificateInfoAsync`, dopo la riga che mappa `RendimentoAttuale` (riga ~100), aggiungere: + +```csharp +info.RendimentoAttuale = r.GetStringSafe("RendimentoAttuale"); +info.Stato = r.GetStringSafe("Stato"); // ← aggiungere questa riga +``` + +Il blocco risultante sarà: +```csharp + info.CpnInMemoria = r.GetNullableDecimal("CpnInMemoria"); + info.CpnDaPagare = r.GetNullableDecimal("CpnDaPagare"); + info.CpnPagati = r.GetNullableDecimal("CpnPagati"); + info.RendimentoAttuale = r.GetStringSafe("RendimentoAttuale"); + info.Stato = r.GetStringSafe("Stato"); +``` + +> **Nota:** `GetStringSafe` è già nel progetto (extension method su `SqlDataReader`). Se la SP non restituisce il campo `Stato`, il metodo restituisce `string.Empty` senza eccezioni — sicuro. + +- [ ] **Step 2: Verificare compilazione** + +```bash +dotnet build +``` +Atteso: `Build succeeded`. + +- [ ] **Step 3: Commit** + +```bash +git add CertReports.Syncfusion/Services/Implementations/CertificateDataService.cs +git commit -m "feat: map Stato field from rpt_Master_CFT_ISIN SP" +``` + +--- + +## Task 3: Creare `ExpiredAnagraficaSectionRenderer` + +**Files:** +- Create: `CertReports.Syncfusion/Services/Implementations/ExpiredAnagraficaSectionRenderer.cs` + +- [ ] **Step 1: Creare il file con il renderer completo** + +Creare il file `CertReports.Syncfusion/Services/Implementations/ExpiredAnagraficaSectionRenderer.cs` con il seguente contenuto: + +```csharp +using CertReports.Syncfusion.Helpers; +using CertReports.Syncfusion.Models; +using CertReports.Syncfusion.Services.Interfaces; +using Syncfusion.Drawing; +using Syncfusion.Pdf; +using Syncfusion.Pdf.Graphics; + +namespace CertReports.Syncfusion.Services.Implementations; + +/// +/// Sezione 1 — Prima pagina del report per certificati non in quotazione +/// (Scaduto, Rimborsato, Revocato). Struttura semplificata rispetto al report attivo: +/// solo Titolo + Caratteristiche Prodotto + Analisi. Niente Bid/Ask, niente Sottostanti. +/// +public class ExpiredAnagraficaSectionRenderer : IPdfSectionRenderer +{ + public string SectionName => "ExpiredAnagrafica"; + public int Order => 1; + + private const float PageW = 595f - 2 * PdfTheme.PageMargin; + private const float PageH = 842f - 2 * PdfTheme.PageMargin - PdfTheme.FooterHeight; + private const float ColGap = 10f; + private const float ColW = (PageW - ColGap) / 2f; + private const float SectionGap = 10f; + + public PdfDocument Render(CertificateReportData data) + { + var doc = new PdfDocument(); + var page = PdfTheme.AddA4Page(doc); + var g = page.Graphics; + var info = data.Info; + float y = 0f; + + // ── TITOLO ──────────────────────────────────────────────────── + y = DrawTitle(g, info, PageW, y); + + // ── SEZIONE A: CARATTERISTICHE PRODOTTO ─────────────────────── + y = DrawSectionLabel(g, "Caratteristiche Prodotto", y); + y = DrawCaratteristiche(g, info, y); + y += SectionGap; + + // ── SEZIONE B: ANALISI ──────────────────────────────────────── + y = DrawSectionLabel(g, "Analisi", y); + DrawAnalisi(g, info, y); + + PdfTheme.DrawFooter(g, PageW, PageH, 1, data.ShowBranding); + + return doc; + } + + // ═══════════════════════════════════════════════════════════════ + // TITOLO — Solo Tipologia box (niente Data/Bid/Ask) + // ═══════════════════════════════════════════════════════════════ + + private float DrawTitle(PdfGraphics g, CertificateInfo info, float w, float y) + { + g.DrawString($"Scheda Prodotto {info.Isin}", + PdfTheme.SectionTitleFont, + new PdfSolidBrush(PdfTheme.AccentBlue), + new RectangleF(0, y, w, 20f), + new PdfStringFormat(PdfTextAlignment.Center)); + y += 24f; + + g.DrawLine(PdfTheme.AccentBluePen, 0, y, w, y); + y += 8f; + + // Solo box Tipologia, niente Data/Bid/Ask + const float boxH = 28f; + if (!string.IsNullOrEmpty(info.Categoria)) + { + DrawInfoBox(g, 0, y, w, boxH, + "TIPOLOGIA", info.Categoria, + PdfTheme.BoxLightBlueBgBrush, PdfTheme.AccentBlueBrush, + PdfTheme.AccentBlueBrush, PdfTheme.AccentBlueDarkBrush, + PdfTheme.Bold); + } + + y += boxH + 10f; + return y; + } + + // ═══════════════════════════════════════════════════════════════ + // INTESTAZIONE DI SEZIONE + // ═══════════════════════════════════════════════════════════════ + + private float DrawSectionLabel(PdfGraphics g, string title, float y) + { + g.DrawRectangle(PdfTheme.AccentBlueBrush, new RectangleF(0, y, 3f, 14f)); + g.DrawString(title, PdfTheme.Bold, + new PdfSolidBrush(PdfTheme.AccentBlue), + new RectangleF(6f, y, PageW, 14f)); + return y + 16f; + } + + // ═══════════════════════════════════════════════════════════════ + // INFO BOX (identico ad AnagraficaSectionRenderer) + // ═══════════════════════════════════════════════════════════════ + + private void DrawInfoBox( + PdfGraphics g, + float x, float y, float w, float boxH, + string label, string value, + PdfBrush bgBrush, PdfBrush accentBrush, + PdfBrush labelBrush, PdfBrush valueBrush, + PdfFont valueFont) + { + g.DrawRectangle(bgBrush, new RectangleF(x, y, w, boxH)); + g.DrawRectangle(accentBrush, new RectangleF(x, y, 4f, boxH)); + float innerX = x + 4f + 6f; + float innerW = w - 4f - 6f - 4f; + g.DrawString(label, PdfTheme.Small, labelBrush, + new RectangleF(innerX, y + 4f, innerW, 10f)); + g.DrawString(value, valueFont, valueBrush, + new RectangleF(innerX, y + 14f, innerW, 14f)); + } + + // ═══════════════════════════════════════════════════════════════ + // SEZIONE A: CARATTERISTICHE PRODOTTO + // Sinistra: tabella emittente con campi rimborso + // ═══════════════════════════════════════════════════════════════ + + private float DrawCaratteristiche(PdfGraphics g, CertificateInfo info, float y) + { + // Tabella occupa la colonna sinistra; destra libera + return DrawEmittenteTable(g, info, 0, ColW, y); + } + + private float DrawEmittenteTable(PdfGraphics g, CertificateInfo info, float x, float w, float y) + { + float rh = PdfTheme.RowHeight; + float pad = PdfTheme.CellPadding; + + // Header blu + g.DrawRectangle(PdfTheme.TableHeaderBrush, new RectangleF(x, y, w, rh)); + string headerText = string.IsNullOrEmpty(info.Emittente) + ? "EMITTENTE" + : $"EMITTENTE {info.Emittente.ToUpper()}"; + g.DrawString(headerText, PdfTheme.TableBold, + PdfTheme.HeaderTextBrush, + new RectangleF(x + pad, y + 3f, w - pad * 2, rh)); + y += rh; + + var rows = new[] + { + ("ISIN", info.Isin), + ("Mercato", info.Mercato), + ("Valuta", info.Valuta), + ("Data Emissione", info.DataEmissione), + ("Valore Rimborso", info.ValoreRimborso), + ("Data Rimborso", info.DataRimborso), + }; + + for (int i = 0; i < rows.Length; i++) + { + var (label, value) = rows[i]; + if (string.IsNullOrWhiteSpace(value)) continue; + + bool alt = i % 2 == 1; + if (alt) + g.DrawRectangle(new PdfSolidBrush(PdfTheme.TableAltRow), + new RectangleF(x, y, w, rh)); + + g.DrawLine(PdfTheme.TableBorderPen, x, y, x + w, y); + + g.DrawString(label, PdfTheme.TableFont, + new PdfSolidBrush(PdfTheme.TextSecondary), + new RectangleF(x + pad, y + 2f, w * 0.5f, rh)); + + var valueBrush = label == "ISIN" + ? new PdfSolidBrush(PdfTheme.AccentBlue) + : new PdfSolidBrush(PdfTheme.TextPrimary); + + g.DrawString(value, PdfTheme.TableBold, valueBrush, + new RectangleF(x + w * 0.5f, y + 2f, w * 0.5f - pad, rh), + new PdfStringFormat(PdfTextAlignment.Right)); + + y += rh; + } + + g.DrawLine(PdfTheme.TableBorderPen, x, y, x + w, y); + return y + 2f; + } + + // ═══════════════════════════════════════════════════════════════ + // SEZIONE B: ANALISI — KV list colonna destra + // ═══════════════════════════════════════════════════════════════ + + private float DrawAnalisi(PdfGraphics g, CertificateInfo info, float y) + { + var items = new (string Label, string Value)[] + { + ("Importo Cedola (p.a.)", info.NominalAnnualYield), + ("Frequenza Cedola", info.FrequenzaCedole), + ("Valore Nominale", info.NominalValue?.ToString("N0") ?? string.Empty), + ("Memoria Cedola", info.Memory), + ("Tipo Barriera", info.BarrierType), + ("Tipo Basket", info.BasketType), + ("Rendimento Totale", info.RendimentoTotale), + }; + + return DrawKVList(g, items, ColW + ColGap, ColW, y); + } + + private float DrawKVList(PdfGraphics g, (string Label, string Value)[] items, float x, float w, float y) + { + float rh = PdfTheme.RowHeight; + float pad = PdfTheme.CellPadding; + float labelW = w * 0.58f; + float valueW = w * 0.42f; + + foreach (var (label, value) in items) + { + if (string.IsNullOrWhiteSpace(value)) continue; + + g.DrawString(label, PdfTheme.TableFont, + new PdfSolidBrush(PdfTheme.TextSecondary), + new RectangleF(x + pad, y + 1f, labelW, rh)); + + bool isNeg = value.TrimStart().StartsWith('-'); + bool isKey = label == "Rendimento Totale"; + + var brush = isNeg ? new PdfSolidBrush(PdfTheme.NegativeRed) + : isKey ? new PdfSolidBrush(PdfTheme.AccentBlue) + : new PdfSolidBrush(PdfTheme.TextPrimary); + + g.DrawString(value, PdfTheme.TableBold, brush, + new RectangleF(x + labelW, y + 1f, valueW - pad, rh), + new PdfStringFormat(PdfTextAlignment.Right)); + + g.DrawLine(new PdfPen(PdfTheme.SeparatorLine, 0.3f), + x, y + rh, x + w, y + rh); + + y += rh; + } + return y; + } +} +``` + +- [ ] **Step 2: Verificare compilazione** + +```bash +dotnet build +``` +Atteso: `Build succeeded`. Se manca un `using`, aggiungerlo. Riferimento per i `using` necessari: guardare `AnagraficaSectionRenderer.cs` (stessi namespace). + +- [ ] **Step 3: Commit** + +```bash +git add CertReports.Syncfusion/Services/Implementations/ExpiredAnagraficaSectionRenderer.cs +git commit -m "feat: add ExpiredAnagraficaSectionRenderer for non-quoted certificates" +``` + +--- + +## Task 4: Aggiornare `ReportOrchestrator` — branching + cache + injection + +**Files:** +- Modify: `CertReports.Syncfusion/Services/Implementations/ReportOrchestrator.cs` + +- [ ] **Step 1: Aggiungere `ExpiredAnagraficaSectionRenderer` al costruttore** + +Il costruttore attuale riceve `IEnumerable sectionRenderers`. Aggiungere `ExpiredAnagraficaSectionRenderer expiredAnagraficaRenderer` come parametro diretto: + +```csharp +private readonly ExpiredAnagraficaSectionRenderer _expiredAnagraficaRenderer; + +public ReportOrchestrator( + ICertificateDataService dataService, + IEnumerable sectionRenderers, + IChartSectionRenderer chartRenderer, + IPdfMergerService merger, + IPdfCacheService cache, + ILogger logger, + ExpiredAnagraficaSectionRenderer expiredAnagraficaRenderer) // ← aggiunto +{ + _dataService = dataService; + _sectionRenderers = sectionRenderers; + _chartRenderer = chartRenderer; + _merger = merger; + _cache = cache; + _logger = logger; + _expiredAnagraficaRenderer = expiredAnagraficaRenderer; // ← aggiunto +} +``` + +- [ ] **Step 2: Aggiornare `GenerateReportAsync` — cache key e branching** + +Sostituire la logica `GenerateReportAsync` (dal `var cacheKey` in poi) con questa: + +```csharp +public async Task GenerateReportAsync(string isin, bool showBranding = false) +{ + // ── Cache check ──────────────────────────────────────────────── + // La chiave include branding E tipo report. Il tipo verrà determinato + // dopo il caricamento dati, ma usiamo una chiave provvisoria per + // il primo lookup — se c'è cache, non leggiamo nemmeno il tipo. + // Strategia: chiave separata per expired dopo determinazione tipo. + // Per semplicità: tentativo cache con chiave base prima del DB, + // poi con chiave tipizzata dopo la lettura Stato. + + // Prima proviamo con la chiave "standard" — hit solo se già cached + var baseCacheKey = showBranding ? $"{isin}:branded" : isin; + var expiredCacheKey = showBranding ? $"{isin}:expired:branded" : $"{isin}:expired"; + + // Controlla entrambe le chiavi (una sarà un miss, l'altra un possibile hit) + var cached = _cache.Get(baseCacheKey) ?? _cache.Get(expiredCacheKey); + if (cached != null) + { + _logger.LogInformation("Report per ISIN {Isin} servito da cache ({Size} bytes)", isin, cached.Length); + return cached; + } + + _logger.LogInformation("Inizio generazione report per ISIN {Isin}", isin); + + // ── 1. Recupera tutti i dati ─────────────────────────────────── + var reportData = new CertificateReportData + { + Info = await _dataService.GetCertificateInfoAsync(isin), + Eventi = await _dataService.GetCertificateEventsAsync(isin), + Scenario = await _dataService.GetScenarioAnalysisAsync(isin), + ShowBranding = showBranding, + }; + + // ── 2. Determina il tipo di report ──────────────────────────── + bool isExpired = !string.IsNullOrEmpty(reportData.Info.Stato) + && reportData.Info.Stato != "Quotazione"; + + var cacheKey = isExpired ? expiredCacheKey : baseCacheKey; + + _logger.LogInformation( + "Dati recuperati per {Isin}: Stato={Stato}, isExpired={IsExpired}, {EventiCount} eventi", + isin, reportData.Info.Stato, isExpired, reportData.Eventi.Count); + + // ── 3. Genera le sezioni PDF ────────────────────────────────── + var pdfSections = new List(); + + if (isExpired) + { + // Flusso expired: ExpiredAnagrafica + Eventi + Chart + pdfSections.Add(_expiredAnagraficaRenderer.Render(reportData)); + _logger.LogInformation("Sezione 'ExpiredAnagrafica' generata per {Isin}", isin); + + var eventiRenderer = _sectionRenderers.First(r => r.SectionName == "Eventi"); + pdfSections.Add(eventiRenderer.Render(reportData)); + _logger.LogInformation("Sezione 'Eventi' generata per {Isin}", isin); + } + else + { + // Flusso attuale: Anagrafica + Eventi + Scenario (condizionale) + bool isScenarioAllowed = reportData.Scenario.Rows.Count > 0; + + foreach (var renderer in _sectionRenderers.OrderBy(r => r.Order)) + { + if (renderer.SectionName == "Scenario" && !isScenarioAllowed) + { + _logger.LogInformation("Sezione Scenario saltata per {Isin}", isin); + continue; + } + + try + { + pdfSections.Add(renderer.Render(reportData)); + _logger.LogInformation("Sezione '{Section}' generata per {Isin}", renderer.SectionName, isin); + } + catch (Exception ex) + { + _logger.LogError(ex, "Errore nella sezione '{Section}' per {Isin}", renderer.SectionName, isin); + throw; + } + } + } + + // ── 4. Grafico (entrambi i flussi) ──────────────────────────── + var chartPdf = await _chartRenderer.RenderAsync(isin); + if (chartPdf != null) + { + pdfSections.Add(chartPdf); + _logger.LogInformation("Sezione Grafico aggiunta per {Isin}", isin); + } + + // ── 5. Unisci tutto ──────────────────────────────────────────── + var finalPdf = _merger.Merge(pdfSections); + + _logger.LogInformation("Report generato per {Isin}: {Size} bytes, {Sections} sezioni", + isin, finalPdf.Length, pdfSections.Count); + + _cache.Set(cacheKey, finalPdf); + + foreach (var doc in pdfSections) + doc.Close(true); + + return finalPdf; +} +``` + +- [ ] **Step 3: Verificare compilazione** + +```bash +dotnet build +``` +Atteso: `Build succeeded`. Se appare errore su `_expiredAnagraficaRenderer`, verificare che il campo privato e il costruttore siano entrambi aggiornati. + +- [ ] **Step 4: Commit** + +```bash +git add CertReports.Syncfusion/Services/Implementations/ReportOrchestrator.cs +git commit -m "feat: add expired certificate report branching in orchestrator" +``` + +--- + +## Task 5: Registrare `ExpiredAnagraficaSectionRenderer` in DI + +**Files:** +- Modify: `CertReports.Syncfusion/Program.cs` + +- [ ] **Step 1: Aggiungere la registrazione** + +In `Program.cs`, nel blocco dove sono registrati gli altri renderer (dopo le registrazioni `AddScoped`), aggiungere: + +```csharp +// Registrazione diretta (non come IPdfSectionRenderer) — +// l'orchestratore la inietta direttamente per il flusso expired. +builder.Services.AddScoped(); +``` + +La sezione registrazioni renderer in `Program.cs` sarà simile a: +```csharp +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // ← aggiunta +builder.Services.AddScoped(); +``` + +- [ ] **Step 2: Verificare compilazione finale** + +```bash +dotnet build +``` +Atteso: `Build succeeded` senza warning critici. + +- [ ] **Step 3: Commit** + +```bash +git add CertReports.Syncfusion/Program.cs +git commit -m "feat: register ExpiredAnagraficaSectionRenderer in DI" +``` + +--- + +## Task 6: Verifica manuale end-to-end + +- [ ] **Step 1: Avviare l'applicazione** + +```bash +dotnet run --project CertReports.Syncfusion +``` + +- [ ] **Step 2: Testare con un ISIN expired** + +Aprire nel browser o usare curl con un ISIN reale nello stato "Scaduto"/"Rimborsato"/"Revocato": + +``` +GET https://localhost:{porta}/api/report/by-isin/IT0006745704 +``` + +Atteso: +- PDF a 3 pagine (non 4) +- Pagina 1: titolo + solo box Tipologia (niente Bid/Ask) + tabella con Valore Rimborso e Data Rimborso + KV Analisi con 7 campi +- Pagina 2: Lista eventi (identica all'attuale) +- Pagina 3: Grafico + +- [ ] **Step 3: Testare branding** + +``` +GET https://localhost:{porta}/api/report/by-isin/IT0006745704?branding=true +``` + +Atteso: footer con "Powered by Smart Roots" su tutte le 3 pagine. + +- [ ] **Step 4: Testare che il report attivo non sia rotto** + +``` +GET https://localhost:{porta}/api/report/by-isin/{ISIN_ATTIVO} +``` + +Atteso: report attivo invariato a 4 pagine (o 3 senza scenario). + +- [ ] **Step 5: Commit finale** + +```bash +git tag v-expired-report-complete +``` + +--- + +## Note di implementazione + +**Brush e font disponibili in `PdfTheme`** (già esistenti, non creare nuovi): +- `PdfTheme.AccentBlue`, `PdfTheme.AccentBlueBrush`, `PdfTheme.AccentBluePen` +- `PdfTheme.AccentBlueDarkBrush`, `PdfTheme.BoxLightBlueBgBrush` +- `PdfTheme.TableHeaderBrush`, `PdfTheme.HeaderTextBrush`, `PdfTheme.TableAltRowBrush` +- `PdfTheme.TableAltRow` (Color), `PdfTheme.TextSecondary`, `PdfTheme.TextPrimary` +- `PdfTheme.NegativeRed`, `PdfTheme.SeparatorLine` +- `PdfTheme.TableBorderPen` +- Font: `PdfTheme.SectionTitleFont`, `PdfTheme.Bold`, `PdfTheme.Small`, `PdfTheme.TableFont`, `PdfTheme.TableBold` +- `PdfTheme.RowHeight`, `PdfTheme.CellPadding`, `PdfTheme.PageMargin`, `PdfTheme.FooterHeight` +- `PdfTheme.AddA4Page(doc)`, `PdfTheme.DrawFooter(g, w, h, pageNum, showBranding)` + +**Cache lookup doppio:** Il lookup `_cache.Get(baseCacheKey) ?? _cache.Get(expiredCacheKey)` nel nuovo orchestratore è necessario perché non conosciamo il tipo del certificato prima di leggere il DB. Il caching avviene poi con la chiave corretta basata su `Stato`. Questo comporta al più 2 miss di cache prima della prima generazione, poi 1 hit per le richieste successive — accettabile.