chore: initial commit - baseline before redesign
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
using CertReports.Syncfusion.Helpers;
|
||||
using CertReports.Syncfusion.Models;
|
||||
using CertReports.Syncfusion.Services.Interfaces;
|
||||
using Syncfusion.Drawing;
|
||||
using Syncfusion.Pdf;
|
||||
using Syncfusion.Pdf.Graphics;
|
||||
using Syncfusion.Pdf.Grid;
|
||||
|
||||
namespace CertReports.Syncfusion.Services.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Sezione 1: Dati anagrafici del certificato + sottostanti.
|
||||
/// Dati da: rpt_Master_CFT_ISIN + rpt_Details_UL_ISIN
|
||||
/// </summary>
|
||||
public class AnagraficaSectionRenderer : IPdfSectionRenderer
|
||||
{
|
||||
public string SectionName => "Anagrafica";
|
||||
public int Order => 1;
|
||||
|
||||
public PdfDocument Render(CertificateReportData data)
|
||||
{
|
||||
var doc = new PdfDocument();
|
||||
var page = PdfTheme.AddA4Page(doc);
|
||||
var g = page.Graphics;
|
||||
float y = 0;
|
||||
float w = page.GetClientSize().Width;
|
||||
var info = data.Info;
|
||||
|
||||
// ── Titolo ─────────────────────────────────────────────────────
|
||||
g.DrawString($"Scheda Prodotto {info.Isin}", PdfTheme.Title, PdfTheme.TextBrush,
|
||||
new RectangleF(0, y, w, 25));
|
||||
y += 28;
|
||||
|
||||
if (!string.IsNullOrEmpty(info.Categoria))
|
||||
{
|
||||
g.DrawString($"Tipologia: {info.Categoria}", PdfTheme.SectionTitleFont,
|
||||
new PdfSolidBrush(PdfTheme.SectionTitle), new RectangleF(0, y, w, 18));
|
||||
y += 22;
|
||||
}
|
||||
|
||||
// ── Due colonne: Caratteristiche (sx) + Info emittente (dx) ────
|
||||
float colW = (w - 20) / 2;
|
||||
|
||||
// Colonna sinistra
|
||||
float ly = DrawSectionHeader(g, "Caratteristiche Prodotto", 0, colW, y);
|
||||
ly = DrawKV(g, 0, colW, ly, new()
|
||||
{
|
||||
["Cedola Annua"] = info.NominalAnnualYield,
|
||||
["Valore Nominale"] = info.NominalValue?.ToString("F0") ?? "-",
|
||||
["Prezzo Emissione"] = info.PrezzoEmissione?.ToString("F0") ?? "-",
|
||||
["Memoria"] = info.Memory,
|
||||
["Frequenza Cedole"] = info.FrequenzaCedole,
|
||||
["Tipo Barriera"] = info.BarrierType,
|
||||
["Tipo Basket"] = info.BasketType,
|
||||
["Livello Barriera"] = info.LivelloBarriera,
|
||||
["Direzione"] = info.Direzione,
|
||||
["Airbag"] = info.Airbag,
|
||||
["Leva"] = info.Leva,
|
||||
});
|
||||
|
||||
// Colonna destra
|
||||
float rx = colW + 20;
|
||||
float ry = DrawSectionHeader(g, "Informazioni", rx, colW, y);
|
||||
ry = DrawKV(g, rx, colW, ry, new()
|
||||
{
|
||||
["Emittente"] = info.Emittente,
|
||||
["ISIN"] = info.Isin,
|
||||
["Mercato"] = info.Mercato,
|
||||
["Valuta"] = info.Valuta,
|
||||
["Data Emissione"] = info.DataEmissione,
|
||||
["Data Scadenza"] = info.Scadenza,
|
||||
["Prossimo Autocall"] = info.NextAutocallDate,
|
||||
["Data Rimborso"] = info.DataRimborso,
|
||||
["Nome"] = info.Nome,
|
||||
});
|
||||
|
||||
y = Math.Max(ly, ry) + 12;
|
||||
|
||||
// ── Prezzi ─────────────────────────────────────────────────────
|
||||
ly = DrawSectionHeader(g, "Prezzi", 0, colW, y);
|
||||
ly = DrawKV(g, 0, colW, ly, new()
|
||||
{
|
||||
["Bid"] = info.Bid,
|
||||
["Ask"] = info.Ask,
|
||||
["Data/Ora Prezzo"] = info.LastPriceDate,
|
||||
["Valore Intrinseco"] = info.IntrinsicValue,
|
||||
["Premio"] = info.Premium,
|
||||
["RTS"] = info.RTS,
|
||||
["VaR 95%"] = info.Var95,
|
||||
});
|
||||
|
||||
// ── Rendimenti ─────────────────────────────────────────────────
|
||||
ry = DrawSectionHeader(g, "Rendimenti e Protezioni", rx, colW, y);
|
||||
ry = DrawKV(g, rx, colW, ry, new()
|
||||
{
|
||||
["Rend. Capitale a Scadenza"] = info.CapitalReturnAtMaturity,
|
||||
["Rend. Annuo Capitale Scad."] = info.CapitalAnnualReturnAtMaturity,
|
||||
["Rend. Autocall"] = info.AutocallReturn,
|
||||
["Rend. Annuo Autocall"] = info.AutocallAnnualReturn,
|
||||
["Distanza Autocall"] = info.TriggerAutocallDistance,
|
||||
["IRR"] = info.IRR,
|
||||
["Rendimento Totale"] = info.RendimentoTotale,
|
||||
["Rendimento Attuale"] = info.RendimentoAttuale,
|
||||
["Protezione Capitale"] = info.BufferKProt,
|
||||
["Protezione Coupon"] = info.BufferCPNProt,
|
||||
});
|
||||
|
||||
y = Math.Max(ly, ry) + 12;
|
||||
|
||||
// ── Cedole e Valori ────────────────────────────────────────────
|
||||
ly = DrawSectionHeader(g, "Cedole", 0, colW, y);
|
||||
ly = DrawKV(g, 0, colW, ly, new()
|
||||
{
|
||||
["Cedola Annua"] = info.NominalAnnualYield,
|
||||
["Coupon Yield"] = info.CouponYield,
|
||||
["Potential Coupon Yield"] = info.PotentialCouponYield,
|
||||
["Minimum Yield"] = info.MinimumYield,
|
||||
["Cedole Pagate"] = info.CpnPagati?.ToString("F2") ?? "-",
|
||||
["Cedole da Pagare"] = info.CpnDaPagare?.ToString("F2") ?? "-",
|
||||
["Cedole in Memoria"] = info.CpnInMemoria?.ToString("F2") ?? "-",
|
||||
});
|
||||
|
||||
ry = DrawSectionHeader(g, "Valori", rx, colW, y);
|
||||
ry = DrawKV(g, rx, colW, ry, new()
|
||||
{
|
||||
["Valore Capitale"] = info.CapitalValue,
|
||||
["Valore Autocall"] = info.AutocallValue,
|
||||
["Valore Rimborso"] = info.ValoreRimborso,
|
||||
["Fattore Airbag"] = info.FattoreAirbag,
|
||||
["Trigger OneStar"] = info.TriggerOneStar,
|
||||
});
|
||||
|
||||
y = Math.Max(ly, ry) + 15;
|
||||
|
||||
// ── Tabella Sottostanti ────────────────────────────────────────
|
||||
if (info.Sottostanti.Count > 0)
|
||||
{
|
||||
// Se non c'è spazio nella pagina corrente, aggiungi nuova pagina
|
||||
if (y > page.GetClientSize().Height - 150)
|
||||
{
|
||||
page = doc.Pages.Add();
|
||||
g = page.Graphics;
|
||||
y = 0;
|
||||
}
|
||||
|
||||
y = DrawSottostantiTable(g, info.Sottostanti, w, y);
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Sottostanti
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
private float DrawSottostantiTable(PdfGraphics g, List<Sottostante> sottostanti, float width, float y)
|
||||
{
|
||||
y = DrawSectionHeader(g, "Analisi Sottostanti", 0, width, y);
|
||||
|
||||
var grid = new PdfGrid();
|
||||
grid.Style.CellPadding = new PdfPaddings(3, 3, 2, 2);
|
||||
|
||||
string[] headers = { "Nome", "Strike", "Last", "% Perf.", "Barr.K", "Buffer K",
|
||||
"Barr.CPN", "Buffer CPN", "Trigger AC", "Dist.AC" };
|
||||
|
||||
foreach (var _ in headers) grid.Columns.Add();
|
||||
|
||||
// Header
|
||||
var hr = grid.Headers.Add(1)[0];
|
||||
for (int i = 0; i < headers.Length; i++)
|
||||
{
|
||||
hr.Cells[i].Value = headers[i];
|
||||
hr.Cells[i].Style.Font = PdfTheme.Header;
|
||||
hr.Cells[i].Style.BackgroundBrush = PdfTheme.HeaderBrush;
|
||||
hr.Cells[i].Style.TextBrush = PdfTheme.HeaderTextBrush as PdfBrush;
|
||||
hr.Cells[i].StringFormat = new PdfStringFormat(PdfTextAlignment.Center);
|
||||
}
|
||||
hr.Cells[0].StringFormat = new PdfStringFormat(PdfTextAlignment.Left);
|
||||
|
||||
// Righe
|
||||
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.ULTriggerAutocallDistance;
|
||||
|
||||
foreach (var cell in row.Cells.OfType<PdfGridCell>())
|
||||
{
|
||||
cell.Style.Font = PdfTheme.Small;
|
||||
cell.StringFormat = new PdfStringFormat(PdfTextAlignment.Right);
|
||||
}
|
||||
row.Cells[0].StringFormat = new PdfStringFormat(PdfTextAlignment.Left);
|
||||
|
||||
if (i % 2 == 1)
|
||||
foreach (var cell in row.Cells.OfType<PdfGridCell>())
|
||||
cell.Style.BackgroundBrush = PdfTheme.AlternateRowBrush;
|
||||
}
|
||||
|
||||
// Larghezze
|
||||
float[] cw = { 80, 52, 52, 48, 52, 48, 52, 48, 52, 48 };
|
||||
float total = cw.Sum();
|
||||
float scale = width / total;
|
||||
for (int i = 0; i < cw.Length; i++)
|
||||
grid.Columns[i].Width = cw[i] * scale;
|
||||
|
||||
PdfTheme.ApplyThinBorders(grid);
|
||||
|
||||
grid.Draw(g, new PointF(0, y));
|
||||
return y + (grid.Rows.Count + 1) * PdfTheme.RowHeight + 5;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Helper di disegno
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
private float DrawSectionHeader(PdfGraphics g, string title, float x, float width, float y)
|
||||
{
|
||||
g.DrawRectangle(PdfTheme.HeaderBrush, new RectangleF(x, y, width, PdfTheme.HeaderRowHeight));
|
||||
g.DrawString(title, PdfTheme.Header, PdfTheme.HeaderTextBrush,
|
||||
new RectangleF(x + PdfTheme.CellPadding, y + 3, width, PdfTheme.HeaderRowHeight));
|
||||
return y + PdfTheme.HeaderRowHeight + 2;
|
||||
}
|
||||
|
||||
private float DrawKV(PdfGraphics g, float x, float width, float y, Dictionary<string, string> data)
|
||||
{
|
||||
float labelW = width * 0.55f;
|
||||
float valueW = width * 0.45f;
|
||||
|
||||
foreach (var kvp in data)
|
||||
{
|
||||
// Salta campi vuoti per compattare il layout
|
||||
if (string.IsNullOrWhiteSpace(kvp.Value) || kvp.Value == "-") continue;
|
||||
|
||||
g.DrawString(kvp.Key, PdfTheme.Small, PdfTheme.TextSecondaryBrush,
|
||||
new RectangleF(x + PdfTheme.CellPadding, y, labelW, PdfTheme.RowHeight));
|
||||
g.DrawString(kvp.Value, PdfTheme.SmallBold, PdfTheme.TextBrush,
|
||||
new RectangleF(x + labelW, y, valueW - PdfTheme.CellPadding, PdfTheme.RowHeight),
|
||||
new PdfStringFormat(PdfTextAlignment.Right));
|
||||
|
||||
g.DrawLine(new PdfPen(PdfTheme.BorderColor, 0.3f),
|
||||
x, y + PdfTheme.RowHeight, x + width, y + PdfTheme.RowHeight);
|
||||
|
||||
y += PdfTheme.RowHeight;
|
||||
}
|
||||
return y;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
using CertReports.Syncfusion.Models;
|
||||
using CertReports.Syncfusion.Services.Interfaces;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using System.Data;
|
||||
|
||||
namespace CertReports.Syncfusion.Services.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Accesso dati SQL Server tramite le stored procedures reali del database FirstSolutionDB.
|
||||
///
|
||||
/// SP utilizzate:
|
||||
/// - rpt_Master_CFT_ISIN: Anagrafica certificato (un singolo record)
|
||||
/// - rpt_Details_UL_ISIN: Sottostanti del certificato (N record)
|
||||
/// - rpt_Events_CFT_ISIN: Eventi del certificato (N record)
|
||||
/// - rpt_AnalisiRischio_ISIN: Analisi scenario pivot (3 righe × 11 colonne)
|
||||
/// - rpt_FindIsinbyAliasID: Risoluzione alias → ISIN
|
||||
///
|
||||
/// NOTA: Le SP restituiscono la maggior parte dei valori già formattati come stringhe
|
||||
/// (FORMAT(...,'P2'), FORMAT(...,'F2'), etc.). I modelli usano string per questi campi
|
||||
/// per evitare parsing/riformattazione non necessaria.
|
||||
/// </summary>
|
||||
public class CertificateDataService : ICertificateDataService
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly ILogger<CertificateDataService> _logger;
|
||||
|
||||
public CertificateDataService(IConfiguration config, ILogger<CertificateDataService> logger)
|
||||
{
|
||||
_connectionString = config.GetConnectionString("CertDb")
|
||||
?? throw new InvalidOperationException("ConnectionString 'CertDb' non configurata.");
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// ─── Anagrafica + Sottostanti ──────────────────────────────────────
|
||||
|
||||
public async Task<CertificateInfo> GetCertificateInfoAsync(string isin)
|
||||
{
|
||||
var info = new CertificateInfo { Isin = isin };
|
||||
|
||||
await using var conn = new SqlConnection(_connectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
// ── 1. Dati anagrafici (rpt_Master_CFT_ISIN) ──────────────────
|
||||
await using (var cmd = new SqlCommand("rpt_Master_CFT_ISIN", conn) { CommandType = CommandType.StoredProcedure })
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@ISIN", isin);
|
||||
|
||||
await using var r = await cmd.ExecuteReaderAsync();
|
||||
if (await r.ReadAsync())
|
||||
{
|
||||
info.Id = r.GetSafe<int>("ID");
|
||||
info.Emittente = r.GetStringSafe("Emittente");
|
||||
info.Nome = r.GetStringSafe("Nome");
|
||||
info.Categoria = r.GetStringSafe("Categoria");
|
||||
info.Scadenza = r.GetStringSafe("Scadenza");
|
||||
info.Valuta = r.GetStringSafe("Valuta");
|
||||
info.Mercato = r.GetStringSafe("Mercato");
|
||||
info.Direzione = r.GetStringSafe("Direzione");
|
||||
info.BasketType = r.GetStringSafe("BasketType");
|
||||
info.BarrierType = r.GetStringSafe("BarrierType");
|
||||
info.LastPriceDate = r.GetStringSafe("LastPriceDate");
|
||||
info.Ask = r.GetStringSafe("Ask");
|
||||
info.Bid = r.GetStringSafe("Bid");
|
||||
info.NextAutocallDate = r.GetStringSafe("NextAutocallDate");
|
||||
info.TriggerAutocallDistance = r.GetStringSafe("TriggerAutocallDistance");
|
||||
info.AutocallReturn = r.GetStringSafe("AutocallReturn");
|
||||
info.AutocallAnnualReturn = r.GetStringSafe("AutocallAnnualReturn");
|
||||
info.CapitalReturnAtMaturity = r.GetStringSafe("CapitalReturnAtMaturity");
|
||||
info.CapitalAnnualReturnAtMaturity = r.GetStringSafe("CapitalAnnualReturnAtMaturity");
|
||||
info.NominalAnnualYield = r.GetStringSafe("NominalAnnualYield");
|
||||
info.CouponYield = r.GetStringSafe("CouponYield");
|
||||
info.PotentialCouponYield = r.GetStringSafe("PotentialCouponYield");
|
||||
info.MinimumYield = r.GetStringSafe("MinimumYield");
|
||||
info.IntrinsicValue = r.GetStringSafe("IntrinsicValue");
|
||||
info.NominalValue = r.GetNullableDecimal("NominalValue");
|
||||
info.Premium = r.GetStringSafe("Premium");
|
||||
info.Note = r.GetStringSafe("Note");
|
||||
info.CapitalValue = r.GetStringSafe("CapitalValue");
|
||||
info.AutocallValue = r.GetStringSafe("AutocallValue");
|
||||
info.Memory = r.GetStringSafe("Memory");
|
||||
info.RTS = r.GetStringSafe("RTS");
|
||||
info.Var95 = r.GetStringSafe("Var95");
|
||||
info.BufferKProt = r.GetStringSafe("BufferKProt");
|
||||
info.BufferCPNProt = r.GetStringSafe("BufferCPNProt");
|
||||
info.IRR = r.GetStringSafe("IRR");
|
||||
info.Airbag = r.GetStringSafe("Airbag");
|
||||
info.FrequenzaCedole = r.GetStringSafe("FrequenzaCedole");
|
||||
info.LivelloBarriera = r.GetStringSafe("LivelloBarriera");
|
||||
info.DataEmissione = r.GetStringSafe("DataEmissione");
|
||||
info.RendimentoTotale = r.GetStringSafe("RendimentoTotale");
|
||||
info.ValoreRimborso = r.GetStringSafe("ValoreRimborso");
|
||||
info.DataRimborso = r.GetStringSafe("DataRimborso");
|
||||
info.Leva = r.GetStringSafe("Leva");
|
||||
info.FattoreAirbag = r.GetStringSafe("FattoreAirbag");
|
||||
info.TriggerOneStar = r.GetStringSafe("TriggerOneStar");
|
||||
info.PrezzoEmissione = r.GetNullableDecimal("PrezzoEmissione");
|
||||
info.CpnInMemoria = r.GetNullableDecimal("CpnInMemoria");
|
||||
info.CpnDaPagare = r.GetNullableDecimal("CpnDaPagare");
|
||||
info.CpnPagati = r.GetNullableDecimal("CpnPagati");
|
||||
info.RendimentoAttuale = r.GetStringSafe("RendimentoAttuale");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Sottostanti (rpt_Details_UL_ISIN) ──────────────────────
|
||||
await using (var cmd = new SqlCommand("rpt_Details_UL_ISIN", conn) { CommandType = CommandType.StoredProcedure })
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@ISIN", isin);
|
||||
|
||||
await using var r = await cmd.ExecuteReaderAsync();
|
||||
while (await r.ReadAsync())
|
||||
{
|
||||
info.Sottostanti.Add(new Sottostante
|
||||
{
|
||||
Nome = r.GetStringSafe("Nome"),
|
||||
Strike = r.GetStringSafe("Strike"),
|
||||
LastPrice = r.GetStringSafe("LastPrice"),
|
||||
Performance = r.GetStringSafe("Performance"),
|
||||
CapitalBarrier = r.GetStringSafe("CapitalBarrier"),
|
||||
CouponBarrier = r.GetStringSafe("CouponBarrier"),
|
||||
TriggerAutocall = r.GetStringSafe("TriggerAutocall"),
|
||||
ULCapitalBarrierBuffer = r.GetStringSafe("ULCapitalBarrierBuffer"),
|
||||
ULCouponBarrierBuffer = r.GetStringSafe("ULCouponBarrierBuffer"),
|
||||
ULTriggerAutocallDistance = r.GetStringSafe("ULTriggerAutocallDistance"),
|
||||
DividendExDate = r.GetStringSafe("DividendExDate"),
|
||||
DividendPayDate = r.GetStringSafe("DividendPayDate"),
|
||||
DividendAmount = r.GetStringSafe("DividendAmount"),
|
||||
DividendYield = r.GetStringSafe("DividendYield"),
|
||||
DividendFutAmount = r.GetStringSafe("DividendFutAmount"),
|
||||
DividendFutYield = r.GetStringSafe("DividendFutYield"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Anagrafica caricata per {Isin}: {Emittente}, {Count} sottostanti",
|
||||
isin, info.Emittente, info.Sottostanti.Count);
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
// ─── Eventi ────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<List<CertificateEvent>> GetCertificateEventsAsync(string isin)
|
||||
{
|
||||
var events = new List<CertificateEvent>();
|
||||
|
||||
await using var conn = new SqlConnection(_connectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
await using var cmd = new SqlCommand("rpt_Events_CFT_ISIN", conn) { CommandType = CommandType.StoredProcedure };
|
||||
cmd.Parameters.AddWithValue("@ISIN", isin);
|
||||
|
||||
await using var r = await cmd.ExecuteReaderAsync();
|
||||
while (await r.ReadAsync())
|
||||
{
|
||||
events.Add(new CertificateEvent
|
||||
{
|
||||
ObservationDate = r.GetStringSafe("ObservationDate"),
|
||||
ExDate = r.GetStringSafe("ExDate"),
|
||||
RecordDate = r.GetStringSafe("RecordDate"),
|
||||
PaymentDate = r.GetStringSafe("PaymentDate"),
|
||||
CouponTrigger = r.GetStringSafe("CouponTrigger"),
|
||||
CouponValue = r.GetStringSafe("CouponValue"),
|
||||
Paid = r.GetStringSafe("Paid"),
|
||||
Memory = r.GetStringSafe("Memory"),
|
||||
AmountPaid = r.GetStringSafe("AmountPaid"),
|
||||
CapitalTrigger = r.GetStringSafe("CapitalTrigger"),
|
||||
CapitalValue = r.GetStringSafe("CapitalValue"),
|
||||
AutocallTrigger = r.GetStringSafe("AutocallTrigger"),
|
||||
AutocallValue = r.GetStringSafe("AutocallValue"),
|
||||
});
|
||||
}
|
||||
|
||||
_logger.LogInformation("Eventi caricati per {Isin}: {Count} eventi", isin, events.Count);
|
||||
return events;
|
||||
}
|
||||
|
||||
// ─── Analisi Rischio / Scenario ────────────────────────────────────
|
||||
|
||||
public async Task<ScenarioAnalysis> GetScenarioAnalysisAsync(string isin)
|
||||
{
|
||||
var scenario = new ScenarioAnalysis();
|
||||
|
||||
await using var conn = new SqlConnection(_connectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
await using var cmd = new SqlCommand("rpt_AnalisiScenario_ISIN", conn) { CommandType = CommandType.StoredProcedure };
|
||||
cmd.Parameters.AddWithValue("@ISIN", isin);
|
||||
|
||||
await using var r = await cmd.ExecuteReaderAsync();
|
||||
while (await r.ReadAsync())
|
||||
{
|
||||
var row = new ScenarioRow
|
||||
{
|
||||
Label = r.GetStringSafe("Descrizione"),
|
||||
Values = new List<string>
|
||||
{
|
||||
r.GetStringSafe("col1"),
|
||||
r.GetStringSafe("col2"),
|
||||
r.GetStringSafe("col3"),
|
||||
r.GetStringSafe("col4"),
|
||||
r.GetStringSafe("col5"),
|
||||
r.GetStringSafe("col6"),
|
||||
r.GetStringSafe("col7"),
|
||||
r.GetStringSafe("col8"),
|
||||
r.GetStringSafe("col9"),
|
||||
r.GetStringSafe("col10"),
|
||||
r.GetStringSafe("col11"),
|
||||
r.GetStringSafe("col12"),
|
||||
r.GetStringSafe("col13"),
|
||||
}
|
||||
};
|
||||
scenario.Rows.Add(row);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Scenario caricato per {Isin}: {Count} righe", isin, scenario.Rows.Count);
|
||||
return scenario;
|
||||
}
|
||||
|
||||
// ─── Utility ───────────────────────────────────────────────────────
|
||||
|
||||
public async Task<bool> IsScenarioAnalysisAllowedAsync(string isin)
|
||||
{
|
||||
// Se la SP rpt_AnalisiRischio_ISIN restituisce righe vuote,
|
||||
// il certificato è di tipo Protection e non mostra l'analisi scenario.
|
||||
// In alternativa, puoi usare una SP dedicata o controllare la Categoria.
|
||||
var scenario = await GetScenarioAnalysisAsync(isin);
|
||||
return scenario.Rows.Any(row => row.Values.Any(v => !string.IsNullOrWhiteSpace(v)));
|
||||
}
|
||||
|
||||
public async Task<string?> FindIsinByAliasIdAsync(string aliasId)
|
||||
{
|
||||
await using var conn = new SqlConnection(_connectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
await using var cmd = new SqlCommand("rpt_FindIsinbyAliasID", conn) { CommandType = CommandType.StoredProcedure };
|
||||
cmd.Parameters.AddWithValue("@AliasID", aliasId);
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync();
|
||||
return result?.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Extension methods per SqlDataReader (null-safe)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
public static class SqlDataReaderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Legge un campo stringa, restituisce string.Empty se NULL o colonna inesistente.
|
||||
/// </summary>
|
||||
public static string GetStringSafe(this SqlDataReader reader, string column)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ordinal = reader.GetOrdinal(column);
|
||||
if (reader.IsDBNull(ordinal)) return string.Empty;
|
||||
// Gestisce sia varchar che altri tipi convertendoli a stringa
|
||||
var value = reader.GetValue(ordinal);
|
||||
return value?.ToString() ?? string.Empty;
|
||||
}
|
||||
catch (IndexOutOfRangeException)
|
||||
{
|
||||
// Colonna non presente nel resultset
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legge un campo decimal nullable.
|
||||
/// </summary>
|
||||
public static decimal? GetNullableDecimal(this SqlDataReader reader, string column)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ordinal = reader.GetOrdinal(column);
|
||||
if (reader.IsDBNull(ordinal)) return null;
|
||||
return Convert.ToDecimal(reader.GetValue(ordinal));
|
||||
}
|
||||
catch (IndexOutOfRangeException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legge un campo generico con tipo specifico.
|
||||
/// </summary>
|
||||
public static T GetSafe<T>(this SqlDataReader reader, string column) where T : struct
|
||||
{
|
||||
try
|
||||
{
|
||||
var ordinal = reader.GetOrdinal(column);
|
||||
if (reader.IsDBNull(ordinal)) return default;
|
||||
return (T)Convert.ChangeType(reader.GetValue(ordinal), typeof(T));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return default;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using CertReports.Syncfusion.Models;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using System.Data;
|
||||
|
||||
namespace CertReports.Syncfusion.Services.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Recupera i dati per il grafico dal database.
|
||||
///
|
||||
/// SP utilizzate:
|
||||
/// - FSWeb_Chart_UL: Info sottostanti (IDCertificates, IDUnderlyings, StartDate, Strike, Barriere, Nome)
|
||||
/// - FSWeb_Chart_DailyCTF: Performance giornaliera del certificato (Px_date, Performance)
|
||||
/// - FSWeb_Chart_DailyUL: Performance giornaliera di ogni sottostante (Px_date, Performance)
|
||||
/// </summary>
|
||||
public interface IChartDataService
|
||||
{
|
||||
Task<ChartRenderData?> GetChartDataAsync(string isin);
|
||||
}
|
||||
|
||||
public class ChartDataService : IChartDataService
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly ILogger<ChartDataService> _logger;
|
||||
|
||||
public ChartDataService(IConfiguration config, ILogger<ChartDataService> logger)
|
||||
{
|
||||
_connectionString = config.GetConnectionString("CertDb")
|
||||
?? throw new InvalidOperationException("ConnectionString 'CertDb' non configurata.");
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ChartRenderData?> GetChartDataAsync(string isin)
|
||||
{
|
||||
await using var conn = new SqlConnection(_connectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
// ── 1. Carica info sottostanti (FSWeb_Chart_UL) ────────────────
|
||||
var ulInfos = new List<ChartUnderlyingInfo>();
|
||||
|
||||
await using (var cmd = new SqlCommand("FSWeb_Chart_UL", conn) { CommandType = CommandType.StoredProcedure })
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@isin", isin);
|
||||
await using var r = await cmd.ExecuteReaderAsync();
|
||||
while (await r.ReadAsync())
|
||||
{
|
||||
ulInfos.Add(new ChartUnderlyingInfo
|
||||
{
|
||||
IDCertificates = r.GetInt32(r.GetOrdinal("IDCertificates")),
|
||||
IDUnderlyings = r.GetInt32(r.GetOrdinal("IDUnderlyings")),
|
||||
StartDate = r.GetDateTime(r.GetOrdinal("StartDate")),
|
||||
Strike = r.GetDecimal(r.GetOrdinal("Strike")),
|
||||
BarrieraCoupon = r.GetDecimal(r.GetOrdinal("BarrieraCoupon")),
|
||||
BarrieraCapitale = r.GetDecimal(r.GetOrdinal("BarrieraCapitale")),
|
||||
Sottostante = r.GetString(r.GetOrdinal("Sottostante")),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (ulInfos.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("Nessun sottostante trovato per il grafico di {Isin} (meno di 30 prezzi EOD?)", isin);
|
||||
return null;
|
||||
}
|
||||
|
||||
var chartData = new ChartRenderData
|
||||
{
|
||||
Isin = isin,
|
||||
BarrieraCapitale = ulInfos[0].BarrieraCapitale,
|
||||
BarrieraCoupon = ulInfos[0].BarrieraCoupon,
|
||||
Strike = 100,
|
||||
};
|
||||
|
||||
// ── 2. Carica performance certificato (FSWeb_Chart_DailyCTF) ───
|
||||
var ctfSeries = new ChartSeries { Name = isin, IsCertificate = true };
|
||||
|
||||
await using (var cmd = new SqlCommand("FSWeb_Chart_DailyCTF", conn) { CommandType = CommandType.StoredProcedure })
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@ISIN", isin);
|
||||
await using var r = await cmd.ExecuteReaderAsync();
|
||||
while (await r.ReadAsync())
|
||||
{
|
||||
ctfSeries.Points.Add(new ChartDataPoint
|
||||
{
|
||||
Date = r.GetDateTime(r.GetOrdinal("px_date")),
|
||||
Performance = r.GetDecimal(r.GetOrdinal("Performance")),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Ordina per data ascendente (la SP restituisce DESC)
|
||||
ctfSeries.Points.Sort((a, b) => a.Date.CompareTo(b.Date));
|
||||
chartData.Series.Add(ctfSeries);
|
||||
|
||||
// ── 3. Carica performance per ogni sottostante (FSWeb_Chart_DailyUL) ──
|
||||
foreach (var ul in ulInfos)
|
||||
{
|
||||
var ulSeries = new ChartSeries
|
||||
{
|
||||
Name = ul.Sottostante.Replace(" ", string.Empty),
|
||||
IsCertificate = false
|
||||
};
|
||||
|
||||
await using (var cmd = new SqlCommand("FSWeb_Chart_DailyUL", conn) { CommandType = CommandType.StoredProcedure })
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@IDCertificates", ul.IDCertificates);
|
||||
cmd.Parameters.AddWithValue("@IDUnderlyings", ul.IDUnderlyings);
|
||||
cmd.Parameters.Add("@StartDate", SqlDbType.Date).Value = ul.StartDate;
|
||||
cmd.Parameters.AddWithValue("@Strike", (double)ul.Strike);
|
||||
|
||||
await using var r = await cmd.ExecuteReaderAsync();
|
||||
while (await r.ReadAsync())
|
||||
{
|
||||
ulSeries.Points.Add(new ChartDataPoint
|
||||
{
|
||||
Date = r.GetDateTime(r.GetOrdinal("Px_date")),
|
||||
Performance = r.GetDecimal(r.GetOrdinal("Performance")),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ulSeries.Points.Sort((a, b) => a.Date.CompareTo(b.Date));
|
||||
chartData.Series.Add(ulSeries);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Dati grafico caricati per {Isin}: {SeriesCount} serie, certificato {CftPoints} punti",
|
||||
isin, chartData.Series.Count, ctfSeries.Points.Count);
|
||||
|
||||
return chartData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using CertReports.Syncfusion.Services.Interfaces;
|
||||
using Syncfusion.Drawing;
|
||||
using Syncfusion.Pdf;
|
||||
using Syncfusion.Pdf.Graphics;
|
||||
|
||||
namespace CertReports.Syncfusion.Services.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Sezione 4: Grafico certificato/sottostanti/barriere.
|
||||
///
|
||||
/// Genera il grafico interamente in memoria con SkiaSharp,
|
||||
/// lo inserisce come immagine in una pagina PDF landscape.
|
||||
/// Elimina la dipendenza dall'endpoint esterno ChartFSWeb.aspx.
|
||||
/// </summary>
|
||||
public class ChartSectionRenderer : IChartSectionRenderer
|
||||
{
|
||||
private readonly IChartDataService _chartDataService;
|
||||
private readonly ILogger<ChartSectionRenderer> _logger;
|
||||
|
||||
public ChartSectionRenderer(
|
||||
IChartDataService chartDataService,
|
||||
ILogger<ChartSectionRenderer> logger)
|
||||
{
|
||||
_chartDataService = chartDataService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<PdfDocument?> RenderAsync(string isin)
|
||||
{
|
||||
try
|
||||
{
|
||||
// ── 1. Recupera dati dal DB ────────────────────────────────
|
||||
var chartData = await _chartDataService.GetChartDataAsync(isin);
|
||||
|
||||
if (chartData == null || chartData.Series.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("Nessun dato per il grafico di {Isin}. Sezione saltata.", isin);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── 2. Genera immagine PNG con SkiaSharp ───────────────────
|
||||
byte[] pngBytes = SkiaChartRenderer.RenderToPng(chartData, 1100, 700);
|
||||
|
||||
_logger.LogInformation("Grafico generato per {Isin}: {Size} bytes PNG, {Series} serie",
|
||||
isin, pngBytes.Length, chartData.Series.Count);
|
||||
|
||||
// ── 3. Inserisci nel PDF ───────────────────────────────────
|
||||
var doc = new PdfDocument();
|
||||
doc.PageSettings.Size = PdfPageSize.A4;
|
||||
doc.PageSettings.Orientation = PdfPageOrientation.Landscape;
|
||||
doc.PageSettings.Margins.All = 30;
|
||||
|
||||
var page = doc.Pages.Add();
|
||||
var g = page.Graphics;
|
||||
float pageWidth = page.GetClientSize().Width;
|
||||
float pageHeight = page.GetClientSize().Height;
|
||||
|
||||
// Carica immagine PNG
|
||||
using var imgStream = new MemoryStream(pngBytes);
|
||||
var pdfImage = new PdfBitmap(imgStream);
|
||||
|
||||
// Calcola dimensioni per fit in pagina mantenendo aspect ratio
|
||||
float imgRatio = (float)pdfImage.Width / pdfImage.Height;
|
||||
float drawWidth = pageWidth;
|
||||
float drawHeight = drawWidth / imgRatio;
|
||||
|
||||
if (drawHeight > pageHeight)
|
||||
{
|
||||
drawHeight = pageHeight;
|
||||
drawWidth = drawHeight * imgRatio;
|
||||
}
|
||||
|
||||
// Centra verticalmente
|
||||
float x = (pageWidth - drawWidth) / 2;
|
||||
float y = (pageHeight - drawHeight) / 2;
|
||||
|
||||
g.DrawImage(pdfImage, x, y, drawWidth, drawHeight);
|
||||
|
||||
return doc;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Errore nella generazione del grafico per ISIN {Isin}. Report senza grafico.", isin);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using CertReports.Syncfusion.Helpers;
|
||||
using CertReports.Syncfusion.Models;
|
||||
using CertReports.Syncfusion.Services.Interfaces;
|
||||
using Syncfusion.Drawing;
|
||||
using Syncfusion.Pdf;
|
||||
using Syncfusion.Pdf.Graphics;
|
||||
using Syncfusion.Pdf.Grid;
|
||||
|
||||
namespace CertReports.Syncfusion.Services.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Sezione 2: Lista eventi del certificato (tabella multi-pagina con paginazione automatica).
|
||||
/// Dati da: rpt_Events_CFT_ISIN
|
||||
/// </summary>
|
||||
public class EventiSectionRenderer : IPdfSectionRenderer
|
||||
{
|
||||
public string SectionName => "Eventi";
|
||||
public int Order => 2;
|
||||
|
||||
public PdfDocument Render(CertificateReportData data)
|
||||
{
|
||||
var doc = new PdfDocument();
|
||||
doc.PageSettings.Size = PdfPageSize.A4;
|
||||
doc.PageSettings.Orientation = PdfPageOrientation.Landscape;
|
||||
doc.PageSettings.Margins.All = PdfTheme.PageMargin;
|
||||
|
||||
var page = doc.Pages.Add();
|
||||
var g = page.Graphics;
|
||||
float w = page.GetClientSize().Width;
|
||||
float y = 0;
|
||||
|
||||
// ── Titolo ─────────────────────────────────────────────────────
|
||||
g.DrawString("Lista Eventi", PdfTheme.SectionTitleFont,
|
||||
new PdfSolidBrush(PdfTheme.SectionTitle), new RectangleF(0, y, w, 18));
|
||||
y += 22;
|
||||
|
||||
// ── Griglia ────────────────────────────────────────────────────
|
||||
var grid = new PdfGrid();
|
||||
grid.Style.CellPadding = new PdfPaddings(3, 3, 2, 2);
|
||||
|
||||
// Colonne mappate sui campi della SP rpt_Events_CFT_ISIN
|
||||
string[] headers =
|
||||
{
|
||||
"Osservazione", "Ex Date", "Record", "Pagamento",
|
||||
"Trigger CPN", "Cedola %", "Pagato", "Memoria",
|
||||
"Importo Pagato", "Trigger Autocall", "Valore Autocall"
|
||||
};
|
||||
|
||||
foreach (var _ in headers) grid.Columns.Add();
|
||||
|
||||
// Header
|
||||
var hr = grid.Headers.Add(1)[0];
|
||||
for (int i = 0; i < headers.Length; i++)
|
||||
{
|
||||
hr.Cells[i].Value = headers[i];
|
||||
hr.Cells[i].Style.Font = PdfTheme.Header;
|
||||
hr.Cells[i].Style.BackgroundBrush = PdfTheme.HeaderBrush;
|
||||
hr.Cells[i].Style.TextBrush = PdfTheme.HeaderTextBrush as PdfBrush;
|
||||
hr.Cells[i].StringFormat = new PdfStringFormat(PdfTextAlignment.Center);
|
||||
}
|
||||
|
||||
// Righe dati
|
||||
for (int i = 0; i < data.Eventi.Count; i++)
|
||||
{
|
||||
var evt = data.Eventi[i];
|
||||
var row = grid.Rows.Add();
|
||||
|
||||
row.Cells[0].Value = evt.ObservationDate;
|
||||
row.Cells[1].Value = evt.ExDate;
|
||||
row.Cells[2].Value = evt.RecordDate;
|
||||
row.Cells[3].Value = evt.PaymentDate;
|
||||
row.Cells[4].Value = evt.CouponTrigger;
|
||||
row.Cells[5].Value = evt.CouponValue;
|
||||
row.Cells[6].Value = evt.Paid;
|
||||
row.Cells[7].Value = evt.Memory;
|
||||
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[10].Value = evt.AutocallValue;
|
||||
|
||||
foreach (var cell in row.Cells.OfType<PdfGridCell>())
|
||||
{
|
||||
cell.Style.Font = PdfTheme.Small;
|
||||
cell.StringFormat = new PdfStringFormat(PdfTextAlignment.Center);
|
||||
}
|
||||
|
||||
// Righe alternate
|
||||
if (i % 2 == 1)
|
||||
foreach (var cell in row.Cells.OfType<PdfGridCell>())
|
||||
cell.Style.BackgroundBrush = PdfTheme.AlternateRowBrush;
|
||||
|
||||
// Evidenzia "SI" nella colonna Pagato
|
||||
if (evt.Paid == "SI")
|
||||
row.Cells[6].Style.TextBrush = PdfTheme.PositiveBrush as PdfBrush;
|
||||
}
|
||||
|
||||
// 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 scale = w / total;
|
||||
for (int i = 0; i < cw.Length; i++)
|
||||
grid.Columns[i].Width = cw[i] * scale;
|
||||
|
||||
// Paginazione automatica con header ripetuto
|
||||
grid.RepeatHeader = true;
|
||||
var layout = new PdfGridLayoutFormat
|
||||
{
|
||||
Layout = PdfLayoutType.Paginate,
|
||||
Break = PdfLayoutBreakType.FitPage
|
||||
};
|
||||
|
||||
PdfTheme.ApplyThinBorders(grid);
|
||||
|
||||
grid.Draw(page, new PointF(0, y), layout);
|
||||
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace CertReports.Syncfusion.Services.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Cache in memoria per i PDF generati.
|
||||
/// Evita di rigenerare lo stesso report se richiesto più volte
|
||||
/// in un breve intervallo (es. utente che ricarica la pagina).
|
||||
/// </summary>
|
||||
public interface IPdfCacheService
|
||||
{
|
||||
byte[]? Get(string isin);
|
||||
void Set(string isin, byte[] pdfBytes);
|
||||
void Invalidate(string isin);
|
||||
}
|
||||
|
||||
public class PdfCacheService : IPdfCacheService
|
||||
{
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly TimeSpan _expiration;
|
||||
private readonly ILogger<PdfCacheService> _logger;
|
||||
|
||||
public PdfCacheService(IMemoryCache cache, IConfiguration config, ILogger<PdfCacheService> logger)
|
||||
{
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
|
||||
// Default: 5 minuti, configurabile
|
||||
var minutes = config.GetValue("ReportSettings:CacheMinutes", 5);
|
||||
_expiration = TimeSpan.FromMinutes(minutes);
|
||||
}
|
||||
|
||||
public byte[]? Get(string isin)
|
||||
{
|
||||
var key = CacheKey(isin);
|
||||
if (_cache.TryGetValue(key, out byte[]? cached) && cached != null)
|
||||
{
|
||||
_logger.LogDebug("Cache HIT per ISIN {Isin}", isin);
|
||||
return cached;
|
||||
}
|
||||
_logger.LogDebug("Cache MISS per ISIN {Isin}", isin);
|
||||
return null;
|
||||
}
|
||||
|
||||
public void Set(string isin, byte[] pdfBytes)
|
||||
{
|
||||
var options = new MemoryCacheEntryOptions()
|
||||
.SetAbsoluteExpiration(_expiration)
|
||||
.SetSize(pdfBytes.Length); // per limitare la memoria usata
|
||||
|
||||
_cache.Set(CacheKey(isin), pdfBytes, options);
|
||||
_logger.LogDebug("Cache SET per ISIN {Isin} ({Size} bytes, TTL {Minutes}min)",
|
||||
isin, pdfBytes.Length, _expiration.TotalMinutes);
|
||||
}
|
||||
|
||||
public void Invalidate(string isin)
|
||||
{
|
||||
_cache.Remove(CacheKey(isin));
|
||||
_logger.LogDebug("Cache INVALIDATE per ISIN {Isin}", isin);
|
||||
}
|
||||
|
||||
private static string CacheKey(string isin) => $"report_pdf_{isin}";
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using CertReports.Syncfusion.Services.Interfaces;
|
||||
using Syncfusion.Pdf;
|
||||
|
||||
namespace CertReports.Syncfusion.Services.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Unisce più PdfDocument in un singolo PDF usando Syncfusion.
|
||||
/// Sostituisce PdfSharp CopyPages.
|
||||
///
|
||||
/// NOTA: gli stream temporanei devono restare aperti fino al Save() finale,
|
||||
/// perché Syncfusion mantiene riferimenti lazy alle pagine importate.
|
||||
/// </summary>
|
||||
public class PdfMergerService : IPdfMergerService
|
||||
{
|
||||
public byte[] Merge(IEnumerable<PdfDocument> documents)
|
||||
{
|
||||
var finalDoc = new PdfDocument();
|
||||
var tempStreams = new List<MemoryStream>();
|
||||
var loadedDocs = new List<PdfLoadedDocument>();
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var doc in documents)
|
||||
{
|
||||
if (doc.Pages.Count == 0) continue;
|
||||
|
||||
// Salva il documento sorgente in uno stream temporaneo
|
||||
var tempStream = new MemoryStream();
|
||||
doc.Save(tempStream);
|
||||
tempStream.Position = 0;
|
||||
tempStreams.Add(tempStream);
|
||||
|
||||
// Carica come PdfLoadedDocument e importa le pagine
|
||||
var loadedDoc = new PdfLoadedDocument(tempStream);
|
||||
loadedDocs.Add(loadedDoc);
|
||||
finalDoc.ImportPageRange(loadedDoc, 0, loadedDoc.Pages.Count - 1);
|
||||
}
|
||||
|
||||
// Salva il documento finale (tutti gli stream sono ancora aperti)
|
||||
using var outputStream = new MemoryStream();
|
||||
finalDoc.Save(outputStream);
|
||||
return outputStream.ToArray();
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Cleanup in ordine inverso: prima i loaded docs, poi gli stream
|
||||
foreach (var loadedDoc in loadedDocs)
|
||||
{
|
||||
try { loadedDoc.Close(true); } catch { }
|
||||
}
|
||||
foreach (var stream in tempStreams)
|
||||
{
|
||||
try { stream.Dispose(); } catch { }
|
||||
}
|
||||
try { finalDoc.Close(true); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using CertReports.Syncfusion.Models;
|
||||
using CertReports.Syncfusion.Services.Interfaces;
|
||||
using Syncfusion.Pdf;
|
||||
|
||||
namespace CertReports.Syncfusion.Services.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestratore principale: coordina il flusso di generazione report.
|
||||
///
|
||||
/// Flusso:
|
||||
/// 1. Recupera dati dal DB (stored procedures)
|
||||
/// 2. Renderizza le sezioni PDF (anagrafica, eventi, scenario)
|
||||
/// 3. Recupera/genera il grafico
|
||||
/// 4. Unisce tutto in un unico PDF
|
||||
/// </summary>
|
||||
public class ReportOrchestrator : IReportOrchestrator
|
||||
{
|
||||
private readonly ICertificateDataService _dataService;
|
||||
private readonly IEnumerable<IPdfSectionRenderer> _sectionRenderers;
|
||||
private readonly IChartSectionRenderer _chartRenderer;
|
||||
private readonly IPdfMergerService _merger;
|
||||
private readonly IPdfCacheService _cache;
|
||||
private readonly ILogger<ReportOrchestrator> _logger;
|
||||
|
||||
public ReportOrchestrator(
|
||||
ICertificateDataService dataService,
|
||||
IEnumerable<IPdfSectionRenderer> sectionRenderers,
|
||||
IChartSectionRenderer chartRenderer,
|
||||
IPdfMergerService merger,
|
||||
IPdfCacheService cache,
|
||||
ILogger<ReportOrchestrator> logger)
|
||||
{
|
||||
_dataService = dataService;
|
||||
_sectionRenderers = sectionRenderers;
|
||||
_chartRenderer = chartRenderer;
|
||||
_merger = merger;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<byte[]> GenerateReportAsync(string isin)
|
||||
{
|
||||
// ── Cache check ──────────────────────────────────────────────────
|
||||
var cached = _cache.Get(isin);
|
||||
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),
|
||||
};
|
||||
|
||||
// Determina se lo scenario ha dati validi (evita doppia chiamata SP)
|
||||
bool isScenarioAllowed = reportData.Scenario.Rows.Count > 0;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Dati recuperati per {Isin}: {SottostantiCount} sottostanti, {EventiCount} eventi, Scenario: {ScenarioAllowed}",
|
||||
isin, reportData.Info.Sottostanti.Count, reportData.Eventi.Count, isScenarioAllowed);
|
||||
|
||||
// ── 2. Genera le sezioni PDF ───────────────────────────────────
|
||||
var pdfSections = new List<PdfDocument>();
|
||||
|
||||
foreach (var renderer in _sectionRenderers.OrderBy(r => r.Order))
|
||||
{
|
||||
// Salta la sezione scenario se il certificato è Protection
|
||||
if (renderer.SectionName == "Scenario" && !isScenarioAllowed)
|
||||
{
|
||||
_logger.LogInformation("Sezione Scenario saltata per {Isin} (certificato Protection)", isin);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var sectionPdf = renderer.Render(reportData);
|
||||
pdfSections.Add(sectionPdf);
|
||||
_logger.LogInformation("Sezione '{Section}' generata per {Isin}", renderer.SectionName, isin);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Errore nella generazione della sezione '{Section}' per {Isin}",
|
||||
renderer.SectionName, isin);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Genera/recupera il grafico ──────────────────────────────
|
||||
var chartPdf = await _chartRenderer.RenderAsync(isin);
|
||||
if (chartPdf != null)
|
||||
{
|
||||
pdfSections.Add(chartPdf);
|
||||
_logger.LogInformation("Sezione Grafico aggiunta per {Isin}", isin);
|
||||
}
|
||||
|
||||
// ── 4. Unisci tutto ────────────────────────────────────────────
|
||||
var finalPdf = _merger.Merge(pdfSections);
|
||||
|
||||
_logger.LogInformation("Report generato per {Isin}: {Size} bytes, {Sections} sezioni",
|
||||
isin, finalPdf.Length, pdfSections.Count);
|
||||
|
||||
// Salva in cache
|
||||
_cache.Set(isin, finalPdf);
|
||||
|
||||
// Cleanup
|
||||
foreach (var doc in pdfSections)
|
||||
{
|
||||
doc.Close(true);
|
||||
}
|
||||
|
||||
return finalPdf;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using CertReports.Syncfusion.Helpers;
|
||||
using CertReports.Syncfusion.Models;
|
||||
using CertReports.Syncfusion.Services.Interfaces;
|
||||
using Syncfusion.Drawing;
|
||||
using Syncfusion.Pdf;
|
||||
using Syncfusion.Pdf.Graphics;
|
||||
using Syncfusion.Pdf.Grid;
|
||||
|
||||
namespace CertReports.Syncfusion.Services.Implementations;
|
||||
|
||||
public class ScenarioSectionRenderer : IPdfSectionRenderer
|
||||
{
|
||||
public string SectionName => "Scenario";
|
||||
public int Order => 3;
|
||||
|
||||
// Gradiente header: da rosso scuro (-90%) a verde (+30%)
|
||||
private static readonly Color[] HeaderGradient =
|
||||
{
|
||||
Color.FromArgb(255, 192, 0, 0), // -90% rosso scuro
|
||||
Color.FromArgb(255, 220, 40, 20), // -80%
|
||||
Color.FromArgb(255, 235, 80, 20), // -70%
|
||||
Color.FromArgb(255, 245, 120, 15), // -60%
|
||||
Color.FromArgb(255, 250, 160, 10), // -50%
|
||||
Color.FromArgb(255, 255, 192, 0), // -40% giallo/arancio
|
||||
Color.FromArgb(255, 255, 215, 0), // -30%
|
||||
Color.FromArgb(255, 230, 220, 50), // -20%
|
||||
Color.FromArgb(255, 200, 210, 60), // -10%
|
||||
Color.FromArgb(255, 150, 195, 80), // 0%
|
||||
Color.FromArgb(255, 100, 180, 80), // +10%
|
||||
Color.FromArgb(255, 70, 165, 70), // +20%
|
||||
Color.FromArgb(255, 50, 150, 50), // +30% verde
|
||||
};
|
||||
|
||||
public PdfDocument Render(CertificateReportData data)
|
||||
{
|
||||
var doc = new PdfDocument();
|
||||
doc.PageSettings.Size = PdfPageSize.A4;
|
||||
doc.PageSettings.Orientation = PdfPageOrientation.Landscape;
|
||||
doc.PageSettings.Margins.All = PdfTheme.PageMargin;
|
||||
|
||||
var page = doc.Pages.Add();
|
||||
var g = page.Graphics;
|
||||
float w = page.GetClientSize().Width;
|
||||
float y = 0;
|
||||
|
||||
// ── Titolo centrato ────────────────────────────────────────────
|
||||
var titleFont = new PdfStandardFont(PdfFontFamily.Helvetica, 16f, PdfFontStyle.Bold);
|
||||
g.DrawString("Analisi Scenario", titleFont,
|
||||
new PdfSolidBrush(Color.FromArgb(255, 46, 80, 144)),
|
||||
new RectangleF(0, y, w, 28),
|
||||
new PdfStringFormat(PdfTextAlignment.Center));
|
||||
y += 40;
|
||||
|
||||
var scenario = data.Scenario;
|
||||
if (scenario.Rows.Count == 0)
|
||||
{
|
||||
g.DrawString("Nessun dato scenario disponibile.", PdfTheme.Regular,
|
||||
PdfTheme.TextBrush, new PointF(0, y));
|
||||
return doc;
|
||||
}
|
||||
|
||||
int dataColCount = ScenarioAnalysis.VariationHeaders.Length;
|
||||
int totalCols = dataColCount + 1;
|
||||
|
||||
var grid = new PdfGrid();
|
||||
grid.Style.CellPadding = new PdfPaddings(4, 4, 3, 3);
|
||||
|
||||
for (int i = 0; i < totalCols; i++) grid.Columns.Add();
|
||||
|
||||
// ── Header: "Scenario da oggi:" + variazioni con gradiente ─────
|
||||
var hr = grid.Headers.Add(1)[0];
|
||||
hr.Cells[0].Value = "Scenario da oggi:";
|
||||
hr.Cells[0].Style.Font = PdfTheme.SmallBold;
|
||||
hr.Cells[0].Style.BackgroundBrush = new PdfSolidBrush(Color.FromArgb(255, 240, 240, 240));
|
||||
|
||||
for (int i = 0; i < dataColCount; i++)
|
||||
{
|
||||
hr.Cells[i + 1].Value = ScenarioAnalysis.VariationHeaders[i];
|
||||
hr.Cells[i + 1].Style.Font = PdfTheme.SmallBold;
|
||||
hr.Cells[i + 1].Style.BackgroundBrush = new PdfSolidBrush(HeaderGradient[i]);
|
||||
hr.Cells[i + 1].Style.TextBrush = new PdfSolidBrush(Color.FromArgb(255, 255, 255, 255));
|
||||
hr.Cells[i + 1].StringFormat = new PdfStringFormat(PdfTextAlignment.Center);
|
||||
}
|
||||
|
||||
// ── Righe dati ─────────────────────────────────────────────────
|
||||
var altBg = new PdfSolidBrush(Color.FromArgb(255, 245, 245, 245));
|
||||
var negativeBrush = new PdfSolidBrush(Color.FromArgb(255, 180, 30, 30));
|
||||
var normalBrush = new PdfSolidBrush(Color.FromArgb(255, 60, 60, 60));
|
||||
|
||||
for (int r = 0; r < scenario.Rows.Count; r++)
|
||||
{
|
||||
var scenarioRow = scenario.Rows[r];
|
||||
var row = grid.Rows.Add();
|
||||
|
||||
// Label
|
||||
row.Cells[0].Value = scenarioRow.Label;
|
||||
row.Cells[0].Style.Font = PdfTheme.SmallBold;
|
||||
|
||||
// Valori
|
||||
for (int c = 0; c < Math.Min(scenarioRow.Values.Count, dataColCount); c++)
|
||||
{
|
||||
string val = scenarioRow.Values[c];
|
||||
row.Cells[c + 1].Value = val;
|
||||
row.Cells[c + 1].Style.Font = PdfTheme.Small;
|
||||
row.Cells[c + 1].StringFormat = new PdfStringFormat(PdfTextAlignment.Right);
|
||||
|
||||
// Rosso per valori negativi
|
||||
bool isNeg = !string.IsNullOrWhiteSpace(val) && val.Trim().StartsWith('-');
|
||||
row.Cells[c + 1].Style.TextBrush = isNeg ? negativeBrush : normalBrush;
|
||||
}
|
||||
|
||||
// Righe alternate
|
||||
if (r % 2 == 1)
|
||||
{
|
||||
for (int c = 0; c < totalCols; c++)
|
||||
row.Cells[c].Style.BackgroundBrush = altBg;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Larghezze colonne ──────────────────────────────────────────
|
||||
float labelW = 130;
|
||||
float dataColW = (w - labelW) / dataColCount;
|
||||
grid.Columns[0].Width = labelW;
|
||||
for (int i = 1; i < totalCols; i++)
|
||||
grid.Columns[i].Width = dataColW;
|
||||
|
||||
PdfTheme.ApplyThinBorders(grid);
|
||||
|
||||
grid.Draw(page, new PointF(0, y));
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
using CertReports.Syncfusion.Models;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace CertReports.Syncfusion.Services.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Genera il grafico certificato/sottostanti/barriere con SkiaSharp.
|
||||
/// Produce un'immagine PNG in memoria, pronta per essere inserita nel PDF.
|
||||
///
|
||||
/// Replica il comportamento del vecchio ChartFSWeb.aspx:
|
||||
/// - Serie spline per il certificato (linea nera, spessore 2)
|
||||
/// - Serie spline per ogni sottostante (colori diversi, spessore 1)
|
||||
/// - Linea costante rossa per Barriera Capitale
|
||||
/// - Linea costante viola per Barriera Coupon (se diversa da Capitale)
|
||||
/// - Linea costante verde per Strike (100%)
|
||||
/// - Asse Y in percentuale, asse X con date
|
||||
/// - Legenda in alto a destra
|
||||
/// - Auto-scaling Y con margine 10%
|
||||
/// </summary>
|
||||
public static class SkiaChartRenderer
|
||||
{
|
||||
// Colori per le serie dei sottostanti (ciclici)
|
||||
private static readonly SKColor[] SeriesColors =
|
||||
{
|
||||
new(183, 28, 28), // rosso scuro
|
||||
new(139, 195, 74), // verde lime
|
||||
new(103, 58, 183), // viola
|
||||
new(0, 172, 193), // ciano
|
||||
new(255, 152, 0), // arancione
|
||||
new(121, 85, 72), // marrone
|
||||
new(233, 30, 99), // rosa
|
||||
new(0, 150, 136), // teal
|
||||
};
|
||||
|
||||
// Font condivisi (creati una volta)
|
||||
private static SKFont CreateFont(float size) =>
|
||||
new(SKTypeface.FromFamilyName("Arial"), size);
|
||||
|
||||
/// <summary>
|
||||
/// Genera il grafico come immagine PNG.
|
||||
/// </summary>
|
||||
public static byte[] RenderToPng(ChartRenderData data, int width = 1100, int height = 700)
|
||||
{
|
||||
using var surface = SKSurface.Create(new SKImageInfo(width, height));
|
||||
var canvas = surface.Canvas;
|
||||
canvas.Clear(SKColors.White);
|
||||
|
||||
// ── Margini area di disegno ────────────────────────────────────
|
||||
float marginLeft = 70;
|
||||
float marginRight = 160; // spazio per legenda
|
||||
float marginTop = 30;
|
||||
float marginBottom = 50;
|
||||
|
||||
var plotArea = new SKRect(marginLeft, marginTop, width - marginRight, height - marginBottom);
|
||||
|
||||
// ── Calcola range assi ─────────────────────────────────────────
|
||||
var (minDate, maxDate, minY, maxY) = CalculateRanges(data);
|
||||
|
||||
// ── Disegna griglia e assi ─────────────────────────────────────
|
||||
DrawGrid(canvas, plotArea, minDate, maxDate, minY, maxY);
|
||||
DrawAxisLabels(canvas, plotArea, minDate, maxDate, minY, maxY);
|
||||
|
||||
// ── Disegna linee costanti (barriere + strike) ─────────────────
|
||||
var legendItems = new List<(string name, SKColor color, bool dashed, float thickness)>();
|
||||
|
||||
DrawHorizontalLine(canvas, plotArea, minY, maxY, (float)data.Strike, SKColors.Green, 1.5f, false);
|
||||
legendItems.Add(("Strike", SKColors.Green, false, 1.5f));
|
||||
|
||||
DrawHorizontalLine(canvas, plotArea, minY, maxY, (float)data.BarrieraCapitale, SKColors.Red, 1.5f, false);
|
||||
legendItems.Add(("Barriera Capitale", SKColors.Red, false, 1.5f));
|
||||
|
||||
if (data.BarrieraCoupon != data.BarrieraCapitale && data.BarrieraCoupon > 0)
|
||||
{
|
||||
DrawHorizontalLine(canvas, plotArea, minY, maxY, (float)data.BarrieraCoupon, new SKColor(128, 0, 128), 1.5f, false);
|
||||
legendItems.Add(("Barriera Coupon", new SKColor(128, 0, 128), false, 1.5f));
|
||||
}
|
||||
|
||||
// ── Disegna serie ──────────────────────────────────────────────
|
||||
int colorIndex = 0;
|
||||
foreach (var series in data.Series)
|
||||
{
|
||||
if (series.Points.Count < 2) continue;
|
||||
|
||||
SKColor color;
|
||||
float thickness;
|
||||
|
||||
if (series.IsCertificate)
|
||||
{
|
||||
color = SKColors.Black;
|
||||
thickness = 2.5f;
|
||||
}
|
||||
else
|
||||
{
|
||||
color = SeriesColors[colorIndex % SeriesColors.Length];
|
||||
thickness = 1.5f;
|
||||
colorIndex++;
|
||||
}
|
||||
|
||||
DrawSeries(canvas, plotArea, series, minDate, maxDate, minY, maxY, color, thickness);
|
||||
legendItems.Insert(series.IsCertificate ? 0 : legendItems.Count - (data.BarrieraCoupon != data.BarrieraCapitale ? 3 : 2),
|
||||
(series.Name, color, false, thickness));
|
||||
}
|
||||
|
||||
// ── Disegna bordo area plot ────────────────────────────────────
|
||||
using var borderPaint = new SKPaint { Color = SKColors.Gray, StrokeWidth = 1, Style = SKPaintStyle.Stroke, IsAntialias = true };
|
||||
canvas.DrawRect(plotArea, borderPaint);
|
||||
|
||||
// ── Disegna legenda ────────────────────────────────────────────
|
||||
DrawLegend(canvas, plotArea, legendItems);
|
||||
|
||||
// ── Esporta PNG ────────────────────────────────────────────────
|
||||
using var image = surface.Snapshot();
|
||||
using var pngData = image.Encode(SKEncodedImageFormat.Png, 95);
|
||||
return pngData.ToArray();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Calcolo ranges
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
private static (DateTime minDate, DateTime maxDate, double minY, double maxY) CalculateRanges(ChartRenderData data)
|
||||
{
|
||||
DateTime minDate = DateTime.MaxValue, maxDate = DateTime.MinValue;
|
||||
double minY = double.MaxValue, maxY = double.MinValue;
|
||||
|
||||
foreach (var series in data.Series)
|
||||
{
|
||||
foreach (var pt in series.Points)
|
||||
{
|
||||
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 le linee costanti nel range
|
||||
var constants = new List<double> { (double)data.Strike, (double)data.BarrieraCapitale };
|
||||
if (data.BarrieraCoupon > 0) constants.Add((double)data.BarrieraCoupon);
|
||||
|
||||
foreach (var c in constants)
|
||||
{
|
||||
if (c < minY) minY = c;
|
||||
if (c > maxY) maxY = c;
|
||||
}
|
||||
|
||||
// Margine 10%
|
||||
double range = maxY - minY;
|
||||
double margin = range * 0.1;
|
||||
minY -= margin;
|
||||
maxY += margin;
|
||||
|
||||
return (minDate, maxDate, minY, maxY);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Disegno 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
|
||||
};
|
||||
|
||||
// Linee orizzontali
|
||||
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);
|
||||
}
|
||||
|
||||
// Linee verticali
|
||||
var totalDays = (maxDate - minDate).TotalDays;
|
||||
var 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 (API moderna: SKFont + DrawText con textAlign)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
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 };
|
||||
|
||||
// Asse Y
|
||||
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;
|
||||
string text = $"{val:F0} %";
|
||||
canvas.DrawText(text, area.Left - 55, y + 4, SKTextAlign.Left, font, paint);
|
||||
}
|
||||
|
||||
// Asse X
|
||||
var totalDays = (maxDate - minDate).TotalDays;
|
||||
var 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 DrawSeries(SKCanvas canvas, SKRect area, ChartSeries series,
|
||||
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 series.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();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Linee orizzontali costanti
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
private static void DrawHorizontalLine(SKCanvas canvas, SKRect area, double minY, double maxY,
|
||||
float value, SKColor color, float thickness, bool dashed)
|
||||
{
|
||||
float y = ValueToY(value, area, minY, maxY);
|
||||
if (y < area.Top || y > area.Bottom) return;
|
||||
|
||||
using var paint = new SKPaint
|
||||
{
|
||||
Color = color,
|
||||
StrokeWidth = thickness,
|
||||
Style = SKPaintStyle.Stroke,
|
||||
IsAntialias = true,
|
||||
};
|
||||
|
||||
if (dashed)
|
||||
paint.PathEffect = SKPathEffect.CreateDash(new[] { 8f, 4f }, 0);
|
||||
|
||||
canvas.DrawLine(area.Left, y, area.Right, y, paint);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Legenda (API moderna: SKFont + DrawText con textAlign)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
private static void DrawLegend(SKCanvas canvas, SKRect plotArea, List<(string name, SKColor color, bool dashed, float thickness)> items)
|
||||
{
|
||||
float legendX = plotArea.Right + 15;
|
||||
float legendY = plotArea.Top + 10;
|
||||
float lineHeight = 20;
|
||||
float lineWidth = 25;
|
||||
|
||||
using var font = CreateFont(10.5f);
|
||||
using var textPaint = new SKPaint { Color = SKColors.DimGray, IsAntialias = true };
|
||||
|
||||
// Calcola larghezza massima testo
|
||||
float maxTextWidth = items.Max(i => font.MeasureText(i.name));
|
||||
float legendWidth = lineWidth + maxTextWidth + 20;
|
||||
float legendHeight = items.Count * lineHeight + 10;
|
||||
|
||||
// Background legenda
|
||||
using var bgPaint = new SKPaint { Color = new SKColor(255, 255, 255, 230), Style = SKPaintStyle.Fill };
|
||||
using var borderPaint = new SKPaint { Color = new SKColor(200, 200, 200), Style = SKPaintStyle.Stroke, StrokeWidth = 0.5f };
|
||||
|
||||
var legendRect = new SKRect(legendX - 5, legendY - 5, legendX + legendWidth, legendY + legendHeight);
|
||||
canvas.DrawRect(legendRect, bgPaint);
|
||||
canvas.DrawRect(legendRect, borderPaint);
|
||||
|
||||
foreach (var (name, color, dashed, thickness) in items)
|
||||
{
|
||||
// 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(legendX, legendY + 8, legendX + lineWidth, legendY + 8, linePaint);
|
||||
|
||||
// Testo
|
||||
canvas.DrawText(name, legendX + lineWidth + 6, legendY + 12, SKTextAlign.Left, font, textPaint);
|
||||
legendY += lineHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Conversioni coordinate
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user