25 KiB
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à
StatoaCertificateInfo
Aprire CertificateModels.cs. Alla fine della sezione // Vari (dopo TriggerOneStar e prima di Note), aggiungere:
// 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:
// 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:
// 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
cd "CertReports.Syncfusion" && dotnet build
Atteso: Build succeeded (0 errori).
- Step 3: Commit
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:
info.RendimentoAttuale = r.GetStringSafe("RendimentoAttuale");
info.Stato = r.GetStringSafe("Stato"); // ← aggiungere questa riga
Il blocco risultante sarà:
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 suSqlDataReader). Se la SP non restituisce il campoStato, il metodo restituiscestring.Emptysenza eccezioni — sicuro.
- Step 2: Verificare compilazione
dotnet build
Atteso: Build succeeded.
- Step 3: Commit
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:
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
dotnet build
Atteso: Build succeeded. Se manca un using, aggiungerlo. Riferimento per i using necessari: guardare AnagraficaSectionRenderer.cs (stessi namespace).
- Step 3: Commit
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
ExpiredAnagraficaSectionRendereral costruttore
Il costruttore attuale riceve IEnumerable<IPdfSectionRenderer> sectionRenderers. Aggiungere ExpiredAnagraficaSectionRenderer expiredAnagraficaRenderer come parametro diretto:
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:
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
dotnet build
Atteso: Build succeeded. Se appare errore su _expiredAnagraficaRenderer, verificare che il campo privato e il costruttore siano entrambi aggiornati.
- Step 4: Commit
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:
// 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:
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
dotnet build
Atteso: Build succeeded senza warning critici.
- Step 3: Commit
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
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
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.AccentBluePenPdfTheme.AccentBlueDarkBrush,PdfTheme.BoxLightBlueBgBrushPdfTheme.TableHeaderBrush,PdfTheme.HeaderTextBrush,PdfTheme.TableAltRowBrushPdfTheme.TableAltRow(Color),PdfTheme.TextSecondary,PdfTheme.TextPrimaryPdfTheme.NegativeRed,PdfTheme.SeparatorLinePdfTheme.TableBorderPen- Font:
PdfTheme.SectionTitleFont,PdfTheme.Bold,PdfTheme.Small,PdfTheme.TableFont,PdfTheme.TableBold PdfTheme.RowHeight,PdfTheme.CellPadding,PdfTheme.PageMargin,PdfTheme.FooterHeightPdfTheme.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.