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