diff --git a/CertReports.Syncfusion/Properties/PublishProfiles/IISProfile.pubxml b/CertReports.Syncfusion/Properties/PublishProfiles/IISProfile.pubxml new file mode 100644 index 0000000..9542e00 --- /dev/null +++ b/CertReports.Syncfusion/Properties/PublishProfiles/IISProfile.pubxml @@ -0,0 +1,26 @@ + + + + + MSDeploy + **\*.pdb + true + Release + Any CPU + https://www.smart-roots.net/smartreports + false + 6ef1f59d-feab-9797-733a-5711f6c6221b + false + http://26.69.45.60 + /smart-roots/smartreports + + true + RemoteAgent + true + true + Administrator + <_SavePWD>true + <_TargetId>IISWebDeploy + net8.0 + + \ No newline at end of file diff --git a/CertReports.Syncfusion/dotnet-tools.json b/CertReports.Syncfusion/dotnet-tools.json new file mode 100644 index 0000000..b6f4c2f --- /dev/null +++ b/CertReports.Syncfusion/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.5", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/docs/superpowers/plans/2026-03-23-dividend-section.md b/docs/superpowers/plans/2026-03-23-dividend-section.md new file mode 100644 index 0000000..78e8938 --- /dev/null +++ b/docs/superpowers/plans/2026-03-23-dividend-section.md @@ -0,0 +1,789 @@ +# Dividend Section 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 il parametro `?dividend=true` che sostituisce la tabella Sottostanti nelle sezioni Anagrafica con una pagina landscape dedicata Sottostanti+Dividendi. + +**Architecture:** Il flag `ShowDividend` viene aggiunto a `CertificateReportData` e propagato attraverso controller → orchestratore → renderer. `DividendSectionRenderer` è un renderer diretto (non `IPdfSectionRenderer`) iniettato nell'orchestratore come già avviene per `ExpiredAnagraficaSectionRenderer`. Entrambi i flussi (attivo + expired) ricevono la pagina landscape subito dopo la Sezione 1. Le chiavi cache vengono estese a 4 combinazioni. + +**Tech Stack:** ASP.NET Core 8, Syncfusion PDF Net.Core v33, SkiaSharp, Microsoft.Data.SqlClient + +--- + +## File Map + +| File | Azione | +|------|--------| +| `Models/CertificateModels.cs` | Modify — aggiungere `ShowDividend` | +| `Services/Interfaces/IServices.cs` | Modify — aggiungere `showDividend` a `IReportOrchestrator` | +| `Controllers/ReportController.cs` | Modify — aggiungere param `dividend` a tutti e 3 gli endpoint | +| `Services/Implementations/ReportOrchestrator.cs` | Modify — signature, cache keys, inject renderer, inserire sezione | +| `Services/Implementations/AnagraficaSectionRenderer.cs` | Modify — skip SEZIONE C se `ShowDividend=true` | +| `Services/Implementations/ExpiredAnagraficaSectionRenderer.cs` | Modify — skip SEZIONE C se `ShowDividend=true` | +| `Services/Implementations/DividendSectionRenderer.cs` | Create — nuova pagina landscape con tabella a 2 livelli | +| `Program.cs` | Modify — registrare `DividendSectionRenderer` | + +--- + +## Task 1: Aggiungere `ShowDividend` al modello + +**Files:** +- Modify: `Models/CertificateModels.cs` + +- [ ] **Step 1: Leggere il file corrente** + + Aprire `Models/CertificateModels.cs` e individuare la classe `CertificateReportData`. + Il file contiene già `public bool ShowBranding { get; set; } = false;`. + +- [ ] **Step 2: Aggiungere la proprietà** + + Subito dopo `ShowBranding`: + ```csharp + public bool ShowBranding { get; set; } = false; + public bool ShowDividend { get; set; } = false; + ``` + +- [ ] **Step 3: Build** + + ```bash + dotnet build CertReports.Syncfusion + ``` + Expected: Build succeeded, 0 errors. + +- [ ] **Step 4: Commit** + + ```bash + git add CertReports.Syncfusion/Models/CertificateModels.cs + git commit -m "feat: add ShowDividend flag to CertificateReportData" + ``` + +--- + +## Task 2: Aggiornare l'interfaccia `IReportOrchestrator` + +**Files:** +- Modify: `Services/Interfaces/IServices.cs` + +- [ ] **Step 1: Leggere il file** + + Trovare `IReportOrchestrator`. Attualmente: + ```csharp + public interface IReportOrchestrator + { + Task GenerateReportAsync(string isin, bool showBranding = false); + } + ``` + +- [ ] **Step 2: Aggiungere il parametro** + + ```csharp + public interface IReportOrchestrator + { + Task GenerateReportAsync(string isin, bool showBranding = false, bool showDividend = false); + } + ``` + +- [ ] **Step 3: Build** + + ```bash + dotnet build CertReports.Syncfusion + ``` + Expected: errore di compilazione su `ReportOrchestrator` (firma non corrisponde) — normale, fix al Task 4. + +- [ ] **Step 4: Commit** + + ```bash + git add CertReports.Syncfusion/Services/Interfaces/IServices.cs + git commit -m "feat: add showDividend param to IReportOrchestrator interface" + ``` + +--- + +## Task 3: Aggiornare `ReportController` — parametro `dividend` + +**Files:** +- Modify: `Controllers/ReportController.cs` + +- [ ] **Step 1: Leggere il file** + + Ci sono 3 endpoint che chiamano `_orchestrator.GenerateReportAsync(isin, showBranding)`: + - `GenerateReport` (GET `/api/report?p=...`) + - `GenerateReportByIsin` (GET `/api/report/by-isin/{isin}`) + - `DownloadReport` (GET `/api/report/download?p=...`) + +- [ ] **Step 2: Modificare `GenerateReport`** + + Aggiungere il parametro `dividend` e passarlo all'orchestratore: + ```csharp + public async Task GenerateReport( + [FromQuery(Name = "p")] string? encryptedIsin = null, + [FromQuery(Name = "alias")] string? aliasId = null, + [FromQuery(Name = "branding")] bool showBranding = false, + [FromQuery(Name = "dividend")] bool showDividend = false) + ``` + E nella chiamata: + ```csharp + return await GenerateAndReturnPdf(isin, showBranding, showDividend); + ``` + +- [ ] **Step 3: Modificare `GenerateReportByIsin`** + + ```csharp + public async Task GenerateReportByIsin( + string isin, + [FromQuery(Name = "branding")] bool showBranding = false, + [FromQuery(Name = "dividend")] bool showDividend = false) + ``` + E nella chiamata: + ```csharp + return await GenerateAndReturnPdf(isin, showBranding, showDividend); + ``` + +- [ ] **Step 4: Modificare `DownloadReport`** + + ```csharp + public async Task DownloadReport( + [FromQuery(Name = "p")] string? encryptedIsin = null, + [FromQuery(Name = "alias")] string? aliasId = null, + [FromQuery(Name = "branding")] bool showBranding = false, + [FromQuery(Name = "dividend")] bool showDividend = false) + ``` + Nella chiamata interna: + ```csharp + var pdfBytes = await _orchestrator.GenerateReportAsync(isin, showBranding, showDividend); + ``` + +- [ ] **Step 5: Aggiornare il metodo privato `GenerateAndReturnPdf`** + + Trovare la firma di `GenerateAndReturnPdf` e aggiungere `bool showDividend = false`, poi passarla a `GenerateReportAsync`: + ```csharp + private async Task GenerateAndReturnPdf(string isin, bool showBranding = false, bool showDividend = false) + { + // ... + var pdfBytes = await _orchestrator.GenerateReportAsync(isin, showBranding, showDividend); + // ... + } + ``` + +- [ ] **Step 6: Build** + + ```bash + dotnet build CertReports.Syncfusion + ``` + Expected: errore su `ReportOrchestrator` (non ancora aggiornato) — normale. + +- [ ] **Step 7: Commit** + + ```bash + git add CertReports.Syncfusion/Controllers/ReportController.cs + git commit -m "feat: add ?dividend query param to all report endpoints" + ``` + +--- + +## Task 4: Aggiornare `AnagraficaSectionRenderer` — skip SEZIONE C + +**Files:** +- Modify: `Services/Implementations/AnagraficaSectionRenderer.cs` + +- [ ] **Step 1: Leggere il file** + + Trovare il blocco SEZIONE C. Attualmente: + ```csharp + // ── SEZIONE C: SOTTOSTANTI ──────────────────────────────────── + if (info.Sottostanti.Count > 0) + { + // Se lo spazio rimanente è meno di 80pt, nuova pagina + if (y > PageH - 80f) + { + PdfTheme.DrawFooter(g, PageW, PageH, 1, data.ShowBranding); + page = doc.Pages.Add(); + g = page.Graphics; + y = 0f; + } + DrawSottostanti(g, info.Sottostanti, PageW, y); + } + ``` + +- [ ] **Step 2: Wrappare il blocco con la condizione** + + ```csharp + // ── SEZIONE C: SOTTOSTANTI ──────────────────────────────────── + if (!data.ShowDividend && info.Sottostanti.Count > 0) + { + // Se lo spazio rimanente è meno di 80pt, nuova pagina + if (y > PageH - 80f) + { + PdfTheme.DrawFooter(g, PageW, PageH, 1, data.ShowBranding); + page = doc.Pages.Add(); + g = page.Graphics; + y = 0f; + } + DrawSottostanti(g, info.Sottostanti, PageW, y); + } + ``` + +- [ ] **Step 3: Build** + + ```bash + dotnet build CertReports.Syncfusion + ``` + Expected: stesso errore su `ReportOrchestrator` — normale. + +- [ ] **Step 4: Commit** + + ```bash + git add CertReports.Syncfusion/Services/Implementations/AnagraficaSectionRenderer.cs + git commit -m "feat: skip sottostanti table in anagrafica when ShowDividend=true" + ``` + +--- + +## Task 5: Aggiornare `ExpiredAnagraficaSectionRenderer` — skip SEZIONE C + +**Files:** +- Modify: `Services/Implementations/ExpiredAnagraficaSectionRenderer.cs` + +- [ ] **Step 1: Leggere il file** + + Trovare il blocco SEZIONE C (struttura identica ad `AnagraficaSectionRenderer`): + ```csharp + // ── SEZIONE C: SOTTOSTANTI ──────────────────────────────────── + if (info.Sottostanti.Count > 0) + { + if (y > PageH - 80f) + { + PdfTheme.DrawFooter(g, PageW, PageH, 1, data.ShowBranding); + page = doc.Pages.Add(); + g = page.Graphics; + ... + } + DrawSottostanti(g, info.Sottostanti, PageW, y); + } + ``` + +- [ ] **Step 2: Stessa modifica del Task 4** + + ```csharp + // ── SEZIONE C: SOTTOSTANTI ──────────────────────────────────── + if (!data.ShowDividend && info.Sottostanti.Count > 0) + { + if (y > PageH - 80f) + { + PdfTheme.DrawFooter(g, PageW, PageH, 1, data.ShowBranding); + page = doc.Pages.Add(); + g = page.Graphics; + ... + } + DrawSottostanti(g, info.Sottostanti, PageW, y); + } + ``` + +- [ ] **Step 3: Build** + + ```bash + dotnet build CertReports.Syncfusion + ``` + +- [ ] **Step 4: Commit** + + ```bash + git add CertReports.Syncfusion/Services/Implementations/ExpiredAnagraficaSectionRenderer.cs + git commit -m "feat: skip sottostanti table in expired anagrafica when ShowDividend=true" + ``` + +--- + +## Task 6: Creare `DividendSectionRenderer` + +**Files:** +- Create: `Services/Implementations/DividendSectionRenderer.cs` + +- [ ] **Step 1: Creare il file con la struttura base** + + ```csharp + using CertReports.Syncfusion.Models; + using Syncfusion.Pdf; + using Syncfusion.Pdf.Graphics; + using Syncfusion.Pdf.Grid; + using System.Drawing; + + namespace CertReports.Syncfusion.Services.Implementations; + + /// + /// Genera la pagina landscape con la tabella unificata Sottostanti + Dividendi. + /// Non implementa IPdfSectionRenderer — iniettato direttamente nell'orchestratore. + /// + public class DividendSectionRenderer + { + // Larghezze colonne indicative (totale 742pt) — scalate proporzionalmente a runtime + private static readonly float[] ColWidths = + [ + 90f, // Nome + 50f, // Strike + 50f, // Last + 46f, // % Perf. + 50f, // Barr.Cap. + 46f, // Buf.Cap. + 50f, // Trig.CPN + 46f, // Buf.CPN + 46f, // Trig.AC + 54f, // Data Stacco + 54f, // Data Pag. + 40f, // Importo + 40f, // Rend. + 40f, // Imp.Fut. + 40f, // Rend.Fut. + ]; + + private static readonly string[] Col2Headers = + [ + "", "Strike", "Last", "% Perf.", + "Barr.Cap.", "Buf.Cap.", "Trig.CPN", "Buf.CPN", "Trig.AC", + "Data Stacco", "Data Pag.", "Importo", "Rend.", "Imp.Fut.", "Rend.Fut.", + ]; + + // Indici colonne soggette a colore performance (0-based) + private static readonly HashSet PerfCols = [3, 5, 7, 12, 14]; + + public PdfDocument Render(CertificateReportData data) + { + var doc = new PdfDocument(); + doc.PageSettings.Orientation = PdfPageOrientation.Landscape; + doc.PageSettings.Size = PdfPageSize.A4; + + var page = doc.Pages.Add(); + var g = page.Graphics; + var size = page.GetClientSize(); + + float w = size.Width - 2 * PdfTheme.PageMargin; + float h = size.Height - 2 * PdfTheme.PageMargin - PdfTheme.FooterHeight; + + float x0 = PdfTheme.PageMargin; + float y = PdfTheme.PageMargin; + + // ── Titolo ──────────────────────────────────────────────────── + var titleFont = PdfTheme.TitleFont; + g.DrawString("Sottostanti e Dividendi", titleFont, PdfTheme.AccentBlueBrush, + new RectangleF(x0, y, w, 20f)); + y += 20f + 4f; + + // ── Linea separatrice ───────────────────────────────────────── + g.DrawLine(PdfTheme.AccentBluePen, new PointF(x0, y), new PointF(x0 + w, y)); + y += 6f; + + // ── Calcolo scale fattore su larghezza reale ────────────────── + float totalNominal = ColWidths.Sum(); + float scale = w / totalNominal; + float[] cw = ColWidths.Select(c => c * scale).ToArray(); + + // ── Disegno header a 2 livelli ──────────────────────────────── + float rh = PdfTheme.RowHeight; + + DrawHeader(g, x0, y, cw, rh); + + y += rh * 2; + + // ── Tabella dati ────────────────────────────────────────────── + DrawDataGrid(g, data, x0, y, cw, w); + + // ── Footer ──────────────────────────────────────────────────── + PdfTheme.DrawFooter(g, w, h, 1, data.ShowBranding); + + return doc; + } + + private static void DrawHeader(PdfGraphics g, float x0, float y, float[] cw, float rh) + { + var headerFont = PdfTheme.TableFont; + var whiteBrush = PdfBrushes.White; + var accentBrush = PdfTheme.AccentBlueBrush; + var darkBlueBrush = new PdfSolidBrush(Color.FromArgb(255, 10, 56, 128)); // #0A3880 + + // ── Riga 1: Gruppi ──────────────────────────────────────────── + // "Nome" (col 0) + float cx = x0; + DrawHeaderCell(g, cx, y, cw[0], rh, "", accentBrush, whiteBrush, headerFont); + cx += cw[0]; + + // "SOTTOSTANTE" (cols 1-3) + float sottostanteW = cw[1] + cw[2] + cw[3]; + DrawHeaderCell(g, cx, y, sottostanteW, rh, "SOTTOSTANTE", accentBrush, whiteBrush, headerFont); + cx += sottostanteW; + + // "BARRIERE" (cols 4-8) + float barriereW = cw[4] + cw[5] + cw[6] + cw[7] + cw[8]; + DrawHeaderCell(g, cx, y, barriereW, rh, "BARRIERE", accentBrush, whiteBrush, headerFont); + cx += barriereW; + + // "DIVIDENDI" (cols 9-14) — blu scuro + float dividendiW = cw[9] + cw[10] + cw[11] + cw[12] + cw[13] + cw[14]; + DrawHeaderCell(g, cx, y, dividendiW, rh, "DIVIDENDI", darkBlueBrush, whiteBrush, headerFont); + + // ── Riga 2: Sottocolonne ────────────────────────────────────── + cx = x0; + for (int i = 0; i < cw.Length; i++) + { + var bg = i >= 9 ? darkBlueBrush : accentBrush; + DrawHeaderCell(g, cx, y + rh, cw[i], rh, Col2Headers[i], bg, whiteBrush, headerFont); + cx += cw[i]; + } + + // ── Separatore verticale tra col 8 e col 9 ─────────────────── + float sepX = x0 + cw.Take(9).Sum(); + var sepPen = new PdfPen(Color.FromArgb(255, 100, 181, 246), 1.5f); // #64B5F6 + g.DrawLine(sepPen, new PointF(sepX, y), new PointF(sepX, y + rh * 2)); + } + + private static void DrawHeaderCell(PdfGraphics g, float x, float y, float w, float h, + string text, PdfBrush bg, PdfBrush fg, PdfFont font) + { + g.DrawRectangle(bg, new RectangleF(x, y, w, h)); + if (!string.IsNullOrEmpty(text)) + { + var fmt = new PdfStringFormat + { + Alignment = PdfTextAlignment.Center, + LineAlignment = PdfVerticalAlignment.Middle, + }; + g.DrawString(text, font, fg, new RectangleF(x + 1, y, w - 2, h), fmt); + } + } + + private static void DrawDataGrid(PdfGraphics g, CertificateReportData data, + float x0, float y, float[] cw, float pageW) + { + var grid = new PdfGrid(); + grid.Style.CellPadding = new PdfPaddings(2, 2, 2, 2); + grid.Style.Font = PdfTheme.TableFont; + + // 15 colonne + grid.Columns.Add(15); + for (int i = 0; i < 15; i++) + grid.Columns[i].Width = cw[i]; + + var sottostanti = data.Info.Sottostanti; + + for (int i = 0; i < sottostanti.Count; i++) + { + var s = sottostanti[i]; + var row = grid.Rows.Add(); + + row.Cells[0].Value = s.Nome; + row.Cells[1].Value = s.Strike; + row.Cells[2].Value = s.LastPrice; + row.Cells[3].Value = s.Performance ?? "—"; + row.Cells[4].Value = s.CapitalBarrier ?? "—"; + row.Cells[5].Value = s.ULCapitalBarrierBuffer ?? "—"; + row.Cells[6].Value = s.CouponBarrier ?? "—"; + row.Cells[7].Value = s.ULCouponBarrierBuffer ?? "—"; + row.Cells[8].Value = s.TriggerAutocall ?? "—"; + row.Cells[9].Value = s.DividendExDate ?? "—"; + row.Cells[10].Value = s.DividendPayDate ?? "—"; + row.Cells[11].Value = s.DividendAmount ?? "—"; + row.Cells[12].Value = s.DividendYield ?? "—"; + row.Cells[13].Value = s.DividendFutAmount ?? "—"; + row.Cells[14].Value = s.DividendFutYield ?? "—"; + + // Righe alternate + if (i % 2 == 1) + { + for (int c = 0; c < 15; c++) + row.Cells[c].Style.BackgroundBrush = PdfTheme.TableAltRowBrush; + } + + // Colore performance (negativi rosso, positivi verde) + foreach (int c in PerfCols) + { + var val = row.Cells[c].Value?.ToString(); + if (string.IsNullOrEmpty(val) || val == "—") continue; + + // Le stringhe arrivano già formattate dalla SP, es. "-12,34%" + bool isNegative = val.TrimStart().StartsWith('-'); + row.Cells[c].Style.TextBrush = isNegative + ? PdfTheme.NegativeRedBrush + : PdfTheme.PositiveGreenBrush; + } + + // Allineamento centrato per tutte le celle tranne Nome + for (int c = 1; c < 15; c++) + { + row.Cells[c].StringFormat = new PdfStringFormat + { + Alignment = PdfTextAlignment.Center, + LineAlignment = PdfVerticalAlignment.Middle, + }; + } + } + + grid.Draw(g, new PointF(x0, y)); + } + } + ``` + +- [ ] **Step 2: Build** + + ```bash + dotnet build CertReports.Syncfusion + ``` + Expected: errore su `ReportOrchestrator` — normale. + +- [ ] **Step 3: Commit** + + ```bash + git add CertReports.Syncfusion/Services/Implementations/DividendSectionRenderer.cs + git commit -m "feat: add DividendSectionRenderer with landscape two-level header table" + ``` + +--- + +## Task 7: Registrare `DividendSectionRenderer` in `Program.cs` + +**Files:** +- Modify: `Program.cs` + +- [ ] **Step 1: Leggere `Program.cs`** + + Trovare il blocco delle registrazioni renderer. Attualmente c'è: + ```csharp + builder.Services.AddScoped(); + ``` + +- [ ] **Step 2: Aggiungere la registrazione subito sotto** + + ```csharp + builder.Services.AddScoped(); + builder.Services.AddScoped(); + ``` + +- [ ] **Step 3: Build** + + ```bash + dotnet build CertReports.Syncfusion + ``` + Expected: solo errore su `ReportOrchestrator` — normale. + +- [ ] **Step 4: Commit** + + ```bash + git add CertReports.Syncfusion/Program.cs + git commit -m "feat: register DividendSectionRenderer in DI container" + ``` + +--- + +## Task 8: Aggiornare `ReportOrchestrator` + +**Files:** +- Modify: `Services/Implementations/ReportOrchestrator.cs` + +- [ ] **Step 1: Leggere il file completo** + + Leggere l'intero file per avere contesto preciso prima di modificare. + +- [ ] **Step 2: Aggiungere il campo e il parametro costruttore** + + Aggiungere dopo `_expiredAnagraficaRenderer`: + ```csharp + private readonly ExpiredAnagraficaSectionRenderer _expiredAnagraficaRenderer; + private readonly DividendSectionRenderer _dividendRenderer; + ``` + + Nel costruttore aggiungere il parametro dopo `expiredAnagraficaRenderer`: + ```csharp + ExpiredAnagraficaSectionRenderer expiredAnagraficaRenderer, + DividendSectionRenderer dividendRenderer) + ``` + + E l'assegnazione: + ```csharp + _expiredAnagraficaRenderer = expiredAnagraficaRenderer; + _dividendRenderer = dividendRenderer; + ``` + +- [ ] **Step 3: Aggiornare la signature del metodo e le chiavi cache** + + Cambiare la firma: + ```csharp + public async Task GenerateReportAsync(string isin, bool showBranding = false, bool showDividend = false) + ``` + + Aggiornare le chiavi cache (le 4 combinazioni): + ```csharp + var dividendSuffix = showDividend ? ":dividend" : ""; + + var baseCacheKey = showBranding ? $"{isin}:branded{dividendSuffix}" : $"{isin}{dividendSuffix}"; + var expiredCacheKey = showBranding ? $"{isin}:expired:branded{dividendSuffix}" : $"{isin}:expired{dividendSuffix}"; + ``` + + Nota: questo produce le 4 chiavi: + - `{isin}` — no branding, no dividend + - `{isin}:branded` — branding, no dividend + - `{isin}:dividend` — no branding, dividend + - `{isin}:branded:dividend` — branding + dividend + - (+ varianti expired) + +- [ ] **Step 4: Impostare `ShowDividend` nel `CertificateReportData`** + + Nel blocco di inizializzazione: + ```csharp + var reportData = new CertificateReportData + { + Info = await _dataService.GetCertificateInfoAsync(isin), + Eventi = await _dataService.GetCertificateEventsAsync(isin), + Scenario = await _dataService.GetScenarioAnalysisAsync(isin), + ShowBranding = showBranding, + ShowDividend = showDividend, + }; + ``` + +- [ ] **Step 5: Inserire la sezione dividend nel flusso expired** + + Dopo `pdfSections.Add(_expiredAnagraficaRenderer.Render(reportData));`, aggiungere: + ```csharp + pdfSections.Add(_expiredAnagraficaRenderer.Render(reportData)); + + if (reportData.ShowDividend) + { + try + { + pdfSections.Add(_dividendRenderer.Render(reportData)); + _logger.LogInformation("Sezione 'Dividend' generata per {Isin}", isin); + } + catch (Exception ex) + { + _logger.LogError(ex, "Errore nella sezione 'Dividend' per {Isin}", isin); + throw; + } + } + ``` + +- [ ] **Step 6: Inserire la sezione dividend nel flusso attivo (attuale)** + + Il flusso attivo usa un `foreach` su `_sectionRenderers.OrderBy(r => r.Order)`. + `AnagraficaSectionRenderer` ha `Order = 1` (Sezione 1). + Dobbiamo inserire la pagina dividend subito dopo Anagrafica. + + Sostituire il `foreach` con: + ```csharp + 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; + } + + // Inserire pagina dividend subito dopo Anagrafica (Sezione 1) + if (renderer.SectionName == "Anagrafica" && reportData.ShowDividend) + { + try + { + pdfSections.Add(_dividendRenderer.Render(reportData)); + _logger.LogInformation("Sezione 'Dividend' generata per {Isin}", isin); + } + catch (Exception ex) + { + _logger.LogError(ex, "Errore nella sezione 'Dividend' per {Isin}", isin); + throw; + } + } + } + ``` + +- [ ] **Step 7: Build** + + ```bash + dotnet build CertReports.Syncfusion + ``` + Expected: **Build succeeded, 0 errors**. + +- [ ] **Step 8: Commit** + + ```bash + git add CertReports.Syncfusion/Services/Implementations/ReportOrchestrator.cs + git commit -m "feat: wire DividendSectionRenderer into orchestrator — both active and expired flows" + ``` + +--- + +## Task 9: Verifica manuale + +Non esistono test automatici nel progetto. Verificare manualmente: + +- [ ] **Step 1: Avviare l'applicazione** + + ```bash + dotnet run --project CertReports.Syncfusion + ``` + +- [ ] **Step 2: Test baseline — comportamento invariato** + + Aprire nel browser: + ``` + https://localhost:{porta}/api/report/by-isin/{ISIN_VALIDO} + ``` + Expected: report normale con tabella Sottostanti visibile in Sezione 1. Nessuna pagina landscape. + +- [ ] **Step 3: Test con `?dividend=true`** + + ``` + https://localhost:{porta}/api/report/by-isin/{ISIN_VALIDO}?dividend=true + ``` + Expected: + - Sezione 1 (Anagrafica): **nessuna** tabella Sottostanti + - Dopo Sezione 1: **pagina landscape** con titolo "Sottostanti e Dividendi" + - Header a 2 livelli: "SOTTOSTANTE" (blu) | "BARRIERE" (blu) | "DIVIDENDI" (blu scuro) + - Separatore verticale tra colonna Trig.AC e Data Stacco + - Righe dati con colori performance su % Perf., Buf.Cap., Buf.CPN, Rend., Rend.Fut. + - Colonne dividendo vuote mostrano "—" + - Footer con numero pagina + +- [ ] **Step 4: Test combinato `?dividend=true&branding=true`** + + Expected: come sopra + footer con "Powered by Smart Roots" su tutte le pagine. + +- [ ] **Step 5: Test su certificato expired** + + Usare un ISIN con `Stato != "Quotazione"`. Verificare lo stesso comportamento nei flusso expired. + +- [ ] **Step 6: Commit finale** + + ```bash + git add -A + git commit -m "feat: dividend section complete — landscape page with two-level header" + ``` + +--- + +## Note implementative + +### Gotcha Syncfusion v33 da rispettare + +| Problema | Soluzione applicata nel piano | +|----------|-------------------------------| +| `Color(r,g,b)` rimosso | `Color.FromArgb(255, r, g, b)` usato ovunque | +| `grid.Draw()` restituisce `void` | Usato senza assegnazione | +| `RectangleF` no named args | `new RectangleF(x, y, w, h)` — no named parameters | +| Namespace conflict | Aggiungere `using Syncfusion.Pdf;` se necessario | + +### Dimensioni pagina landscape + +A4 landscape in Syncfusion: `Width ≈ 841pt`, `Height ≈ 595pt`. +Con `PageMargin = 30pt` (verificare in `PdfTheme`), la larghezza utile `w ≈ 781pt`. +Le colonne vengono scalate proporzionalmente da 742pt a `w` → `scale ≈ 1.05`. + +### `SectionName == "Anagrafica"` + +Il valore esatto confermato in `AnagraficaSectionRenderer.cs` è `"Anagrafica"` — la stringa di confronto nel Task 8 Step 6 è corretta. diff --git a/docs/superpowers/plans/2026-05-27-chart-v2.md b/docs/superpowers/plans/2026-05-27-chart-v2.md new file mode 100644 index 0000000..3863c99 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-chart-v2.md @@ -0,0 +1,932 @@ +# Chart V2 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 l'endpoint `GET /api/chart/v2/{isin}` che genera un grafico certificati migliorato con titolo, colori distinti CTF/WorstOf, label sulle linee costanti e legenda orizzontale in basso. + +**Architecture:** Due nuove SP (`cedlab_Chart_UL1` per metadata, `cedlab_Chart_AllSeriesV2` per tutte le serie in un unico round-trip), tre nuovi file C# (modelli, servizio, renderer), e un secondo action method in `ChartController`. Il vecchio v1 rimane invariato. + +**Tech Stack:** ASP.NET Core 8, SkiaSharp, Microsoft.Data.SqlClient, SQL Server (`FirstSolutionDB`) + +--- + +## Prerequisito DB — SP da creare PRIMA di eseguire i task C# + +Le due stored procedure devono esistere in `FirstSolutionDB`. Schemi: + +### `cedlab_Chart_UL1 @isin NVARCHAR(12)` + +Estende `FSWeb_Chart_UL` con campi aggiuntivi. Deve restituire: + +| Colonna | Tipo | Descrizione | +|---------|------|-------------| +| IDCertificates | int | ID certificato | +| IDUnderlyings | int | ID sottostante | +| StartDate | date | Data inizio certificato | +| Strike | decimal | Valore strike assoluto | +| BarrieraCouponPerc | decimal | Barriera coupon in % (es. 60) | +| BarrieraCoupon | decimal | Barriera coupon assoluta | +| BarrieraCapitalePerc | decimal | Barriera capitale in % (es. 60) | +| BarrieraCapitale | decimal | Barriera capitale assoluta | +| Sottostante | nvarchar | Nome sottostante | +| IsWorstOf | int | 1 = è il worst-of, 0 = altri | +| PriceWorst | decimal | Prezzo attuale del worst-of (0 per altri) | +| PriceWorstPerc | decimal | Prezzo worst-of in % su strike (0 per altri) | +| NumPrezziCFT | int | Numero prezzi EOD disponibili | +| NomeCFT | nvarchar | Descrizione + ' - ' + ISIN | +| TriggerAutocallPerc | decimal | Trigger autocall in % (0 se assente) | +| AutocallValue | decimal | Trigger autocall assoluto | + +Ordinato: `IsWorstOf DESC` (worst-of nella prima riga). + +### `cedlab_Chart_AllSeriesV2 @isin NVARCHAR(12)` + +Restituisce tutte le serie CTF + UL in un unico resultset: + +| Colonna | Tipo | Descrizione | +|---------|------|-------------| +| IDUnderlyings | int | 0 = CTF, altrimenti = UnderlyingsID | +| Px_date | date | Data EOD | +| Performance | decimal | % su strike (CTF: PX_LAST_EOD/Nominal*100; UL: Px_close(adj)/Strike*100) | + +Logica: +- TOP 350 per-serie via `ROW_NUMBER() OVER (PARTITION BY IDUnderlyings ORDER BY Px_date DESC)` +- UL: solo date presenti nella serie CTF (INNER JOIN su Px_date), px_date >= StartDate +- UL AdjustedPrices: `CASE WHEN AdjustedPrices=1 THEN Px_closeadj ELSE Px_close END` +- ORDER BY IDUnderlyings ASC, Px_date ASC + +--- + +## File da creare / modificare + +| Operazione | File | +|------------|------| +| **Crea** | `CertReports.Syncfusion/Models/ChartModelsV2.cs` | +| **Crea** | `CertReports.Syncfusion/Services/Implementations/ChartDataServiceV2.cs` | +| **Crea** | `CertReports.Syncfusion/Services/Implementations/SkiaChartRendererV2.cs` | +| **Modifica** | `CertReports.Syncfusion/Controllers/ChartController.cs` | +| **Modifica** | `CertReports.Syncfusion/Program.cs` | + +--- + +## Task 1: ChartModelsV2 + +**Files:** +- Create: `CertReports.Syncfusion/Models/ChartModelsV2.cs` + +- [ ] **Step 1: Crea il file** + +```csharp +namespace CertReports.Syncfusion.Models; + +/// +/// Metadata di un sottostante (da SP cedlab_Chart_UL1). +/// Prima riga = worst-of (IsWorstOf = 1). +/// +public class ChartUlMetadata +{ + public int IDCertificates { get; set; } + public int IDUnderlyings { get; set; } + public DateTime StartDate { get; set; } + public decimal Strike { get; set; } + public decimal BarrieraCouponPerc { get; set; } + public decimal BarrieraCoupon { get; set; } + public decimal BarrieraCapitalePerc { get; set; } + public decimal BarrieraCapitale { get; set; } + public string Sottostante { get; set; } = string.Empty; + public int IsWorstOf { get; set; } + public decimal PriceWorst { get; set; } + public decimal PriceWorstPerc { get; set; } + public int NumPrezziCFT { get; set; } + public string NomeCFT { get; set; } = string.Empty; + public decimal TriggerAutocallPerc { get; set; } + public decimal AutocallValue { get; set; } +} + +/// +/// Singolo punto di una serie (da SP cedlab_Chart_AllSeriesV2). +/// IDUnderlyings = 0 → serie del certificato. +/// +public class ChartSeriesPoint +{ + public int IDUnderlyings { get; set; } + public DateTime Date { get; set; } + public decimal Performance { get; set; } +} + +/// +/// Dati completi per il grafico V2. +/// +public class ChartDataV2 +{ + public string Isin { get; set; } = string.Empty; + + /// + /// Metadata globale: prima riga di cedlab_Chart_UL1 (il worst-of). + /// Contiene NomeCFT, NumPrezziCFT, barriere, trigger — uguali per tutte le righe. + /// + public ChartUlMetadata GlobalMeta { get; set; } = new(); + + /// Tutti i sottostanti (per IsWorstOf, PriceWorst, nomi legenda). + public List Underlyings { get; set; } = new(); + + /// Tutti i punti di tutte le serie (CTF + UL), ordinati per data. + public List SeriesPoints { get; set; } = new(); +} +``` + +- [ ] **Step 2: Verifica compilazione** + +```powershell +dotnet build CertReports.Syncfusion +``` + +Expected: `Build succeeded. 0 Error(s)` + +- [ ] **Step 3: Commit** + +```bash +git add CertReports.Syncfusion/Models/ChartModelsV2.cs +git commit -m "feat: add ChartModelsV2 (ChartUlMetadata, ChartSeriesPoint, ChartDataV2)" +``` + +--- + +## Task 2: ChartDataServiceV2 + +**Files:** +- Create: `CertReports.Syncfusion/Services/Implementations/ChartDataServiceV2.cs` + +⚠️ Prerequisito: le SP `cedlab_Chart_UL1` e `cedlab_Chart_AllSeriesV2` devono già esistere nel DB. + +- [ ] **Step 1: Crea il file** + +```csharp +using CertReports.Syncfusion.Models; +using Microsoft.Data.SqlClient; +using System.Data; + +namespace CertReports.Syncfusion.Services.Implementations; + +/// +/// Recupera i dati per il grafico V2 con solo 2 round-trip al DB. +/// +/// SP utilizzate: +/// - cedlab_Chart_UL1: Metadata sottostanti (1 query, N sottostanti) +/// - cedlab_Chart_AllSeriesV2: Tutte le serie CTF + UL in una query (TOP 350 per-serie) +/// +public interface IChartDataServiceV2 +{ + Task GetChartDataV2Async(string isin); +} + +public class ChartDataServiceV2 : IChartDataServiceV2 +{ + private readonly string _connectionString; + private readonly ILogger _logger; + + public ChartDataServiceV2(IConfiguration config, ILogger logger) + { + _connectionString = config.GetConnectionString("CertDb") + ?? throw new InvalidOperationException("ConnectionString 'CertDb' non configurata."); + _logger = logger; + } + + public async Task GetChartDataV2Async(string isin) + { + await using var conn = new SqlConnection(_connectionString); + await conn.OpenAsync(); + + // ── 1. Metadata sottostanti (cedlab_Chart_UL1) ───────────────── + var underlyings = new List(); + + await using (var cmd = new SqlCommand("cedlab_Chart_UL1", conn) + { CommandType = CommandType.StoredProcedure }) + { + cmd.Parameters.AddWithValue("@isin", isin); + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + { + underlyings.Add(new ChartUlMetadata + { + IDCertificates = r.GetInt32(r.GetOrdinal("IDCertificates")), + IDUnderlyings = r.GetInt32(r.GetOrdinal("IDUnderlyings")), + StartDate = r.GetDateTime(r.GetOrdinal("StartDate")), + Strike = r.GetDecimal(r.GetOrdinal("Strike")), + BarrieraCouponPerc = r.GetDecimal(r.GetOrdinal("BarrieraCouponPerc")), + BarrieraCoupon = r.GetDecimal(r.GetOrdinal("BarrieraCoupon")), + BarrieraCapitalePerc = r.GetDecimal(r.GetOrdinal("BarrieraCapitalePerc")), + BarrieraCapitale = r.GetDecimal(r.GetOrdinal("BarrieraCapitale")), + Sottostante = r.GetString(r.GetOrdinal("Sottostante")), + IsWorstOf = r.GetInt32(r.GetOrdinal("IsWorstOf")), + PriceWorst = r.GetDecimal(r.GetOrdinal("PriceWorst")), + PriceWorstPerc = r.GetDecimal(r.GetOrdinal("PriceWorstPerc")), + NumPrezziCFT = r.GetInt32(r.GetOrdinal("NumPrezziCFT")), + NomeCFT = r.GetString(r.GetOrdinal("NomeCFT")), + TriggerAutocallPerc = r.GetDecimal(r.GetOrdinal("TriggerAutocallPerc")), + AutocallValue = r.GetDecimal(r.GetOrdinal("AutocallValue")), + }); + } + } + + if (underlyings.Count == 0) + { + _logger.LogWarning( + "Nessun sottostante trovato per il grafico V2 di {Isin} (meno di 30 prezzi EOD?)", isin); + return null; + } + + var result = new ChartDataV2 + { + Isin = isin, + GlobalMeta = underlyings[0], // worst-of è il primo (SP ordina IsWorstOf DESC) + Underlyings = underlyings, + }; + + // ── 2. Tutte le serie (cedlab_Chart_AllSeriesV2) ──────────────── + await using (var cmd = new SqlCommand("cedlab_Chart_AllSeriesV2", conn) + { CommandType = CommandType.StoredProcedure }) + { + cmd.Parameters.AddWithValue("@isin", isin); + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + { + result.SeriesPoints.Add(new ChartSeriesPoint + { + IDUnderlyings = r.GetInt32(r.GetOrdinal("IDUnderlyings")), + Date = r.GetDateTime(r.GetOrdinal("Px_date")), + Performance = r.GetDecimal(r.GetOrdinal("Performance")), + }); + } + } + + _logger.LogInformation( + "Dati grafico V2 caricati per {Isin}: {UlCount} sottostanti, {Points} punti totali", + isin, underlyings.Count, result.SeriesPoints.Count); + + return result; + } +} +``` + +- [ ] **Step 2: Verifica compilazione** + +```powershell +dotnet build CertReports.Syncfusion +``` + +Expected: `Build succeeded. 0 Error(s)` + +- [ ] **Step 3: Commit** + +```bash +git add CertReports.Syncfusion/Services/Implementations/ChartDataServiceV2.cs +git commit -m "feat: add ChartDataServiceV2 with IChartDataServiceV2 (2-SP approach)" +``` + +--- + +## Task 3: SkiaChartRendererV2 + +**Files:** +- Create: `CertReports.Syncfusion/Services/Implementations/SkiaChartRendererV2.cs` + +- [ ] **Step 1: Crea il file** (`~380 righe`) + +```csharp +using CertReports.Syncfusion.Models; +using SkiaSharp; + +namespace CertReports.Syncfusion.Services.Implementations; + +/// +/// Renderer grafico V2 per certificati con SkiaSharp. +/// +/// Miglioramenti rispetto a v1: +/// - Titolo in cima (NomeCFT + avviso se < 30 prezzi) +/// - CTF in rosso (#CC0000), WorstOf in blu (#1565C0), altri in grigio +/// - Linee costanti con label direttamente sull'estremità destra +/// - Legenda orizzontale in BASSO (non a destra) +/// - Linea tratteggiata blu per prezzo attuale worst-of (non in legenda) +/// +public static class SkiaChartRendererV2 +{ + // ── Colori V2 ────────────────────────────────────────────────────── + private static readonly SKColor CertColor = new(204, 0, 0); // #CC0000 rosso CTF + private static readonly SKColor WorstOfColor = new(21, 101, 192); // #1565C0 blu WorstOf + private static readonly SKColor StrikeColor = new(46, 125, 50); // #2E7D32 verde + private static readonly SKColor CapitaleColor = new(204, 0, 0); // rosso (= CTF) + private static readonly SKColor CouponColor = new(128, 0, 128); // viola + private static readonly SKColor AutocallColor = new(230, 81, 0); // arancione + private static readonly SKColor PrezzoWorstColor = new(21, 101, 192); // blu tratteggiato + private static readonly SKColor TitleColor = new(21, 101, 192); // blu titolo + + private static readonly SKColor[] OtherUlColors = + { + new(120, 120, 120), + new(160, 160, 160), + new(90, 90, 90), + new(140, 140, 140), + }; + + // ── Font ─────────────────────────────────────────────────────────── + private static SKFont CreateFont(float size, bool bold = false) => + new(SKTypeface.FromFamilyName("Arial", bold ? SKFontStyle.Bold : SKFontStyle.Normal), size); + + // ═══════════════════════════════════════════════════════════════════ + // Entry point + // ═══════════════════════════════════════════════════════════════════ + + /// Genera il grafico come PNG. + public static byte[] RenderToPng(ChartDataV2 data, int width = 1100, int height = 700) + { + using var surface = SKSurface.Create(new SKImageInfo(width, height)); + var canvas = surface.Canvas; + canvas.Clear(SKColors.White); + + // ── Titolo ───────────────────────────────────────────────────── + float titleBottom = DrawTitle(canvas, width, data); + + // ── Margini area plot ────────────────────────────────────────── + float marginLeft = 70; + float marginRight = 210; // spazio per label linee costanti + float marginTop = titleBottom + 10; + float marginBottom = 95; // asse X + legenda orizzontale + + var plotArea = new SKRect(marginLeft, marginTop, width - marginRight, height - marginBottom); + + // ── Raggruppa punti per serie ────────────────────────────────── + var worstOf = data.Underlyings.FirstOrDefault(u => u.IsWorstOf == 1); + var seriesByUl = data.SeriesPoints + .GroupBy(p => p.IDUnderlyings) + .ToDictionary(g => g.Key, g => g.OrderBy(p => p.Date).ToList()); + + if (seriesByUl.Count == 0) + return Array.Empty(); + + // ── Calcola range assi ───────────────────────────────────────── + var (minDate, maxDate, minY, maxY) = CalculateRanges(data, seriesByUl, worstOf); + + // ── Griglia e assi ───────────────────────────────────────────── + DrawGrid(canvas, plotArea, minDate, maxDate, minY, maxY); + DrawAxisLabels(canvas, plotArea, minDate, maxDate, minY, maxY); + + // ── Linee costanti con label ─────────────────────────────────── + var constLegend = new List<(string name, SKColor color, bool dashed, float thickness)>(); + + // Barriera Capitale (label unificata se stessa % di Coupon) + string bcLabel = data.GlobalMeta.BarrieraCouponPerc == data.GlobalMeta.BarrieraCapitalePerc + ? $"Barriera {data.GlobalMeta.BarrieraCapitalePerc:0}% ({data.GlobalMeta.BarrieraCapitale:0.00})" + : $"Barriera Capitale {data.GlobalMeta.BarrieraCapitalePerc:0}% ({data.GlobalMeta.BarrieraCapitale:0.00})"; + DrawHorizontalLineWithLabel(canvas, plotArea, minY, maxY, + (float)data.GlobalMeta.BarrieraCapitalePerc, CapitaleColor, 1.5f, false, bcLabel); + constLegend.Add((bcLabel, CapitaleColor, false, 1.5f)); + + // Barriera Coupon (solo se diversa) + if (data.GlobalMeta.BarrieraCouponPerc != data.GlobalMeta.BarrieraCapitalePerc + && data.GlobalMeta.BarrieraCouponPerc > 0) + { + string bkLabel = $"Barriera Coupon {data.GlobalMeta.BarrieraCouponPerc:0}% ({data.GlobalMeta.BarrieraCoupon:0.00})"; + DrawHorizontalLineWithLabel(canvas, plotArea, minY, maxY, + (float)data.GlobalMeta.BarrieraCouponPerc, CouponColor, 1.5f, false, bkLabel); + constLegend.Add((bkLabel, CouponColor, false, 1.5f)); + } + + // Strike + string strikeLabel = $"Strike 100% ({data.GlobalMeta.Strike:0.00})"; + DrawHorizontalLineWithLabel(canvas, plotArea, minY, maxY, + 100f, StrikeColor, 1.5f, false, strikeLabel); + constLegend.Add((strikeLabel, StrikeColor, false, 1.5f)); + + // Trigger Autocall (solo se applicabile) + bool showAutocall = data.GlobalMeta.TriggerAutocallPerc != 0 + && data.GlobalMeta.TriggerAutocallPerc != 100 + && data.GlobalMeta.TriggerAutocallPerc != data.GlobalMeta.BarrieraCapitalePerc + && data.GlobalMeta.TriggerAutocallPerc != data.GlobalMeta.BarrieraCouponPerc; + if (showAutocall) + { + string taLabel = $"Trigger Autocall {data.GlobalMeta.TriggerAutocallPerc:0}% ({data.GlobalMeta.AutocallValue:0.00})"; + DrawHorizontalLineWithLabel(canvas, plotArea, minY, maxY, + (float)data.GlobalMeta.TriggerAutocallPerc, AutocallColor, 1.5f, false, taLabel); + constLegend.Add((taLabel, AutocallColor, false, 1.5f)); + } + + // Prezzo attuale WorstOf — tratteggiato, NON in legenda + if (worstOf != null && worstOf.PriceWorstPerc > 0) + { + string pwLabel = $"{worstOf.Sottostante} ({worstOf.PriceWorst:0.00})"; + DrawHorizontalLineWithLabel(canvas, plotArea, minY, maxY, + (float)worstOf.PriceWorstPerc, PrezzoWorstColor, 1f, true, pwLabel); + } + + // ── Serie ────────────────────────────────────────────────────── + var seriesLegend = new List<(string name, SKColor color, bool dashed, float thickness)>(); + int otherColorIdx = 0; + + // CTF (IDUnderlyings = 0) + if (seriesByUl.TryGetValue(0, out var ctfPoints) && ctfPoints.Count >= 2) + { + DrawSeriesV2(canvas, plotArea, ctfPoints, minDate, maxDate, minY, maxY, CertColor, 2.5f); + seriesLegend.Add((data.Isin, CertColor, false, 2.5f)); + } + + // Sottostanti (WorstOf prima, poi altri) + foreach (var ul in data.Underlyings.OrderByDescending(u => u.IsWorstOf)) + { + if (!seriesByUl.TryGetValue(ul.IDUnderlyings, out var ulPoints) || ulPoints.Count < 2) + continue; + + SKColor color; + float thickness; + if (ul.IsWorstOf == 1) + { + color = WorstOfColor; + thickness = 2f; + } + else + { + color = OtherUlColors[otherColorIdx++ % OtherUlColors.Length]; + thickness = 1f; + } + + DrawSeriesV2(canvas, plotArea, ulPoints, minDate, maxDate, minY, maxY, color, thickness); + seriesLegend.Add((ul.Sottostante, color, false, thickness)); + } + + // ── Bordo area plot ──────────────────────────────────────────── + using var borderPaint = new SKPaint + { + Color = SKColors.Gray, StrokeWidth = 1, + Style = SKPaintStyle.Stroke, IsAntialias = true, + }; + canvas.DrawRect(plotArea, borderPaint); + + // ── Legenda orizzontale in basso ─────────────────────────────── + var allLegend = seriesLegend.Concat(constLegend).ToList(); + DrawLegendBottom(canvas, plotArea, allLegend, width); + + // ── Export PNG ───────────────────────────────────────────────── + using var image = surface.Snapshot(); + using var pngData = image.Encode(SKEncodedImageFormat.Png, 95); + return pngData.ToArray(); + } + + // ═══════════════════════════════════════════════════════════════════ + // Titolo + // ═══════════════════════════════════════════════════════════════════ + + private static float DrawTitle(SKCanvas canvas, int width, ChartDataV2 data) + { + float y = 15f; + + // Riga 1: NomeCFT in grassetto blu + using var boldFont = CreateFont(13f, bold: true); + using var titlePaint = new SKPaint { Color = TitleColor, IsAntialias = true }; + canvas.DrawText(data.GlobalMeta.NomeCFT, width / 2f, y + 13, SKTextAlign.Center, boldFont, titlePaint); + y += 22; + + // Riga 2 (opzionale): avviso se meno di 30 prezzi CTF + if (data.GlobalMeta.NumPrezziCFT < 30) + { + using var subFont = CreateFont(10f); + using var subPaint = new SKPaint { Color = new SKColor(204, 0, 0), IsAntialias = true }; + const string subtitle = "Il certificato viene mostrato nel grafico solo dopo 30gg dalla sua emissione"; + canvas.DrawText(subtitle, width / 2f, y + 11, SKTextAlign.Center, subFont, subPaint); + y += 18; + } + + return y + 5; // bottom della zona titolo + } + + // ═══════════════════════════════════════════════════════════════════ + // Calcolo range assi + // ═══════════════════════════════════════════════════════════════════ + + private static (DateTime minDate, DateTime maxDate, double minY, double maxY) CalculateRanges( + ChartDataV2 data, + Dictionary> seriesByUl, + ChartUlMetadata? worstOf) + { + DateTime minDate = DateTime.MaxValue, maxDate = DateTime.MinValue; + double minY = double.MaxValue, maxY = double.MinValue; + + foreach (var pts in seriesByUl.Values) + { + foreach (var pt in pts) + { + if (pt.Date < minDate) minDate = pt.Date; + if (pt.Date > maxDate) maxDate = pt.Date; + double v = (double)pt.Performance; + if (v < minY) minY = v; + if (v > maxY) maxY = v; + } + } + + // Includi linee costanti nel range + var constants = new List + { + 100.0, + (double)data.GlobalMeta.BarrieraCapitalePerc, + }; + if (data.GlobalMeta.BarrieraCouponPerc > 0) + constants.Add((double)data.GlobalMeta.BarrieraCouponPerc); + if (showAutocallValue(data)) + constants.Add((double)data.GlobalMeta.TriggerAutocallPerc); + if (worstOf != null && worstOf.PriceWorstPerc > 0) + constants.Add((double)worstOf.PriceWorstPerc); + + foreach (var c in constants) + { + if (c < minY) minY = c; + if (c > maxY) maxY = c; + } + + // Margine 10% + double range = maxY - minY; + if (range == 0) range = 10; + double margin = range * 0.1; + minY -= margin; + maxY += margin; + + return (minDate, maxDate, minY, maxY); + } + + private static bool showAutocallValue(ChartDataV2 data) => + data.GlobalMeta.TriggerAutocallPerc != 0 + && data.GlobalMeta.TriggerAutocallPerc != 100 + && data.GlobalMeta.TriggerAutocallPerc != data.GlobalMeta.BarrieraCapitalePerc + && data.GlobalMeta.TriggerAutocallPerc != data.GlobalMeta.BarrieraCouponPerc; + + // ═══════════════════════════════════════════════════════════════════ + // Griglia + // ═══════════════════════════════════════════════════════════════════ + + private static void DrawGrid(SKCanvas canvas, SKRect area, + DateTime minDate, DateTime maxDate, double minY, double maxY) + { + using var gridPaint = new SKPaint + { + Color = new SKColor(230, 230, 230), StrokeWidth = 0.5f, + Style = SKPaintStyle.Stroke, IsAntialias = true, + }; + + int ySteps = 8; + for (int i = 0; i <= ySteps; i++) + { + float y = area.Top + (area.Height / ySteps) * i; + canvas.DrawLine(area.Left, y, area.Right, y, gridPaint); + } + + var totalDays = (maxDate - minDate).TotalDays; + int step = totalDays > 1000 ? 365 : totalDays > 500 ? 180 : 90; + var d = new DateTime(minDate.Year, minDate.Month > 6 ? 7 : 1, 1); + while (d <= maxDate) + { + float x = DateToX(d, area, minDate, maxDate); + if (x >= area.Left && x <= area.Right) + canvas.DrawLine(x, area.Top, x, area.Bottom, gridPaint); + d = d.AddDays(step); + } + } + + // ═══════════════════════════════════════════════════════════════════ + // Labels assi + // ═══════════════════════════════════════════════════════════════════ + + private static void DrawAxisLabels(SKCanvas canvas, SKRect area, + DateTime minDate, DateTime maxDate, double minY, double maxY) + { + using var font = CreateFont(11); + using var paint = new SKPaint { Color = SKColors.DimGray, IsAntialias = true }; + + int ySteps = 8; + for (int i = 0; i <= ySteps; i++) + { + double val = maxY - ((maxY - minY) / ySteps) * i; + float y = area.Top + (area.Height / ySteps) * i; + canvas.DrawText($"{val:F0} %", area.Left - 55, y + 4, SKTextAlign.Left, font, paint); + } + + var totalDays = (maxDate - minDate).TotalDays; + int step = totalDays > 1000 ? 365 : totalDays > 500 ? 180 : 90; + var d = new DateTime(minDate.Year, minDate.Month > 6 ? 7 : 1, 1); + while (d <= maxDate) + { + float x = DateToX(d, area, minDate, maxDate); + if (x >= area.Left && x <= area.Right) + { + string text = totalDays > 500 ? d.ToString("yyyy") : d.ToString("MMM yyyy"); + canvas.DrawText(text, x, area.Bottom + 20, SKTextAlign.Center, font, paint); + } + d = d.AddDays(step); + } + } + + // ═══════════════════════════════════════════════════════════════════ + // Disegno serie + // ═══════════════════════════════════════════════════════════════════ + + private static void DrawSeriesV2(SKCanvas canvas, SKRect area, + List points, + DateTime minDate, DateTime maxDate, double minY, double maxY, + SKColor color, float thickness) + { + using var paint = new SKPaint + { + Color = color, StrokeWidth = thickness, + Style = SKPaintStyle.Stroke, IsAntialias = true, + StrokeCap = SKStrokeCap.Round, StrokeJoin = SKStrokeJoin.Round, + }; + + using var path = new SKPath(); + bool first = true; + foreach (var pt in points) + { + float x = DateToX(pt.Date, area, minDate, maxDate); + float y = ValueToY((double)pt.Performance, area, minY, maxY); + y = Math.Max(area.Top, Math.Min(area.Bottom, y)); + if (first) { path.MoveTo(x, y); first = false; } + else path.LineTo(x, y); + } + + canvas.Save(); + canvas.ClipRect(area); + canvas.DrawPath(path, paint); + canvas.Restore(); + } + + // ═══════════════════════════════════════════════════════════════════ + // Linea orizzontale costante con label a destra + // ═══════════════════════════════════════════════════════════════════ + + private static void DrawHorizontalLineWithLabel(SKCanvas canvas, SKRect area, + double minY, double maxY, + float value, SKColor color, float thickness, bool dashed, string label) + { + float y = ValueToY(value, area, minY, maxY); + if (y < area.Top || y > area.Bottom) return; + + using var linePaint = new SKPaint + { + Color = color, StrokeWidth = thickness, + Style = SKPaintStyle.Stroke, IsAntialias = true, + }; + if (dashed) + linePaint.PathEffect = SKPathEffect.CreateDash(new[] { 8f, 4f }, 0); + + canvas.DrawLine(area.Left, y, area.Right, y, linePaint); + + // Label a destra della linea + using var font = CreateFont(9.5f); + using var textPaint = new SKPaint { Color = color, IsAntialias = true }; + canvas.DrawText(label, area.Right + 5, y + 4, SKTextAlign.Left, font, textPaint); + } + + // ═══════════════════════════════════════════════════════════════════ + // Legenda orizzontale in basso (flow layout, max 2 righe) + // ═══════════════════════════════════════════════════════════════════ + + private static void DrawLegendBottom(SKCanvas canvas, SKRect plotArea, + List<(string name, SKColor color, bool dashed, float thickness)> items, int totalWidth) + { + if (items.Count == 0) return; + + float legendY = plotArea.Bottom + 38; // sotto le label asse X + float x = plotArea.Left; + const float lineW = 22; + const float gap = 6; + const float itemGap = 14; + const float rowHeight = 19; + + using var font = CreateFont(10f); + + foreach (var (name, color, dashed, thickness) in items) + { + float textW = font.MeasureText(name); + float itemW = lineW + gap + textW + itemGap; + + // Vai a capo se non c'è spazio + if (x + itemW > totalWidth - plotArea.Left + plotArea.Left && x > plotArea.Left) + { + x = plotArea.Left; + legendY += rowHeight; + } + + float midY = legendY + rowHeight / 2f - 2; + + // Linea campione + using var linePaint = new SKPaint + { + Color = color, StrokeWidth = Math.Min(thickness, 2f), + Style = SKPaintStyle.Stroke, IsAntialias = true, + }; + if (dashed) + linePaint.PathEffect = SKPathEffect.CreateDash(new[] { 6f, 3f }, 0); + canvas.DrawLine(x, midY, x + lineW, midY, linePaint); + + // Testo + using var textPaint = new SKPaint { Color = SKColors.DimGray, IsAntialias = true }; + canvas.DrawText(name, x + lineW + gap, midY + 4, SKTextAlign.Left, font, textPaint); + + x += itemW; + } + } + + // ═══════════════════════════════════════════════════════════════════ + // Conversioni coordinate (identiche a v1) + // ═══════════════════════════════════════════════════════════════════ + + private static float DateToX(DateTime date, SKRect area, DateTime minDate, DateTime maxDate) + { + double totalDays = (maxDate - minDate).TotalDays; + if (totalDays == 0) return area.Left; + double ratio = (date - minDate).TotalDays / totalDays; + return area.Left + (float)(ratio * area.Width); + } + + private static float ValueToY(double value, SKRect area, double minY, double maxY) + { + double range = maxY - minY; + if (range == 0) return area.MidY; + double ratio = (value - minY) / range; + return area.Bottom - (float)(ratio * area.Height); + } +} +``` + +- [ ] **Step 2: Verifica compilazione** + +```powershell +dotnet build CertReports.Syncfusion +``` + +Expected: `Build succeeded. 0 Error(s)` + +> 💡 Se il build fallisce su `showAutocallValue` (metodo privato statico con nome in minuscolo): rinomina in `ShowAutocallValue` e aggiorna i due riferimenti in `CalculateRanges` e nel body di `RenderToPng`. + +- [ ] **Step 3: Commit** + +```bash +git add CertReports.Syncfusion/Services/Implementations/SkiaChartRendererV2.cs +git commit -m "feat: add SkiaChartRendererV2 (title, colored series, line labels, bottom legend)" +``` + +--- + +## Task 4: Controller V2 + DI + +**Files:** +- Modify: `CertReports.Syncfusion/Controllers/ChartController.cs` +- Modify: `CertReports.Syncfusion/Program.cs` + +- [ ] **Step 1: Aggiorna `ChartController.cs` — aggiungi injection e action V2** + +Aggiungi `IChartDataServiceV2 _chartDataServiceV2` al controller. Modifica il costruttore e aggiungi il nuovo action method: + +Nella sezione campi (dopo `private readonly IChartDataService _chartDataService;`): +```csharp +private readonly IChartDataServiceV2 _chartDataServiceV2; +``` + +Sostituisci il costruttore esistente: +```csharp +// PRIMA: +public ChartController(IChartDataService chartDataService, ILogger logger) +{ + _chartDataService = chartDataService; + _logger = logger; +} + +// DOPO: +public ChartController( + IChartDataService chartDataService, + IChartDataServiceV2 chartDataServiceV2, + ILogger logger) +{ + _chartDataService = chartDataService; + _chartDataServiceV2 = chartDataServiceV2; + _logger = logger; +} +``` + +Aggiungi il nuovo action method subito dopo il `}` di `GenerateChart` e prima di `WrapPngInPdf`: + +```csharp +/// +/// Endpoint V2: grafico migliorato con titolo, colori distinti, label sulle linee e legenda in basso. +/// Richiede SP cedlab_Chart_UL1 e cedlab_Chart_AllSeriesV2 nel DB. +/// +[HttpGet("v2/{isin}")] +public async Task GenerateChartV2( + string isin, + [FromQuery] int width = 1100, + [FromQuery] int height = 700, + [FromQuery] string format = "png") +{ + if (string.IsNullOrWhiteSpace(isin)) + return BadRequest("ISIN non valido."); + + width = Math.Clamp(width, 400, 2000); + height = Math.Clamp(height, 300, 1500); + + try + { + var chartData = await _chartDataServiceV2.GetChartDataV2Async(isin); + + if (chartData == null || chartData.SeriesPoints.Count == 0) + { + return NotFound(new + { + status = "KO", + message = $"Nessun dato per il grafico V2 di {isin} (meno di 30 prezzi EOD?).", + }); + } + + byte[] pngBytes = SkiaChartRendererV2.RenderToPng(chartData, width, height); + + if (format.Equals("pdf", StringComparison.OrdinalIgnoreCase)) + { + byte[] pdfBytes = WrapPngInPdf(pngBytes); + Response.Headers.Append("Content-Disposition", $"inline; filename=chart_v2_{isin}.pdf"); + return File(pdfBytes, "application/pdf"); + } + + Response.Headers.Append("Content-Disposition", $"inline; filename=chart_v2_{isin}.png"); + return File(pngBytes, "image/png"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Errore generazione chart V2 per ISIN {Isin}", isin); + return StatusCode(500, new { status = "KO", message = "Errore nella generazione del grafico V2." }); + } +} +``` + +- [ ] **Step 2: Registra il servizio in `Program.cs`** + +Aggiungi questa riga subito dopo `builder.Services.AddScoped();`: + +```csharp +builder.Services.AddScoped(); +``` + +- [ ] **Step 3: Verifica compilazione** + +```powershell +dotnet build CertReports.Syncfusion +``` + +Expected: `Build succeeded. 0 Error(s)` + +- [ ] **Step 4: Test manuale — avvia l'API** + +```powershell +dotnet run --project CertReports.Syncfusion +``` + +- [ ] **Step 5: Chiama il nuovo endpoint** + +``` +GET https://localhost:{porta}/api/chart/v2/{ISIN} +GET https://localhost:{porta}/api/chart/v2/{ISIN}?format=pdf +GET https://localhost:{porta}/api/chart/v2/{ISIN}?width=1400&height=800 +``` + +Verifica nel PNG/PDF che: +- [ ] Titolo NomeCFT visibile in blu in cima +- [ ] Linea certificato in **rosso** (non nera come v1) +- [ ] Linea worst-of in **blu** più spessa degli altri UL +- [ ] Linee barriere con **label testuale** a destra (non solo in legenda) +- [ ] Legenda **in basso** (non a destra) +- [ ] Linea tratteggiata blu per prezzo attuale worst-of (se PriceWorstPerc > 0) +- [ ] Se `NumPrezziCFT < 30`: sub-titolo rosso con avviso + +- [ ] **Step 6: Commit** + +```bash +git add CertReports.Syncfusion/Controllers/ChartController.cs +git add CertReports.Syncfusion/Program.cs +git commit -m "feat: add /api/chart/v2/{isin} endpoint and register IChartDataServiceV2" +``` + +--- + +## Self-Review + +### Spec coverage +- ✅ 2 SP (cedlab_Chart_UL1 + cedlab_Chart_AllSeriesV2) → Task 2 +- ✅ Modelli ChartUlMetadata, ChartSeriesPoint, ChartDataV2 → Task 1 +- ✅ Titolo NomeCFT + sub-titolo se < 30 prezzi → Task 3 `DrawTitle` +- ✅ CTF rosso 2.5px, WorstOf blu 2px, altri grigi 1px → Task 3 serie loop +- ✅ Label su linee costanti (Strike, Barriera Capitale, Coupon, Autocall) → Task 3 `DrawHorizontalLineWithLabel` +- ✅ Label unificata se BarrieraCouponPerc == BarrieraCapitalePerc → Task 3 +- ✅ TriggerAutocall: solo se != 0 && != 100 && != Capitale && != Coupon → Task 3 +- ✅ PrezzoWorst tratteggiato, NON in legenda → Task 3 +- ✅ Legenda orizzontale in basso → Task 3 `DrawLegendBottom` +- ✅ Endpoint `/api/chart/v2/{isin}` con ?width ?height ?format → Task 4 +- ✅ Riuso di WrapPngInPdf dal v1 → Task 4 +- ✅ Registrazione DI → Task 4 + +### Note implementative +- Il `DrawLegendBottom` usa un layout flow. Se `x + itemW > totalWidth - plotArea.Left + plotArea.Left` la condizione si semplifica a `x + itemW > totalWidth`. Verificare a runtime se la legenda deborda; se sì, diminuire il font a 9.5f o ridurre `itemGap`. +- `marginBottom = 95` dà spazio per asse X (20px) + 2 righe di legenda (38px). Se gli item leggenda sono molti, aumentare a 110.