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 + 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
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:
-
SKFontNON è IDisposable — nousingsuSKFont -
DrawTextobsoleto →canvas.DrawText(text, x, y, SKTextAlign.Center, font, paint) -
Solo
SKPaint,SKPath,SKSurface,SKImage,SKDatasono 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&width=&height=&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 (verificarePdfTheme.cs), sostituire conPdfTheme.Boldo aggiungerepublic static PdfFont SmallBold => _fontSmallBold;in PdfTheme se_fontSmallBoldè già definito.PdfTheme.TextSecondary: confermato da code search, è unPdfColor.PdfTheme.PageMarginePdfTheme.FooterHeight: confermati da code search, usati esattamente come inAnagraficaSectionRenderer.GetSafe<DateTime>: restituiscedefault(DateTime)se NULL — controllare con== defaultper gestire i null come mostrato inMapFundInfo.FundChartController.WrapPngInPdf: usadoc.Save(outMs)+doc.Close(true)per estrarre i byte, a differenza diFundChartSectionRenderer.WrapInPdfche restituisce ilPdfDocumentaperto per il merge. Non mescolare i due pattern.