Aggiornamento docs

This commit is contained in:
2026-07-21 11:02:32 +02:00
parent 6024deaf35
commit d59f5a0faa
4 changed files with 1760 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
<Project>
<PropertyGroup>
<WebPublishMethod>MSDeploy</WebPublishMethod>
<ExcludeFilesFromDeployment>**\*.pdb</ExcludeFilesFromDeployment>
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
<LastUsedPlatform>Any CPU</LastUsedPlatform>
<SiteUrlToLaunchAfterPublish>https://www.smart-roots.net/smartreports</SiteUrlToLaunchAfterPublish>
<ExcludeApp_Data>false</ExcludeApp_Data>
<ProjectGuid>6ef1f59d-feab-9797-733a-5711f6c6221b</ProjectGuid>
<SelfContained>false</SelfContained>
<MSDeployServiceURL>http://26.69.45.60</MSDeployServiceURL>
<DeployIisAppPath>/smart-roots/smartreports</DeployIisAppPath>
<RemoteSitePhysicalPath />
<SkipExtraFilesOnServer>true</SkipExtraFilesOnServer>
<MSDeployPublishMethod>RemoteAgent</MSDeployPublishMethod>
<EnableMSDeployBackup>true</EnableMSDeployBackup>
<EnableMsDeployAppOffline>true</EnableMsDeployAppOffline>
<UserName>Administrator</UserName>
<_SavePWD>true</_SavePWD>
<_TargetId>IISWebDeploy</_TargetId>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.5",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}

View File

@@ -0,0 +1,789 @@
# Dividend Section 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 il parametro `?dividend=true` che sostituisce la tabella Sottostanti nelle sezioni Anagrafica con una pagina landscape dedicata Sottostanti+Dividendi.
**Architecture:** Il flag `ShowDividend` viene aggiunto a `CertificateReportData` e propagato attraverso controller → orchestratore → renderer. `DividendSectionRenderer` è un renderer diretto (non `IPdfSectionRenderer`) iniettato nell'orchestratore come già avviene per `ExpiredAnagraficaSectionRenderer`. Entrambi i flussi (attivo + expired) ricevono la pagina landscape subito dopo la Sezione 1. Le chiavi cache vengono estese a 4 combinazioni.
**Tech Stack:** ASP.NET Core 8, Syncfusion PDF Net.Core v33, SkiaSharp, Microsoft.Data.SqlClient
---
## File Map
| File | Azione |
|------|--------|
| `Models/CertificateModels.cs` | Modify — aggiungere `ShowDividend` |
| `Services/Interfaces/IServices.cs` | Modify — aggiungere `showDividend` a `IReportOrchestrator` |
| `Controllers/ReportController.cs` | Modify — aggiungere param `dividend` a tutti e 3 gli endpoint |
| `Services/Implementations/ReportOrchestrator.cs` | Modify — signature, cache keys, inject renderer, inserire sezione |
| `Services/Implementations/AnagraficaSectionRenderer.cs` | Modify — skip SEZIONE C se `ShowDividend=true` |
| `Services/Implementations/ExpiredAnagraficaSectionRenderer.cs` | Modify — skip SEZIONE C se `ShowDividend=true` |
| `Services/Implementations/DividendSectionRenderer.cs` | Create — nuova pagina landscape con tabella a 2 livelli |
| `Program.cs` | Modify — registrare `DividendSectionRenderer` |
---
## Task 1: Aggiungere `ShowDividend` al modello
**Files:**
- Modify: `Models/CertificateModels.cs`
- [ ] **Step 1: Leggere il file corrente**
Aprire `Models/CertificateModels.cs` e individuare la classe `CertificateReportData`.
Il file contiene già `public bool ShowBranding { get; set; } = false;`.
- [ ] **Step 2: Aggiungere la proprietà**
Subito dopo `ShowBranding`:
```csharp
public bool ShowBranding { get; set; } = false;
public bool ShowDividend { get; set; } = false;
```
- [ ] **Step 3: Build**
```bash
dotnet build CertReports.Syncfusion
```
Expected: Build succeeded, 0 errors.
- [ ] **Step 4: Commit**
```bash
git add CertReports.Syncfusion/Models/CertificateModels.cs
git commit -m "feat: add ShowDividend flag to CertificateReportData"
```
---
## Task 2: Aggiornare l'interfaccia `IReportOrchestrator`
**Files:**
- Modify: `Services/Interfaces/IServices.cs`
- [ ] **Step 1: Leggere il file**
Trovare `IReportOrchestrator`. Attualmente:
```csharp
public interface IReportOrchestrator
{
Task<byte[]> GenerateReportAsync(string isin, bool showBranding = false);
}
```
- [ ] **Step 2: Aggiungere il parametro**
```csharp
public interface IReportOrchestrator
{
Task<byte[]> GenerateReportAsync(string isin, bool showBranding = false, bool showDividend = false);
}
```
- [ ] **Step 3: Build**
```bash
dotnet build CertReports.Syncfusion
```
Expected: errore di compilazione su `ReportOrchestrator` (firma non corrisponde) — normale, fix al Task 4.
- [ ] **Step 4: Commit**
```bash
git add CertReports.Syncfusion/Services/Interfaces/IServices.cs
git commit -m "feat: add showDividend param to IReportOrchestrator interface"
```
---
## Task 3: Aggiornare `ReportController` — parametro `dividend`
**Files:**
- Modify: `Controllers/ReportController.cs`
- [ ] **Step 1: Leggere il file**
Ci sono 3 endpoint che chiamano `_orchestrator.GenerateReportAsync(isin, showBranding)`:
- `GenerateReport` (GET `/api/report?p=...`)
- `GenerateReportByIsin` (GET `/api/report/by-isin/{isin}`)
- `DownloadReport` (GET `/api/report/download?p=...`)
- [ ] **Step 2: Modificare `GenerateReport`**
Aggiungere il parametro `dividend` e passarlo all'orchestratore:
```csharp
public async Task<IActionResult> GenerateReport(
[FromQuery(Name = "p")] string? encryptedIsin = null,
[FromQuery(Name = "alias")] string? aliasId = null,
[FromQuery(Name = "branding")] bool showBranding = false,
[FromQuery(Name = "dividend")] bool showDividend = false)
```
E nella chiamata:
```csharp
return await GenerateAndReturnPdf(isin, showBranding, showDividend);
```
- [ ] **Step 3: Modificare `GenerateReportByIsin`**
```csharp
public async Task<IActionResult> GenerateReportByIsin(
string isin,
[FromQuery(Name = "branding")] bool showBranding = false,
[FromQuery(Name = "dividend")] bool showDividend = false)
```
E nella chiamata:
```csharp
return await GenerateAndReturnPdf(isin, showBranding, showDividend);
```
- [ ] **Step 4: Modificare `DownloadReport`**
```csharp
public async Task<IActionResult> DownloadReport(
[FromQuery(Name = "p")] string? encryptedIsin = null,
[FromQuery(Name = "alias")] string? aliasId = null,
[FromQuery(Name = "branding")] bool showBranding = false,
[FromQuery(Name = "dividend")] bool showDividend = false)
```
Nella chiamata interna:
```csharp
var pdfBytes = await _orchestrator.GenerateReportAsync(isin, showBranding, showDividend);
```
- [ ] **Step 5: Aggiornare il metodo privato `GenerateAndReturnPdf`**
Trovare la firma di `GenerateAndReturnPdf` e aggiungere `bool showDividend = false`, poi passarla a `GenerateReportAsync`:
```csharp
private async Task<IActionResult> GenerateAndReturnPdf(string isin, bool showBranding = false, bool showDividend = false)
{
// ...
var pdfBytes = await _orchestrator.GenerateReportAsync(isin, showBranding, showDividend);
// ...
}
```
- [ ] **Step 6: Build**
```bash
dotnet build CertReports.Syncfusion
```
Expected: errore su `ReportOrchestrator` (non ancora aggiornato) — normale.
- [ ] **Step 7: Commit**
```bash
git add CertReports.Syncfusion/Controllers/ReportController.cs
git commit -m "feat: add ?dividend query param to all report endpoints"
```
---
## Task 4: Aggiornare `AnagraficaSectionRenderer` — skip SEZIONE C
**Files:**
- Modify: `Services/Implementations/AnagraficaSectionRenderer.cs`
- [ ] **Step 1: Leggere il file**
Trovare il blocco SEZIONE C. Attualmente:
```csharp
// ── SEZIONE C: SOTTOSTANTI ────────────────────────────────────
if (info.Sottostanti.Count > 0)
{
// Se lo spazio rimanente è meno di 80pt, nuova pagina
if (y > PageH - 80f)
{
PdfTheme.DrawFooter(g, PageW, PageH, 1, data.ShowBranding);
page = doc.Pages.Add();
g = page.Graphics;
y = 0f;
}
DrawSottostanti(g, info.Sottostanti, PageW, y);
}
```
- [ ] **Step 2: Wrappare il blocco con la condizione**
```csharp
// ── SEZIONE C: SOTTOSTANTI ────────────────────────────────────
if (!data.ShowDividend && info.Sottostanti.Count > 0)
{
// Se lo spazio rimanente è meno di 80pt, nuova pagina
if (y > PageH - 80f)
{
PdfTheme.DrawFooter(g, PageW, PageH, 1, data.ShowBranding);
page = doc.Pages.Add();
g = page.Graphics;
y = 0f;
}
DrawSottostanti(g, info.Sottostanti, PageW, y);
}
```
- [ ] **Step 3: Build**
```bash
dotnet build CertReports.Syncfusion
```
Expected: stesso errore su `ReportOrchestrator` — normale.
- [ ] **Step 4: Commit**
```bash
git add CertReports.Syncfusion/Services/Implementations/AnagraficaSectionRenderer.cs
git commit -m "feat: skip sottostanti table in anagrafica when ShowDividend=true"
```
---
## Task 5: Aggiornare `ExpiredAnagraficaSectionRenderer` — skip SEZIONE C
**Files:**
- Modify: `Services/Implementations/ExpiredAnagraficaSectionRenderer.cs`
- [ ] **Step 1: Leggere il file**
Trovare il blocco SEZIONE C (struttura identica ad `AnagraficaSectionRenderer`):
```csharp
// ── SEZIONE C: SOTTOSTANTI ────────────────────────────────────
if (info.Sottostanti.Count > 0)
{
if (y > PageH - 80f)
{
PdfTheme.DrawFooter(g, PageW, PageH, 1, data.ShowBranding);
page = doc.Pages.Add();
g = page.Graphics;
...
}
DrawSottostanti(g, info.Sottostanti, PageW, y);
}
```
- [ ] **Step 2: Stessa modifica del Task 4**
```csharp
// ── SEZIONE C: SOTTOSTANTI ────────────────────────────────────
if (!data.ShowDividend && info.Sottostanti.Count > 0)
{
if (y > PageH - 80f)
{
PdfTheme.DrawFooter(g, PageW, PageH, 1, data.ShowBranding);
page = doc.Pages.Add();
g = page.Graphics;
...
}
DrawSottostanti(g, info.Sottostanti, PageW, y);
}
```
- [ ] **Step 3: Build**
```bash
dotnet build CertReports.Syncfusion
```
- [ ] **Step 4: Commit**
```bash
git add CertReports.Syncfusion/Services/Implementations/ExpiredAnagraficaSectionRenderer.cs
git commit -m "feat: skip sottostanti table in expired anagrafica when ShowDividend=true"
```
---
## Task 6: Creare `DividendSectionRenderer`
**Files:**
- Create: `Services/Implementations/DividendSectionRenderer.cs`
- [ ] **Step 1: Creare il file con la struttura base**
```csharp
using CertReports.Syncfusion.Models;
using Syncfusion.Pdf;
using Syncfusion.Pdf.Graphics;
using Syncfusion.Pdf.Grid;
using System.Drawing;
namespace CertReports.Syncfusion.Services.Implementations;
/// <summary>
/// Genera la pagina landscape con la tabella unificata Sottostanti + Dividendi.
/// Non implementa IPdfSectionRenderer — iniettato direttamente nell'orchestratore.
/// </summary>
public class DividendSectionRenderer
{
// Larghezze colonne indicative (totale 742pt) — scalate proporzionalmente a runtime
private static readonly float[] ColWidths =
[
90f, // Nome
50f, // Strike
50f, // Last
46f, // % Perf.
50f, // Barr.Cap.
46f, // Buf.Cap.
50f, // Trig.CPN
46f, // Buf.CPN
46f, // Trig.AC
54f, // Data Stacco
54f, // Data Pag.
40f, // Importo
40f, // Rend.
40f, // Imp.Fut.
40f, // Rend.Fut.
];
private static readonly string[] Col2Headers =
[
"", "Strike", "Last", "% Perf.",
"Barr.Cap.", "Buf.Cap.", "Trig.CPN", "Buf.CPN", "Trig.AC",
"Data Stacco", "Data Pag.", "Importo", "Rend.", "Imp.Fut.", "Rend.Fut.",
];
// Indici colonne soggette a colore performance (0-based)
private static readonly HashSet<int> PerfCols = [3, 5, 7, 12, 14];
public PdfDocument Render(CertificateReportData data)
{
var doc = new PdfDocument();
doc.PageSettings.Orientation = PdfPageOrientation.Landscape;
doc.PageSettings.Size = PdfPageSize.A4;
var page = doc.Pages.Add();
var g = page.Graphics;
var size = page.GetClientSize();
float w = size.Width - 2 * PdfTheme.PageMargin;
float h = size.Height - 2 * PdfTheme.PageMargin - PdfTheme.FooterHeight;
float x0 = PdfTheme.PageMargin;
float y = PdfTheme.PageMargin;
// ── Titolo ────────────────────────────────────────────────────
var titleFont = PdfTheme.TitleFont;
g.DrawString("Sottostanti e Dividendi", titleFont, PdfTheme.AccentBlueBrush,
new RectangleF(x0, y, w, 20f));
y += 20f + 4f;
// ── Linea separatrice ─────────────────────────────────────────
g.DrawLine(PdfTheme.AccentBluePen, new PointF(x0, y), new PointF(x0 + w, y));
y += 6f;
// ── Calcolo scale fattore su larghezza reale ──────────────────
float totalNominal = ColWidths.Sum();
float scale = w / totalNominal;
float[] cw = ColWidths.Select(c => c * scale).ToArray();
// ── Disegno header a 2 livelli ────────────────────────────────
float rh = PdfTheme.RowHeight;
DrawHeader(g, x0, y, cw, rh);
y += rh * 2;
// ── Tabella dati ──────────────────────────────────────────────
DrawDataGrid(g, data, x0, y, cw, w);
// ── Footer ────────────────────────────────────────────────────
PdfTheme.DrawFooter(g, w, h, 1, data.ShowBranding);
return doc;
}
private static void DrawHeader(PdfGraphics g, float x0, float y, float[] cw, float rh)
{
var headerFont = PdfTheme.TableFont;
var whiteBrush = PdfBrushes.White;
var accentBrush = PdfTheme.AccentBlueBrush;
var darkBlueBrush = new PdfSolidBrush(Color.FromArgb(255, 10, 56, 128)); // #0A3880
// ── Riga 1: Gruppi ────────────────────────────────────────────
// "Nome" (col 0)
float cx = x0;
DrawHeaderCell(g, cx, y, cw[0], rh, "", accentBrush, whiteBrush, headerFont);
cx += cw[0];
// "SOTTOSTANTE" (cols 1-3)
float sottostanteW = cw[1] + cw[2] + cw[3];
DrawHeaderCell(g, cx, y, sottostanteW, rh, "SOTTOSTANTE", accentBrush, whiteBrush, headerFont);
cx += sottostanteW;
// "BARRIERE" (cols 4-8)
float barriereW = cw[4] + cw[5] + cw[6] + cw[7] + cw[8];
DrawHeaderCell(g, cx, y, barriereW, rh, "BARRIERE", accentBrush, whiteBrush, headerFont);
cx += barriereW;
// "DIVIDENDI" (cols 9-14) — blu scuro
float dividendiW = cw[9] + cw[10] + cw[11] + cw[12] + cw[13] + cw[14];
DrawHeaderCell(g, cx, y, dividendiW, rh, "DIVIDENDI", darkBlueBrush, whiteBrush, headerFont);
// ── Riga 2: Sottocolonne ──────────────────────────────────────
cx = x0;
for (int i = 0; i < cw.Length; i++)
{
var bg = i >= 9 ? darkBlueBrush : accentBrush;
DrawHeaderCell(g, cx, y + rh, cw[i], rh, Col2Headers[i], bg, whiteBrush, headerFont);
cx += cw[i];
}
// ── Separatore verticale tra col 8 e col 9 ───────────────────
float sepX = x0 + cw.Take(9).Sum();
var sepPen = new PdfPen(Color.FromArgb(255, 100, 181, 246), 1.5f); // #64B5F6
g.DrawLine(sepPen, new PointF(sepX, y), new PointF(sepX, y + rh * 2));
}
private static void DrawHeaderCell(PdfGraphics g, float x, float y, float w, float h,
string text, PdfBrush bg, PdfBrush fg, PdfFont font)
{
g.DrawRectangle(bg, new RectangleF(x, y, w, h));
if (!string.IsNullOrEmpty(text))
{
var fmt = new PdfStringFormat
{
Alignment = PdfTextAlignment.Center,
LineAlignment = PdfVerticalAlignment.Middle,
};
g.DrawString(text, font, fg, new RectangleF(x + 1, y, w - 2, h), fmt);
}
}
private static void DrawDataGrid(PdfGraphics g, CertificateReportData data,
float x0, float y, float[] cw, float pageW)
{
var grid = new PdfGrid();
grid.Style.CellPadding = new PdfPaddings(2, 2, 2, 2);
grid.Style.Font = PdfTheme.TableFont;
// 15 colonne
grid.Columns.Add(15);
for (int i = 0; i < 15; i++)
grid.Columns[i].Width = cw[i];
var sottostanti = data.Info.Sottostanti;
for (int i = 0; i < sottostanti.Count; i++)
{
var s = sottostanti[i];
var row = grid.Rows.Add();
row.Cells[0].Value = s.Nome;
row.Cells[1].Value = s.Strike;
row.Cells[2].Value = s.LastPrice;
row.Cells[3].Value = s.Performance ?? "—";
row.Cells[4].Value = s.CapitalBarrier ?? "—";
row.Cells[5].Value = s.ULCapitalBarrierBuffer ?? "—";
row.Cells[6].Value = s.CouponBarrier ?? "—";
row.Cells[7].Value = s.ULCouponBarrierBuffer ?? "—";
row.Cells[8].Value = s.TriggerAutocall ?? "—";
row.Cells[9].Value = s.DividendExDate ?? "—";
row.Cells[10].Value = s.DividendPayDate ?? "—";
row.Cells[11].Value = s.DividendAmount ?? "—";
row.Cells[12].Value = s.DividendYield ?? "—";
row.Cells[13].Value = s.DividendFutAmount ?? "—";
row.Cells[14].Value = s.DividendFutYield ?? "—";
// Righe alternate
if (i % 2 == 1)
{
for (int c = 0; c < 15; c++)
row.Cells[c].Style.BackgroundBrush = PdfTheme.TableAltRowBrush;
}
// Colore performance (negativi rosso, positivi verde)
foreach (int c in PerfCols)
{
var val = row.Cells[c].Value?.ToString();
if (string.IsNullOrEmpty(val) || val == "—") continue;
// Le stringhe arrivano già formattate dalla SP, es. "-12,34%"
bool isNegative = val.TrimStart().StartsWith('-');
row.Cells[c].Style.TextBrush = isNegative
? PdfTheme.NegativeRedBrush
: PdfTheme.PositiveGreenBrush;
}
// Allineamento centrato per tutte le celle tranne Nome
for (int c = 1; c < 15; c++)
{
row.Cells[c].StringFormat = new PdfStringFormat
{
Alignment = PdfTextAlignment.Center,
LineAlignment = PdfVerticalAlignment.Middle,
};
}
}
grid.Draw(g, new PointF(x0, y));
}
}
```
- [ ] **Step 2: Build**
```bash
dotnet build CertReports.Syncfusion
```
Expected: errore su `ReportOrchestrator` — normale.
- [ ] **Step 3: Commit**
```bash
git add CertReports.Syncfusion/Services/Implementations/DividendSectionRenderer.cs
git commit -m "feat: add DividendSectionRenderer with landscape two-level header table"
```
---
## Task 7: Registrare `DividendSectionRenderer` in `Program.cs`
**Files:**
- Modify: `Program.cs`
- [ ] **Step 1: Leggere `Program.cs`**
Trovare il blocco delle registrazioni renderer. Attualmente c'è:
```csharp
builder.Services.AddScoped<ExpiredAnagraficaSectionRenderer>();
```
- [ ] **Step 2: Aggiungere la registrazione subito sotto**
```csharp
builder.Services.AddScoped<ExpiredAnagraficaSectionRenderer>();
builder.Services.AddScoped<DividendSectionRenderer>();
```
- [ ] **Step 3: Build**
```bash
dotnet build CertReports.Syncfusion
```
Expected: solo errore su `ReportOrchestrator` — normale.
- [ ] **Step 4: Commit**
```bash
git add CertReports.Syncfusion/Program.cs
git commit -m "feat: register DividendSectionRenderer in DI container"
```
---
## Task 8: Aggiornare `ReportOrchestrator`
**Files:**
- Modify: `Services/Implementations/ReportOrchestrator.cs`
- [ ] **Step 1: Leggere il file completo**
Leggere l'intero file per avere contesto preciso prima di modificare.
- [ ] **Step 2: Aggiungere il campo e il parametro costruttore**
Aggiungere dopo `_expiredAnagraficaRenderer`:
```csharp
private readonly ExpiredAnagraficaSectionRenderer _expiredAnagraficaRenderer;
private readonly DividendSectionRenderer _dividendRenderer;
```
Nel costruttore aggiungere il parametro dopo `expiredAnagraficaRenderer`:
```csharp
ExpiredAnagraficaSectionRenderer expiredAnagraficaRenderer,
DividendSectionRenderer dividendRenderer)
```
E l'assegnazione:
```csharp
_expiredAnagraficaRenderer = expiredAnagraficaRenderer;
_dividendRenderer = dividendRenderer;
```
- [ ] **Step 3: Aggiornare la signature del metodo e le chiavi cache**
Cambiare la firma:
```csharp
public async Task<byte[]> GenerateReportAsync(string isin, bool showBranding = false, bool showDividend = false)
```
Aggiornare le chiavi cache (le 4 combinazioni):
```csharp
var dividendSuffix = showDividend ? ":dividend" : "";
var baseCacheKey = showBranding ? $"{isin}:branded{dividendSuffix}" : $"{isin}{dividendSuffix}";
var expiredCacheKey = showBranding ? $"{isin}:expired:branded{dividendSuffix}" : $"{isin}:expired{dividendSuffix}";
```
Nota: questo produce le 4 chiavi:
- `{isin}` — no branding, no dividend
- `{isin}:branded` — branding, no dividend
- `{isin}:dividend` — no branding, dividend
- `{isin}:branded:dividend` — branding + dividend
- (+ varianti expired)
- [ ] **Step 4: Impostare `ShowDividend` nel `CertificateReportData`**
Nel blocco di inizializzazione:
```csharp
var reportData = new CertificateReportData
{
Info = await _dataService.GetCertificateInfoAsync(isin),
Eventi = await _dataService.GetCertificateEventsAsync(isin),
Scenario = await _dataService.GetScenarioAnalysisAsync(isin),
ShowBranding = showBranding,
ShowDividend = showDividend,
};
```
- [ ] **Step 5: Inserire la sezione dividend nel flusso expired**
Dopo `pdfSections.Add(_expiredAnagraficaRenderer.Render(reportData));`, aggiungere:
```csharp
pdfSections.Add(_expiredAnagraficaRenderer.Render(reportData));
if (reportData.ShowDividend)
{
try
{
pdfSections.Add(_dividendRenderer.Render(reportData));
_logger.LogInformation("Sezione 'Dividend' generata per {Isin}", isin);
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nella sezione 'Dividend' per {Isin}", isin);
throw;
}
}
```
- [ ] **Step 6: Inserire la sezione dividend nel flusso attivo (attuale)**
Il flusso attivo usa un `foreach` su `_sectionRenderers.OrderBy(r => r.Order)`.
`AnagraficaSectionRenderer` ha `Order = 1` (Sezione 1).
Dobbiamo inserire la pagina dividend subito dopo Anagrafica.
Sostituire il `foreach` con:
```csharp
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;
}
// Inserire pagina dividend subito dopo Anagrafica (Sezione 1)
if (renderer.SectionName == "Anagrafica" && reportData.ShowDividend)
{
try
{
pdfSections.Add(_dividendRenderer.Render(reportData));
_logger.LogInformation("Sezione 'Dividend' generata per {Isin}", isin);
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nella sezione 'Dividend' per {Isin}", isin);
throw;
}
}
}
```
- [ ] **Step 7: Build**
```bash
dotnet build CertReports.Syncfusion
```
Expected: **Build succeeded, 0 errors**.
- [ ] **Step 8: Commit**
```bash
git add CertReports.Syncfusion/Services/Implementations/ReportOrchestrator.cs
git commit -m "feat: wire DividendSectionRenderer into orchestrator — both active and expired flows"
```
---
## Task 9: Verifica manuale
Non esistono test automatici nel progetto. Verificare manualmente:
- [ ] **Step 1: Avviare l'applicazione**
```bash
dotnet run --project CertReports.Syncfusion
```
- [ ] **Step 2: Test baseline — comportamento invariato**
Aprire nel browser:
```
https://localhost:{porta}/api/report/by-isin/{ISIN_VALIDO}
```
Expected: report normale con tabella Sottostanti visibile in Sezione 1. Nessuna pagina landscape.
- [ ] **Step 3: Test con `?dividend=true`**
```
https://localhost:{porta}/api/report/by-isin/{ISIN_VALIDO}?dividend=true
```
Expected:
- Sezione 1 (Anagrafica): **nessuna** tabella Sottostanti
- Dopo Sezione 1: **pagina landscape** con titolo "Sottostanti e Dividendi"
- Header a 2 livelli: "SOTTOSTANTE" (blu) | "BARRIERE" (blu) | "DIVIDENDI" (blu scuro)
- Separatore verticale tra colonna Trig.AC e Data Stacco
- Righe dati con colori performance su % Perf., Buf.Cap., Buf.CPN, Rend., Rend.Fut.
- Colonne dividendo vuote mostrano "—"
- Footer con numero pagina
- [ ] **Step 4: Test combinato `?dividend=true&branding=true`**
Expected: come sopra + footer con "Powered by Smart Roots" su tutte le pagine.
- [ ] **Step 5: Test su certificato expired**
Usare un ISIN con `Stato != "Quotazione"`. Verificare lo stesso comportamento nei flusso expired.
- [ ] **Step 6: Commit finale**
```bash
git add -A
git commit -m "feat: dividend section complete — landscape page with two-level header"
```
---
## Note implementative
### Gotcha Syncfusion v33 da rispettare
| Problema | Soluzione applicata nel piano |
|----------|-------------------------------|
| `Color(r,g,b)` rimosso | `Color.FromArgb(255, r, g, b)` usato ovunque |
| `grid.Draw()` restituisce `void` | Usato senza assegnazione |
| `RectangleF` no named args | `new RectangleF(x, y, w, h)` — no named parameters |
| Namespace conflict | Aggiungere `using Syncfusion.Pdf;` se necessario |
### Dimensioni pagina landscape
A4 landscape in Syncfusion: `Width ≈ 841pt`, `Height ≈ 595pt`.
Con `PageMargin = 30pt` (verificare in `PdfTheme`), la larghezza utile `w ≈ 781pt`.
Le colonne vengono scalate proporzionalmente da 742pt a `w` → `scale ≈ 1.05`.
### `SectionName == "Anagrafica"`
Il valore esatto confermato in `AnagraficaSectionRenderer.cs` è `"Anagrafica"` — la stringa di confronto nel Task 8 Step 6 è corretta.

View File

@@ -0,0 +1,932 @@
# Chart V2 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 l'endpoint `GET /api/chart/v2/{isin}` che genera un grafico certificati migliorato con titolo, colori distinti CTF/WorstOf, label sulle linee costanti e legenda orizzontale in basso.
**Architecture:** Due nuove SP (`cedlab_Chart_UL1` per metadata, `cedlab_Chart_AllSeriesV2` per tutte le serie in un unico round-trip), tre nuovi file C# (modelli, servizio, renderer), e un secondo action method in `ChartController`. Il vecchio v1 rimane invariato.
**Tech Stack:** ASP.NET Core 8, SkiaSharp, Microsoft.Data.SqlClient, SQL Server (`FirstSolutionDB`)
---
## Prerequisito DB — SP da creare PRIMA di eseguire i task C#
Le due stored procedure devono esistere in `FirstSolutionDB`. Schemi:
### `cedlab_Chart_UL1 @isin NVARCHAR(12)`
Estende `FSWeb_Chart_UL` con campi aggiuntivi. Deve restituire:
| Colonna | Tipo | Descrizione |
|---------|------|-------------|
| IDCertificates | int | ID certificato |
| IDUnderlyings | int | ID sottostante |
| StartDate | date | Data inizio certificato |
| Strike | decimal | Valore strike assoluto |
| BarrieraCouponPerc | decimal | Barriera coupon in % (es. 60) |
| BarrieraCoupon | decimal | Barriera coupon assoluta |
| BarrieraCapitalePerc | decimal | Barriera capitale in % (es. 60) |
| BarrieraCapitale | decimal | Barriera capitale assoluta |
| Sottostante | nvarchar | Nome sottostante |
| IsWorstOf | int | 1 = è il worst-of, 0 = altri |
| PriceWorst | decimal | Prezzo attuale del worst-of (0 per altri) |
| PriceWorstPerc | decimal | Prezzo worst-of in % su strike (0 per altri) |
| NumPrezziCFT | int | Numero prezzi EOD disponibili |
| NomeCFT | nvarchar | Descrizione + ' - ' + ISIN |
| TriggerAutocallPerc | decimal | Trigger autocall in % (0 se assente) |
| AutocallValue | decimal | Trigger autocall assoluto |
Ordinato: `IsWorstOf DESC` (worst-of nella prima riga).
### `cedlab_Chart_AllSeriesV2 @isin NVARCHAR(12)`
Restituisce tutte le serie CTF + UL in un unico resultset:
| Colonna | Tipo | Descrizione |
|---------|------|-------------|
| IDUnderlyings | int | 0 = CTF, altrimenti = UnderlyingsID |
| Px_date | date | Data EOD |
| Performance | decimal | % su strike (CTF: PX_LAST_EOD/Nominal*100; UL: Px_close(adj)/Strike*100) |
Logica:
- TOP 350 per-serie via `ROW_NUMBER() OVER (PARTITION BY IDUnderlyings ORDER BY Px_date DESC)`
- UL: solo date presenti nella serie CTF (INNER JOIN su Px_date), px_date >= StartDate
- UL AdjustedPrices: `CASE WHEN AdjustedPrices=1 THEN Px_closeadj ELSE Px_close END`
- ORDER BY IDUnderlyings ASC, Px_date ASC
---
## File da creare / modificare
| Operazione | File |
|------------|------|
| **Crea** | `CertReports.Syncfusion/Models/ChartModelsV2.cs` |
| **Crea** | `CertReports.Syncfusion/Services/Implementations/ChartDataServiceV2.cs` |
| **Crea** | `CertReports.Syncfusion/Services/Implementations/SkiaChartRendererV2.cs` |
| **Modifica** | `CertReports.Syncfusion/Controllers/ChartController.cs` |
| **Modifica** | `CertReports.Syncfusion/Program.cs` |
---
## Task 1: ChartModelsV2
**Files:**
- Create: `CertReports.Syncfusion/Models/ChartModelsV2.cs`
- [ ] **Step 1: Crea il file**
```csharp
namespace CertReports.Syncfusion.Models;
/// <summary>
/// Metadata di un sottostante (da SP cedlab_Chart_UL1).
/// Prima riga = worst-of (IsWorstOf = 1).
/// </summary>
public class ChartUlMetadata
{
public int IDCertificates { get; set; }
public int IDUnderlyings { get; set; }
public DateTime StartDate { get; set; }
public decimal Strike { get; set; }
public decimal BarrieraCouponPerc { get; set; }
public decimal BarrieraCoupon { get; set; }
public decimal BarrieraCapitalePerc { get; set; }
public decimal BarrieraCapitale { get; set; }
public string Sottostante { get; set; } = string.Empty;
public int IsWorstOf { get; set; }
public decimal PriceWorst { get; set; }
public decimal PriceWorstPerc { get; set; }
public int NumPrezziCFT { get; set; }
public string NomeCFT { get; set; } = string.Empty;
public decimal TriggerAutocallPerc { get; set; }
public decimal AutocallValue { get; set; }
}
/// <summary>
/// Singolo punto di una serie (da SP cedlab_Chart_AllSeriesV2).
/// IDUnderlyings = 0 → serie del certificato.
/// </summary>
public class ChartSeriesPoint
{
public int IDUnderlyings { get; set; }
public DateTime Date { get; set; }
public decimal Performance { get; set; }
}
/// <summary>
/// Dati completi per il grafico V2.
/// </summary>
public class ChartDataV2
{
public string Isin { get; set; } = string.Empty;
/// <summary>
/// Metadata globale: prima riga di cedlab_Chart_UL1 (il worst-of).
/// Contiene NomeCFT, NumPrezziCFT, barriere, trigger — uguali per tutte le righe.
/// </summary>
public ChartUlMetadata GlobalMeta { get; set; } = new();
/// <summary>Tutti i sottostanti (per IsWorstOf, PriceWorst, nomi legenda).</summary>
public List<ChartUlMetadata> Underlyings { get; set; } = new();
/// <summary>Tutti i punti di tutte le serie (CTF + UL), ordinati per data.</summary>
public List<ChartSeriesPoint> SeriesPoints { get; set; } = new();
}
```
- [ ] **Step 2: Verifica compilazione**
```powershell
dotnet build CertReports.Syncfusion
```
Expected: `Build succeeded. 0 Error(s)`
- [ ] **Step 3: Commit**
```bash
git add CertReports.Syncfusion/Models/ChartModelsV2.cs
git commit -m "feat: add ChartModelsV2 (ChartUlMetadata, ChartSeriesPoint, ChartDataV2)"
```
---
## Task 2: ChartDataServiceV2
**Files:**
- Create: `CertReports.Syncfusion/Services/Implementations/ChartDataServiceV2.cs`
⚠️ Prerequisito: le SP `cedlab_Chart_UL1` e `cedlab_Chart_AllSeriesV2` devono già esistere nel DB.
- [ ] **Step 1: Crea il file**
```csharp
using CertReports.Syncfusion.Models;
using Microsoft.Data.SqlClient;
using System.Data;
namespace CertReports.Syncfusion.Services.Implementations;
/// <summary>
/// Recupera i dati per il grafico V2 con solo 2 round-trip al DB.
///
/// SP utilizzate:
/// - cedlab_Chart_UL1: Metadata sottostanti (1 query, N sottostanti)
/// - cedlab_Chart_AllSeriesV2: Tutte le serie CTF + UL in una query (TOP 350 per-serie)
/// </summary>
public interface IChartDataServiceV2
{
Task<ChartDataV2?> GetChartDataV2Async(string isin);
}
public class ChartDataServiceV2 : IChartDataServiceV2
{
private readonly string _connectionString;
private readonly ILogger<ChartDataServiceV2> _logger;
public ChartDataServiceV2(IConfiguration config, ILogger<ChartDataServiceV2> logger)
{
_connectionString = config.GetConnectionString("CertDb")
?? throw new InvalidOperationException("ConnectionString 'CertDb' non configurata.");
_logger = logger;
}
public async Task<ChartDataV2?> GetChartDataV2Async(string isin)
{
await using var conn = new SqlConnection(_connectionString);
await conn.OpenAsync();
// ── 1. Metadata sottostanti (cedlab_Chart_UL1) ─────────────────
var underlyings = new List<ChartUlMetadata>();
await using (var cmd = new SqlCommand("cedlab_Chart_UL1", conn)
{ CommandType = CommandType.StoredProcedure })
{
cmd.Parameters.AddWithValue("@isin", isin);
await using var r = await cmd.ExecuteReaderAsync();
while (await r.ReadAsync())
{
underlyings.Add(new ChartUlMetadata
{
IDCertificates = r.GetInt32(r.GetOrdinal("IDCertificates")),
IDUnderlyings = r.GetInt32(r.GetOrdinal("IDUnderlyings")),
StartDate = r.GetDateTime(r.GetOrdinal("StartDate")),
Strike = r.GetDecimal(r.GetOrdinal("Strike")),
BarrieraCouponPerc = r.GetDecimal(r.GetOrdinal("BarrieraCouponPerc")),
BarrieraCoupon = r.GetDecimal(r.GetOrdinal("BarrieraCoupon")),
BarrieraCapitalePerc = r.GetDecimal(r.GetOrdinal("BarrieraCapitalePerc")),
BarrieraCapitale = r.GetDecimal(r.GetOrdinal("BarrieraCapitale")),
Sottostante = r.GetString(r.GetOrdinal("Sottostante")),
IsWorstOf = r.GetInt32(r.GetOrdinal("IsWorstOf")),
PriceWorst = r.GetDecimal(r.GetOrdinal("PriceWorst")),
PriceWorstPerc = r.GetDecimal(r.GetOrdinal("PriceWorstPerc")),
NumPrezziCFT = r.GetInt32(r.GetOrdinal("NumPrezziCFT")),
NomeCFT = r.GetString(r.GetOrdinal("NomeCFT")),
TriggerAutocallPerc = r.GetDecimal(r.GetOrdinal("TriggerAutocallPerc")),
AutocallValue = r.GetDecimal(r.GetOrdinal("AutocallValue")),
});
}
}
if (underlyings.Count == 0)
{
_logger.LogWarning(
"Nessun sottostante trovato per il grafico V2 di {Isin} (meno di 30 prezzi EOD?)", isin);
return null;
}
var result = new ChartDataV2
{
Isin = isin,
GlobalMeta = underlyings[0], // worst-of è il primo (SP ordina IsWorstOf DESC)
Underlyings = underlyings,
};
// ── 2. Tutte le serie (cedlab_Chart_AllSeriesV2) ────────────────
await using (var cmd = new SqlCommand("cedlab_Chart_AllSeriesV2", conn)
{ CommandType = CommandType.StoredProcedure })
{
cmd.Parameters.AddWithValue("@isin", isin);
await using var r = await cmd.ExecuteReaderAsync();
while (await r.ReadAsync())
{
result.SeriesPoints.Add(new ChartSeriesPoint
{
IDUnderlyings = r.GetInt32(r.GetOrdinal("IDUnderlyings")),
Date = r.GetDateTime(r.GetOrdinal("Px_date")),
Performance = r.GetDecimal(r.GetOrdinal("Performance")),
});
}
}
_logger.LogInformation(
"Dati grafico V2 caricati per {Isin}: {UlCount} sottostanti, {Points} punti totali",
isin, underlyings.Count, result.SeriesPoints.Count);
return result;
}
}
```
- [ ] **Step 2: Verifica compilazione**
```powershell
dotnet build CertReports.Syncfusion
```
Expected: `Build succeeded. 0 Error(s)`
- [ ] **Step 3: Commit**
```bash
git add CertReports.Syncfusion/Services/Implementations/ChartDataServiceV2.cs
git commit -m "feat: add ChartDataServiceV2 with IChartDataServiceV2 (2-SP approach)"
```
---
## Task 3: SkiaChartRendererV2
**Files:**
- Create: `CertReports.Syncfusion/Services/Implementations/SkiaChartRendererV2.cs`
- [ ] **Step 1: Crea il file** (`~380 righe`)
```csharp
using CertReports.Syncfusion.Models;
using SkiaSharp;
namespace CertReports.Syncfusion.Services.Implementations;
/// <summary>
/// Renderer grafico V2 per certificati con SkiaSharp.
///
/// Miglioramenti rispetto a v1:
/// - Titolo in cima (NomeCFT + avviso se < 30 prezzi)
/// - CTF in rosso (#CC0000), WorstOf in blu (#1565C0), altri in grigio
/// - Linee costanti con label direttamente sull'estremità destra
/// - Legenda orizzontale in BASSO (non a destra)
/// - Linea tratteggiata blu per prezzo attuale worst-of (non in legenda)
/// </summary>
public static class SkiaChartRendererV2
{
// ── Colori V2 ──────────────────────────────────────────────────────
private static readonly SKColor CertColor = new(204, 0, 0); // #CC0000 rosso CTF
private static readonly SKColor WorstOfColor = new(21, 101, 192); // #1565C0 blu WorstOf
private static readonly SKColor StrikeColor = new(46, 125, 50); // #2E7D32 verde
private static readonly SKColor CapitaleColor = new(204, 0, 0); // rosso (= CTF)
private static readonly SKColor CouponColor = new(128, 0, 128); // viola
private static readonly SKColor AutocallColor = new(230, 81, 0); // arancione
private static readonly SKColor PrezzoWorstColor = new(21, 101, 192); // blu tratteggiato
private static readonly SKColor TitleColor = new(21, 101, 192); // blu titolo
private static readonly SKColor[] OtherUlColors =
{
new(120, 120, 120),
new(160, 160, 160),
new(90, 90, 90),
new(140, 140, 140),
};
// ── Font ───────────────────────────────────────────────────────────
private static SKFont CreateFont(float size, bool bold = false) =>
new(SKTypeface.FromFamilyName("Arial", bold ? SKFontStyle.Bold : SKFontStyle.Normal), size);
// ═══════════════════════════════════════════════════════════════════
// Entry point
// ═══════════════════════════════════════════════════════════════════
/// <summary>Genera il grafico come PNG.</summary>
public static byte[] RenderToPng(ChartDataV2 data, int width = 1100, int height = 700)
{
using var surface = SKSurface.Create(new SKImageInfo(width, height));
var canvas = surface.Canvas;
canvas.Clear(SKColors.White);
// ── Titolo ─────────────────────────────────────────────────────
float titleBottom = DrawTitle(canvas, width, data);
// ── Margini area plot ──────────────────────────────────────────
float marginLeft = 70;
float marginRight = 210; // spazio per label linee costanti
float marginTop = titleBottom + 10;
float marginBottom = 95; // asse X + legenda orizzontale
var plotArea = new SKRect(marginLeft, marginTop, width - marginRight, height - marginBottom);
// ── Raggruppa punti per serie ──────────────────────────────────
var worstOf = data.Underlyings.FirstOrDefault(u => u.IsWorstOf == 1);
var seriesByUl = data.SeriesPoints
.GroupBy(p => p.IDUnderlyings)
.ToDictionary(g => g.Key, g => g.OrderBy(p => p.Date).ToList());
if (seriesByUl.Count == 0)
return Array.Empty<byte>();
// ── Calcola range assi ─────────────────────────────────────────
var (minDate, maxDate, minY, maxY) = CalculateRanges(data, seriesByUl, worstOf);
// ── Griglia e assi ─────────────────────────────────────────────
DrawGrid(canvas, plotArea, minDate, maxDate, minY, maxY);
DrawAxisLabels(canvas, plotArea, minDate, maxDate, minY, maxY);
// ── Linee costanti con label ───────────────────────────────────
var constLegend = new List<(string name, SKColor color, bool dashed, float thickness)>();
// Barriera Capitale (label unificata se stessa % di Coupon)
string bcLabel = data.GlobalMeta.BarrieraCouponPerc == data.GlobalMeta.BarrieraCapitalePerc
? $"Barriera {data.GlobalMeta.BarrieraCapitalePerc:0}% ({data.GlobalMeta.BarrieraCapitale:0.00})"
: $"Barriera Capitale {data.GlobalMeta.BarrieraCapitalePerc:0}% ({data.GlobalMeta.BarrieraCapitale:0.00})";
DrawHorizontalLineWithLabel(canvas, plotArea, minY, maxY,
(float)data.GlobalMeta.BarrieraCapitalePerc, CapitaleColor, 1.5f, false, bcLabel);
constLegend.Add((bcLabel, CapitaleColor, false, 1.5f));
// Barriera Coupon (solo se diversa)
if (data.GlobalMeta.BarrieraCouponPerc != data.GlobalMeta.BarrieraCapitalePerc
&& data.GlobalMeta.BarrieraCouponPerc > 0)
{
string bkLabel = $"Barriera Coupon {data.GlobalMeta.BarrieraCouponPerc:0}% ({data.GlobalMeta.BarrieraCoupon:0.00})";
DrawHorizontalLineWithLabel(canvas, plotArea, minY, maxY,
(float)data.GlobalMeta.BarrieraCouponPerc, CouponColor, 1.5f, false, bkLabel);
constLegend.Add((bkLabel, CouponColor, false, 1.5f));
}
// Strike
string strikeLabel = $"Strike 100% ({data.GlobalMeta.Strike:0.00})";
DrawHorizontalLineWithLabel(canvas, plotArea, minY, maxY,
100f, StrikeColor, 1.5f, false, strikeLabel);
constLegend.Add((strikeLabel, StrikeColor, false, 1.5f));
// Trigger Autocall (solo se applicabile)
bool showAutocall = data.GlobalMeta.TriggerAutocallPerc != 0
&& data.GlobalMeta.TriggerAutocallPerc != 100
&& data.GlobalMeta.TriggerAutocallPerc != data.GlobalMeta.BarrieraCapitalePerc
&& data.GlobalMeta.TriggerAutocallPerc != data.GlobalMeta.BarrieraCouponPerc;
if (showAutocall)
{
string taLabel = $"Trigger Autocall {data.GlobalMeta.TriggerAutocallPerc:0}% ({data.GlobalMeta.AutocallValue:0.00})";
DrawHorizontalLineWithLabel(canvas, plotArea, minY, maxY,
(float)data.GlobalMeta.TriggerAutocallPerc, AutocallColor, 1.5f, false, taLabel);
constLegend.Add((taLabel, AutocallColor, false, 1.5f));
}
// Prezzo attuale WorstOf — tratteggiato, NON in legenda
if (worstOf != null && worstOf.PriceWorstPerc > 0)
{
string pwLabel = $"{worstOf.Sottostante} ({worstOf.PriceWorst:0.00})";
DrawHorizontalLineWithLabel(canvas, plotArea, minY, maxY,
(float)worstOf.PriceWorstPerc, PrezzoWorstColor, 1f, true, pwLabel);
}
// ── Serie ──────────────────────────────────────────────────────
var seriesLegend = new List<(string name, SKColor color, bool dashed, float thickness)>();
int otherColorIdx = 0;
// CTF (IDUnderlyings = 0)
if (seriesByUl.TryGetValue(0, out var ctfPoints) && ctfPoints.Count >= 2)
{
DrawSeriesV2(canvas, plotArea, ctfPoints, minDate, maxDate, minY, maxY, CertColor, 2.5f);
seriesLegend.Add((data.Isin, CertColor, false, 2.5f));
}
// Sottostanti (WorstOf prima, poi altri)
foreach (var ul in data.Underlyings.OrderByDescending(u => u.IsWorstOf))
{
if (!seriesByUl.TryGetValue(ul.IDUnderlyings, out var ulPoints) || ulPoints.Count < 2)
continue;
SKColor color;
float thickness;
if (ul.IsWorstOf == 1)
{
color = WorstOfColor;
thickness = 2f;
}
else
{
color = OtherUlColors[otherColorIdx++ % OtherUlColors.Length];
thickness = 1f;
}
DrawSeriesV2(canvas, plotArea, ulPoints, minDate, maxDate, minY, maxY, color, thickness);
seriesLegend.Add((ul.Sottostante, color, false, thickness));
}
// ── Bordo area plot ────────────────────────────────────────────
using var borderPaint = new SKPaint
{
Color = SKColors.Gray, StrokeWidth = 1,
Style = SKPaintStyle.Stroke, IsAntialias = true,
};
canvas.DrawRect(plotArea, borderPaint);
// ── Legenda orizzontale in basso ───────────────────────────────
var allLegend = seriesLegend.Concat(constLegend).ToList();
DrawLegendBottom(canvas, plotArea, allLegend, width);
// ── Export PNG ─────────────────────────────────────────────────
using var image = surface.Snapshot();
using var pngData = image.Encode(SKEncodedImageFormat.Png, 95);
return pngData.ToArray();
}
// ═══════════════════════════════════════════════════════════════════
// Titolo
// ═══════════════════════════════════════════════════════════════════
private static float DrawTitle(SKCanvas canvas, int width, ChartDataV2 data)
{
float y = 15f;
// Riga 1: NomeCFT in grassetto blu
using var boldFont = CreateFont(13f, bold: true);
using var titlePaint = new SKPaint { Color = TitleColor, IsAntialias = true };
canvas.DrawText(data.GlobalMeta.NomeCFT, width / 2f, y + 13, SKTextAlign.Center, boldFont, titlePaint);
y += 22;
// Riga 2 (opzionale): avviso se meno di 30 prezzi CTF
if (data.GlobalMeta.NumPrezziCFT < 30)
{
using var subFont = CreateFont(10f);
using var subPaint = new SKPaint { Color = new SKColor(204, 0, 0), IsAntialias = true };
const string subtitle = "Il certificato viene mostrato nel grafico solo dopo 30gg dalla sua emissione";
canvas.DrawText(subtitle, width / 2f, y + 11, SKTextAlign.Center, subFont, subPaint);
y += 18;
}
return y + 5; // bottom della zona titolo
}
// ═══════════════════════════════════════════════════════════════════
// Calcolo range assi
// ═══════════════════════════════════════════════════════════════════
private static (DateTime minDate, DateTime maxDate, double minY, double maxY) CalculateRanges(
ChartDataV2 data,
Dictionary<int, List<ChartSeriesPoint>> seriesByUl,
ChartUlMetadata? worstOf)
{
DateTime minDate = DateTime.MaxValue, maxDate = DateTime.MinValue;
double minY = double.MaxValue, maxY = double.MinValue;
foreach (var pts in seriesByUl.Values)
{
foreach (var pt in pts)
{
if (pt.Date < minDate) minDate = pt.Date;
if (pt.Date > maxDate) maxDate = pt.Date;
double v = (double)pt.Performance;
if (v < minY) minY = v;
if (v > maxY) maxY = v;
}
}
// Includi linee costanti nel range
var constants = new List<double>
{
100.0,
(double)data.GlobalMeta.BarrieraCapitalePerc,
};
if (data.GlobalMeta.BarrieraCouponPerc > 0)
constants.Add((double)data.GlobalMeta.BarrieraCouponPerc);
if (showAutocallValue(data))
constants.Add((double)data.GlobalMeta.TriggerAutocallPerc);
if (worstOf != null && worstOf.PriceWorstPerc > 0)
constants.Add((double)worstOf.PriceWorstPerc);
foreach (var c in constants)
{
if (c < minY) minY = c;
if (c > maxY) maxY = c;
}
// Margine 10%
double range = maxY - minY;
if (range == 0) range = 10;
double margin = range * 0.1;
minY -= margin;
maxY += margin;
return (minDate, maxDate, minY, maxY);
}
private static bool showAutocallValue(ChartDataV2 data) =>
data.GlobalMeta.TriggerAutocallPerc != 0
&& data.GlobalMeta.TriggerAutocallPerc != 100
&& data.GlobalMeta.TriggerAutocallPerc != data.GlobalMeta.BarrieraCapitalePerc
&& data.GlobalMeta.TriggerAutocallPerc != data.GlobalMeta.BarrieraCouponPerc;
// ═══════════════════════════════════════════════════════════════════
// Griglia
// ═══════════════════════════════════════════════════════════════════
private static void DrawGrid(SKCanvas canvas, SKRect area,
DateTime minDate, DateTime maxDate, double minY, double maxY)
{
using var gridPaint = new SKPaint
{
Color = new SKColor(230, 230, 230), StrokeWidth = 0.5f,
Style = SKPaintStyle.Stroke, IsAntialias = true,
};
int ySteps = 8;
for (int i = 0; i <= ySteps; i++)
{
float y = area.Top + (area.Height / ySteps) * i;
canvas.DrawLine(area.Left, y, area.Right, y, gridPaint);
}
var totalDays = (maxDate - minDate).TotalDays;
int step = totalDays > 1000 ? 365 : totalDays > 500 ? 180 : 90;
var d = new DateTime(minDate.Year, minDate.Month > 6 ? 7 : 1, 1);
while (d <= maxDate)
{
float x = DateToX(d, area, minDate, maxDate);
if (x >= area.Left && x <= area.Right)
canvas.DrawLine(x, area.Top, x, area.Bottom, gridPaint);
d = d.AddDays(step);
}
}
// ═══════════════════════════════════════════════════════════════════
// Labels assi
// ═══════════════════════════════════════════════════════════════════
private static void DrawAxisLabels(SKCanvas canvas, SKRect area,
DateTime minDate, DateTime maxDate, double minY, double maxY)
{
using var font = CreateFont(11);
using var paint = new SKPaint { Color = SKColors.DimGray, IsAntialias = true };
int ySteps = 8;
for (int i = 0; i <= ySteps; i++)
{
double val = maxY - ((maxY - minY) / ySteps) * i;
float y = area.Top + (area.Height / ySteps) * i;
canvas.DrawText($"{val:F0} %", area.Left - 55, y + 4, SKTextAlign.Left, font, paint);
}
var totalDays = (maxDate - minDate).TotalDays;
int step = totalDays > 1000 ? 365 : totalDays > 500 ? 180 : 90;
var d = new DateTime(minDate.Year, minDate.Month > 6 ? 7 : 1, 1);
while (d <= maxDate)
{
float x = DateToX(d, area, minDate, maxDate);
if (x >= area.Left && x <= area.Right)
{
string text = totalDays > 500 ? d.ToString("yyyy") : d.ToString("MMM yyyy");
canvas.DrawText(text, x, area.Bottom + 20, SKTextAlign.Center, font, paint);
}
d = d.AddDays(step);
}
}
// ═══════════════════════════════════════════════════════════════════
// Disegno serie
// ═══════════════════════════════════════════════════════════════════
private static void DrawSeriesV2(SKCanvas canvas, SKRect area,
List<ChartSeriesPoint> points,
DateTime minDate, DateTime maxDate, double minY, double maxY,
SKColor color, float thickness)
{
using var paint = new SKPaint
{
Color = color, StrokeWidth = thickness,
Style = SKPaintStyle.Stroke, IsAntialias = true,
StrokeCap = SKStrokeCap.Round, StrokeJoin = SKStrokeJoin.Round,
};
using var path = new SKPath();
bool first = true;
foreach (var pt in points)
{
float x = DateToX(pt.Date, area, minDate, maxDate);
float y = ValueToY((double)pt.Performance, area, minY, maxY);
y = Math.Max(area.Top, Math.Min(area.Bottom, y));
if (first) { path.MoveTo(x, y); first = false; }
else path.LineTo(x, y);
}
canvas.Save();
canvas.ClipRect(area);
canvas.DrawPath(path, paint);
canvas.Restore();
}
// ═══════════════════════════════════════════════════════════════════
// Linea orizzontale costante con label a destra
// ═══════════════════════════════════════════════════════════════════
private static void DrawHorizontalLineWithLabel(SKCanvas canvas, SKRect area,
double minY, double maxY,
float value, SKColor color, float thickness, bool dashed, string label)
{
float y = ValueToY(value, area, minY, maxY);
if (y < area.Top || y > area.Bottom) return;
using var linePaint = new SKPaint
{
Color = color, StrokeWidth = thickness,
Style = SKPaintStyle.Stroke, IsAntialias = true,
};
if (dashed)
linePaint.PathEffect = SKPathEffect.CreateDash(new[] { 8f, 4f }, 0);
canvas.DrawLine(area.Left, y, area.Right, y, linePaint);
// Label a destra della linea
using var font = CreateFont(9.5f);
using var textPaint = new SKPaint { Color = color, IsAntialias = true };
canvas.DrawText(label, area.Right + 5, y + 4, SKTextAlign.Left, font, textPaint);
}
// ═══════════════════════════════════════════════════════════════════
// Legenda orizzontale in basso (flow layout, max 2 righe)
// ═══════════════════════════════════════════════════════════════════
private static void DrawLegendBottom(SKCanvas canvas, SKRect plotArea,
List<(string name, SKColor color, bool dashed, float thickness)> items, int totalWidth)
{
if (items.Count == 0) return;
float legendY = plotArea.Bottom + 38; // sotto le label asse X
float x = plotArea.Left;
const float lineW = 22;
const float gap = 6;
const float itemGap = 14;
const float rowHeight = 19;
using var font = CreateFont(10f);
foreach (var (name, color, dashed, thickness) in items)
{
float textW = font.MeasureText(name);
float itemW = lineW + gap + textW + itemGap;
// Vai a capo se non c'è spazio
if (x + itemW > totalWidth - plotArea.Left + plotArea.Left && x > plotArea.Left)
{
x = plotArea.Left;
legendY += rowHeight;
}
float midY = legendY + rowHeight / 2f - 2;
// Linea campione
using var linePaint = new SKPaint
{
Color = color, StrokeWidth = Math.Min(thickness, 2f),
Style = SKPaintStyle.Stroke, IsAntialias = true,
};
if (dashed)
linePaint.PathEffect = SKPathEffect.CreateDash(new[] { 6f, 3f }, 0);
canvas.DrawLine(x, midY, x + lineW, midY, linePaint);
// Testo
using var textPaint = new SKPaint { Color = SKColors.DimGray, IsAntialias = true };
canvas.DrawText(name, x + lineW + gap, midY + 4, SKTextAlign.Left, font, textPaint);
x += itemW;
}
}
// ═══════════════════════════════════════════════════════════════════
// Conversioni coordinate (identiche a v1)
// ═══════════════════════════════════════════════════════════════════
private static float DateToX(DateTime date, SKRect area, DateTime minDate, DateTime maxDate)
{
double totalDays = (maxDate - minDate).TotalDays;
if (totalDays == 0) return area.Left;
double ratio = (date - minDate).TotalDays / totalDays;
return area.Left + (float)(ratio * area.Width);
}
private static float ValueToY(double value, SKRect area, double minY, double maxY)
{
double range = maxY - minY;
if (range == 0) return area.MidY;
double ratio = (value - minY) / range;
return area.Bottom - (float)(ratio * area.Height);
}
}
```
- [ ] **Step 2: Verifica compilazione**
```powershell
dotnet build CertReports.Syncfusion
```
Expected: `Build succeeded. 0 Error(s)`
> 💡 Se il build fallisce su `showAutocallValue` (metodo privato statico con nome in minuscolo): rinomina in `ShowAutocallValue` e aggiorna i due riferimenti in `CalculateRanges` e nel body di `RenderToPng`.
- [ ] **Step 3: Commit**
```bash
git add CertReports.Syncfusion/Services/Implementations/SkiaChartRendererV2.cs
git commit -m "feat: add SkiaChartRendererV2 (title, colored series, line labels, bottom legend)"
```
---
## Task 4: Controller V2 + DI
**Files:**
- Modify: `CertReports.Syncfusion/Controllers/ChartController.cs`
- Modify: `CertReports.Syncfusion/Program.cs`
- [ ] **Step 1: Aggiorna `ChartController.cs` — aggiungi injection e action V2**
Aggiungi `IChartDataServiceV2 _chartDataServiceV2` al controller. Modifica il costruttore e aggiungi il nuovo action method:
Nella sezione campi (dopo `private readonly IChartDataService _chartDataService;`):
```csharp
private readonly IChartDataServiceV2 _chartDataServiceV2;
```
Sostituisci il costruttore esistente:
```csharp
// PRIMA:
public ChartController(IChartDataService chartDataService, ILogger<ChartController> logger)
{
_chartDataService = chartDataService;
_logger = logger;
}
// DOPO:
public ChartController(
IChartDataService chartDataService,
IChartDataServiceV2 chartDataServiceV2,
ILogger<ChartController> logger)
{
_chartDataService = chartDataService;
_chartDataServiceV2 = chartDataServiceV2;
_logger = logger;
}
```
Aggiungi il nuovo action method subito dopo il `}` di `GenerateChart` e prima di `WrapPngInPdf`:
```csharp
/// <summary>
/// Endpoint V2: grafico migliorato con titolo, colori distinti, label sulle linee e legenda in basso.
/// Richiede SP cedlab_Chart_UL1 e cedlab_Chart_AllSeriesV2 nel DB.
/// </summary>
[HttpGet("v2/{isin}")]
public async Task<IActionResult> GenerateChartV2(
string isin,
[FromQuery] int width = 1100,
[FromQuery] int height = 700,
[FromQuery] string format = "png")
{
if (string.IsNullOrWhiteSpace(isin))
return BadRequest("ISIN non valido.");
width = Math.Clamp(width, 400, 2000);
height = Math.Clamp(height, 300, 1500);
try
{
var chartData = await _chartDataServiceV2.GetChartDataV2Async(isin);
if (chartData == null || chartData.SeriesPoints.Count == 0)
{
return NotFound(new
{
status = "KO",
message = $"Nessun dato per il grafico V2 di {isin} (meno di 30 prezzi EOD?).",
});
}
byte[] pngBytes = SkiaChartRendererV2.RenderToPng(chartData, width, height);
if (format.Equals("pdf", StringComparison.OrdinalIgnoreCase))
{
byte[] pdfBytes = WrapPngInPdf(pngBytes);
Response.Headers.Append("Content-Disposition", $"inline; filename=chart_v2_{isin}.pdf");
return File(pdfBytes, "application/pdf");
}
Response.Headers.Append("Content-Disposition", $"inline; filename=chart_v2_{isin}.png");
return File(pngBytes, "image/png");
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore generazione chart V2 per ISIN {Isin}", isin);
return StatusCode(500, new { status = "KO", message = "Errore nella generazione del grafico V2." });
}
}
```
- [ ] **Step 2: Registra il servizio in `Program.cs`**
Aggiungi questa riga subito dopo `builder.Services.AddScoped<IChartDataService, ChartDataService>();`:
```csharp
builder.Services.AddScoped<IChartDataServiceV2, ChartDataServiceV2>();
```
- [ ] **Step 3: Verifica compilazione**
```powershell
dotnet build CertReports.Syncfusion
```
Expected: `Build succeeded. 0 Error(s)`
- [ ] **Step 4: Test manuale — avvia l'API**
```powershell
dotnet run --project CertReports.Syncfusion
```
- [ ] **Step 5: Chiama il nuovo endpoint**
```
GET https://localhost:{porta}/api/chart/v2/{ISIN}
GET https://localhost:{porta}/api/chart/v2/{ISIN}?format=pdf
GET https://localhost:{porta}/api/chart/v2/{ISIN}?width=1400&height=800
```
Verifica nel PNG/PDF che:
- [ ] Titolo NomeCFT visibile in blu in cima
- [ ] Linea certificato in **rosso** (non nera come v1)
- [ ] Linea worst-of in **blu** più spessa degli altri UL
- [ ] Linee barriere con **label testuale** a destra (non solo in legenda)
- [ ] Legenda **in basso** (non a destra)
- [ ] Linea tratteggiata blu per prezzo attuale worst-of (se PriceWorstPerc > 0)
- [ ] Se `NumPrezziCFT < 30`: sub-titolo rosso con avviso
- [ ] **Step 6: Commit**
```bash
git add CertReports.Syncfusion/Controllers/ChartController.cs
git add CertReports.Syncfusion/Program.cs
git commit -m "feat: add /api/chart/v2/{isin} endpoint and register IChartDataServiceV2"
```
---
## Self-Review
### Spec coverage
- ✅ 2 SP (cedlab_Chart_UL1 + cedlab_Chart_AllSeriesV2) → Task 2
- ✅ Modelli ChartUlMetadata, ChartSeriesPoint, ChartDataV2 → Task 1
- ✅ Titolo NomeCFT + sub-titolo se < 30 prezzi → Task 3 `DrawTitle`
- ✅ CTF rosso 2.5px, WorstOf blu 2px, altri grigi 1px → Task 3 serie loop
- ✅ Label su linee costanti (Strike, Barriera Capitale, Coupon, Autocall) → Task 3 `DrawHorizontalLineWithLabel`
- ✅ Label unificata se BarrieraCouponPerc == BarrieraCapitalePerc → Task 3
- ✅ TriggerAutocall: solo se != 0 && != 100 && != Capitale && != Coupon → Task 3
- ✅ PrezzoWorst tratteggiato, NON in legenda → Task 3
- ✅ Legenda orizzontale in basso → Task 3 `DrawLegendBottom`
- ✅ Endpoint `/api/chart/v2/{isin}` con ?width ?height ?format → Task 4
- ✅ Riuso di WrapPngInPdf dal v1 → Task 4
- ✅ Registrazione DI → Task 4
### Note implementative
- Il `DrawLegendBottom` usa un layout flow. Se `x + itemW > totalWidth - plotArea.Left + plotArea.Left` la condizione si semplifica a `x + itemW > totalWidth`. Verificare a runtime se la legenda deborda; se sì, diminuire il font a 9.5f o ridurre `itemGap`.
- `marginBottom = 95` dà spazio per asse X (20px) + 2 righe di legenda (38px). Se gli item leggenda sono molti, aumentare a 110.