Compare commits

...

19 Commits

Author SHA1 Message Date
9d8acbae6b docs: update README with fund/ETF report feature (endpoints, layout, architecture)
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-08 18:19:22 +02:00
9a73ae9a1b fix: negative perf color, parallel SP calls in chart controller, sort comment 2026-06-08 18:01:20 +02:00
201b3b39c4 feat: register fund report services in DI (Program.cs) 2026-06-08 17:57:10 +02:00
3e6c5c6dff feat: add FundChartController (/api/chart/fund/{isin})
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-08 17:54:45 +02:00
14bc7f772b refactor: extract ResolveIsinAsync helper in FundReportController 2026-06-08 17:53:18 +02:00
b3024f953d feat: add FundReportController (4 endpoint /api/report/fund/) 2026-06-08 17:51:52 +02:00
461cffa442 feat: add FundReportOrchestrator (anagrafica + chart + merge + cache) 2026-06-08 17:49:59 +02:00
5347424e42 feat: add FundChartSectionRenderer (chart PNG wrapped as PDF landscape page) 2026-06-08 17:47:21 +02:00
d26ca9f7bd fix: correct Y-grid to 5 lines, add defensive date sort in GetChartPricesAsync 2026-06-08 17:46:26 +02:00
7dbcb8ab16 feat: add FundSkiaChartRenderer (SkiaSharp price line chart)
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-08 17:44:50 +02:00
8e6341d980 feat: add FundAnagraficaRenderer (layout C: title strip + 3 columns)
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-08 17:41:48 +02:00
d3c5395c58 fix: add error handling to FindIsinByAliasIdAsync, use NullIfEmpty 2026-06-08 17:39:02 +02:00
bbb7e4207a feat: add FundDataService (sfih_GetOptDettagli + sfih_GetChartPrices) 2026-06-08 17:37:26 +02:00
fa1fc94fd8 feat: add IFundDataService, IFundReportOrchestrator interfaces 2026-06-08 17:35:59 +02:00
8d9f9272f1 style: use string.Empty convention, add SP comments to FundModels 2026-06-08 17:35:25 +02:00
7331142a36 feat: add FundInfo, FundReportData, FundChartPoint models
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-08 17:33:31 +02:00
d90212c206 docs: add fund report implementation plan (10 tasks, FundAnagraficaRenderer + FundSkiaChartRenderer + orchestrator)
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-08 17:29:41 +02:00
f1825a1b13 docs: add sfih_GetChartPrices SP details to fund report spec
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-08 17:20:43 +02:00
86c8c8b3f3 docs: add fund/ETF report design spec (layout C, endpoints, models)
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-08 17:10:47 +02:00
13 changed files with 2744 additions and 5 deletions

View File

@@ -0,0 +1,145 @@
using CertReports.Syncfusion.Services.Implementations;
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.");
width = Math.Clamp(width, 400, 2000);
height = Math.Clamp(height, 300, 1500);
try
{
var infoTask = _dataService.GetFundInfoAsync(isin);
var pointsTask = _dataService.GetChartPricesAsync(isin);
await Task.WhenAll(infoTask, pointsTask);
var info = infoTask.Result;
var points = pointsTask.Result;
if (points.Count == 0)
return NotFound($"Nessun dato per ISIN {isin}");
var chartTitle = info?.Strumento ?? isin;
bool isJpeg = format.Equals("jpg", StringComparison.OrdinalIgnoreCase)
|| format.Equals("jpeg", StringComparison.OrdinalIgnoreCase);
if (format.Equals("pdf", StringComparison.OrdinalIgnoreCase))
{
var pngBytes = FundSkiaChartRenderer.Render(points, chartTitle, 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, chartTitle, 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)
{
try
{
string? folder = _configuration["ChartSettings:SavePath"];
if (string.IsNullOrEmpty(folder))
{
_logger.LogWarning("Salvataggio chart fondo saltato: ChartSettings:SavePath non configurato");
return;
}
Directory.CreateDirectory(folder);
var fullPath = Path.Combine(folder, $"{isin}_fund.jpg");
await System.IO.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();
var g = page.Graphics;
float pw = page.GetClientSize().Width;
float ph = page.GetClientSize().Height;
using var stream = new MemoryStream(pngBytes);
var img = new PdfBitmap(stream);
float ratio = (float)img.Width / img.Height;
float dw = pw;
float dh = dw / ratio;
if (dh > ph) { dh = ph; dw = dh * ratio; }
float x = (pw - dw) / 2;
float y = (ph - dh) / 2;
g.DrawImage(img, x, y, dw, dh);
using var output = new MemoryStream();
doc.Save(output);
doc.Close(true);
return output.ToArray();
}
}

View File

@@ -0,0 +1,110 @@
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)
{
var (isin, error) = await ResolveIsinAsync(p, alias, "fund");
if (error != null) return error;
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)
{
var (isin, error) = await ResolveIsinAsync(p, alias, "fund download");
if (error != null) return error;
if (string.IsNullOrEmpty(isin))
return BadRequest("Specificare 'p' (ISIN cifrato) o 'alias'.");
return await GenerateAndReturnPdf(isin, branding, inline: false);
}
private async Task<(string? Isin, IActionResult? Error)> ResolveIsinAsync(
string? p, string? alias, string context)
{
if (!string.IsNullOrEmpty(p))
{
try
{
return (_crypto.DecryptIsin(p), null);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Errore decodifica ISIN cifrato ({Context})", context);
return (null, BadRequest("Parametro 'p' non valido."));
}
}
if (!string.IsNullOrEmpty(alias))
{
var isin = await _dataService.FindIsinByAliasIdAsync(alias);
return (isin, null);
}
return (null, null);
}
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.");
}
}
}

View File

@@ -0,0 +1,57 @@
namespace CertReports.Syncfusion.Models;
// SP: sfih_GetOptDettagli
public class FundInfo
{
public string Isin { get; set; } = string.Empty;
public string Strumento { get; set; } = string.Empty;
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; }
}
// SP: sfih_GetChartPrices
public class FundChartPoint
{
public DateTime Date { get; set; }
public decimal Close { get; set; }
}

View File

@@ -46,6 +46,12 @@ builder.Services.AddScoped<IReportOrchestrator, ReportOrchestrator>();
builder.Services.AddSingleton<IPdfCacheService, PdfCacheService>(); builder.Services.AddSingleton<IPdfCacheService, PdfCacheService>();
builder.Services.AddSingleton<CryptoHelper>(); builder.Services.AddSingleton<CryptoHelper>();
// ── Fund/ETF report services ───────────────────────────────────────────
builder.Services.AddScoped<IFundDataService, FundDataService>();
builder.Services.AddScoped<FundAnagraficaRenderer>();
builder.Services.AddScoped<FundChartSectionRenderer>();
builder.Services.AddScoped<IFundReportOrchestrator, FundReportOrchestrator>();
var app = builder.Build(); var app = builder.Build();
// ── Middleware Pipeline ──────────────────────────────────────────────── // ── Middleware Pipeline ────────────────────────────────────────────────

View File

@@ -0,0 +1,266 @@
using CertReports.Syncfusion.Helpers;
using CertReports.Syncfusion.Models;
using Syncfusion.Drawing;
using Syncfusion.Pdf;
using Syncfusion.Pdf.Graphics;
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;
}
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;
}
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 bgBrush = new PdfSolidBrush(new PdfColor(Color.FromArgb(255, 232, 238, 248)));
var borderPen = new PdfPen(new PdfColor(PdfTheme.AccentBlue), 0.5f);
g.DrawRectangle(bgBrush, new RectangleF(x, y, w, h));
g.DrawRectangle(borderPen, 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 leftFmt = new PdfStringFormat { Alignment = PdfTextAlignment.Left };
g.DrawString(label, PdfTheme.Small, grayBrush,
new RectangleF(x + 6f, y + 2f, w - 8f, 10f), leftFmt);
g.DrawString(value, PdfTheme.Bold, PdfTheme.AccentBlueBrush,
new RectangleF(x + 6f, y + 13f, w - 8f, h - 15f), leftFmt);
}
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);
}
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(new PdfColor(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;
}
}
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
};
g.DrawString(period, PdfTheme.SmallBold, PdfTheme.AccentBlueBrush,
new RectangleF(x, y + 2f, w, 12f), center);
if (perf.HasValue)
{
var perfBrush = perf.Value < 0
? new PdfSolidBrush(new PdfColor(PdfTheme.NegativeRed))
: new PdfSolidBrush(new PdfColor(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(new PdfColor(PdfTheme.TextSecondary)),
new RectangleF(x, y + 14f, w, 12f), center);
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);
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));
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);
}
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(new PdfColor(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;
}
}
}

View File

@@ -0,0 +1,52 @@
using CertReports.Syncfusion.Services.Interfaces;
using Syncfusion.Drawing;
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;
}
}

View File

@@ -0,0 +1,133 @@
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;
}
points.Sort((a, b) => a.Date.CompareTo(b.Date)); // defensive: SP returns ASC but enforce here
return points;
}
public async Task<string?> FindIsinByAliasIdAsync(string aliasId)
{
try
{
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();
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore FindIsinByAliasIdAsync per AliasID {AliasId}", aliasId);
throw;
}
}
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") == 0m ? null : r.GetSafe<decimal>("pat"),
SpeseCorrenti = r.GetSafe<decimal>("spc"),
Prezzo = r.GetSafe<decimal>("prz") == 0m ? 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"),
};
}
file static class StringExtensions
{
public static string? NullIfEmpty(this string s) =>
string.IsNullOrEmpty(s) ? null : s;
}

View File

@@ -0,0 +1,70 @@
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>();
sections.Add(_anagraficaRenderer.Render(reportData));
_logger.LogInformation("Sezione 'FundAnagrafica' generata per {Isin}", isin);
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 o errore)", isin);
}
var finalPdf = _merger.Merge(sections);
_cache.Set(cacheKey, finalPdf);
_logger.LogInformation("Fund report generato per {Isin}: {Size} bytes", isin, finalPdf.Length);
return finalPdf;
}
}

View File

@@ -0,0 +1,188 @@
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;
// Plot area background
using var bgPaint = new SKPaint { Color = new SKColor(250, 250, 255), Style = SKPaintStyle.Fill };
canvas.DrawRect(plotX, plotY, plotW, plotH, bgPaint);
DrawGrid(canvas, plotX, plotY, plotW, plotH, yMin, yMax, minDate, maxDate, totalDays);
// Price line
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);
// End label (last price)
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);
// Chart title
var titleFont = new SKFont(SKTypeface.Default, 14f) { Embolden = true };
using var titlePaint = new SKPaint
{
Color = new SKColor(21, 101, 192),
IsAntialias = true
};
canvas.DrawText(title, (float)width / 2, MarginTop - 10f,
SKTextAlign.Center, titleFont, titlePaint);
// Plot border
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 horizontal lines)
for (int i = 0; i < 5; i++)
{
decimal price = yMin + (yMax - yMin) * i / 4;
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 (adaptive monthly intervals)
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);
}
}

View File

@@ -0,0 +1,15 @@
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);
}

View File

@@ -5,12 +5,13 @@ Generatore di report PDF per certificati finanziari strutturati, sviluppato in A
## Caratteristiche principali ## Caratteristiche principali
- Generazione PDF multi-sezione da dati SQL Server - Generazione PDF multi-sezione da dati SQL Server
- **Due template report**: certificati in quotazione (4 sezioni) e certificati non in quotazione / scaduti / rimborsati / revocati (3 sezioni) — rilevamento automatico dal campo `Stato` - **Due template report certificati**: in quotazione (4 sezioni) e non in quotazione / scaduti / rimborsati / revocati (3 sezioni) — rilevamento automatico dal campo `Stato`
- **Pagina dividendi opzionale** (`?dividend=true`): pagina landscape con tabella Sottostanti+Dividendi a header raggruppati (SOTTOSTANTE / BARRIERE / DIVIDENDI), inserita dopo la Sezione 1 - **Report ETF/Fondi**: 2 pagine — anagrafica con layout a 3 colonne (Dati Anagrafici, ESG Score, griglia Performance/Volatilità/RendRisk) + grafico storico prezzi
- **Pagina dividendi opzionale** (`?dividend=true`): pagina landscape con tabella Sottostanti+Dividendi a header raggruppati
- Grafico performance sottostanti (SkiaSharp → PNG in memoria) - Grafico performance sottostanti (SkiaSharp → PNG in memoria)
- Footer con branding opzionale e hyperlink cliccabile - Footer con branding opzionale e hyperlink cliccabile
- Cache in memoria per ISIN già generati (chiavi separate per ogni combinazione di flag: suffissi `:branded`, `:dividend`, `:natixis` concatenati) - Cache in memoria per ISIN già generati (chiavi separate per ogni combinazione di flag)
- Endpoint per PDF inline, download, grafico standalone v1 e **grafico V2 con multi-formato e salvataggio su disco** - Endpoint per PDF inline, download, grafico standalone v1, grafico V2 multi-formato e **grafico fondi**
- Supporto Docker - Supporto Docker
## Stack tecnologico ## Stack tecnologico
@@ -46,8 +47,18 @@ Generatore di report PDF per certificati finanziari strutturati, sviluppato in A
Il tipo di report viene selezionato automaticamente dall'orchestratore in base al campo `Stato` restituito dalla SP `rpt_Master_CFT_ISIN`. Il tipo di report viene selezionato automaticamente dall'orchestratore in base al campo `Stato` restituito dalla SP `rpt_Master_CFT_ISIN`.
### Report ETF/Fondi — 2 pagine
| Pagina | Contenuto |
|--------|-----------|
| **1 — Anagrafica** | Title bar con Tipo/Nome/ISIN; strip Rank+Prezzo+Data; 3 colonne: **Dati Anagrafici** (Società, Categoria MS, Valuta, Hedged, Benchmark, Spese, Catalogo, Proventi, Data lancio, Patrimonio) · **ESG Score** (Sustainability, Environmental, Social, Governance) · **Griglia Performance/Volatilità/RendRisk** (3M, 6M, YTD, 1Y, 3Y, 5Y) |
| **2 — Grafico** | Storico prezzi close (linea singola, SkiaSharp), asse X con intervalli mensili adattivi |
SP: `sfih_GetOptDettagli` (anagrafica) · `sfih_GetChartPrices` (prezzi storici).
## API Endpoints ## API Endpoints
**Certificati:**
``` ```
GET /api/report/by-isin/{ISIN} → PDF inline GET /api/report/by-isin/{ISIN} → PDF inline
GET /api/report?p={isin_cifrato} → PDF inline (ISIN cifrato) GET /api/report?p={isin_cifrato} → PDF inline (ISIN cifrato)
@@ -56,10 +67,23 @@ GET /api/report/download?p={...} → PDF come allegato
GET /api/chart/{isin} → Grafico standalone v1 (PNG/PDF) GET /api/chart/{isin} → Grafico standalone v1 (PNG/PDF)
GET /api/chart/v2/{isin} → Grafico standalone v2 (multi-formato, vedi sotto) GET /api/chart/v2/{isin} → Grafico standalone v2 (multi-formato, vedi sotto)
```
**ETF/Fondi:**
```
GET /api/report/fund/by-isin/{ISIN} → PDF inline
GET /api/report/fund?p={isin_cifrato} → PDF inline (ISIN cifrato)
GET /api/report/fund?alias={id} → PDF inline (alias)
GET /api/report/fund/download?p={...} → PDF come allegato
GET /api/chart/fund/{isin} → Grafico storico prezzi (PNG/JPEG/PDF)
```
```
GET /health → Health check DB + chart service GET /health → Health check DB + chart service
``` ```
Parametri opzionali accettati da tutti gli endpoint report: Parametri opzionali accettati da tutti gli endpoint report certificati:
| Parametro | Default | Effetto | | Parametro | Default | Effetto |
|-----------|---------|---------| |-----------|---------|---------|
@@ -69,6 +93,23 @@ Parametri opzionali accettati da tutti gli endpoint report:
I parametri sono combinabili, es. `?branding=true&dividend=true&natixis=true`. I parametri sono combinabili, es. `?branding=true&dividend=true&natixis=true`.
Parametri opzionali accettati dagli endpoint report **ETF/Fondi**:
| Parametro | Default | Effetto |
|-----------|---------|---------|
| `?branding=true` | `false` | Aggiunge footer "Powered by Smart Roots" con hyperlink |
### Grafico Fondi — parametri
```
GET /api/chart/fund/{isin}[?format=png|jpg|jpeg|pdf&width=&height=&save=true]
```
| Parametro | Valori | Effetto |
|-----------|--------|---------|
| `?format=` | `png` (default), `jpg`/`jpeg`, `pdf` | Formato output |
| `?save=true` | — | Salva JPEG su disco (`ChartSettings:SavePath/{isin}_fund.jpg`) — solo per `jpg`/`jpeg` |
### Grafico V2 — parametri ### Grafico V2 — parametri
``` ```
@@ -161,6 +202,19 @@ HTTP (ISIN) → ReportController → ReportOrchestrator
HTTP (ISIN) → ChartController → /api/chart/v2/{isin} HTTP (ISIN) → ChartController → /api/chart/v2/{isin}
├── ChartDataServiceV2 (cedlab_Chart_UL1 + cedlab_Chart_AllSeriesV2) ├── ChartDataServiceV2 (cedlab_Chart_UL1 + cedlab_Chart_AllSeriesV2)
└── SkiaChartRendererV2 → PNG/JPEG/PDF → Response (+ salvataggio disco se ?save=true) └── SkiaChartRendererV2 → PNG/JPEG/PDF → Response (+ salvataggio disco se ?save=true)
HTTP (ISIN) → FundReportController → /api/report/fund/
├── FundDataService (sfih_GetOptDettagli + sfih_GetChartPrices)
├── FundReportOrchestrator
│ ├── FundAnagraficaRenderer → PdfDocument (Pagina 1: layout C)
│ ├── FundChartSectionRenderer → PdfDocument (Pagina 2: grafico landscape)
│ │ └── FundSkiaChartRenderer [static] → PNG in memoria
│ └── PdfMergerService → byte[] → Response
└── PdfCacheService (chiave fund:{isin}[:branded])
HTTP (ISIN) → FundChartController → /api/chart/fund/{isin}
├── FundDataService (sfih_GetOptDettagli + sfih_GetChartPrices, in parallelo)
└── FundSkiaChartRenderer [static] → PNG/JPEG/PDF → Response (+ salvataggio disco se ?save=true)
``` ```
--- ---

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,249 @@
# Design Spec — Report ETF/Fondi
**Data:** 2026-06-08
**Scope:** Nuovo report PDF a 2 pagine per ETF/fondi, con endpoint dedicati `/api/report/fund/` e `/api/chart/fund/`. Indipendente dal flusso certificati.
---
## 1. Endpoints
### Report PDF
| Metodo | Route | Comportamento |
|--------|-------|---------------|
| GET | `/api/report/fund/by-isin/{isin}` | PDF inline |
| GET | `/api/report/fund?p={isin_cifrato}` | PDF inline (ISIN cifrato, stesso CryptoHelper) |
| GET | `/api/report/fund?alias={id}` | PDF inline (alias → ISIN via `FindIsinByAliasIdAsync`) |
| GET | `/api/report/fund/download?p={...}` | PDF come allegato (`Content-Disposition: attachment`) |
### Grafico standalone
| Metodo | Route | Comportamento |
|--------|-------|---------------|
| GET | `/api/chart/fund/{isin}` | PNG/PDF inline (vedi §4) |
### Parametri opzionali (tutti gli endpoint report)
| Parametro | Default | Effetto |
|-----------|---------|---------|
| `?branding=true` | `false` | Footer "Powered by [Smart Roots](https://www.smart-roots.net)" — riusa `PdfTheme.DrawFooter` già esistente |
### Parametri opzionali (endpoint chart)
| Parametro | Default | Effetto |
|-----------|---------|---------|
| `?format=png\|pdf\|jpg\|jpeg` | `png` | Formato output |
| `?save=true` | `false` | Salva JPEG su disco (`ChartSettings:SavePath`) |
---
## 2. Architettura
```
HTTP → FundReportController
├── FundDataService sfih_GetOptDettagli @ISIN → FundInfo
├── FundReportOrchestrator
│ ├── FundAnagraficaRenderer → PdfDocument (Pagina 1)
│ ├── FundChartRenderer → PdfDocument (Pagina 2, placeholder)
│ └── PdfMergerService → byte[] (riusato)
└── PdfCacheService → cache chiave "fund:{isin}[:branded]"
HTTP → FundChartController
├── FundChartDataService SP da definire → FundChartData
└── FundSkiaChartRenderer → PNG/JPEG/PDF
```
**Riuso componenti esistenti:** `PdfMergerService`, `PdfCacheService`, `PdfTheme` (incluso `DrawFooter`), `CryptoHelper`.
**Nessuna modifica** ai controller/renderer/service certificati esistenti.
---
## 3. Pagina 1 — Anagrafica (Layout C)
### Struttura visiva
```
┌─────────────────────────────────────────────────────────┐
│ [BLUE BAR] {typ} — {str} — {isn} │
├──────────────────────────────────────────────────────────┤
│ [RANK strip] rnk │ [PREZZO strip] prz val │ dpz │
├──────────────────────────────────────────────────────────┤
│ Dati Anagrafici │ ESG Score │ Perf·Vol·R/R grid │
│ (flex 1.3) │ (flex 0.9) │ (flex 1.4) │
└──────────────────────────────────────────────────────────┘
│ footer: [Powered by Smart Roots]? pagina N │
```
### Mapping campi SP → sezioni
**Titolo:**
- `{typ} — {str} — {isn}` (es. `ETF — WisdomTree … — IE00BDVPNG13`)
**Strip Rank/Prezzo:**
- Rank: `rnk`
- Prezzo: `prz` + `val`
- Data aggiornamento: `dpz` (formato `dd/MM/yyyy`)
**Dati Anagrafici** (tabella label/valore):
| Label | Campo SP |
|-------|----------|
| Società | `soc` |
| Categoria MS | `msc` |
| Tipo | `typ` |
| Valuta | `val` |
| Hedged | `hed` (→ `"sì"` / `"no"`) |
| Benchmark | `bmk` |
| Spese correnti | `spc` (→ formato `0.##%`) |
| Catalogo | `itr` |
| Proventi | `prv` |
| Data lancio | `daf` (formato `dd/MM/yyyy`) |
| Patrimonio | `pat` (formato `#,##0 EUR`) |
**ESG Score** (4 card verdi verticali):
- Sustainability: `sus`
- Environmental: `env`
- Social: `ssc`
- Governance: `gov`
- Se un singolo valore è `0` (AMC/fallback), viene mostrato come `"—"`. La sezione ESG è sempre visibile.
**Griglia Performance/Volatilità/RendRisk** (3×2):
| Periodo | Perf | Vol | R/R |
|---------|------|-----|-----|
| 3 Mesi | `p3M` | `v3M` | `r3M` |
| 6 Mesi | `p6M` | `v6M` | `r6M` |
| Da inizio anno | `pYD` | `vYD` | `rYD` |
| 1 Anno | `p1Y` | `v1Y` | `r1Y` |
| 3 Anni | `p3Y` | `v3Y` | `r3Y` |
| 5 Anni | `p5Y` | `v5Y` | `r5Y` |
Colori: Perf positiva → `PositiveGreenBrush`, negativa → `NegativeRedBrush`, Vol e R/R → testo normale.
---
## 4. Pagina 2 — Grafico
**SP:** `sfih_GetChartPrices @ISIN VARCHAR(20)`
Restituisce `Px_Close (decimal)` e `Px_Date (date)` ordinati ASC, solo valori non NULL.
`FundChartRenderer` (pagina 2 nel PDF report):
- Singola linea nera sul close price storico
- Asse X: date (intervalli mensili adattivi come chart V2)
- Asse Y: scala automatica su min/max dei prezzi
- Titolo: `"Andamento Prezzo — {str}"` + sottotitolo con valuta
- Se la SP restituisce < 2 punti, la pagina mostra un messaggio "Dati insufficienti"
`FundSkiaChartRenderer` (endpoint standalone `/api/chart/fund/{isin}`):
- Stessa logica di rendering, output PNG/JPEG/PDF
- Supporta `?format=png|jpg|jpeg|pdf`, `?width=`, `?height=`, `?save=true`
- `?save=true` salva JPEG in `ChartSettings:SavePath\{isin}_fund.jpg`
- Nessuna logica WorstOf/barriere/label complesse (renderer dedicato, non V2)
---
## 5. Modelli
**File:** `Models/FundModels.cs`
```csharp
// Mapping 1:1 con result set di sfih_GetOptDettagli
public class FundInfo
{
public string Isin { get; set; } // isn
public string Strumento { get; set; } // str
public string Tipo { get; set; } // typ
public string Societa { get; set; } // soc
public string CategoriaMorningstar { get; set; } // msc
public string Valuta { get; set; } // val
public string Hedged { get; set; } // hed
public string Benchmark { get; set; } // bmk
public string Catalogo { get; set; } // itr
public string Proventi { get; set; } // prv
public DateTime? DataLancio { get; set; } // daf
public decimal? Patrimonio { get; set; } // pat
public decimal? SpeseCorrenti { get; set; } // spc
public decimal? Prezzo { get; set; } // prz
public DateTime? DataPrezzo { get; set; } // dpz
public decimal? Rank { get; set; } // rnk
// Performance
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; }
// Volatilità
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; }
// RendRisk
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; }
// ESG
public decimal? Sustainability { get; set; } // sus
public decimal? Environmental { get; set; } // env
public decimal? Social { get; set; } // ssc
public decimal? Governance { get; set; } // gov
}
public class FundReportData
{
public FundInfo Info { get; set; }
public bool ShowBranding { get; set; }
}
```
---
## 6. Interfacce
```csharp
// IFundDataService
Task<FundInfo?> GetFundInfoAsync(string isin);
Task<string?> FindIsinByAliasIdAsync(string aliasId); // riusa stessa logica
// IFundReportOrchestrator
Task<byte[]> GenerateReportAsync(string isin, bool showBranding = false);
```
---
## 7. Registrazioni Program.cs
```csharp
builder.Services.AddScoped<IFundDataService, FundDataService>();
builder.Services.AddScoped<IFundReportOrchestrator, FundReportOrchestrator>();
builder.Services.AddScoped<FundAnagraficaRenderer>();
builder.Services.AddScoped<FundChartRenderer>(); // placeholder
builder.Services.AddScoped<FundSkiaChartRenderer>(); // per /api/chart/fund/
```
---
## 8. Cache
Chiave pattern: `fund:{isin}` + suffisso `:branded` se `showBranding=true`.
Riusa il `PdfCacheService` esistente (stesso TTL da `appsettings.json`).
---
## 9. File da creare
| File | Descrizione |
|------|-------------|
| `Models/FundModels.cs` | `FundInfo`, `FundReportData`, `FundChartData` (placeholder) |
| `Services/Interfaces/IFundServices.cs` | `IFundDataService`, `IFundReportOrchestrator` |
| `Services/Implementations/FundDataService.cs` | Chiama `sfih_GetOptDettagli` |
| `Services/Implementations/FundAnagraficaRenderer.cs` | Pagina 1 layout C |
| `Services/Implementations/FundChartRenderer.cs` | Pagina 2 placeholder |
| `Services/Implementations/FundReportOrchestrator.cs` | Coordina anagrafica + chart + merge + cache |
| `Services/Implementations/FundSkiaChartRenderer.cs` | Rendering grafico (placeholder, SP da aggiungere) |
| `Controllers/FundReportController.cs` | 4 endpoint `/api/report/fund/` |
| `Controllers/FundChartController.cs` | 1 endpoint `/api/chart/fund/` |
---
## 10. Fuori scope (questa iterazione)
- Parametri `?natixis`, `?dividend` — non applicabili ai fondi
- Test automatici (nessun test nel progetto al momento)