# 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.