Compare commits

..

12 Commits

Author SHA1 Message Date
2546990556 fix: set Analisi section width to match Caratteristiche Prodotto (ColW) 2026-03-21 11:42:57 +01:00
7916cbc93b fix: align Analisi section left, always show KV labels, update expired events table columns 2026-03-21 11:38:07 +01:00
f3e0a8254a fix: add Stato empty warning log and fix y-increment when Categoria is empty
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:49:03 +01:00
2e320e6511 feat: register ExpiredAnagraficaSectionRenderer in DI 2026-03-21 10:46:06 +01:00
8c35cb5127 fix: add try/catch error logging in expired flow renderers 2026-03-21 10:45:25 +01:00
b60956db9a feat: add expired certificate report branching in orchestrator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:38:41 +01:00
571df139e5 fix: capture DrawAnalisi return value in ExpiredAnagraficaSectionRenderer 2026-03-21 10:36:10 +01:00
3cca41a77b feat: add ExpiredAnagraficaSectionRenderer for non-quoted certificates
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:33:18 +01:00
7ad14c129c feat: add Stato field to CertificateInfo model and map from SP 2026-03-21 10:30:13 +01:00
26ebd320e5 docs: add expired certificate report implementation plan 2026-03-21 10:27:51 +01:00
cdbdfeede1 docs: update expired report spec after review (cache keys, orchestrator logic, chart signature) 2026-03-21 10:21:20 +01:00
71d6a4c32d docs: add expired certificate report design spec 2026-03-21 10:17:53 +01:00
8 changed files with 1205 additions and 47 deletions

View File

@@ -75,6 +75,10 @@ public class CertificateInfo
public string TriggerOneStar { get; set; } = string.Empty; public string TriggerOneStar { get; set; } = string.Empty;
public string Note { get; set; } = string.Empty; public string Note { get; set; } = string.Empty;
// Stato quotazione (da SP rpt_Master_CFT_ISIN)
// Valori: "Quotazione" | "Revocato" | "Scaduto" | "Rimborsato"
public string Stato { get; set; } = string.Empty;
// Sottostanti (da SP separata: rpt_Details_UL_ISIN) // Sottostanti (da SP separata: rpt_Details_UL_ISIN)
public List<Sottostante> Sottostanti { get; set; } = new(); public List<Sottostante> Sottostanti { get; set; } = new();
} }

View File

@@ -44,6 +44,7 @@ builder.Services.AddScoped<IChartDataService, ChartDataService>();
builder.Services.AddScoped<IPdfSectionRenderer, AnagraficaSectionRenderer>(); builder.Services.AddScoped<IPdfSectionRenderer, AnagraficaSectionRenderer>();
builder.Services.AddScoped<IPdfSectionRenderer, EventiSectionRenderer>(); builder.Services.AddScoped<IPdfSectionRenderer, EventiSectionRenderer>();
builder.Services.AddScoped<IPdfSectionRenderer, ScenarioSectionRenderer>(); builder.Services.AddScoped<IPdfSectionRenderer, ScenarioSectionRenderer>();
builder.Services.AddScoped<ExpiredAnagraficaSectionRenderer>();
builder.Services.AddScoped<IChartSectionRenderer, ChartSectionRenderer>(); builder.Services.AddScoped<IChartSectionRenderer, ChartSectionRenderer>();
builder.Services.AddScoped<IPdfMergerService, PdfMergerService>(); builder.Services.AddScoped<IPdfMergerService, PdfMergerService>();
builder.Services.AddScoped<IReportOrchestrator, ReportOrchestrator>(); builder.Services.AddScoped<IReportOrchestrator, ReportOrchestrator>();

View File

@@ -98,6 +98,7 @@ public class CertificateDataService : ICertificateDataService
info.CpnDaPagare = r.GetNullableDecimal("CpnDaPagare"); info.CpnDaPagare = r.GetNullableDecimal("CpnDaPagare");
info.CpnPagati = r.GetNullableDecimal("CpnPagati"); info.CpnPagati = r.GetNullableDecimal("CpnPagati");
info.RendimentoAttuale = r.GetStringSafe("RendimentoAttuale"); info.RendimentoAttuale = r.GetStringSafe("RendimentoAttuale");
info.Stato = r.GetStringSafe("Stato");
} }
} }

View File

@@ -38,13 +38,39 @@ public class EventiSectionRenderer : IPdfSectionRenderer
var grid = new PdfGrid(); var grid = new PdfGrid();
grid.Style.CellPadding = new PdfPaddings(3, 3, 2, 2); grid.Style.CellPadding = new PdfPaddings(3, 3, 2, 2);
// Colonne mappate sui campi della SP rpt_Events_CFT_ISIN bool isExpired = !string.IsNullOrEmpty(data.Info.Stato) && data.Info.Stato != "Quotazione";
string[] headers =
// Colonne e larghezze dipendono dal tipo di certificato
string[] headers;
float[] cw;
int paidColIndex;
if (isExpired)
{
// Certificati non in quotazione: senza Ex Date e Record,
// rinominati Trigger Cedola→Barriera Cedola e Trigger Autocall→Soglia Rimborso,
// aggiunte Barriera Capitale e Rimborso Capitale
headers = new[]
{
"Osservazione", "Pagamento",
"Barriera Cedola", "Cedola %", "Pagato", "Memoria",
"Importo Pagato", "Soglia Rimborso", "Valore Autocall",
"Barriera Capitale", "Rimborso Capitale"
};
cw = new float[] { 62, 58, 52, 46, 36, 46, 52, 58, 52, 58, 58 };
paidColIndex = 4;
}
else
{
headers = new[]
{ {
"Osservazione", "Ex Date", "Record", "Pagamento", "Osservazione", "Ex Date", "Record", "Pagamento",
"Trigger Cedola", "Cedola %", "Pagato", "Memoria", "Trigger Cedola", "Cedola %", "Pagato", "Memoria",
"Importo Pagato", "Trigger Autocall", "Valore Autocall" "Importo Pagato", "Trigger Autocall", "Valore Autocall"
}; };
cw = new float[] { 62, 52, 52, 58, 52, 46, 36, 46, 52, 58, 52 };
paidColIndex = 6;
}
foreach (var _ in headers) grid.Columns.Add(); foreach (var _ in headers) grid.Columns.Add();
@@ -65,6 +91,22 @@ public class EventiSectionRenderer : IPdfSectionRenderer
var evt = data.Eventi[i]; var evt = data.Eventi[i];
var row = grid.Rows.Add(); var row = grid.Rows.Add();
if (isExpired)
{
row.Cells[0].Value = evt.ObservationDate;
row.Cells[1].Value = evt.PaymentDate;
row.Cells[2].Value = evt.CouponTrigger;
row.Cells[3].Value = evt.CouponValue;
row.Cells[4].Value = evt.Paid;
row.Cells[5].Value = evt.Memory;
row.Cells[6].Value = evt.AmountPaid;
row.Cells[7].Value = evt.AutocallTrigger;
row.Cells[8].Value = evt.AutocallValue;
row.Cells[9].Value = evt.CapitalTrigger;
row.Cells[10].Value = evt.CapitalValue;
}
else
{
row.Cells[0].Value = evt.ObservationDate; row.Cells[0].Value = evt.ObservationDate;
row.Cells[1].Value = evt.ExDate; row.Cells[1].Value = evt.ExDate;
row.Cells[2].Value = evt.RecordDate; row.Cells[2].Value = evt.RecordDate;
@@ -74,10 +116,9 @@ public class EventiSectionRenderer : IPdfSectionRenderer
row.Cells[6].Value = evt.Paid; row.Cells[6].Value = evt.Paid;
row.Cells[7].Value = evt.Memory; row.Cells[7].Value = evt.Memory;
row.Cells[8].Value = evt.AmountPaid; row.Cells[8].Value = evt.AmountPaid;
//row.Cells[9].Value = evt.CapitalTrigger;
//row.Cells[10].Value = evt.CapitalValue;
row.Cells[9].Value = evt.AutocallTrigger; row.Cells[9].Value = evt.AutocallTrigger;
row.Cells[10].Value = evt.AutocallValue; row.Cells[10].Value = evt.AutocallValue;
}
foreach (var cell in row.Cells.OfType<PdfGridCell>()) foreach (var cell in row.Cells.OfType<PdfGridCell>())
{ {
@@ -92,11 +133,10 @@ public class EventiSectionRenderer : IPdfSectionRenderer
// Evidenzia "SI" nella colonna Pagato // Evidenzia "SI" nella colonna Pagato
if (evt.Paid == "SI") if (evt.Paid == "SI")
row.Cells[6].Style.TextBrush = PdfTheme.PositiveBrush as PdfBrush; row.Cells[paidColIndex].Style.TextBrush = PdfTheme.PositiveBrush as PdfBrush;
} }
// Larghezze colonne (landscape A4 ~ 757 punti utili) // Larghezze colonne (landscape A4 ~ 757 punti utili)
float[] cw = { 62, 52, 52, 58, 52, 46, 36, 46, 52, 58, 52 };
float total = cw.Sum(); float total = cw.Sum();
float scale = w / total; float scale = w / total;
for (int i = 0; i < cw.Length; i++) for (int i = 0; i < cw.Length; i++)

View File

@@ -0,0 +1,237 @@
using CertReports.Syncfusion.Helpers;
using CertReports.Syncfusion.Models;
using CertReports.Syncfusion.Services.Interfaces;
using Syncfusion.Drawing;
using Syncfusion.Pdf;
using Syncfusion.Pdf.Graphics;
namespace CertReports.Syncfusion.Services.Implementations;
/// <summary>
/// Sezione 1 — Prima pagina del report per certificati non in quotazione
/// (Scaduto, Rimborsato, Revocato). Struttura semplificata rispetto al report attivo:
/// solo Titolo + Caratteristiche Prodotto + Analisi. Niente Bid/Ask, niente Sottostanti.
/// </summary>
public class ExpiredAnagraficaSectionRenderer : IPdfSectionRenderer
{
public string SectionName => "ExpiredAnagrafica";
public int Order => 1;
private const float PageW = 595f - 2 * PdfTheme.PageMargin;
private const float PageH = 842f - 2 * PdfTheme.PageMargin - PdfTheme.FooterHeight;
private const float ColGap = 10f;
private const float ColW = (PageW - ColGap) / 2f;
private const float SectionGap = 10f;
public PdfDocument Render(CertificateReportData data)
{
var doc = new PdfDocument();
var page = PdfTheme.AddA4Page(doc);
var g = page.Graphics;
var info = data.Info;
float y = 0f;
// ── TITOLO ────────────────────────────────────────────────────
y = DrawTitle(g, info, PageW, y);
// ── SEZIONE A: CARATTERISTICHE PRODOTTO ───────────────────────
y = DrawSectionLabel(g, "Caratteristiche Prodotto", y);
y = DrawCaratteristiche(g, info, y);
y += SectionGap;
// ── SEZIONE B: ANALISI ────────────────────────────────────────
y = DrawSectionLabel(g, "Analisi", y);
y = DrawAnalisi(g, info, y);
PdfTheme.DrawFooter(g, PageW, PageH, 1, data.ShowBranding);
return doc;
}
// ═══════════════════════════════════════════════════════════════
// TITOLO — Solo Tipologia box (niente Data/Bid/Ask)
// ═══════════════════════════════════════════════════════════════
private float DrawTitle(PdfGraphics g, CertificateInfo info, float w, float y)
{
g.DrawString($"Scheda Prodotto {info.Isin}",
PdfTheme.SectionTitleFont,
new PdfSolidBrush(PdfTheme.AccentBlue),
new RectangleF(0, y, w, 20f),
new PdfStringFormat(PdfTextAlignment.Center));
y += 24f;
g.DrawLine(PdfTheme.AccentBluePen, 0, y, w, y);
y += 8f;
// Solo box Tipologia, niente Data/Bid/Ask
const float boxH = 28f;
if (!string.IsNullOrEmpty(info.Categoria))
{
DrawInfoBox(g, 0, y, w, boxH,
"TIPOLOGIA", info.Categoria,
PdfTheme.BoxLightBlueBgBrush, PdfTheme.AccentBlueBrush,
PdfTheme.AccentBlueBrush, PdfTheme.AccentBlueDarkBrush,
PdfTheme.Bold);
y += boxH + 10f;
}
return y;
}
// ═══════════════════════════════════════════════════════════════
// INTESTAZIONE DI SEZIONE
// ═══════════════════════════════════════════════════════════════
private float DrawSectionLabel(PdfGraphics g, string title, float y)
{
g.DrawRectangle(PdfTheme.AccentBlueBrush, new RectangleF(0, y, 3f, 14f));
g.DrawString(title, PdfTheme.Bold,
new PdfSolidBrush(PdfTheme.AccentBlue),
new RectangleF(6f, y, PageW, 14f));
return y + 16f;
}
// ═══════════════════════════════════════════════════════════════
// INFO BOX
// ═══════════════════════════════════════════════════════════════
private void DrawInfoBox(
PdfGraphics g,
float x, float y, float w, float boxH,
string label, string value,
PdfBrush bgBrush, PdfBrush accentBrush,
PdfBrush labelBrush, PdfBrush valueBrush,
PdfFont valueFont)
{
g.DrawRectangle(bgBrush, new RectangleF(x, y, w, boxH));
g.DrawRectangle(accentBrush, new RectangleF(x, y, 4f, boxH));
float innerX = x + 4f + 6f;
float innerW = w - 4f - 6f - 4f;
g.DrawString(label, PdfTheme.Small, labelBrush,
new RectangleF(innerX, y + 4f, innerW, 10f));
g.DrawString(value, valueFont, valueBrush,
new RectangleF(innerX, y + 14f, innerW, 14f));
}
// ═══════════════════════════════════════════════════════════════
// SEZIONE A: CARATTERISTICHE PRODOTTO
// ═══════════════════════════════════════════════════════════════
private float DrawCaratteristiche(PdfGraphics g, CertificateInfo info, float y)
{
return DrawEmittenteTable(g, info, 0, ColW, y);
}
private float DrawEmittenteTable(PdfGraphics g, CertificateInfo info, float x, float w, float y)
{
float rh = PdfTheme.RowHeight;
float pad = PdfTheme.CellPadding;
// Header blu
g.DrawRectangle(PdfTheme.TableHeaderBrush, new RectangleF(x, y, w, rh));
string headerText = string.IsNullOrEmpty(info.Emittente)
? "EMITTENTE"
: $"EMITTENTE {info.Emittente.ToUpper()}";
g.DrawString(headerText, PdfTheme.TableBold,
PdfTheme.HeaderTextBrush,
new RectangleF(x + pad, y + 3f, w - pad * 2, rh));
y += rh;
var rows = new[]
{
("ISIN", info.Isin),
("Mercato", info.Mercato),
("Valuta", info.Valuta),
("Data Emissione", info.DataEmissione),
("Valore Rimborso", info.ValoreRimborso),
("Data Rimborso", info.DataRimborso),
};
for (int i = 0; i < rows.Length; i++)
{
var (label, value) = rows[i];
if (string.IsNullOrWhiteSpace(value)) continue;
bool alt = i % 2 == 1;
if (alt)
g.DrawRectangle(new PdfSolidBrush(PdfTheme.TableAltRow),
new RectangleF(x, y, w, rh));
g.DrawLine(PdfTheme.TableBorderPen, x, y, x + w, y);
g.DrawString(label, PdfTheme.TableFont,
new PdfSolidBrush(PdfTheme.TextSecondary),
new RectangleF(x + pad, y + 2f, w * 0.5f, rh));
var valueBrush = label == "ISIN"
? new PdfSolidBrush(PdfTheme.AccentBlue)
: new PdfSolidBrush(PdfTheme.TextPrimary);
g.DrawString(value, PdfTheme.TableBold, valueBrush,
new RectangleF(x + w * 0.5f, y + 2f, w * 0.5f - pad, rh),
new PdfStringFormat(PdfTextAlignment.Right));
y += rh;
}
g.DrawLine(PdfTheme.TableBorderPen, x, y, x + w, y);
return y + 2f;
}
// ═══════════════════════════════════════════════════════════════
// SEZIONE B: ANALISI — KV list allineata a sinistra, larghezza piena
// Le etichette sono sempre visibili; valore "-" se assente dal DB.
// ═══════════════════════════════════════════════════════════════
private float DrawAnalisi(PdfGraphics g, CertificateInfo info, float y)
{
var items = new (string Label, string Value)[]
{
("Importo Cedola (p.a.)", info.NominalAnnualYield),
("Frequenza Cedola", info.FrequenzaCedole),
("Valore Nominale", info.NominalValue?.ToString("N0") ?? string.Empty),
("Memoria Cedola", info.Memory),
("Tipo Barriera", info.BarrierType),
("Tipo Basket", info.BasketType),
("Rendimento Totale", info.RendimentoTotale),
};
// Posizione x=0, stessa larghezza della tabella Caratteristiche Prodotto
return DrawKVList(g, items, 0, ColW, y);
}
private float DrawKVList(PdfGraphics g, (string Label, string Value)[] items, float x, float w, float y)
{
float rh = PdfTheme.RowHeight;
float pad = PdfTheme.CellPadding;
float labelW = w * 0.35f;
float valueW = w * 0.65f;
foreach (var (label, rawValue) in items)
{
// L'etichetta è sempre visibile; se il valore è assente mostro "-"
var value = string.IsNullOrWhiteSpace(rawValue) ? "-" : rawValue;
g.DrawString(label, PdfTheme.TableFont,
new PdfSolidBrush(PdfTheme.TextSecondary),
new RectangleF(x + pad, y + 1f, labelW, rh));
bool isNeg = value.TrimStart().StartsWith('-') && value != "-";
bool isKey = label == "Rendimento Totale";
var brush = isNeg ? new PdfSolidBrush(PdfTheme.NegativeRed)
: isKey ? new PdfSolidBrush(PdfTheme.AccentBlue)
: new PdfSolidBrush(PdfTheme.TextPrimary);
g.DrawString(value, PdfTheme.TableBold, brush,
new RectangleF(x + labelW, y + 1f, valueW - pad, rh));
g.DrawLine(new PdfPen(PdfTheme.SeparatorLine, 0.3f),
x, y + rh, x + w, y + rh);
y += rh;
}
return y;
}
}

View File

@@ -21,6 +21,7 @@ public class ReportOrchestrator : IReportOrchestrator
private readonly IPdfMergerService _merger; private readonly IPdfMergerService _merger;
private readonly IPdfCacheService _cache; private readonly IPdfCacheService _cache;
private readonly ILogger<ReportOrchestrator> _logger; private readonly ILogger<ReportOrchestrator> _logger;
private readonly ExpiredAnagraficaSectionRenderer _expiredAnagraficaRenderer;
public ReportOrchestrator( public ReportOrchestrator(
ICertificateDataService dataService, ICertificateDataService dataService,
@@ -28,7 +29,8 @@ public class ReportOrchestrator : IReportOrchestrator
IChartSectionRenderer chartRenderer, IChartSectionRenderer chartRenderer,
IPdfMergerService merger, IPdfMergerService merger,
IPdfCacheService cache, IPdfCacheService cache,
ILogger<ReportOrchestrator> logger) ILogger<ReportOrchestrator> logger,
ExpiredAnagraficaSectionRenderer expiredAnagraficaRenderer)
{ {
_dataService = dataService; _dataService = dataService;
_sectionRenderers = sectionRenderers; _sectionRenderers = sectionRenderers;
@@ -36,13 +38,16 @@ public class ReportOrchestrator : IReportOrchestrator
_merger = merger; _merger = merger;
_cache = cache; _cache = cache;
_logger = logger; _logger = logger;
_expiredAnagraficaRenderer = expiredAnagraficaRenderer;
} }
public async Task<byte[]> GenerateReportAsync(string isin, bool showBranding = false) public async Task<byte[]> GenerateReportAsync(string isin, bool showBranding = false)
{ {
// ── Cache check (chiave include branding) ───────────────────────── // ── Cache check ────────────────────────────────────────────────
var cacheKey = showBranding ? $"{isin}:branded" : isin; var baseCacheKey = showBranding ? $"{isin}:branded" : isin;
var cached = _cache.Get(cacheKey); var expiredCacheKey = showBranding ? $"{isin}:expired:branded" : $"{isin}:expired";
var cached = _cache.Get(baseCacheKey) ?? _cache.Get(expiredCacheKey);
if (cached != null) if (cached != null)
{ {
_logger.LogInformation("Report per ISIN {Isin} servito da cache ({Size} bytes)", isin, cached.Length); _logger.LogInformation("Report per ISIN {Isin} servito da cache ({Size} bytes)", isin, cached.Length);
@@ -60,40 +65,75 @@ public class ReportOrchestrator : IReportOrchestrator
ShowBranding = showBranding, ShowBranding = showBranding,
}; };
// Determina se lo scenario ha dati validi (evita doppia chiamata SP) // ── 2. Determina il tipo di report ────────────────────────────
bool isScenarioAllowed = reportData.Scenario.Rows.Count > 0; bool isExpired = !string.IsNullOrEmpty(reportData.Info.Stato)
&& reportData.Info.Stato != "Quotazione";
var cacheKey = isExpired ? expiredCacheKey : baseCacheKey;
if (string.IsNullOrEmpty(reportData.Info.Stato))
_logger.LogWarning("Campo Stato assente per ISIN {Isin}: utilizzo flusso standard", isin);
_logger.LogInformation( _logger.LogInformation(
"Dati recuperati per {Isin}: {SottostantiCount} sottostanti, {EventiCount} eventi, Scenario: {ScenarioAllowed}", "Dati recuperati per {Isin}: Stato={Stato}, isExpired={IsExpired}, {EventiCount} eventi",
isin, reportData.Info.Sottostanti.Count, reportData.Eventi.Count, isScenarioAllowed); isin, reportData.Info.Stato, isExpired, reportData.Eventi.Count);
// ── 2. Genera le sezioni PDF ────────────────────────────────── // ── 3. Genera le sezioni PDF ──────────────────────────────────
var pdfSections = new List<PdfDocument>(); var pdfSections = new List<PdfDocument>();
if (isExpired)
{
// Flusso expired: ExpiredAnagrafica + Eventi + Chart
try
{
pdfSections.Add(_expiredAnagraficaRenderer.Render(reportData));
_logger.LogInformation("Sezione 'ExpiredAnagrafica' generata per {Isin}", isin);
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nella sezione 'ExpiredAnagrafica' per {Isin}", isin);
throw;
}
try
{
var eventiRenderer = _sectionRenderers.First(r => r.SectionName == "Eventi");
pdfSections.Add(eventiRenderer.Render(reportData));
_logger.LogInformation("Sezione 'Eventi' generata per {Isin}", isin);
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nella sezione 'Eventi' per {Isin}", isin);
throw;
}
}
else
{
// Flusso attuale: Anagrafica + Eventi + Scenario (condizionale)
bool isScenarioAllowed = reportData.Scenario.Rows.Count > 0;
foreach (var renderer in _sectionRenderers.OrderBy(r => r.Order)) foreach (var renderer in _sectionRenderers.OrderBy(r => r.Order))
{ {
// Salta la sezione scenario se il certificato è Protection
if (renderer.SectionName == "Scenario" && !isScenarioAllowed) if (renderer.SectionName == "Scenario" && !isScenarioAllowed)
{ {
_logger.LogInformation("Sezione Scenario saltata per {Isin} (certificato Protection)", isin); _logger.LogInformation("Sezione Scenario saltata per {Isin}", isin);
continue; continue;
} }
try try
{ {
var sectionPdf = renderer.Render(reportData); pdfSections.Add(renderer.Render(reportData));
pdfSections.Add(sectionPdf);
_logger.LogInformation("Sezione '{Section}' generata per {Isin}", renderer.SectionName, isin); _logger.LogInformation("Sezione '{Section}' generata per {Isin}", renderer.SectionName, isin);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Errore nella generazione della sezione '{Section}' per {Isin}", _logger.LogError(ex, "Errore nella sezione '{Section}' per {Isin}", renderer.SectionName, isin);
renderer.SectionName, isin);
throw; throw;
} }
} }
}
// ── 3. Genera/recupera il grafico ────────────────────────────── // ── 4. Grafico (entrambi i flussi) ────────────────────────────
var chartPdf = await _chartRenderer.RenderAsync(isin); var chartPdf = await _chartRenderer.RenderAsync(isin);
if (chartPdf != null) if (chartPdf != null)
{ {
@@ -101,20 +141,16 @@ public class ReportOrchestrator : IReportOrchestrator
_logger.LogInformation("Sezione Grafico aggiunta per {Isin}", isin); _logger.LogInformation("Sezione Grafico aggiunta per {Isin}", isin);
} }
// ── 4. Unisci tutto ──────────────────────────────────────────── // ── 5. Unisci tutto ────────────────────────────────────────────
var finalPdf = _merger.Merge(pdfSections); var finalPdf = _merger.Merge(pdfSections);
_logger.LogInformation("Report generato per {Isin}: {Size} bytes, {Sections} sezioni", _logger.LogInformation("Report generato per {Isin}: {Size} bytes, {Sections} sezioni",
isin, finalPdf.Length, pdfSections.Count); isin, finalPdf.Length, pdfSections.Count);
// Salva in cache
_cache.Set(cacheKey, finalPdf); _cache.Set(cacheKey, finalPdf);
// Cleanup
foreach (var doc in pdfSections) foreach (var doc in pdfSections)
{
doc.Close(true); doc.Close(true);
}
return finalPdf; return finalPdf;
} }

View File

@@ -0,0 +1,642 @@
# Expired Certificate 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 un secondo template PDF per certificati non in quotazione (Scaduto/Rimborsato/Revocato), auto-rilevato dal campo `Stato` della SP esistente, senza modificare endpoint né renderer esistenti.
**Architecture:** Il campo `Stato` viene aggiunto al modello e mappato dal DB. L'orchestratore legge `Stato` dopo il caricamento dati e sceglie il flusso: attivo (flusso esistente invariato) oppure expired (nuovo `ExpiredAnagraficaSectionRenderer` + `EventiSectionRenderer` riusato + Chart). Il nuovo renderer è iniettato direttamente nell'orchestratore, non via `IEnumerable<IPdfSectionRenderer>`, per evitare inclusione nel ciclo normale.
**Tech Stack:** ASP.NET Core 8, Syncfusion PDF (Community), SkiaSharp, Microsoft.Data.SqlClient, `dotnet build` per verifica compilazione.
> **Nota:** Non esistono test automatici nel progetto. La verifica avviene tramite `dotnet build` (compilazione) e richiesta HTTP manuale con un ISIN expired reale.
---
## File Map
| File | Operazione |
|------|-----------|
| `CertReports.Syncfusion/Models/CertificateModels.cs` | Modifica — aggiunge campo `Stato` |
| `CertReports.Syncfusion/Services/Implementations/CertificateDataService.cs` | Modifica — mappa `Stato` dal reader |
| `CertReports.Syncfusion/Services/Implementations/ExpiredAnagraficaSectionRenderer.cs` | **Nuovo file** |
| `CertReports.Syncfusion/Services/Implementations/ReportOrchestrator.cs` | Modifica — branching + cache keys + inject ExpiredRenderer |
| `CertReports.Syncfusion/Program.cs` | Modifica — registra `ExpiredAnagraficaSectionRenderer` |
---
## Task 1: Aggiungere campo `Stato` al modello
**Files:**
- Modify: `CertReports.Syncfusion/Models/CertificateModels.cs`
- [ ] **Step 1: Aggiungere la proprietà `Stato` a `CertificateInfo`**
Aprire `CertificateModels.cs`. Alla fine della sezione `// Vari` (dopo `TriggerOneStar` e prima di `Note`), aggiungere:
```csharp
// Stato quotazione (da SP rpt_Master_CFT_ISIN)
// Valori: "Quotazione" | "Revocato" | "Scaduto" | "Rimborsato"
public string Stato { get; set; } = string.Empty;
```
La sezione interessata è intorno a riga 73-76:
```csharp
// Vari
public string Leva { get; set; } = string.Empty;
public string FattoreAirbag { get; set; } = string.Empty;
public string TriggerOneStar { get; set; } = string.Empty;
public string Note { get; set; } = string.Empty;
```
Diventa:
```csharp
// Vari
public string Leva { get; set; } = string.Empty;
public string FattoreAirbag { get; set; } = string.Empty;
public string TriggerOneStar { get; set; } = string.Empty;
public string Note { get; set; } = string.Empty;
// Stato quotazione (da SP rpt_Master_CFT_ISIN)
// Valori: "Quotazione" | "Revocato" | "Scaduto" | "Rimborsato"
public string Stato { get; set; } = string.Empty;
```
- [ ] **Step 2: Verificare compilazione**
```bash
cd "CertReports.Syncfusion" && dotnet build
```
Atteso: `Build succeeded` (0 errori).
- [ ] **Step 3: Commit**
```bash
git add CertReports.Syncfusion/Models/CertificateModels.cs
git commit -m "feat: add Stato field to CertificateInfo model"
```
---
## Task 2: Mappare `Stato` nel data service
**Files:**
- Modify: `CertReports.Syncfusion/Services/Implementations/CertificateDataService.cs`
- [ ] **Step 1: Aggiungere la mappatura nel reader di `rpt_Master_CFT_ISIN`**
In `GetCertificateInfoAsync`, dopo la riga che mappa `RendimentoAttuale` (riga ~100), aggiungere:
```csharp
info.RendimentoAttuale = r.GetStringSafe("RendimentoAttuale");
info.Stato = r.GetStringSafe("Stato"); // ← aggiungere questa riga
```
Il blocco risultante sarà:
```csharp
info.CpnInMemoria = r.GetNullableDecimal("CpnInMemoria");
info.CpnDaPagare = r.GetNullableDecimal("CpnDaPagare");
info.CpnPagati = r.GetNullableDecimal("CpnPagati");
info.RendimentoAttuale = r.GetStringSafe("RendimentoAttuale");
info.Stato = r.GetStringSafe("Stato");
```
> **Nota:** `GetStringSafe` è già nel progetto (extension method su `SqlDataReader`). Se la SP non restituisce il campo `Stato`, il metodo restituisce `string.Empty` senza eccezioni — sicuro.
- [ ] **Step 2: Verificare compilazione**
```bash
dotnet build
```
Atteso: `Build succeeded`.
- [ ] **Step 3: Commit**
```bash
git add CertReports.Syncfusion/Services/Implementations/CertificateDataService.cs
git commit -m "feat: map Stato field from rpt_Master_CFT_ISIN SP"
```
---
## Task 3: Creare `ExpiredAnagraficaSectionRenderer`
**Files:**
- Create: `CertReports.Syncfusion/Services/Implementations/ExpiredAnagraficaSectionRenderer.cs`
- [ ] **Step 1: Creare il file con il renderer completo**
Creare il file `CertReports.Syncfusion/Services/Implementations/ExpiredAnagraficaSectionRenderer.cs` con il seguente contenuto:
```csharp
using CertReports.Syncfusion.Helpers;
using CertReports.Syncfusion.Models;
using CertReports.Syncfusion.Services.Interfaces;
using Syncfusion.Drawing;
using Syncfusion.Pdf;
using Syncfusion.Pdf.Graphics;
namespace CertReports.Syncfusion.Services.Implementations;
/// <summary>
/// Sezione 1 — Prima pagina del report per certificati non in quotazione
/// (Scaduto, Rimborsato, Revocato). Struttura semplificata rispetto al report attivo:
/// solo Titolo + Caratteristiche Prodotto + Analisi. Niente Bid/Ask, niente Sottostanti.
/// </summary>
public class ExpiredAnagraficaSectionRenderer : IPdfSectionRenderer
{
public string SectionName => "ExpiredAnagrafica";
public int Order => 1;
private const float PageW = 595f - 2 * PdfTheme.PageMargin;
private const float PageH = 842f - 2 * PdfTheme.PageMargin - PdfTheme.FooterHeight;
private const float ColGap = 10f;
private const float ColW = (PageW - ColGap) / 2f;
private const float SectionGap = 10f;
public PdfDocument Render(CertificateReportData data)
{
var doc = new PdfDocument();
var page = PdfTheme.AddA4Page(doc);
var g = page.Graphics;
var info = data.Info;
float y = 0f;
// ── TITOLO ────────────────────────────────────────────────────
y = DrawTitle(g, info, PageW, y);
// ── SEZIONE A: CARATTERISTICHE PRODOTTO ───────────────────────
y = DrawSectionLabel(g, "Caratteristiche Prodotto", y);
y = DrawCaratteristiche(g, info, y);
y += SectionGap;
// ── SEZIONE B: ANALISI ────────────────────────────────────────
y = DrawSectionLabel(g, "Analisi", y);
DrawAnalisi(g, info, y);
PdfTheme.DrawFooter(g, PageW, PageH, 1, data.ShowBranding);
return doc;
}
// ═══════════════════════════════════════════════════════════════
// TITOLO — Solo Tipologia box (niente Data/Bid/Ask)
// ═══════════════════════════════════════════════════════════════
private float DrawTitle(PdfGraphics g, CertificateInfo info, float w, float y)
{
g.DrawString($"Scheda Prodotto {info.Isin}",
PdfTheme.SectionTitleFont,
new PdfSolidBrush(PdfTheme.AccentBlue),
new RectangleF(0, y, w, 20f),
new PdfStringFormat(PdfTextAlignment.Center));
y += 24f;
g.DrawLine(PdfTheme.AccentBluePen, 0, y, w, y);
y += 8f;
// Solo box Tipologia, niente Data/Bid/Ask
const float boxH = 28f;
if (!string.IsNullOrEmpty(info.Categoria))
{
DrawInfoBox(g, 0, y, w, boxH,
"TIPOLOGIA", info.Categoria,
PdfTheme.BoxLightBlueBgBrush, PdfTheme.AccentBlueBrush,
PdfTheme.AccentBlueBrush, PdfTheme.AccentBlueDarkBrush,
PdfTheme.Bold);
}
y += boxH + 10f;
return y;
}
// ═══════════════════════════════════════════════════════════════
// INTESTAZIONE DI SEZIONE
// ═══════════════════════════════════════════════════════════════
private float DrawSectionLabel(PdfGraphics g, string title, float y)
{
g.DrawRectangle(PdfTheme.AccentBlueBrush, new RectangleF(0, y, 3f, 14f));
g.DrawString(title, PdfTheme.Bold,
new PdfSolidBrush(PdfTheme.AccentBlue),
new RectangleF(6f, y, PageW, 14f));
return y + 16f;
}
// ═══════════════════════════════════════════════════════════════
// INFO BOX (identico ad AnagraficaSectionRenderer)
// ═══════════════════════════════════════════════════════════════
private void DrawInfoBox(
PdfGraphics g,
float x, float y, float w, float boxH,
string label, string value,
PdfBrush bgBrush, PdfBrush accentBrush,
PdfBrush labelBrush, PdfBrush valueBrush,
PdfFont valueFont)
{
g.DrawRectangle(bgBrush, new RectangleF(x, y, w, boxH));
g.DrawRectangle(accentBrush, new RectangleF(x, y, 4f, boxH));
float innerX = x + 4f + 6f;
float innerW = w - 4f - 6f - 4f;
g.DrawString(label, PdfTheme.Small, labelBrush,
new RectangleF(innerX, y + 4f, innerW, 10f));
g.DrawString(value, valueFont, valueBrush,
new RectangleF(innerX, y + 14f, innerW, 14f));
}
// ═══════════════════════════════════════════════════════════════
// SEZIONE A: CARATTERISTICHE PRODOTTO
// Sinistra: tabella emittente con campi rimborso
// ═══════════════════════════════════════════════════════════════
private float DrawCaratteristiche(PdfGraphics g, CertificateInfo info, float y)
{
// Tabella occupa la colonna sinistra; destra libera
return DrawEmittenteTable(g, info, 0, ColW, y);
}
private float DrawEmittenteTable(PdfGraphics g, CertificateInfo info, float x, float w, float y)
{
float rh = PdfTheme.RowHeight;
float pad = PdfTheme.CellPadding;
// Header blu
g.DrawRectangle(PdfTheme.TableHeaderBrush, new RectangleF(x, y, w, rh));
string headerText = string.IsNullOrEmpty(info.Emittente)
? "EMITTENTE"
: $"EMITTENTE {info.Emittente.ToUpper()}";
g.DrawString(headerText, PdfTheme.TableBold,
PdfTheme.HeaderTextBrush,
new RectangleF(x + pad, y + 3f, w - pad * 2, rh));
y += rh;
var rows = new[]
{
("ISIN", info.Isin),
("Mercato", info.Mercato),
("Valuta", info.Valuta),
("Data Emissione", info.DataEmissione),
("Valore Rimborso", info.ValoreRimborso),
("Data Rimborso", info.DataRimborso),
};
for (int i = 0; i < rows.Length; i++)
{
var (label, value) = rows[i];
if (string.IsNullOrWhiteSpace(value)) continue;
bool alt = i % 2 == 1;
if (alt)
g.DrawRectangle(new PdfSolidBrush(PdfTheme.TableAltRow),
new RectangleF(x, y, w, rh));
g.DrawLine(PdfTheme.TableBorderPen, x, y, x + w, y);
g.DrawString(label, PdfTheme.TableFont,
new PdfSolidBrush(PdfTheme.TextSecondary),
new RectangleF(x + pad, y + 2f, w * 0.5f, rh));
var valueBrush = label == "ISIN"
? new PdfSolidBrush(PdfTheme.AccentBlue)
: new PdfSolidBrush(PdfTheme.TextPrimary);
g.DrawString(value, PdfTheme.TableBold, valueBrush,
new RectangleF(x + w * 0.5f, y + 2f, w * 0.5f - pad, rh),
new PdfStringFormat(PdfTextAlignment.Right));
y += rh;
}
g.DrawLine(PdfTheme.TableBorderPen, x, y, x + w, y);
return y + 2f;
}
// ═══════════════════════════════════════════════════════════════
// SEZIONE B: ANALISI — KV list colonna destra
// ═══════════════════════════════════════════════════════════════
private float DrawAnalisi(PdfGraphics g, CertificateInfo info, float y)
{
var items = new (string Label, string Value)[]
{
("Importo Cedola (p.a.)", info.NominalAnnualYield),
("Frequenza Cedola", info.FrequenzaCedole),
("Valore Nominale", info.NominalValue?.ToString("N0") ?? string.Empty),
("Memoria Cedola", info.Memory),
("Tipo Barriera", info.BarrierType),
("Tipo Basket", info.BasketType),
("Rendimento Totale", info.RendimentoTotale),
};
return DrawKVList(g, items, ColW + ColGap, ColW, y);
}
private float DrawKVList(PdfGraphics g, (string Label, string Value)[] items, float x, float w, float y)
{
float rh = PdfTheme.RowHeight;
float pad = PdfTheme.CellPadding;
float labelW = w * 0.58f;
float valueW = w * 0.42f;
foreach (var (label, value) in items)
{
if (string.IsNullOrWhiteSpace(value)) continue;
g.DrawString(label, PdfTheme.TableFont,
new PdfSolidBrush(PdfTheme.TextSecondary),
new RectangleF(x + pad, y + 1f, labelW, rh));
bool isNeg = value.TrimStart().StartsWith('-');
bool isKey = label == "Rendimento Totale";
var brush = isNeg ? new PdfSolidBrush(PdfTheme.NegativeRed)
: isKey ? new PdfSolidBrush(PdfTheme.AccentBlue)
: new PdfSolidBrush(PdfTheme.TextPrimary);
g.DrawString(value, PdfTheme.TableBold, brush,
new RectangleF(x + labelW, y + 1f, valueW - pad, rh),
new PdfStringFormat(PdfTextAlignment.Right));
g.DrawLine(new PdfPen(PdfTheme.SeparatorLine, 0.3f),
x, y + rh, x + w, y + rh);
y += rh;
}
return y;
}
}
```
- [ ] **Step 2: Verificare compilazione**
```bash
dotnet build
```
Atteso: `Build succeeded`. Se manca un `using`, aggiungerlo. Riferimento per i `using` necessari: guardare `AnagraficaSectionRenderer.cs` (stessi namespace).
- [ ] **Step 3: Commit**
```bash
git add CertReports.Syncfusion/Services/Implementations/ExpiredAnagraficaSectionRenderer.cs
git commit -m "feat: add ExpiredAnagraficaSectionRenderer for non-quoted certificates"
```
---
## Task 4: Aggiornare `ReportOrchestrator` — branching + cache + injection
**Files:**
- Modify: `CertReports.Syncfusion/Services/Implementations/ReportOrchestrator.cs`
- [ ] **Step 1: Aggiungere `ExpiredAnagraficaSectionRenderer` al costruttore**
Il costruttore attuale riceve `IEnumerable<IPdfSectionRenderer> sectionRenderers`. Aggiungere `ExpiredAnagraficaSectionRenderer expiredAnagraficaRenderer` come parametro diretto:
```csharp
private readonly ExpiredAnagraficaSectionRenderer _expiredAnagraficaRenderer;
public ReportOrchestrator(
ICertificateDataService dataService,
IEnumerable<IPdfSectionRenderer> sectionRenderers,
IChartSectionRenderer chartRenderer,
IPdfMergerService merger,
IPdfCacheService cache,
ILogger<ReportOrchestrator> logger,
ExpiredAnagraficaSectionRenderer expiredAnagraficaRenderer) // ← aggiunto
{
_dataService = dataService;
_sectionRenderers = sectionRenderers;
_chartRenderer = chartRenderer;
_merger = merger;
_cache = cache;
_logger = logger;
_expiredAnagraficaRenderer = expiredAnagraficaRenderer; // ← aggiunto
}
```
- [ ] **Step 2: Aggiornare `GenerateReportAsync` — cache key e branching**
Sostituire la logica `GenerateReportAsync` (dal `var cacheKey` in poi) con questa:
```csharp
public async Task<byte[]> GenerateReportAsync(string isin, bool showBranding = false)
{
// ── Cache check ────────────────────────────────────────────────
// La chiave include branding E tipo report. Il tipo verrà determinato
// dopo il caricamento dati, ma usiamo una chiave provvisoria per
// il primo lookup — se c'è cache, non leggiamo nemmeno il tipo.
// Strategia: chiave separata per expired dopo determinazione tipo.
// Per semplicità: tentativo cache con chiave base prima del DB,
// poi con chiave tipizzata dopo la lettura Stato.
// Prima proviamo con la chiave "standard" — hit solo se già cached
var baseCacheKey = showBranding ? $"{isin}:branded" : isin;
var expiredCacheKey = showBranding ? $"{isin}:expired:branded" : $"{isin}:expired";
// Controlla entrambe le chiavi (una sarà un miss, l'altra un possibile hit)
var cached = _cache.Get(baseCacheKey) ?? _cache.Get(expiredCacheKey);
if (cached != null)
{
_logger.LogInformation("Report per ISIN {Isin} servito da cache ({Size} bytes)", isin, cached.Length);
return cached;
}
_logger.LogInformation("Inizio generazione report per ISIN {Isin}", isin);
// ── 1. Recupera tutti i dati ───────────────────────────────────
var reportData = new CertificateReportData
{
Info = await _dataService.GetCertificateInfoAsync(isin),
Eventi = await _dataService.GetCertificateEventsAsync(isin),
Scenario = await _dataService.GetScenarioAnalysisAsync(isin),
ShowBranding = showBranding,
};
// ── 2. Determina il tipo di report ────────────────────────────
bool isExpired = !string.IsNullOrEmpty(reportData.Info.Stato)
&& reportData.Info.Stato != "Quotazione";
var cacheKey = isExpired ? expiredCacheKey : baseCacheKey;
_logger.LogInformation(
"Dati recuperati per {Isin}: Stato={Stato}, isExpired={IsExpired}, {EventiCount} eventi",
isin, reportData.Info.Stato, isExpired, reportData.Eventi.Count);
// ── 3. Genera le sezioni PDF ──────────────────────────────────
var pdfSections = new List<PdfDocument>();
if (isExpired)
{
// Flusso expired: ExpiredAnagrafica + Eventi + Chart
pdfSections.Add(_expiredAnagraficaRenderer.Render(reportData));
_logger.LogInformation("Sezione 'ExpiredAnagrafica' generata per {Isin}", isin);
var eventiRenderer = _sectionRenderers.First(r => r.SectionName == "Eventi");
pdfSections.Add(eventiRenderer.Render(reportData));
_logger.LogInformation("Sezione 'Eventi' generata per {Isin}", isin);
}
else
{
// Flusso attuale: Anagrafica + Eventi + Scenario (condizionale)
bool isScenarioAllowed = reportData.Scenario.Rows.Count > 0;
foreach (var renderer in _sectionRenderers.OrderBy(r => r.Order))
{
if (renderer.SectionName == "Scenario" && !isScenarioAllowed)
{
_logger.LogInformation("Sezione Scenario saltata per {Isin}", isin);
continue;
}
try
{
pdfSections.Add(renderer.Render(reportData));
_logger.LogInformation("Sezione '{Section}' generata per {Isin}", renderer.SectionName, isin);
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nella sezione '{Section}' per {Isin}", renderer.SectionName, isin);
throw;
}
}
}
// ── 4. Grafico (entrambi i flussi) ────────────────────────────
var chartPdf = await _chartRenderer.RenderAsync(isin);
if (chartPdf != null)
{
pdfSections.Add(chartPdf);
_logger.LogInformation("Sezione Grafico aggiunta per {Isin}", isin);
}
// ── 5. Unisci tutto ────────────────────────────────────────────
var finalPdf = _merger.Merge(pdfSections);
_logger.LogInformation("Report generato per {Isin}: {Size} bytes, {Sections} sezioni",
isin, finalPdf.Length, pdfSections.Count);
_cache.Set(cacheKey, finalPdf);
foreach (var doc in pdfSections)
doc.Close(true);
return finalPdf;
}
```
- [ ] **Step 3: Verificare compilazione**
```bash
dotnet build
```
Atteso: `Build succeeded`. Se appare errore su `_expiredAnagraficaRenderer`, verificare che il campo privato e il costruttore siano entrambi aggiornati.
- [ ] **Step 4: Commit**
```bash
git add CertReports.Syncfusion/Services/Implementations/ReportOrchestrator.cs
git commit -m "feat: add expired certificate report branching in orchestrator"
```
---
## Task 5: Registrare `ExpiredAnagraficaSectionRenderer` in DI
**Files:**
- Modify: `CertReports.Syncfusion/Program.cs`
- [ ] **Step 1: Aggiungere la registrazione**
In `Program.cs`, nel blocco dove sono registrati gli altri renderer (dopo le registrazioni `AddScoped<IPdfSectionRenderer, ...>`), aggiungere:
```csharp
// Registrazione diretta (non come IPdfSectionRenderer) —
// l'orchestratore la inietta direttamente per il flusso expired.
builder.Services.AddScoped<ExpiredAnagraficaSectionRenderer>();
```
La sezione registrazioni renderer in `Program.cs` sarà simile a:
```csharp
builder.Services.AddScoped<IPdfSectionRenderer, AnagraficaSectionRenderer>();
builder.Services.AddScoped<IPdfSectionRenderer, EventiSectionRenderer>();
builder.Services.AddScoped<IPdfSectionRenderer, ScenarioSectionRenderer>();
builder.Services.AddScoped<ExpiredAnagraficaSectionRenderer>(); // ← aggiunta
builder.Services.AddScoped<IChartSectionRenderer, ChartSectionRenderer>();
```
- [ ] **Step 2: Verificare compilazione finale**
```bash
dotnet build
```
Atteso: `Build succeeded` senza warning critici.
- [ ] **Step 3: Commit**
```bash
git add CertReports.Syncfusion/Program.cs
git commit -m "feat: register ExpiredAnagraficaSectionRenderer in DI"
```
---
## Task 6: Verifica manuale end-to-end
- [ ] **Step 1: Avviare l'applicazione**
```bash
dotnet run --project CertReports.Syncfusion
```
- [ ] **Step 2: Testare con un ISIN expired**
Aprire nel browser o usare curl con un ISIN reale nello stato "Scaduto"/"Rimborsato"/"Revocato":
```
GET https://localhost:{porta}/api/report/by-isin/IT0006745704
```
Atteso:
- PDF a 3 pagine (non 4)
- Pagina 1: titolo + solo box Tipologia (niente Bid/Ask) + tabella con Valore Rimborso e Data Rimborso + KV Analisi con 7 campi
- Pagina 2: Lista eventi (identica all'attuale)
- Pagina 3: Grafico
- [ ] **Step 3: Testare branding**
```
GET https://localhost:{porta}/api/report/by-isin/IT0006745704?branding=true
```
Atteso: footer con "Powered by Smart Roots" su tutte le 3 pagine.
- [ ] **Step 4: Testare che il report attivo non sia rotto**
```
GET https://localhost:{porta}/api/report/by-isin/{ISIN_ATTIVO}
```
Atteso: report attivo invariato a 4 pagine (o 3 senza scenario).
- [ ] **Step 5: Commit finale**
```bash
git tag v-expired-report-complete
```
---
## Note di implementazione
**Brush e font disponibili in `PdfTheme`** (già esistenti, non creare nuovi):
- `PdfTheme.AccentBlue`, `PdfTheme.AccentBlueBrush`, `PdfTheme.AccentBluePen`
- `PdfTheme.AccentBlueDarkBrush`, `PdfTheme.BoxLightBlueBgBrush`
- `PdfTheme.TableHeaderBrush`, `PdfTheme.HeaderTextBrush`, `PdfTheme.TableAltRowBrush`
- `PdfTheme.TableAltRow` (Color), `PdfTheme.TextSecondary`, `PdfTheme.TextPrimary`
- `PdfTheme.NegativeRed`, `PdfTheme.SeparatorLine`
- `PdfTheme.TableBorderPen`
- Font: `PdfTheme.SectionTitleFont`, `PdfTheme.Bold`, `PdfTheme.Small`, `PdfTheme.TableFont`, `PdfTheme.TableBold`
- `PdfTheme.RowHeight`, `PdfTheme.CellPadding`, `PdfTheme.PageMargin`, `PdfTheme.FooterHeight`
- `PdfTheme.AddA4Page(doc)`, `PdfTheme.DrawFooter(g, w, h, pageNum, showBranding)`
**Cache lookup doppio:** Il lookup `_cache.Get(baseCacheKey) ?? _cache.Get(expiredCacheKey)` nel nuovo orchestratore è necessario perché non conosciamo il tipo del certificato prima di leggere il DB. Il caching avviene poi con la chiave corretta basata su `Stato`. Questo comporta al più 2 miss di cache prima della prima generazione, poi 1 hit per le richieste successive — accettabile.

View File

@@ -0,0 +1,197 @@
# Design Spec: Report Certificati Non in Quotazione (Expired)
**Data:** 2026-03-21
**Progetto:** SmartReports / CertReports.Syncfusion
**Autore:** Brainstorming session
---
## Obiettivo
Implementare un secondo template di report PDF per certificati non più in quotazione (Stato: Revocato, Scaduto, Rimborsato). Il report mantiene lo stesso stile grafico del report attivo ma mostra meno informazioni: 3 pagine totali (Anagrafica semplificata + Lista Eventi + Grafico). Niente Scenario. Il rilevamento del tipo è automatico tramite il campo `Stato` restituito dalla SP esistente `rpt_Master_CFT_ISIN`.
---
## Struttura del Report Expired (3 pagine)
| Pagina | Renderer | Contenuto |
|--------|----------|-----------|
| 1 | `ExpiredAnagraficaSectionRenderer` (nuovo) | Titolo + Caratteristiche + Analisi |
| 2 | `EventiSectionRenderer` (riusato) | Lista eventi (identica all'attuale) |
| 3 | `ChartSectionRenderer` (riusato) | Grafico performance (identico all'attuale) |
---
## Modifiche al Modello
### `CertificateInfo` — aggiungere un campo
```csharp
public string Stato { get; set; } = string.Empty;
// Valori possibili: "Quotazione" | "Revocato" | "Scaduto" | "Rimborsato"
```
### `CertificateDataService` — mappare il nuovo campo
Nel metodo che chiama `rpt_Master_CFT_ISIN`, aggiungere la mappatura:
```csharp
Stato = reader["Stato"]?.ToString() ?? string.Empty
```
Nessuna altra modifica alle query o agli altri servizi dati.
---
## Nuovo Renderer: `ExpiredAnagraficaSectionRenderer`
**File:** `CertReports.Syncfusion/Services/Implementations/ExpiredAnagraficaSectionRenderer.cs`
**Interfaccia:** `IPdfSectionRenderer`
**Order:** 1
**SectionName:** `"ExpiredAnagrafica"`
### Layout Pagina 1 (Portrait A4)
#### Blocco Titolo
- "Scheda Prodotto {ISIN}" — stesso stile del titolo attuale (font grande, colore AccentBlue)
- Riga sotto: "Tipologia: {Categoria}" — font normale
- **Niente** Bid/Ask, niente LastPriceDate
- Linea separatrice orizzontale blu (come attuale)
#### Blocco Caratteristiche Prodotto (tabella sinistra, stessa grafica attuale)
| Label | Campo modello |
|-------|---------------|
| EMITTENTE | `Info.Emittente` |
| ISIN | `Info.Isin` |
| Mercato | `Info.Mercato` |
| Valuta | `Info.Valuta` |
| Data Emissione | `Info.DataEmissione` |
| Valore Rimborso | `Info.ValoreRimborso` |
| Data Rimborso | `Info.DataRimborso` |
#### Blocco Analisi (KV, stessa grafica attuale)
| Label | Campo modello |
|-------|---------------|
| Importo Cedola (p.a.) | `Info.NominalAnnualYield` |
| Frequenza Cedola | `Info.FrequenzaCedole` |
| Valore Nominale | `Info.NominalValue` |
| Memoria Cedola | `Info.Memory` |
| Tipo Barriera | `Info.BarrierType` |
| Tipo Basket | `Info.BasketType` |
| Rendimento Totale | `Info.RendimentoTotale` |
#### Escluso dalla pagina 1
- Sezione Bid/Ask/LastPriceDate
- Tabella Sottostanti (sezione C del report attivo)
- Sezione cedole (CpnPagati/DaPagare/InMemoria)
- Sezione Analisi (8+9 KV del report attivo)
### Footer
`PdfTheme.DrawFooter(g, pageWidth, pageHeight, pageNumber, showBranding)` — identico a tutti gli altri renderer, supporta il parametro branding.
---
## Modifiche all'Orchestratore
### `ReportOrchestrator.cs`
Dopo il caricamento dei dati (`CertificateDataService`), leggere `data.Info.Stato` per scegliere il flusso:
**Strategia isExpired — denylist:** il check usa `!= "Quotazione"` (denylist), così qualsiasi nuovo stato non previsto viene trattato come expired piuttosto che come attivo. Se in futuro si aggiungono stati con report differenti, si sostituirà con un allowlist esplicito.
```csharp
bool isExpired = !string.IsNullOrEmpty(data.Info.Stato) && data.Info.Stato != "Quotazione";
// Copre: "Revocato", "Scaduto", "Rimborsato" — e qualsiasi stato futuro non-attivo
if (isExpired)
{
// Flusso expired: ExpiredAnagrafica + Eventi + Chart
var eventiRenderer = _sectionRenderers.First(r => r.SectionName == "Eventi");
pdfSections.Add(_expiredAnagraficaRenderer.Render(data));
pdfSections.Add(eventiRenderer.Render(data));
// Chart: await _chartRenderer.RenderAsync(data.Info.Isin) — aggiunto se non null
}
else
{
// Flusso attuale: foreach _sectionRenderers.OrderBy(r => r.Order) — logica esistente invariata
}
```
**Come si ottiene EventiSectionRenderer nel flusso expired:** tramite `_sectionRenderers.First(r => r.SectionName == "Eventi")`. È già registrato come `IPdfSectionRenderer` e presente nella collection iniettata.
**Firma ChartRenderer:** `await _chartRenderer.RenderAsync(data.Info.Isin)` — stessa chiamata del flusso attivo, restituisce `PdfDocument?` (null se il grafico non è disponibile).
Il renderer `ExpiredAnagraficaSectionRenderer` è iniettato direttamente nell'orchestratore (non via `IEnumerable<IPdfSectionRenderer>`) per evitare che venga incluso nel ciclo del flusso normale.
### Cache — chiavi separate
Le chiavi sono passate a `_cache.Get/Set` a livello orchestratore; `PdfCacheService.CacheKey()` aggiunge poi il prefisso `report_pdf_` internamente (comportamento invariato per entrambi i flussi).
| Scenario | Chiave passata all'orchestratore | Chiave effettiva in memoria |
|----------|----------------------------------|-----------------------------|
| Attivo, no branding | `{isin}` | `report_pdf_{isin}` |
| Attivo, branding | `{isin}:branded` | `report_pdf_{isin}:branded` |
| Expired, no branding | `{isin}:expired` | `report_pdf_{isin}:expired` |
| Expired, branding | `{isin}:expired:branded` | `report_pdf_{isin}:expired:branded` |
---
## Branding
Il parametro `?branding=true` funziona identicamente al report attivo:
- Propagato come `CertificateReportData.ShowBranding`
- Passato a `PdfTheme.DrawFooter(..., showBranding)` in ogni pagina del report expired
- Footer sinistra: "Powered by " + hyperlink "Smart Roots" → `https://www.smart-roots.net`
- Footer destra: numero pagina
- Cache usa chiave `{isin}:expired:branded` vs `{isin}:expired`
---
## Dependency Injection — `Program.cs`
```csharp
// Registrazione diretta (non come IPdfSectionRenderer) per evitare inclusione nel ciclo normale
builder.Services.AddScoped<ExpiredAnagraficaSectionRenderer>();
```
Gli altri renderer esistenti rimangono invariati.
---
## Endpoint
**Nessun nuovo endpoint.** Il rilevamento del tipo report è trasparente al chiamante. Tutti gli endpoint esistenti funzionano per entrambi i tipi:
| Endpoint | Comportamento |
|----------|---------------|
| `GET /api/report?p={encrypted}` | Auto-rileva da Stato |
| `GET /api/report?alias={id}` | Auto-rileva da Stato |
| `GET /api/report/by-isin/{isin}` | Auto-rileva da Stato |
| `GET /api/report/download?p={...}` | Auto-rileva da Stato |
| `GET /api/report/download?alias={...}` | Auto-rileva da Stato |
| Tutti | Supportano `?branding=true` |
---
## File Modificati / Creati
| File | Tipo modifica |
|------|---------------|
| `Models/CertificateModels.cs` | Aggiunta campo `Stato` a `CertificateInfo` |
| `Services/Implementations/CertificateDataService.cs` | Mappatura campo `Stato` dalla SP |
| `Services/Implementations/ExpiredAnagraficaSectionRenderer.cs` | **Nuovo file** |
| `Services/Implementations/ReportOrchestrator.cs` | Logica branching expired/attivo + cache keys |
| `Program.cs` | Registrazione `ExpiredAnagraficaSectionRenderer` |
---
## Non modificati
- `AnagraficaSectionRenderer.cs` — invariato
- `EventiSectionRenderer.cs` — riusato as-is
- `ScenarioSectionRenderer.cs` — invariato (semplicemente non incluso nel flusso expired)
- `ChartSectionRenderer.cs` — riusato as-is
- `PdfTheme.cs` — invariato
- `ReportController.cs` — invariato
- Tutti gli endpoint — invariati