Files
SmartReports/docs/superpowers/plans/2026-06-08-fund-report.md

50 KiB

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 + FundChartControllerFundReportOrchestratorFundDataService (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

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

using CertReports.Syncfusion.Models;

namespace CertReports.Syncfusion.Services.Interfaces;

public interface IFundDataService
{
    Task<FundInfo?> GetFundInfoAsync(string isin);
    Task<List<FundChartPoint>> GetChartPricesAsync(string isin);
    Task<string?> FindIsinByAliasIdAsync(string aliasId);
}

public interface IFundReportOrchestrator
{
    Task<byte[]> 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<T> 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<decimal> che usa Convert.ChangeType.

  • Step 1: Crea il file
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<FundDataService> _logger;

    public FundDataService(IConfiguration config, ILogger<FundDataService> logger)
    {
        _connectionString = config.GetConnectionString("CertDb")
            ?? throw new InvalidOperationException("ConnectionString 'CertDb' non configurata.");
        _logger = logger;
    }

    public async Task<FundInfo?> 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<List<FundChartPoint>> GetChartPricesAsync(string isin)
    {
        var points = new List<FundChartPoint>();
        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<DateTime>("Px_Date"),
                    Close = r.GetSafe<decimal>("Px_Close")
                });
            }
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Errore GetChartPricesAsync per ISIN {Isin}", isin);
            throw;
        }
        return points;
    }

    public async Task<string?> 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<DateTime>("daf") == default ? null : r.GetSafe<DateTime>("daf"),
        Patrimonio          = r.GetSafe<decimal>("pat") == 0 ? null : r.GetSafe<decimal>("pat"),
        SpeseCorrenti       = r.GetSafe<decimal>("spc"),
        Prezzo              = r.GetSafe<decimal>("prz") == 0 ? null : r.GetSafe<decimal>("prz"),
        DataPrezzo          = r.GetSafe<DateTime>("dpz") == default ? null : r.GetSafe<DateTime>("dpz"),
        Rank                = r.GetSafe<decimal>("rnk"),
        P3M = r.GetSafe<decimal>("p3M"), P6M = r.GetSafe<decimal>("p6M"),
        PYD = r.GetSafe<decimal>("pYD"), P1Y = r.GetSafe<decimal>("p1Y"),
        P3Y = r.GetSafe<decimal>("p3Y"), P5Y = r.GetSafe<decimal>("p5Y"),
        V3M = r.GetSafe<decimal>("v3M"), V6M = r.GetSafe<decimal>("v6M"),
        VYD = r.GetSafe<decimal>("vYD"), V1Y = r.GetSafe<decimal>("v1Y"),
        V3Y = r.GetSafe<decimal>("v3Y"), V5Y = r.GetSafe<decimal>("v5Y"),
        R3M = r.GetSafe<decimal>("r3M"), R6M = r.GetSafe<decimal>("r6M"),
        RYD = r.GetSafe<decimal>("rYD"), R1Y = r.GetSafe<decimal>("r1Y"),
        R3Y = r.GetSafe<decimal>("r3Y"), R5Y = r.GetSafe<decimal>("r5Y"),
        Sustainability = r.GetSafe<decimal>("sus"),
        Environmental  = r.GetSafe<decimal>("env"),
        Social         = r.GetSafe<decimal>("ssc"),
        Governance     = r.GetSafe<decimal>("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
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

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<FundChartPoint> 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<FundChartPoint> 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

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<FundChartSectionRenderer> _logger;

    public FundChartSectionRenderer(IFundDataService dataService,
        ILogger<FundChartSectionRenderer> logger)
    {
        _dataService = dataService;
        _logger      = logger;
    }

    public async Task<PdfDocument?> 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
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<FundReportOrchestrator> _logger;

    public FundReportOrchestrator(
        IFundDataService dataService,
        FundAnagraficaRenderer anagraficaRenderer,
        FundChartSectionRenderer chartRenderer,
        IPdfMergerService merger,
        IPdfCacheService cache,
        ILogger<FundReportOrchestrator> logger)
    {
        _dataService        = dataService;
        _anagraficaRenderer = anagraficaRenderer;
        _chartRenderer      = chartRenderer;
        _merger             = merger;
        _cache              = cache;
        _logger             = logger;
    }

    public async Task<byte[]> 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<PdfDocument>();

        // 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
using CertReports.Syncfusion.Helpers;
using CertReports.Syncfusion.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;

namespace CertReports.Syncfusion.Controllers;

/// <summary>
/// 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}
/// </summary>
[ApiController]
[Route("api/report/fund")]
public class FundReportController : ControllerBase
{
    private readonly IFundReportOrchestrator _orchestrator;
    private readonly IFundDataService _dataService;
    private readonly CryptoHelper _crypto;
    private readonly ILogger<FundReportController> _logger;

    public FundReportController(
        IFundReportOrchestrator orchestrator,
        IFundDataService dataService,
        CryptoHelper crypto,
        ILogger<FundReportController> logger)
    {
        _orchestrator = orchestrator;
        _dataService  = dataService;
        _crypto       = crypto;
        _logger       = logger;
    }

    [HttpGet("by-isin/{isin}")]
    public async Task<IActionResult> ByIsin(string isin,
        [FromQuery] bool branding = false)
        => await GenerateAndReturnPdf(isin, branding, inline: true);

    [HttpGet("")]
    public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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
using CertReports.Syncfusion.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
using Syncfusion.Pdf;
using Syncfusion.Pdf.Graphics;

namespace CertReports.Syncfusion.Controllers;

/// <summary>
/// Endpoint:
///   GET /api/chart/fund/{isin}[?format=png|jpg|jpeg|pdf&amp;width=&amp;height=&amp;save=true]
/// </summary>
[ApiController]
[Route("api/chart/fund")]
public class FundChartController : ControllerBase
{
    private readonly IFundDataService _dataService;
    private readonly IConfiguration _configuration;
    private readonly ILogger<FundChartController> _logger;

    public FundChartController(
        IFundDataService dataService,
        IConfiguration configuration,
        ILogger<FundChartController> logger)
    {
        _dataService    = dataService;
        _configuration  = configuration;
        _logger         = logger;
    }

    [HttpGet("{isin}")]
    public async Task<IActionResult> 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();):

// ── Fund/ETF report services ───────────────────────────────────────────
builder.Services.AddScoped<IFundDataService, FundDataService>();
builder.Services.AddScoped<FundAnagraficaRenderer>();
builder.Services.AddScoped<FundChartSectionRenderer>();
builder.Services.AddScoped<IFundReportOrchestrator, FundReportOrchestrator>();
  • 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<DateTime>: 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.