From d90212c20660cdd15caa2bcca43992ea3a3ad9f2 Mon Sep 17 00:00:00 2001 From: SmartRootsSrl Date: Mon, 8 Jun 2026 17:29:41 +0200 Subject: [PATCH] docs: add fund report implementation plan (10 tasks, FundAnagraficaRenderer + FundSkiaChartRenderer + orchestrator) Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../plans/2026-06-08-fund-report.md | 1394 +++++++++++++++++ 1 file changed, 1394 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-08-fund-report.md diff --git a/docs/superpowers/plans/2026-06-08-fund-report.md b/docs/superpowers/plans/2026-06-08-fund-report.md new file mode 100644 index 0000000..b401e8c --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-fund-report.md @@ -0,0 +1,1394 @@ +# Fund/ETF 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 report PDF a 2 pagine per ETF/fondi con endpoint `/api/report/fund/` e `/api/chart/fund/`, senza toccare il flusso certificati esistente. + +**Architecture:** `FundReportController` + `FundChartController` → `FundReportOrchestrator` → `FundDataService` (SP `sfih_GetOptDettagli` + `sfih_GetChartPrices`) → `FundAnagraficaRenderer` (pagina 1, layout C) + `FundChartSectionRenderer` (pagina 2, linea close price) → `PdfMergerService` (riusato). `FundSkiaChartRenderer` è static, usato sia in `FundChartSectionRenderer` che in `FundChartController`. + +**Tech Stack:** ASP.NET Core 8, Syncfusion PDF v33, SkiaSharp, Microsoft.Data.SqlClient v5, pattern identici a `AnagraficaSectionRenderer` / `CertificateDataService` / `ReportOrchestrator` / `ChartController`. + +--- + +## File Map + +| File | Azione | Responsabilità | +|------|--------|----------------| +| `Models/FundModels.cs` | Crea | `FundInfo`, `FundReportData`, `FundChartPoint` | +| `Services/Interfaces/IFundServices.cs` | Crea | `IFundDataService`, `IFundReportOrchestrator` | +| `Services/Implementations/FundDataService.cs` | Crea | Chiama `sfih_GetOptDettagli` + `sfih_GetChartPrices` | +| `Services/Implementations/FundAnagraficaRenderer.cs` | Crea | Pagina 1 PDF (layout C: strip Rank/Prezzo, 3 colonne) | +| `Services/Implementations/FundSkiaChartRenderer.cs` | Crea | SkiaSharp statico: linea close price → PNG/JPEG | +| `Services/Implementations/FundChartSectionRenderer.cs` | Crea | Pagina 2 PDF: chiama FundSkiaChartRenderer, wrappa in PdfDocument | +| `Services/Implementations/FundReportOrchestrator.cs` | Crea | Coordina dati + render + merge + cache | +| `Controllers/FundReportController.cs` | Crea | 4 endpoint `/api/report/fund/` | +| `Controllers/FundChartController.cs` | Crea | 1 endpoint `/api/chart/fund/{isin}` | +| `Program.cs` | Modifica | Registra tutti i nuovi servizi in DI | + +--- + +## Task 1: Models + +**Files:** +- Create: `CertReports.Syncfusion/Models/FundModels.cs` + +- [ ] **Step 1: Crea il file** + +```csharp +using CertReports.Syncfusion.Models; + +namespace CertReports.Syncfusion.Models; + +public class FundInfo +{ + public string Isin { get; set; } = ""; + public string Strumento { get; set; } = ""; + public string? Tipo { get; set; } + public string? Societa { get; set; } + public string? CategoriaMorningstar { get; set; } + public string? Valuta { get; set; } + public string? Hedged { get; set; } + public string? Benchmark { get; set; } + public string? Catalogo { get; set; } + public string? Proventi { get; set; } + public DateTime? DataLancio { get; set; } + public decimal? Patrimonio { get; set; } + public decimal? SpeseCorrenti { get; set; } + public decimal? Prezzo { get; set; } + public DateTime? DataPrezzo { get; set; } + public decimal? Rank { get; set; } + public decimal? P3M { get; set; } + public decimal? P6M { get; set; } + public decimal? PYD { get; set; } + public decimal? P1Y { get; set; } + public decimal? P3Y { get; set; } + public decimal? P5Y { get; set; } + public decimal? V3M { get; set; } + public decimal? V6M { get; set; } + public decimal? VYD { get; set; } + public decimal? V1Y { get; set; } + public decimal? V3Y { get; set; } + public decimal? V5Y { get; set; } + public decimal? R3M { get; set; } + public decimal? R6M { get; set; } + public decimal? RYD { get; set; } + public decimal? R1Y { get; set; } + public decimal? R3Y { get; set; } + public decimal? R5Y { get; set; } + public decimal? Sustainability { get; set; } + public decimal? Environmental { get; set; } + public decimal? Social { get; set; } + public decimal? Governance { get; set; } +} + +public class FundReportData +{ + public FundInfo Info { get; set; } = new(); + public bool ShowBranding { get; set; } +} + +public class FundChartPoint +{ + public DateTime Date { get; set; } + public decimal Close { get; set; } +} +``` + +- [ ] **Step 2: Build** + +``` +dotnet build CertReports.Syncfusion +``` +Expected: 0 errors. + +- [ ] **Step 3: Commit** + +``` +git add CertReports.Syncfusion/Models/FundModels.cs +git commit -m "feat: add FundInfo, FundReportData, FundChartPoint models" +``` + +--- + +## Task 2: Interfaces + +**Files:** +- Create: `CertReports.Syncfusion/Services/Interfaces/IFundServices.cs` + +- [ ] **Step 1: Crea il file** + +```csharp +using CertReports.Syncfusion.Models; + +namespace CertReports.Syncfusion.Services.Interfaces; + +public interface IFundDataService +{ + Task GetFundInfoAsync(string isin); + Task> GetChartPricesAsync(string isin); + Task FindIsinByAliasIdAsync(string aliasId); +} + +public interface IFundReportOrchestrator +{ + Task GenerateReportAsync(string isin, bool showBranding = false); +} +``` + +- [ ] **Step 2: Build** + +``` +dotnet build CertReports.Syncfusion +``` +Expected: 0 errors. + +- [ ] **Step 3: Commit** + +``` +git add CertReports.Syncfusion/Services/Interfaces/IFundServices.cs +git commit -m "feat: add IFundDataService, IFundReportOrchestrator interfaces" +``` + +--- + +## Task 3: FundDataService + +**Files:** +- Create: `CertReports.Syncfusion/Services/Implementations/FundDataService.cs` + +**Note:** `GetStringSafe` e `GetSafe` sono extension methods `public static` definiti in `CertificateDataService.cs` (classe `SqlDataReaderExtensions`), disponibili in tutta l'assembly. +SP alias riusa `rpt_FindIsinbyAliasID` (stessa del flusso certificati, param `@AliasID`). +I campi numerici della SP `sfih_GetOptDettagli` possono essere `float` SQL → usare `Convert.ToDecimal(r.GetValue(ord))` tramite `GetSafe` che usa `Convert.ChangeType`. + +- [ ] **Step 1: Crea il file** + +```csharp +using System.Data; +using CertReports.Syncfusion.Models; +using CertReports.Syncfusion.Services.Interfaces; +using Microsoft.Data.SqlClient; + +namespace CertReports.Syncfusion.Services.Implementations; + +public class FundDataService : IFundDataService +{ + private readonly string _connectionString; + private readonly ILogger _logger; + + public FundDataService(IConfiguration config, ILogger logger) + { + _connectionString = config.GetConnectionString("CertDb") + ?? throw new InvalidOperationException("ConnectionString 'CertDb' non configurata."); + _logger = logger; + } + + public async Task GetFundInfoAsync(string isin) + { + try + { + await using var conn = new SqlConnection(_connectionString); + await conn.OpenAsync(); + await using var cmd = new SqlCommand("sfih_GetOptDettagli", conn) + { + CommandType = CommandType.StoredProcedure + }; + cmd.Parameters.AddWithValue("@ISIN", isin); + await using var r = await cmd.ExecuteReaderAsync(); + if (!await r.ReadAsync()) return null; + return MapFundInfo(r); + } + catch (Exception ex) + { + _logger.LogError(ex, "Errore GetFundInfoAsync per ISIN {Isin}", isin); + throw; + } + } + + public async Task> GetChartPricesAsync(string isin) + { + var points = new List(); + try + { + await using var conn = new SqlConnection(_connectionString); + await conn.OpenAsync(); + await using var cmd = new SqlCommand("sfih_GetChartPrices", conn) + { + CommandType = CommandType.StoredProcedure + }; + cmd.Parameters.AddWithValue("@ISIN", isin); + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + { + points.Add(new FundChartPoint + { + Date = r.GetSafe("Px_Date"), + Close = r.GetSafe("Px_Close") + }); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Errore GetChartPricesAsync per ISIN {Isin}", isin); + throw; + } + return points; + } + + public async Task FindIsinByAliasIdAsync(string aliasId) + { + await using var conn = new SqlConnection(_connectionString); + await conn.OpenAsync(); + await using var cmd = new SqlCommand("rpt_FindIsinbyAliasID", conn) + { + CommandType = CommandType.StoredProcedure + }; + cmd.Parameters.AddWithValue("@AliasID", aliasId); + await using var r = await cmd.ExecuteReaderAsync(); + if (!await r.ReadAsync()) return null; + return r.GetStringSafe("ISIN").NullIfEmpty(); + } + + private static FundInfo MapFundInfo(SqlDataReader r) => new() + { + Isin = r.GetStringSafe("isn"), + Strumento = r.GetStringSafe("str"), + Tipo = r.GetStringSafe("typ").NullIfEmpty(), + Societa = r.GetStringSafe("soc").NullIfEmpty(), + CategoriaMorningstar = r.GetStringSafe("msc").NullIfEmpty(), + Valuta = r.GetStringSafe("val").NullIfEmpty(), + Hedged = r.GetStringSafe("hed").NullIfEmpty(), + Benchmark = r.GetStringSafe("bmk").NullIfEmpty(), + Catalogo = r.GetStringSafe("itr").NullIfEmpty(), + Proventi = r.GetStringSafe("prv").NullIfEmpty(), + DataLancio = r.GetSafe("daf") == default ? null : r.GetSafe("daf"), + Patrimonio = r.GetSafe("pat") == 0 ? null : r.GetSafe("pat"), + SpeseCorrenti = r.GetSafe("spc"), + Prezzo = r.GetSafe("prz") == 0 ? null : r.GetSafe("prz"), + DataPrezzo = r.GetSafe("dpz") == default ? null : r.GetSafe("dpz"), + Rank = r.GetSafe("rnk"), + P3M = r.GetSafe("p3M"), P6M = r.GetSafe("p6M"), + PYD = r.GetSafe("pYD"), P1Y = r.GetSafe("p1Y"), + P3Y = r.GetSafe("p3Y"), P5Y = r.GetSafe("p5Y"), + V3M = r.GetSafe("v3M"), V6M = r.GetSafe("v6M"), + VYD = r.GetSafe("vYD"), V1Y = r.GetSafe("v1Y"), + V3Y = r.GetSafe("v3Y"), V5Y = r.GetSafe("v5Y"), + R3M = r.GetSafe("r3M"), R6M = r.GetSafe("r6M"), + RYD = r.GetSafe("rYD"), R1Y = r.GetSafe("r1Y"), + R3Y = r.GetSafe("r3Y"), R5Y = r.GetSafe("r5Y"), + Sustainability = r.GetSafe("sus"), + Environmental = r.GetSafe("env"), + Social = r.GetSafe("ssc"), + Governance = r.GetSafe("gov"), + }; +} + +// Helper locale (non duplica GetStringSafe che restituisce string.Empty) +file static class StringExtensions +{ + public static string? NullIfEmpty(this string s) => + string.IsNullOrEmpty(s) ? null : s; +} +``` + +- [ ] **Step 2: Build** + +``` +dotnet build CertReports.Syncfusion +``` +Expected: 0 errors. + +- [ ] **Step 3: Commit** + +``` +git add CertReports.Syncfusion/Services/Implementations/FundDataService.cs +git commit -m "feat: add FundDataService (sfih_GetOptDettagli + sfih_GetChartPrices)" +``` + +--- + +## Task 4: FundAnagraficaRenderer (Pagina 1) + +**Files:** +- Create: `CertReports.Syncfusion/Services/Implementations/FundAnagraficaRenderer.cs` + +**Gotcha Syncfusion:** `PdfStandardFont` NON è IDisposable — no `using`. `RectangleF` non supporta named args — tutti i parametri posizionali. `Color(r,g,b)` rimosso → `Color.FromArgb(255,r,g,b)`. + +**Layout:** +``` +┌────────────────────── PageW ──────────────────────────┐ +│ BLUE TITLE BAR: {Tipo} — {Strumento} — {ISIN} │ h=22 +├────────────────────────────────────────────────────────┤ +│ [RANK strip w=100] [PREZZO strip w=225] [DATA w=150] │ h=36 gap=8 +├──────────────┬──────────────┬────────────────────────┤ +│ Anagrafici │ ESG Score │ Perf/Vol/R/R grid │ +│ w=190 │ w=125 │ w=PageW-190-125-16 │ +│ (col1X=0) │ (col2X=198) │ (col3X=331) │ +└──────────────┴──────────────┴────────────────────────┘ +│ Footer (branding opzionale + pagina) │ +``` + +- [ ] **Step 1: Crea il file** + +```csharp +using CertReports.Syncfusion.Helpers; +using CertReports.Syncfusion.Models; +using Syncfusion.Pdf; +using Syncfusion.Pdf.Graphics; +using SkiaSharp; + +namespace CertReports.Syncfusion.Services.Implementations; + +public class FundAnagraficaRenderer +{ + private const float PageW = 595f - 2 * PdfTheme.PageMargin; + private const float PageH = 842f - 2 * PdfTheme.PageMargin - PdfTheme.FooterHeight; + private const float ColGap = 8f; + private const float Col1W = 190f; + private const float Col2W = 125f; + private const float Col3W = PageW - Col1W - Col2W - 2 * ColGap; + + public PdfDocument Render(FundReportData data) + { + var doc = new PdfDocument(); + var page = PdfTheme.AddA4Page(doc); + var g = page.Graphics; + var info = data.Info; + float y = 0f; + + y = DrawTitle(g, info, y); + y += 6f; + y = DrawStrip(g, info, y); + y += 8f; + + float col1X = 0f; + float col2X = Col1W + ColGap; + float col3X = col2X + Col2W + ColGap; + + DrawAnagrafici(g, info, col1X, Col1W, y); + DrawEsg(g, info, col2X, Col2W, y); + DrawPerfGrid(g, info, col3X, Col3W, y); + + PdfTheme.DrawFooter(g, PageW, PageH, 1, data.ShowBranding); + return doc; + } + + // ── Title bar ──────────────────────────────────────────────────── + private float DrawTitle(PdfGraphics g, FundInfo info, float y) + { + const float h = 22f; + g.DrawRectangle(PdfTheme.AccentBlueBrush, new RectangleF(0, y, PageW, h)); + var fmt = new PdfStringFormat + { + Alignment = PdfTextAlignment.Center, + LineAlignment = PdfVerticalAlignment.Middle + }; + var title = $"{info.Tipo ?? "Fondo"} — {info.Strumento} — {info.Isin}"; + g.DrawString(title, PdfTheme.Bold, + new PdfSolidBrush(new PdfColor(Color.FromArgb(255, 255, 255, 255))), + new RectangleF(0, y, PageW, h), fmt); + return y + h; + } + + // ── Rank / Prezzo / Data strip ──────────────────────────────────── + private float DrawStrip(PdfGraphics g, FundInfo info, float y) + { + const float h = 36f; + const float w1 = 100f; + const float w2 = 225f; + const float w3 = 150f; + const float gap = 8f; + + DrawStripBox(g, "RATING / RANK", + info.Rank.HasValue ? info.Rank.Value.ToString("F2") : "—", + 0f, y, w1, h); + DrawStripBox(g, "PREZZO", + info.Prezzo.HasValue ? $"{info.Prezzo.Value:F2} {info.Valuta}" : "—", + w1 + gap, y, w2, h); + DrawStripBox(g, "AGGIORNATO AL", + info.DataPrezzo.HasValue ? info.DataPrezzo.Value.ToString("dd/MM/yyyy") : "—", + w1 + gap + w2 + gap, y, w3, h); + + return y + h; + } + + private void DrawStripBox(PdfGraphics g, string label, string value, + float x, float y, float w, float h) + { + var bgColor = new PdfColor(Color.FromArgb(255, 232, 238, 248)); + var penColor = new PdfColor(PdfTheme.AccentBlue); + g.DrawRectangle(new PdfSolidBrush(bgColor), new RectangleF(x, y, w, h)); + g.DrawRectangle(new PdfPen(penColor, 0.5f), new RectangleF(x, y, w, h)); + g.DrawRectangle(PdfTheme.AccentBlueBrush, new RectangleF(x, y, 3f, h)); + var grayBrush = new PdfSolidBrush(new PdfColor(Color.FromArgb(255, 136, 136, 136))); + var labelFmt = new PdfStringFormat { Alignment = PdfTextAlignment.Left }; + g.DrawString(label, PdfTheme.Small, grayBrush, + new RectangleF(x + 6f, y + 2f, w - 8f, 10f), labelFmt); + g.DrawString(value, PdfTheme.Bold, PdfTheme.AccentBlueBrush, + new RectangleF(x + 6f, y + 13f, w - 8f, h - 15f), labelFmt); + } + + // ── Colonna 1: Dati Anagrafici ──────────────────────────────────── + private void DrawAnagrafici(PdfGraphics g, FundInfo info, float x, float w, float y) + { + y = DrawColHeader(g, "Dati Anagrafici", x, w, y); + + var items = new (string Label, string Value)[] + { + ("Società", info.Societa ?? "—"), + ("Categoria MS", info.CategoriaMorningstar ?? "—"), + ("Tipo", info.Tipo ?? "—"), + ("Valuta", info.Valuta ?? "—"), + ("Hedged", string.IsNullOrEmpty(info.Hedged) ? "—" : info.Hedged), + ("Benchmark", info.Benchmark ?? "—"), + ("Spese correnti",info.SpeseCorrenti.HasValue ? $"{info.SpeseCorrenti.Value:F2}%" : "—"), + ("Catalogo", info.Catalogo ?? "—"), + ("Proventi", info.Proventi ?? "—"), + ("Data lancio", info.DataLancio.HasValue ? info.DataLancio.Value.ToString("dd/MM/yyyy") : "—"), + ("Patrimonio", info.Patrimonio.HasValue ? $"{info.Patrimonio.Value:N0} EUR" : "—"), + }; + + DrawKvList(g, items, x, w, y); + } + + // ── Colonna 2: ESG Score ────────────────────────────────────────── + private void DrawEsg(PdfGraphics g, FundInfo info, float x, float w, float y) + { + y = DrawColHeader(g, "ESG Score", x, w, y); + + var scores = new (string Label, decimal? Value)[] + { + ("Sustainability", info.Sustainability), + ("Environmental", info.Environmental), + ("Social", info.Social), + ("Governance", info.Governance), + }; + + var greenBg = new PdfSolidBrush(new PdfColor(Color.FromArgb(255, 232, 245, 233))); + var greenPen = new PdfPen(new PdfColor(Color.FromArgb(255, 200, 230, 201)), 0.5f); + var grayLbl = new PdfSolidBrush(new PdfColor(Color.FromArgb(255, 85, 85, 85))); + var greenVal = new PdfSolidBrush(PdfTheme.PositiveGreen); + + foreach (var (label, val) in scores) + { + const float cardH = 36f; + g.DrawRectangle(greenBg, new RectangleF(x, y, w, cardH)); + g.DrawRectangle(greenPen, new RectangleF(x, y, w, cardH)); + g.DrawString(label, PdfTheme.Small, grayLbl, + new RectangleF(x + 5f, y + 2f, w - 8f, 12f)); + var valText = (val.HasValue && val.Value != 0m) ? val.Value.ToString("F2") : "—"; + g.DrawString(valText, PdfTheme.Bold, greenVal, + new RectangleF(x + 5f, y + 14f, w - 8f, 20f)); + y += cardH + 3f; + } + } + + // ── Colonna 3: Griglia Perf/Vol/R/R ────────────────────────────── + private void DrawPerfGrid(PdfGraphics g, FundInfo info, float x, float w, float y) + { + y = DrawGridHeader(g, "PERFORMANCE · VOLATILITÀ · REND/RISK", x, w, y); + + var periods = new (string Label, decimal? Perf, decimal? Vol, decimal? Rr)[] + { + ("3 Mesi", info.P3M, info.V3M, info.R3M), + ("6 Mesi", info.P6M, info.V6M, info.R6M), + ("Da inizio anno",info.PYD, info.VYD, info.RYD), + ("1 Anno", info.P1Y, info.V1Y, info.R1Y), + ("3 Anni", info.P3Y, info.V3Y, info.R3Y), + ("5 Anni", info.P5Y, info.V5Y, info.R5Y), + }; + + const float cellGap = 3f; + float cellW = (w - 2 * cellGap) / 3f; + const float cellH = 50f; + + for (int i = 0; i < 6; i++) + { + int col = i % 3; + int row = i / 3; + float cx = x + col * (cellW + cellGap); + float cy = y + row * (cellH + cellGap); + var p = periods[i]; + DrawPerfCell(g, p.Label, p.Perf, p.Vol, p.Rr, cx, cy, cellW, cellH); + } + } + + private void DrawPerfCell(PdfGraphics g, string period, + decimal? perf, decimal? vol, decimal? rr, + float x, float y, float w, float h) + { + var cellBg = new PdfSolidBrush(new PdfColor(Color.FromArgb(255, 248, 250, 255))); + var cellPen = new PdfPen(new PdfColor(Color.FromArgb(255, 221, 227, 240)), 0.5f); + g.DrawRectangle(cellBg, new RectangleF(x, y, w, h)); + g.DrawRectangle(cellPen, new RectangleF(x, y, w, h)); + + var center = new PdfStringFormat + { + Alignment = PdfTextAlignment.Center, + LineAlignment = PdfVerticalAlignment.Top + }; + + // Periodo (label blu) + g.DrawString(period, PdfTheme.SmallBold, PdfTheme.AccentBlueBrush, + new RectangleF(x, y + 2f, w, 12f), center); + + // Perf (colorata) + if (perf.HasValue) + { + var perfBrush = perf.Value < 0 + ? new PdfSolidBrush(PdfTheme.NegativeRed) + : new PdfSolidBrush(PdfTheme.PositiveGreen); + g.DrawString($"{perf.Value:F2}%", PdfTheme.TableBold, perfBrush, + new RectangleF(x, y + 14f, w, 12f), center); + } + else + g.DrawString("—", PdfTheme.TableFont, + new PdfSolidBrush(PdfTheme.TextSecondary), + new RectangleF(x, y + 14f, w, 12f), center); + + // Vol + var volText = vol.HasValue ? $"Vol: {vol.Value:F2}%" : "Vol: —"; + g.DrawString(volText, PdfTheme.Small, + new PdfSolidBrush(new PdfColor(Color.FromArgb(255, 85, 85, 85))), + new RectangleF(x, y + 26f, w, 12f), center); + + // Separatore R/R + g.DrawLine(new PdfPen(new PdfColor(Color.FromArgb(255, 238, 238, 238)), 0.5f), + new PointF(x + 4f, y + h - 15f), new PointF(x + w - 4f, y + h - 15f)); + + // RendRisk + var rrText = rr.HasValue ? $"R/R: {rr.Value:F2}" : "R/R: —"; + g.DrawString(rrText, PdfTheme.Small, + new PdfSolidBrush(new PdfColor(Color.FromArgb(255, 136, 136, 136))), + new RectangleF(x, y + h - 14f, w, 12f), center); + } + + // ── Helpers layout ──────────────────────────────────────────────── + private float DrawColHeader(PdfGraphics g, string title, float x, float w, float y) + { + g.DrawRectangle(PdfTheme.AccentBlueBrush, new RectangleF(x, y, 3f, 14f)); + g.DrawString(title, PdfTheme.Bold, new PdfSolidBrush(PdfTheme.AccentBlue), + new RectangleF(x + 6f, y, w - 6f, 14f)); + g.DrawLine(new PdfPen(new PdfColor(PdfTheme.AccentBlue), 0.5f), + new PointF(x, y + 15f), new PointF(x + w, y + 15f)); + return y + 18f; + } + + private float DrawGridHeader(PdfGraphics g, string title, float x, float w, float y) + { + const float h = 16f; + g.DrawRectangle(PdfTheme.AccentBlueBrush, new RectangleF(x, y, w, h)); + var fmt = new PdfStringFormat + { + Alignment = PdfTextAlignment.Left, + LineAlignment = PdfVerticalAlignment.Middle + }; + g.DrawString(title, PdfTheme.SmallBold, + new PdfSolidBrush(new PdfColor(Color.FromArgb(255, 255, 255, 255))), + new RectangleF(x + 4f, y, w - 6f, h), fmt); + return y + h + 4f; + } + + private void DrawKvList(PdfGraphics g, (string Label, string Value)[] items, + float x, float w, float y) + { + float rh = PdfTheme.RowHeight; + float labelW = w * 0.52f; + float valueW = w * 0.48f; + var labelBrush = new PdfSolidBrush(new PdfColor(Color.FromArgb(255, 85, 85, 85))); + var valueBrush = new PdfSolidBrush(new PdfColor(Color.FromArgb(255, 33, 33, 33))); + + foreach (var (label, value) in items) + { + g.DrawString(label + ":", PdfTheme.TableBold, labelBrush, + new RectangleF(x, y, labelW, rh)); + g.DrawString(value, PdfTheme.TableFont, valueBrush, + new RectangleF(x + labelW, y, valueW, rh)); + y += rh; + } + } +} +``` + +- [ ] **Step 2: Build** + +``` +dotnet build CertReports.Syncfusion +``` +Expected: 0 errors. Se `PdfTheme.SmallBold` o `PdfTheme.TextSecondary` non compilano, verificare i nomi esatti in `PdfTheme.cs` e correggere. + +- [ ] **Step 3: Commit** + +``` +git add CertReports.Syncfusion/Services/Implementations/FundAnagraficaRenderer.cs +git commit -m "feat: add FundAnagraficaRenderer (layout C, 3 colonne, griglia perf/vol/rr)" +``` + +--- + +## Task 5: FundSkiaChartRenderer + +**Files:** +- Create: `CertReports.Syncfusion/Services/Implementations/FundSkiaChartRenderer.cs` + +**Gotcha SkiaSharp:** +- `SKFont` NON è IDisposable — no `using` su `SKFont` +- `DrawText` obsoleto → `canvas.DrawText(text, x, y, SKTextAlign.Center, font, paint)` +- Solo `SKPaint`, `SKPath`, `SKSurface`, `SKImage`, `SKData` sono IDisposable + +- [ ] **Step 1: Crea il file** + +```csharp +using CertReports.Syncfusion.Models; +using SkiaSharp; + +namespace CertReports.Syncfusion.Services.Implementations; + +public static class FundSkiaChartRenderer +{ + private const int DefaultWidth = 1100; + private const int DefaultHeight = 700; + private const float MarginLeft = 70f; + private const float MarginRight = 40f; + private const float MarginTop = 55f; + private const float MarginBottom = 55f; + + public static byte[] Render(List points, string instrumentName, + int width = DefaultWidth, int height = DefaultHeight, bool jpeg = false) + { + using var surface = SKSurface.Create(new SKImageInfo(width, height)); + var canvas = surface.Canvas; + canvas.Clear(SKColors.White); + + if (points.Count < 2) + { + DrawNoDataMessage(canvas, width, height, instrumentName); + } + else + { + DrawChart(canvas, points, instrumentName, width, height); + } + + using var image = surface.Snapshot(); + using var data = jpeg + ? image.Encode(SKEncodedImageFormat.Jpeg, 90) + : image.Encode(SKEncodedImageFormat.Png, 100); + return data.ToArray(); + } + + private static void DrawChart(SKCanvas canvas, List points, + string title, int width, int height) + { + float plotX = MarginLeft; + float plotY = MarginTop; + float plotW = width - MarginLeft - MarginRight; + float plotH = height - MarginTop - MarginBottom; + + decimal minPrice = points.Min(p => p.Close); + decimal maxPrice = points.Max(p => p.Close); + decimal priceRange = maxPrice - minPrice; + if (priceRange == 0) priceRange = 1; + decimal pad = priceRange * 0.08m; + decimal yMin = minPrice - pad; + decimal yMax = maxPrice + pad; + + var minDate = points.First().Date; + var maxDate = points.Last().Date; + double totalDays = (maxDate - minDate).TotalDays; + + // ── Sfondo area grafico + using var bgPaint = new SKPaint { Color = new SKColor(250, 250, 255), Style = SKPaintStyle.Fill }; + canvas.DrawRect(plotX, plotY, plotW, plotH, bgPaint); + + // ── Grid + assi + DrawGrid(canvas, plotX, plotY, plotW, plotH, yMin, yMax, minDate, maxDate, totalDays); + + // ── Linea prezzi + using var linePaint = new SKPaint + { + Color = SKColors.Black, + StrokeWidth = 2f, + IsAntialias = true, + Style = SKPaintStyle.Stroke + }; + using var path = new SKPath(); + bool first = true; + foreach (var pt in points) + { + float px = DateToX(pt.Date, minDate, maxDate, plotX, plotW); + float py = PriceToY(pt.Close, yMin, yMax, plotY, plotH); + if (first) { path.MoveTo(px, py); first = false; } + else path.LineTo(px, py); + } + canvas.DrawPath(path, linePaint); + + // ── Label fine linea (ultimo prezzo) + var last = points.Last(); + float lastX = DateToX(last.Date, minDate, maxDate, plotX, plotW); + float lastY = PriceToY(last.Close, yMin, yMax, plotY, plotH); + var labelFont = new SKFont(SKTypeface.Default, 11f); + using var labelPaint = new SKPaint { Color = SKColors.Black, IsAntialias = true }; + canvas.DrawText($"{last.Close:F2}", lastX + 4f, lastY + 4f, SKTextAlign.Left, labelFont, labelPaint); + + // ── Titolo + var titleFont = new SKFont(SKTypeface.Default, 14f); + using var titlePaint = new SKPaint + { + Color = new SKColor(21, 101, 192), + IsAntialias = true, + FakeBoldText = true + }; + canvas.DrawText(title, (float)width / 2, MarginTop - 10f, SKTextAlign.Center, titleFont, titlePaint); + + // ── Cornice area grafico + using var borderPaint = new SKPaint + { + Color = new SKColor(200, 200, 200), + StrokeWidth = 0.8f, + Style = SKPaintStyle.Stroke + }; + canvas.DrawRect(plotX, plotY, plotW, plotH, borderPaint); + } + + private static void DrawGrid(SKCanvas canvas, + float plotX, float plotY, float plotW, float plotH, + decimal yMin, decimal yMax, + DateTime minDate, DateTime maxDate, double totalDays) + { + using var gridPaint = new SKPaint + { + Color = new SKColor(220, 220, 220), + StrokeWidth = 0.5f, + Style = SKPaintStyle.Stroke + }; + using var axisPaint = new SKPaint + { + Color = new SKColor(100, 100, 100), + StrokeWidth = 0.8f, + Style = SKPaintStyle.Stroke + }; + using var axisLabelPaint = new SKPaint + { + Color = new SKColor(100, 100, 100), + IsAntialias = true + }; + var axisLabelFont = new SKFont(SKTypeface.Default, 10f); + + // Y grid (5 linee orizzontali) + for (int i = 0; i <= 5; i++) + { + decimal price = yMin + (yMax - yMin) * i / 5; + float py = PriceToY(price, yMin, yMax, plotY, plotH); + canvas.DrawLine(plotX, py, plotX + plotW, py, i == 0 ? axisPaint : gridPaint); + canvas.DrawText($"{price:F2}", plotX - 5f, py + 4f, + SKTextAlign.Right, axisLabelFont, axisLabelPaint); + } + + // X grid (date labels, intervalli mensili adattivi) + int monthInterval = totalDays > 365 * 3 ? 12 : + totalDays > 365 ? 6 : + totalDays > 90 ? 3 : 1; + + var current = new DateTime(minDate.Year, minDate.Month, 1).AddMonths(1); + while (current <= maxDate) + { + if (current.Month % monthInterval == 0) + { + float px = DateToX(current, minDate, maxDate, plotX, plotW); + canvas.DrawLine(px, plotY, px, plotY + plotH, gridPaint); + var lbl = monthInterval >= 12 + ? current.Year.ToString() + : current.ToString("MMM yy"); + canvas.DrawText(lbl, px, plotY + plotH + 14f, + SKTextAlign.Center, axisLabelFont, axisLabelPaint); + } + current = current.AddMonths(1); + } + } + + private static void DrawNoDataMessage(SKCanvas canvas, int width, int height, string title) + { + var font = new SKFont(SKTypeface.Default, 14f); + using var paint = new SKPaint { Color = new SKColor(150, 150, 150), IsAntialias = true }; + canvas.DrawText($"{title} — Dati insufficienti", + (float)width / 2, (float)height / 2, SKTextAlign.Center, font, paint); + } + + private static float DateToX(DateTime date, DateTime minDate, DateTime maxDate, + float plotX, float plotW) + { + double totalDays = (maxDate - minDate).TotalDays; + if (totalDays == 0) return plotX; + double ratio = (date - minDate).TotalDays / totalDays; + return plotX + (float)(ratio * plotW); + } + + private static float PriceToY(decimal price, decimal yMin, decimal yMax, + float plotY, float plotH) + { + if (yMax == yMin) return plotY + plotH / 2; + double ratio = (double)((price - yMin) / (yMax - yMin)); + return plotY + plotH - (float)(ratio * plotH); + } +} +``` + +- [ ] **Step 2: Build** + +``` +dotnet build CertReports.Syncfusion +``` +Expected: 0 errors. + +- [ ] **Step 3: Commit** + +``` +git add CertReports.Syncfusion/Services/Implementations/FundSkiaChartRenderer.cs +git commit -m "feat: add FundSkiaChartRenderer (SkiaSharp line chart, price history)" +``` + +--- + +## Task 6: FundChartSectionRenderer (Pagina 2 del PDF report) + +**Files:** +- Create: `CertReports.Syncfusion/Services/Implementations/FundChartSectionRenderer.cs` + +- [ ] **Step 1: Crea il file** + +```csharp +using CertReports.Syncfusion.Services.Interfaces; +using Syncfusion.Pdf; +using Syncfusion.Pdf.Graphics; + +namespace CertReports.Syncfusion.Services.Implementations; + +public class FundChartSectionRenderer +{ + private readonly IFundDataService _dataService; + private readonly ILogger _logger; + + public FundChartSectionRenderer(IFundDataService dataService, + ILogger logger) + { + _dataService = dataService; + _logger = logger; + } + + public async Task RenderAsync(string isin, string instrumentName) + { + try + { + var points = await _dataService.GetChartPricesAsync(isin); + var pngBytes = FundSkiaChartRenderer.Render(points, instrumentName, + width: 1100, height: 650); + return WrapInPdf(pngBytes); + } + catch (Exception ex) + { + _logger.LogError(ex, "Errore FundChartSectionRenderer per ISIN {Isin}", isin); + return null; + } + } + + private static PdfDocument WrapInPdf(byte[] pngBytes) + { + var doc = new PdfDocument(); + doc.PageSettings.Size = PdfPageSize.A4; + doc.PageSettings.Orientation = PdfPageOrientation.Landscape; + doc.PageSettings.Margins.All = 20; + var page = doc.Pages.Add(); + var g = page.Graphics; + float w = page.GetClientSize().Width; + float h = page.GetClientSize().Height; + + using var imgStream = new MemoryStream(pngBytes); + var pdfImage = PdfImage.FromStream(imgStream); + g.DrawImage(pdfImage, new RectangleF(0, 0, w, h)); + return doc; + } +} +``` + +- [ ] **Step 2: Build** + +``` +dotnet build CertReports.Syncfusion +``` +Expected: 0 errors. + +- [ ] **Step 3: Commit** + +``` +git add CertReports.Syncfusion/Services/Implementations/FundChartSectionRenderer.cs +git commit -m "feat: add FundChartSectionRenderer (wraps SkiaSharp chart as PDF page)" +``` + +--- + +## Task 7: FundReportOrchestrator + +**Files:** +- Create: `CertReports.Syncfusion/Services/Implementations/FundReportOrchestrator.cs` + +**Cache key pattern:** `fund:{isin}` oppure `fund:{isin}:branded` (uguale al pattern certificati ma con prefisso `fund:`). + +- [ ] **Step 1: Crea il file** + +```csharp +using CertReports.Syncfusion.Models; +using CertReports.Syncfusion.Services.Interfaces; +using Syncfusion.Pdf; + +namespace CertReports.Syncfusion.Services.Implementations; + +public class FundReportOrchestrator : IFundReportOrchestrator +{ + private readonly IFundDataService _dataService; + private readonly FundAnagraficaRenderer _anagraficaRenderer; + private readonly FundChartSectionRenderer _chartRenderer; + private readonly IPdfMergerService _merger; + private readonly IPdfCacheService _cache; + private readonly ILogger _logger; + + public FundReportOrchestrator( + IFundDataService dataService, + FundAnagraficaRenderer anagraficaRenderer, + FundChartSectionRenderer chartRenderer, + IPdfMergerService merger, + IPdfCacheService cache, + ILogger logger) + { + _dataService = dataService; + _anagraficaRenderer = anagraficaRenderer; + _chartRenderer = chartRenderer; + _merger = merger; + _cache = cache; + _logger = logger; + } + + public async Task GenerateReportAsync(string isin, bool showBranding = false) + { + var cacheKey = showBranding ? $"fund:{isin}:branded" : $"fund:{isin}"; + var cached = _cache.Get(cacheKey); + if (cached != null) + { + _logger.LogInformation("Fund report per {Isin} servito da cache ({Size} bytes)", isin, cached.Length); + return cached; + } + + _logger.LogInformation("Generazione fund report per {Isin}", isin); + + var info = await _dataService.GetFundInfoAsync(isin); + if (info == null) + throw new InvalidOperationException($"Nessun dato trovato per ISIN {isin}"); + + var reportData = new FundReportData { Info = info, ShowBranding = showBranding }; + + var sections = new List(); + + // Pagina 1: Anagrafica + sections.Add(_anagraficaRenderer.Render(reportData)); + _logger.LogInformation("Sezione 'FundAnagrafica' generata per {Isin}", isin); + + // Pagina 2: Grafico + var chartDoc = await _chartRenderer.RenderAsync(isin, info.Strumento); + if (chartDoc != null) + { + sections.Add(chartDoc); + _logger.LogInformation("Sezione 'FundChart' generata per {Isin}", isin); + } + else + { + _logger.LogWarning("Sezione 'FundChart' omessa per {Isin} (dati insufficienti)", isin); + } + + var finalPdf = _merger.Merge(sections); + _cache.Set(cacheKey, finalPdf); + _logger.LogInformation("Fund report generato per {Isin}: {Size} bytes", isin, finalPdf.Length); + return finalPdf; + } +} +``` + +- [ ] **Step 2: Build** + +``` +dotnet build CertReports.Syncfusion +``` +Expected: 0 errors. + +- [ ] **Step 3: Commit** + +``` +git add CertReports.Syncfusion/Services/Implementations/FundReportOrchestrator.cs +git commit -m "feat: add FundReportOrchestrator (anagrafica + chart + cache)" +``` + +--- + +## Task 8: FundReportController + +**Files:** +- Create: `CertReports.Syncfusion/Controllers/FundReportController.cs` + +**Pattern:** identico a `ReportController`. Alias lookup tramite `IFundDataService.FindIsinByAliasIdAsync`. Decrypt ISIN tramite `CryptoHelper` iniettato. Helper privato `GenerateAndReturnPdf` riduce duplicazione tra inline e download. + +- [ ] **Step 1: Crea il file** + +```csharp +using CertReports.Syncfusion.Helpers; +using CertReports.Syncfusion.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace CertReports.Syncfusion.Controllers; + +/// +/// Endpoint: +/// GET /api/report/fund/by-isin/{isin} +/// GET /api/report/fund?p={isin_cifrato} +/// GET /api/report/fund?alias={id} +/// GET /api/report/fund/download?p={isin_cifrato} +/// +[ApiController] +[Route("api/report/fund")] +public class FundReportController : ControllerBase +{ + private readonly IFundReportOrchestrator _orchestrator; + private readonly IFundDataService _dataService; + private readonly CryptoHelper _crypto; + private readonly ILogger _logger; + + public FundReportController( + IFundReportOrchestrator orchestrator, + IFundDataService dataService, + CryptoHelper crypto, + ILogger logger) + { + _orchestrator = orchestrator; + _dataService = dataService; + _crypto = crypto; + _logger = logger; + } + + [HttpGet("by-isin/{isin}")] + public async Task ByIsin(string isin, + [FromQuery] bool branding = false) + => await GenerateAndReturnPdf(isin, branding, inline: true); + + [HttpGet("")] + public async Task ByQuery( + [FromQuery] string? p = null, + [FromQuery] string? alias = null, + [FromQuery] bool branding = false) + { + string? isin = null; + if (!string.IsNullOrEmpty(p)) + { + try { isin = _crypto.DecryptIsin(p); } + catch (Exception ex) + { + _logger.LogWarning(ex, "Errore decodifica ISIN cifrato (fund)"); + return BadRequest("Parametro 'p' non valido."); + } + } + else if (!string.IsNullOrEmpty(alias)) + { + isin = await _dataService.FindIsinByAliasIdAsync(alias); + } + + if (string.IsNullOrEmpty(isin)) + return BadRequest("Specificare 'p' (ISIN cifrato) o 'alias'."); + + return await GenerateAndReturnPdf(isin, branding, inline: true); + } + + [HttpGet("download")] + public async Task Download( + [FromQuery] string? p = null, + [FromQuery] string? alias = null, + [FromQuery] bool branding = false) + { + string? isin = null; + if (!string.IsNullOrEmpty(p)) + { + try { isin = _crypto.DecryptIsin(p); } + catch (Exception ex) + { + _logger.LogWarning(ex, "Errore decodifica ISIN cifrato (fund download)"); + return BadRequest("Parametro 'p' non valido."); + } + } + else if (!string.IsNullOrEmpty(alias)) + { + isin = await _dataService.FindIsinByAliasIdAsync(alias); + } + + if (string.IsNullOrEmpty(isin)) + return BadRequest("Specificare 'p' (ISIN cifrato) o 'alias'."); + + return await GenerateAndReturnPdf(isin, branding, inline: false); + } + + private async Task GenerateAndReturnPdf(string isin, bool branding, bool inline) + { + try + { + var pdfBytes = await _orchestrator.GenerateReportAsync(isin, branding); + var disposition = inline ? "inline" : "attachment"; + Response.Headers.Append("Content-Disposition", + $"{disposition}; filename=fund_{isin}.pdf"); + return File(pdfBytes, "application/pdf"); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("Nessun dato")) + { + _logger.LogWarning("ISIN fondo non trovato: {Isin}", isin); + return NotFound($"Nessun dato per ISIN {isin}"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Errore generazione fund report per ISIN {Isin}", isin); + return StatusCode(500, "Errore nella generazione del report."); + } + } +} +``` + +- [ ] **Step 2: Build** + +``` +dotnet build CertReports.Syncfusion +``` +Expected: 0 errors. + +- [ ] **Step 3: Commit** + +``` +git add CertReports.Syncfusion/Controllers/FundReportController.cs +git commit -m "feat: add FundReportController (4 endpoint /api/report/fund/)" +``` + +--- + +## Task 9: FundChartController + +**Files:** +- Create: `CertReports.Syncfusion/Controllers/FundChartController.cs` + +**Pattern:** identico a `ChartController` per la parte V2. `WrapPngInPdf` è private static nel controller. `?save=true` salva in `ChartSettings:SavePath\{isin}_fund.jpg`. + +- [ ] **Step 1: Crea il file** + +```csharp +using CertReports.Syncfusion.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; +using Syncfusion.Pdf; +using Syncfusion.Pdf.Graphics; + +namespace CertReports.Syncfusion.Controllers; + +/// +/// Endpoint: +/// GET /api/chart/fund/{isin}[?format=png|jpg|jpeg|pdf&width=&height=&save=true] +/// +[ApiController] +[Route("api/chart/fund")] +public class FundChartController : ControllerBase +{ + private readonly IFundDataService _dataService; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public FundChartController( + IFundDataService dataService, + IConfiguration configuration, + ILogger logger) + { + _dataService = dataService; + _configuration = configuration; + _logger = logger; + } + + [HttpGet("{isin}")] + public async Task GetChart( + string isin, + [FromQuery] string format = "png", + [FromQuery] int width = 1100, + [FromQuery] int height = 700, + [FromQuery] bool save = false) + { + if (string.IsNullOrWhiteSpace(isin)) + return BadRequest("ISIN richiesto."); + + try + { + var info = await _dataService.GetFundInfoAsync(isin); + var points = await _dataService.GetChartPricesAsync(isin); + + if (info == null || points.Count == 0) + return NotFound($"Nessun dato per ISIN {isin}"); + + bool isJpeg = format.Equals("jpg", StringComparison.OrdinalIgnoreCase) + || format.Equals("jpeg", StringComparison.OrdinalIgnoreCase); + + if (format.Equals("pdf", StringComparison.OrdinalIgnoreCase)) + { + var pngBytes = FundSkiaChartRenderer.Render(points, info.Strumento, width, height); + var pdfBytes = WrapPngInPdf(pngBytes); + Response.Headers.Append("Content-Disposition", + $"inline; filename=chart_fund_{isin}.pdf"); + return File(pdfBytes, "application/pdf"); + } + + var imgBytes = FundSkiaChartRenderer.Render(points, info.Strumento, width, height, jpeg: isJpeg); + + if (save && isJpeg) + await SaveChartToDiskAsync(isin, imgBytes); + + if (isJpeg) + { + Response.Headers.Append("Content-Disposition", + $"inline; filename=chart_fund_{isin}.jpg"); + return File(imgBytes, "image/jpeg"); + } + + Response.Headers.Append("Content-Disposition", + $"inline; filename=chart_fund_{isin}.png"); + return File(imgBytes, "image/png"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Errore generazione chart fondo per ISIN {Isin}", isin); + return StatusCode(500, new { status = "KO", message = "Errore nella generazione del grafico." }); + } + } + + private async Task SaveChartToDiskAsync(string isin, byte[] imgBytes) + { + var savePath = _configuration["ChartSettings:SavePath"]; + if (string.IsNullOrEmpty(savePath)) return; + try + { + var fullPath = Path.Combine(savePath, $"{isin}_fund.jpg"); + await File.WriteAllBytesAsync(fullPath, imgBytes); + _logger.LogInformation("Chart fondo salvato in {Path}", fullPath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Salvataggio chart fondo fallito per {Isin}", isin); + } + } + + private static byte[] WrapPngInPdf(byte[] pngBytes) + { + var doc = new PdfDocument(); + doc.PageSettings.Size = PdfPageSize.A4; + doc.PageSettings.Orientation = PdfPageOrientation.Landscape; + doc.PageSettings.Margins.All = 30; + var page = doc.Pages.Add(); + float w = page.GetClientSize().Width; + float h = page.GetClientSize().Height; + using var ms = new MemoryStream(pngBytes); + var pdfImage = PdfImage.FromStream(ms); + page.Graphics.DrawImage(pdfImage, new RectangleF(0, 0, w, h)); + using var outMs = new MemoryStream(); + doc.Save(outMs); + doc.Close(true); + return outMs.ToArray(); + } +} +``` + +- [ ] **Step 2: Build** + +``` +dotnet build CertReports.Syncfusion +``` +Expected: 0 errors. + +- [ ] **Step 3: Commit** + +``` +git add CertReports.Syncfusion/Controllers/FundChartController.cs +git commit -m "feat: add FundChartController (/api/chart/fund/{isin})" +``` + +--- + +## Task 10: DI Registration + Verifica finale + +**Files:** +- Modify: `CertReports.Syncfusion/Program.cs` + +- [ ] **Step 1: Aggiungere le registrazioni dopo quelle esistenti** + +Aprire `Program.cs`. Trovare il blocco `// Registra i servizi applicativi` e aggiungere **in fondo** (prima di `var app = builder.Build();`): + +```csharp +// ── Fund/ETF report services ─────────────────────────────────────────── +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +``` + +- [ ] **Step 2: Build completo** + +``` +dotnet build CertReports.Syncfusion +``` +Expected: 0 errors, 0 warnings rilevanti. + +- [ ] **Step 3: Avviare l'app** + +``` +dotnet run --project CertReports.Syncfusion +``` + +- [ ] **Step 4: Verificare i nuovi endpoint** + +Con un ISIN fondo valido (es. `IE00BDVPNG13`): + +``` +# Report PDF inline +GET https://localhost:{port}/api/report/fund/by-isin/IE00BDVPNG13 + +# Grafico PNG +GET https://localhost:{port}/api/chart/fund/IE00BDVPNG13 + +# Grafico JPEG con salvataggio +GET https://localhost:{port}/api/chart/fund/IE00BDVPNG13?format=jpg&save=true + +# Grafico PDF +GET https://localhost:{port}/api/chart/fund/IE00BDVPNG13?format=pdf +``` + +Verificare: +- Il PDF report apre correttamente (2 pagine: anagrafica + grafico) +- Pagina 1: title bar blu, strip Rank/Prezzo/Data, 3 colonne (Anagrafici | ESG | griglia perf) +- Pagina 2: grafico landscape con linea prezzi +- Il grafico standalone PNG/JPEG/PDF funziona +- I vecchi endpoint `/api/report/by-isin/{isin}` (certificati) restano invariati + +- [ ] **Step 5: Verifica che i certificati non siano stati rotti** + +``` +GET https://localhost:{port}/api/report/by-isin/{ISIN_certificato_valido} +``` +Expected: PDF certificato generato correttamente. + +- [ ] **Step 6: Commit finale** + +``` +git add CertReports.Syncfusion/Program.cs +git commit -m "feat: register fund report services in DI (Program.cs)" +``` + +--- + +## Note implementative + +- **`PdfTheme.SmallBold`**: se non esiste in PdfTheme (verificare `PdfTheme.cs`), sostituire con `PdfTheme.Bold` o aggiungere `public static PdfFont SmallBold => _fontSmallBold;` in PdfTheme se `_fontSmallBold` è già definito. +- **`PdfTheme.TextSecondary`**: confermato da code search, è un `PdfColor`. +- **`PdfTheme.PageMargin` e `PdfTheme.FooterHeight`**: confermati da code search, usati esattamente come in `AnagraficaSectionRenderer`. +- **`GetSafe`**: restituisce `default(DateTime)` se NULL — controllare con `== default` per gestire i null come mostrato in `MapFundInfo`. +- **`FundChartController.WrapPngInPdf`**: usa `doc.Save(outMs)` + `doc.Close(true)` per estrarre i byte, a differenza di `FundChartSectionRenderer.WrapInPdf` che restituisce il `PdfDocument` aperto per il merge. Non mescolare i due pattern.