commit 85ee66750acbe2c6c623ff6c1e105158dfb3a84d Author: SmartRootsSrl Date: Fri Mar 20 12:03:38 2026 +0100 chore: initial commit - baseline before redesign diff --git a/CertReports.Syncfusion/.gitignore b/CertReports.Syncfusion/.gitignore new file mode 100644 index 0000000..0b97d63 --- /dev/null +++ b/CertReports.Syncfusion/.gitignore @@ -0,0 +1,19 @@ +## .NET +bin/ +obj/ +*.user +*.suo +*.vs/ +.vscode/ + +## Logs +Logs/ + +## Temp files +wwwroot/temp/ + +## Environment-specific +appsettings.*.local.json + +## Secrets (mai committare!) +appsettings.Production.json diff --git a/CertReports.Syncfusion/CertReports.Syncfusion.csproj b/CertReports.Syncfusion/CertReports.Syncfusion.csproj new file mode 100644 index 0000000..5ff333e --- /dev/null +++ b/CertReports.Syncfusion/CertReports.Syncfusion.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + CertReports.Syncfusion + + + + + + + + + + + + + + + + + + + + + diff --git a/CertReports.Syncfusion/Controllers/ChartController.cs b/CertReports.Syncfusion/Controllers/ChartController.cs new file mode 100644 index 0000000..8db7b12 --- /dev/null +++ b/CertReports.Syncfusion/Controllers/ChartController.cs @@ -0,0 +1,109 @@ +using CertReports.Syncfusion.Services.Implementations; +using Microsoft.AspNetCore.Mvc; +using Syncfusion.Pdf; +using Syncfusion.Pdf.Graphics; + +namespace CertReports.Syncfusion.Controllers; + +/// +/// API per la generazione standalone del grafico certificato. +/// Richiamabile da qualsiasi progetto esterno. +/// +/// Endpoint: +/// GET /api/chart/{isin} → PNG inline +/// GET /api/chart/{isin}?format=png → PNG inline +/// GET /api/chart/{isin}?format=pdf → PDF inline +/// GET /api/chart/{isin}?width=1100&height=700&format=png +/// +[ApiController] +[Route("api/[controller]")] +public class ChartController : ControllerBase +{ + private readonly IChartDataService _chartDataService; + private readonly ILogger _logger; + + public ChartController(IChartDataService chartDataService, ILogger logger) + { + _chartDataService = chartDataService; + _logger = logger; + } + + [HttpGet("{isin}")] + public async Task GenerateChart( + string isin, + [FromQuery] int width = 1100, + [FromQuery] int height = 700, + [FromQuery] string format = "png") + { + if (string.IsNullOrWhiteSpace(isin)) + return BadRequest("ISIN non valido."); + + // Limiti ragionevoli + width = Math.Clamp(width, 400, 2000); + height = Math.Clamp(height, 300, 1500); + + try + { + var chartData = await _chartDataService.GetChartDataAsync(isin); + + if (chartData == null || chartData.Series.Count == 0) + { + return NotFound(new + { + status = "KO", + message = $"Nessun dato per il grafico di {isin} (meno di 30 prezzi EOD?)." + }); + } + + // Genera PNG + byte[] pngBytes = SkiaChartRenderer.RenderToPng(chartData, width, height); + + if (format.Equals("pdf", StringComparison.OrdinalIgnoreCase)) + { + // Converti in PDF landscape + byte[] pdfBytes = WrapPngInPdf(pngBytes); + Response.Headers.Append("Content-Disposition", $"inline; filename=chart_{isin}.pdf"); + return File(pdfBytes, "application/pdf"); + } + + // Default: PNG + Response.Headers.Append("Content-Disposition", $"inline; filename=chart_{isin}.png"); + return File(pngBytes, "image/png"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Errore generazione chart per ISIN {Isin}", isin); + return StatusCode(500, new { status = "KO", message = "Errore nella generazione del grafico." }); + } + } + + private static byte[] WrapPngInPdf(byte[] pngBytes) + { + var doc = new PdfDocument(); + doc.PageSettings.Size = PdfPageSize.A4; + doc.PageSettings.Orientation = PdfPageOrientation.Landscape; + doc.PageSettings.Margins.All = 30; + + var page = doc.Pages.Add(); + var g = page.Graphics; + float pw = page.GetClientSize().Width; + float ph = page.GetClientSize().Height; + + using var stream = new MemoryStream(pngBytes); + var img = new PdfBitmap(stream); + + float ratio = (float)img.Width / img.Height; + float dw = pw; + float dh = dw / ratio; + if (dh > ph) { dh = ph; dw = dh * ratio; } + + float x = (pw - dw) / 2; + float y = (ph - dh) / 2; + g.DrawImage(img, x, y, dw, dh); + + using var output = new MemoryStream(); + doc.Save(output); + doc.Close(true); + return output.ToArray(); + } +} diff --git a/CertReports.Syncfusion/Controllers/ReportController.cs b/CertReports.Syncfusion/Controllers/ReportController.cs new file mode 100644 index 0000000..6d702a7 --- /dev/null +++ b/CertReports.Syncfusion/Controllers/ReportController.cs @@ -0,0 +1,139 @@ +using CertReports.Syncfusion.Helpers; +using CertReports.Syncfusion.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace CertReports.Syncfusion.Controllers; + +/// +/// Controller REST per la generazione dei report certificati. +/// Sostituisce la vecchia WebForm ReportFSSiteCrypt.aspx. +/// +/// Endpoint: +/// GET /api/report?p={encrypted_isin} +/// GET /api/report?alias={alias_id} +/// GET /api/report/by-isin/{isin} (uso interno/debug) +/// +[ApiController] +[Route("api/[controller]")] +public class ReportController : ControllerBase +{ + private readonly IReportOrchestrator _orchestrator; + private readonly ICertificateDataService _dataService; + private readonly CryptoHelper _crypto; + private readonly ILogger _logger; + + public ReportController( + IReportOrchestrator orchestrator, + ICertificateDataService dataService, + CryptoHelper crypto, + ILogger logger) + { + _orchestrator = orchestrator; + _dataService = dataService; + _crypto = crypto; + _logger = logger; + } + + /// + /// Endpoint principale - compatibile con la vecchia URL. + /// Accetta parametri 'p' (ISIN cifrato) o 'alias' (alias ID). + /// Restituisce il PDF inline (visualizzabile nel browser). + /// + [HttpGet] + public async Task GenerateReport( + [FromQuery(Name = "p")] string? encryptedIsin = null, + [FromQuery(Name = "alias")] string? aliasId = null) + { + string? isin = null; + + // Risolvi ISIN + if (!string.IsNullOrEmpty(encryptedIsin)) + { + try + { + isin = _crypto.DecryptIsin(encryptedIsin); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Errore nella decodifica ISIN cifrato"); + return BadRequest("Parametro 'p' non valido."); + } + } + else if (!string.IsNullOrEmpty(aliasId)) + { + isin = await _dataService.FindIsinByAliasIdAsync(aliasId); + } + + if (string.IsNullOrEmpty(isin)) + { + return BadRequest("Specificare il parametro 'p' (ISIN cifrato) o 'alias' (alias ID)."); + } + + return await GenerateAndReturnPdf(isin); + } + + /// + /// Endpoint diretto per ISIN (uso interno / debug). + /// In produzione proteggere con autenticazione. + /// + [HttpGet("by-isin/{isin}")] + public async Task GenerateReportByIsin(string isin) + { + if (string.IsNullOrWhiteSpace(isin) || isin.Length < 12) + { + return BadRequest("ISIN non valido."); + } + + return await GenerateAndReturnPdf(isin); + } + + /// + /// Endpoint per download come allegato (content-disposition: attachment) + /// + [HttpGet("download")] + public async Task DownloadReport( + [FromQuery(Name = "p")] string? encryptedIsin = null, + [FromQuery(Name = "alias")] string? aliasId = null) + { + string? isin = null; + + if (!string.IsNullOrEmpty(encryptedIsin)) + isin = _crypto.DecryptIsin(encryptedIsin); + else if (!string.IsNullOrEmpty(aliasId)) + isin = await _dataService.FindIsinByAliasIdAsync(aliasId); + + if (string.IsNullOrEmpty(isin)) + return BadRequest("Specificare il parametro 'p' o 'alias'."); + + try + { + var pdfBytes = await _orchestrator.GenerateReportAsync(isin); + return File(pdfBytes, "application/pdf", $"{isin}.pdf"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Errore generazione report download per ISIN {Isin}", isin); + return StatusCode(500, "Errore nella generazione del report."); + } + } + + // ─── Helper ──────────────────────────────────────────────────────── + + private async Task GenerateAndReturnPdf(string isin) + { + try + { + _logger.LogInformation("Richiesta report per ISIN {Isin}", isin); + var pdfBytes = await _orchestrator.GenerateReportAsync(isin); + + // Inline: il PDF si apre direttamente nel browser + Response.Headers.Append("Content-Disposition", $"inline; filename={isin}.pdf"); + return File(pdfBytes, "application/pdf"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Errore generazione report per ISIN {Isin}", isin); + return StatusCode(500, "Errore nella generazione del report."); + } + } +} diff --git a/CertReports.Syncfusion/Controllers/TestController.cs b/CertReports.Syncfusion/Controllers/TestController.cs new file mode 100644 index 0000000..4fcaf4f --- /dev/null +++ b/CertReports.Syncfusion/Controllers/TestController.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.SqlClient; + +namespace CertReports.Syncfusion.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class TestController : ControllerBase +{ + private readonly IConfiguration _config; + + public TestController(IConfiguration config) + { + _config = config; + } + + [HttpGet("db")] + public async Task TestDb() + { + var connStr = _config.GetConnectionString("CertDb"); + + try + { + await using var conn = new SqlConnection(connStr); + await conn.OpenAsync(); + await using var cmd = new SqlCommand("SELECT 1", conn); + var result = await cmd.ExecuteScalarAsync(); + return Ok(new + { + status = "OK", + result = result?.ToString(), + connectionString = connStr?.Replace(_config["ConnectionStrings:CertDb"]?.Split("Password=").LastOrDefault()?.Split(";").FirstOrDefault() ?? "", "***") + }); + } + catch (Exception ex) + { + return Ok(new + { + status = "ERRORE", + message = ex.Message, + innerMessage = ex.InnerException?.Message, + connectionString = connStr + }); + } + } +} + diff --git a/CertReports.Syncfusion/DOCS.md b/CertReports.Syncfusion/DOCS.md new file mode 100644 index 0000000..c9980f7 --- /dev/null +++ b/CertReports.Syncfusion/DOCS.md @@ -0,0 +1,426 @@ +# CertReports.Syncfusion — Documentazione Tecnica + +> Progetto ASP.NET Core 8 per la generazione di report PDF per certificati finanziari strutturati. +> Sostituisce il vecchio progetto WebForms basato su DevExpress (non licenziato) + PdfSharp. +> Utilizza **Syncfusion PDF Library** (Community License) e **SkiaSharp** per i grafici. + +--- + +## 1. Panoramica + +Il sistema riceve un codice ISIN (in chiaro, cifrato o tramite alias), interroga il database SQL Server tramite stored procedures, genera un report PDF composto da 4 sezioni e lo restituisce come stream inline al browser. + +### Flusso di generazione + +``` +Richiesta HTTP (ISIN) + │ + ▼ +ReportController ──→ decodifica ISIN (se cifrato) + │ + ▼ +ReportOrchestrator + │ + ├──→ CertificateDataService + │ ├── rpt_Master_CFT_ISIN → Anagrafica certificato + │ ├── rpt_Details_UL_ISIN → Sottostanti + │ ├── rpt_Events_CFT_ISIN → Eventi + │ └── rpt_AnalisiScenario_ISIN → Analisi scenario + │ + ├──→ AnagraficaSectionRenderer → PDF Sezione 1 + ├──→ EventiSectionRenderer → PDF Sezione 2 + ├──→ ScenarioSectionRenderer → PDF Sezione 3 (se dati presenti) + │ + ├──→ ChartDataService + │ ├── FSWeb_Chart_UL → Info sottostanti grafico + │ ├── FSWeb_Chart_DailyCTF → Performance certificato + │ └── FSWeb_Chart_DailyUL → Performance sottostanti + │ + ├──→ ChartSectionRenderer → PDF Sezione 4 (SkiaSharp) + │ └── SkiaChartRenderer → Genera PNG in memoria + │ + └──→ PdfMergerService → Unisce i PDF → byte[] + │ + ▼ + Response PDF inline +``` + +--- + +## 2. Struttura del progetto + +``` +SmartReports/ ← Solution +└── CertReports.Syncfusion/ ← Progetto + ├── Controllers/ + │ ├── ReportController.cs ← API report PDF completo + │ └── ChartController.cs ← API chart standalone (PNG/PDF) + │ + ├── Helpers/ + │ ├── CryptoHelper.cs ← Decodifica ISIN (ENCRYPTBYPASSPHRASE) + │ ├── GlobalExceptionMiddleware.cs ← Error handling centralizzato + │ ├── HealthChecks.cs ← Health check DB + Chart service + │ └── PdfTheme.cs ← Colori, font, stili, bordi (tema centralizzato) + │ + ├── Models/ + │ ├── CertificateModels.cs ← Modelli report (anagrafica, eventi, scenario) + │ └── ChartModels.cs ← Modelli grafico (serie, punti, barriere) + │ + ├── Services/ + │ ├── Interfaces/ + │ │ └── IServices.cs ← Contratti DI per tutti i servizi + │ └── Implementations/ + │ ├── CertificateDataService.cs ← Accesso DB: anagrafica, eventi, scenario + │ ├── ChartDataService.cs ← Accesso DB: dati grafico (3 SP) + │ ├── AnagraficaSectionRenderer.cs ← Sezione 1: anagrafica + sottostanti + │ ├── EventiSectionRenderer.cs ← Sezione 2: tabella eventi (multi-pagina) + │ ├── ScenarioSectionRenderer.cs ← Sezione 3: matrice scenario con gradiente + │ ├── ChartSectionRenderer.cs ← Sezione 4: inserisce chart PNG nel PDF + │ ├── SkiaChartRenderer.cs ← Genera il grafico con SkiaSharp + │ ├── PdfMergerService.cs ← Merge PDF con Syncfusion + │ ├── PdfCacheService.cs ← Cache in memoria (IMemoryCache) + │ └── ReportOrchestrator.cs ← Coordinatore principale + │ + ├── Program.cs ← Entry point, DI, middleware + ├── GlobalUsings.cs ← Using globali + ├── CertReports.Syncfusion.csproj ← Progetto + pacchetti NuGet + ├── appsettings.json ← Configurazione produzione + ├── appsettings.Development.json ← Override per sviluppo + ├── Dockerfile ← Build containerizzata + ├── docker-compose.yml ← Compose con SQL Server + └── .gitignore +``` + +--- + +## 3. Mapping vecchio → nuovo + +| Vecchio (DevExpress / WebForms) | Nuovo (Syncfusion / ASP.NET Core 8) | +|----------------------------------------------|-------------------------------------------| +| `ReportFSSiteCrypt.aspx` (Page_Load) | `ReportController.cs` (API REST) | +| `XtraReport` + file `.repx` | Classi `IPdfSectionRenderer` (codice C#) | +| `report.ExportToPdf()` per ogni sezione | Ogni renderer restituisce `PdfDocument` | +| `PdfSharp.PdfReader.Open` + `CopyPages` | `PdfMergerService` (Syncfusion) | +| `CommonClass.DecryptCombined()` | `CryptoHelper.DecryptIsin()` | +| `CommonClass.execSP_Scalar()` | `CertificateDataService` (async) | +| `ChartFSWeb.aspx` (DevExpress WebChart) | `SkiaChartRenderer` + `ChartController` | +| `CallWebApi()` con `WebRequest` | Generazione interna (nessuna chiamata HTTP)| +| File temporanei su disco + `File.Delete` | Tutto in memoria (`MemoryStream`) | +| `Response.BinaryWrite()` | `return File(bytes, "application/pdf")` | +| `System.Data.SqlClient` | `Microsoft.Data.SqlClient` v5 | + +--- + +## 4. Stored procedures utilizzate + +Tutte le SP sono nel database `FirstSolutionDB`. + +### Report (sezioni 1-3) + +| SP | Parametro | Sezione | Descrizione | +|----|-----------|---------|-------------| +| `rpt_Master_CFT_ISIN` | `@ISIN varchar(12)` | 1 — Anagrafica | Dati certificato (1 record). Campi pre-formattati con `FORMAT()`. Join su Issuer, Category, BasketType, BarrierType + numerose `OUTER APPLY` per valori calcolati. | +| `rpt_Details_UL_ISIN` | `@ISIN varchar(12)` | 1 — Sottostanti | N record sottostanti con strike, last price, barriere, buffer, dividendi. Campi formattati con `FORMAT(...,'N3','it-IT')`. | +| `rpt_Events_CFT_ISIN` | `@ISIN varchar(50)` | 2 — Eventi | Lista eventi ordinata per data. Cedole, trigger, autocall, importi pagati. Campi formattati. Colonne Trigger Capitale e Valore Capitale escluse dal report. | +| `rpt_AnalisiScenario_ISIN` | `@ISIN varchar(50)` | 3 — Scenario | 7 righe UNION con label in `Descrizione` e 13 colonne di valori (`col1..col13`). Variazioni: -90%, -80%, -70%, -60%, -50%, -40%, -30%, -20%, -10%, 0%, +10%, +20%, +30%. Tutte stringhe pre-formattate. | +| `rpt_FindIsinbyAliasID` | `@AliasID` | Risoluzione | Restituisce l'ISIN dato un alias ID. | + +### Grafico (sezione 4) + +| SP | Parametri | Descrizione | +|----|-----------|-------------| +| `FSWeb_Chart_UL` | `@isin varchar(15)` | Info sottostanti per il grafico: IDCertificates, IDUnderlyings, StartDate, Strike, BarrieraCoupon, BarrieraCapitale, Nome. Restituisce dati solo se ci sono almeno 30 prezzi EOD. | +| `FSWeb_Chart_DailyCTF` | `@ISIN varchar(50)` | Performance giornaliera del certificato: `Px_date`, `Performance` (= PX_LAST_EOD / Nominal_amount × 100). | +| `FSWeb_Chart_DailyUL` | `@IDCertificates, @IDUnderlyings, @StartDate, @Strike` | Performance giornaliera di ogni sottostante: `Px_date`, `Performance` (= Px_close / Strike × 100). Usa Px_closeadj se AdjustedPrices=1. Filtra per date presenti nel certificato. | + +### Nota sui dati formattati + +Le SP restituiscono la maggior parte dei valori **già formattati come stringhe** (es. `FORMAT(value,'P2','it-IT')`, `FORMAT(value,'F2','it-IT')`). I modelli C# usano `string` per questi campi per evitare parsing e riformattazione non necessari. Solo `NominalValue`, `PrezzoEmissione`, `CpnPagati`, `CpnDaPagare`, `CpnInMemoria` sono `decimal?` perché servono come valori numerici nel rendering. + +--- + +## 5. Endpoint API + +### Generazione report PDF completo (inline nel browser) + +``` +GET /api/report?p={isin_cifrato} +GET /api/report?alias={alias_id} +GET /api/report/by-isin/{isin} +``` + +### Download report come allegato + +``` +GET /api/report/download?p={isin_cifrato} +GET /api/report/download?alias={alias_id} +``` + +### Grafico standalone (richiamabile da altri progetti) + +``` +GET /api/chart/{isin} → PNG inline +GET /api/chart/{isin}?format=png → PNG inline +GET /api/chart/{isin}?format=pdf → PDF inline +GET /api/chart/{isin}?width=1200&height=800 → Dimensioni custom +``` + +Parametri opzionali: `width` (400-2000, default 1100), `height` (300-1500, default 700), `format` (png/pdf, default png). + +### Health check + +``` +GET /health +``` + +--- + +## 6. Configurazione + +### appsettings.json + +```json +{ + "ConnectionStrings": { + "CertDb": "Data Source=INDIRIZZO_SERVER;Initial Catalog=FirstSolutionDB;Persist Security Info=True;User ID=sa;Password=***;TrustServerCertificate=True;Encrypt=False;" + }, + "Syncfusion": { + "LicenseKey": "CHIAVE_COMMUNITY_SYNCFUSION" + }, + "CryptoSettings": { + "Passphrase": "ddCE3hM9BNJXgwtj" + }, + "ReportSettings": { + "CacheMinutes": 5 + } +} +``` + +La sezione `ChartService` non è più necessaria: il grafico viene generato internamente. + +### Note sulla connection string + +- Usare `Data Source` (non `Server`) e `Initial Catalog` (non `Database`) per compatibilità con `Microsoft.Data.SqlClient` v5 +- Aggiungere `Encrypt=False;` se il SQL Server non ha SSL configurato +- Se la connessione non funziona via TCP, aggiungere il prefisso `tcp:` all'indirizzo (es. `Data Source=tcp:26.69.45.60;`) + +### appsettings.Development.json + +Non deve contenere `ConnectionStrings` — altrimenti sovrascrive i valori di `appsettings.json`. Contiene solo override per logging e cache in sviluppo. + +--- + +## 7. Pacchetti NuGet + +| Pacchetto | Versione | Scopo | +|-----------|----------|-------| +| `Syncfusion.Pdf.Net.Core` | 33.x | Generazione e merge PDF | +| `Microsoft.Data.SqlClient` | 5.x | Accesso SQL Server | +| `SkiaSharp` | 2.x | Generazione grafico server-side | +| `Serilog.AspNetCore` | 8.x | Logging strutturato | +| `Serilog.Sinks.File` | 5.x | Log su file con rotazione giornaliera | + +### Note + +- `Syncfusion.Drawing.Net.Core` **non esiste più** nella v33: è inglobato in `Syncfusion.Pdf.Net.Core` +- Per i colori usare `Color.FromArgb(255, r, g, b)` invece di `new Color(r, g, b)` (cambiamento v33) +- `PdfStandardFont` **non è IDisposable** in Syncfusion v33: non usare `using` nella dichiarazione +- SkiaSharp v2.x: usare `SKFont` per dimensione/typeface e `canvas.DrawText(text, x, y, SKTextAlign, font, paint)` — le API su `SKPaint` sono obsolete + +--- + +## 8. Architettura e pattern + +### Dependency Injection + +Tutti i servizi sono registrati in `Program.cs`: + +- `ICertificateDataService` → `CertificateDataService` (Scoped) +- `IChartDataService` → `ChartDataService` (Scoped) +- `IPdfSectionRenderer` → registrati multipli: `Anagrafica`, `Eventi`, `Scenario` (Scoped) +- `IChartSectionRenderer` → `ChartSectionRenderer` (Scoped) +- `IPdfMergerService` → `PdfMergerService` (Scoped) +- `IReportOrchestrator` → `ReportOrchestrator` (Scoped) +- `IPdfCacheService` → `PdfCacheService` (Singleton) +- `CryptoHelper` (Singleton) + +### Aggiungere una nuova sezione al report + +1. Creare una classe che implementa `IPdfSectionRenderer` +2. Assegnare `SectionName` e `Order` (determina la posizione nel PDF) +3. Implementare `Render(CertificateReportData data)` che restituisce un `PdfDocument` +4. Registrarla in `Program.cs`: `builder.Services.AddScoped();` + +L'orchestratore la includerà automaticamente nel report, ordinata per `Order`. + +### Tema e stili + +`PdfTheme.cs` centralizza: + +- **Colori**: `HeaderBackground`, `AlternateRow`, `PositiveValue`, `NegativeValue`, ecc. +- **Font**: `Regular` (8pt), `Bold`, `Small` (6.5pt), `Title` (14pt), `Header` (7pt bold) +- **Layout**: `PageMargin` (40pt), `RowHeight` (18pt), `CellPadding` (4pt) +- **Brushes e Pens**: pronti all'uso, derivati dai colori +- **Bordi**: `ApplyThinBorders(grid, thickness)` per linee tabella sottili e uniformi + +Modificare un valore in `PdfTheme` aggiorna tutti i renderer automaticamente. + +### Bordi tabelle + +Tutte le tabelle PdfGrid usano `PdfTheme.ApplyThinBorders(grid)` prima del `Draw()` per ottenere linee sottili e uniformi (0.25pt di default). Il metodo itera su headers e righe applicando `PdfBorders` con `PdfPen` del colore e spessore definiti nel tema. + +### Cache + +I PDF generati vengono memorizzati in `IMemoryCache` per `CacheMinutes` (default 5 minuti, configurabile). Evita rigenerazioni per ricaricamenti di pagina. La cache ha un limite di 200 MB totali. + +### Gestione errori + +`GlobalExceptionMiddleware` cattura tutte le eccezioni non gestite e restituisce risposte JSON coerenti con status code appropriati (400, 404, 408, 500). + +--- + +## 9. Sezione grafico (Sezione 4) + +Il grafico viene generato **interamente in memoria** con SkiaSharp, eliminando la dipendenza dall'endpoint esterno `ChartFSWeb.aspx`. + +### Architettura + +``` +ChartSectionRenderer + │ + ├──→ ChartDataService ← Recupera dati da DB (3 SP) + │ + └──→ SkiaChartRenderer ← Genera PNG con SkiaSharp + │ + └──→ PNG in memoria → inserito nel PDF come PdfBitmap +``` + +### Cosa disegna il grafico + +- **Serie certificato**: linea nera, spessore 2.5px (performance % nel tempo) +- **Serie sottostanti**: N linee colorate, spessore 1.5px (ciascuna con colore diverso dal palette ciclico) +- **Barriera Capitale**: linea orizzontale rossa +- **Barriera Coupon**: linea orizzontale viola (solo se diversa da Barriera Capitale) +- **Strike (100%)**: linea orizzontale verde +- **Asse Y**: percentuale con auto-scaling e margine 10% +- **Asse X**: date con step automatico (trimestrale/semestrale/annuale) +- **Legenda**: box in alto a destra con tutte le serie e le linee costanti +- **Griglia**: linee orizzontali e verticali grigio chiaro + +### API chart standalone + +Il `ChartController` espone il grafico come API indipendente, richiamabile da qualsiasi altro progetto senza passare per il report completo. + +--- + +## 10. Sezione scenario (Sezione 3) + +### Stile visivo + +La sezione scenario replica lo stile del vecchio report DevExpress: + +- **Header con gradiente colore**: da rosso scuro (-90%) attraverso arancio e giallo fino a verde (+30%) +- **Testo header bianco** su sfondo colorato +- **Titolo "Analisi Scenario"** centrato, grande, colore blu +- **Righe alternate** con sfondo grigio chiaro +- **Valori negativi** in rosso scuro +- **13 colonne di variazione**: -90%, -80%, -70%, -60%, -50%, -40%, -30%, -20%, -10%, 0%, +10%, +20%, +30% + +### Logica di inclusione + +La sezione scenario viene inclusa nel report se `rpt_AnalisiScenario_ISIN` restituisce almeno una riga. La condizione è semplicemente `scenario.Rows.Count > 0` (non controlla i valori, perché la SP restituisce anche `"--"` per dati non calcolabili). + +--- + +## 11. Deploy + +### Locale (Visual Studio) + +``` +F5 → https://localhost:{porta}/api/report/by-isin/{ISIN} +F5 → https://localhost:{porta}/api/chart/{ISIN} +``` + +### Docker + +```bash +docker-compose up --build +# → http://localhost:5080/api/report/by-isin/{ISIN} +# → http://localhost:5080/api/chart/{ISIN} +``` + +Il `Dockerfile` include l'installazione dei font necessari per il rendering PDF e SkiaSharp (`libfontconfig1`, `fonts-dejavu-core`) e gira come utente non-root per sicurezza. + +--- + +## 12. Logging + +Serilog scrive su: + +- **Console**: tutti i log in tempo reale +- **File**: `Logs/certreports-{data}.log` con rotazione giornaliera + +Livelli configurabili in `appsettings.json`. In Development il livello di default è `Debug`, in Production è `Information`. + +Ogni step del flusso è loggato: + +``` +[INF] Richiesta report per ISIN CH1277653163 +[DBG] Cache MISS per ISIN CH1277653163 +[INF] Inizio generazione report per ISIN CH1277653163 +[INF] Anagrafica caricata per CH1277653163: Leonteq, 3 sottostanti +[INF] Eventi caricati per CH1277653163: 36 eventi +[INF] Scenario caricato per CH1277653163: 7 righe +[INF] Sezione 'Anagrafica' generata per CH1277653163 +[INF] Sezione 'Eventi' generata per CH1277653163 +[INF] Sezione 'Scenario' generata per CH1277653163 +[INF] Dati grafico caricati per CH1277653163: 4 serie, certificato 650 punti +[INF] Grafico generato per CH1277653163: 82450 bytes PNG, 4 serie +[INF] Sezione Grafico aggiunta per CH1277653163 +[INF] Report generato per CH1277653163: 125000 bytes, 4 sezioni +[DBG] Cache SET per CH1277653163 (125000 bytes, TTL 5min) +``` + +--- + +## 13. Problemi noti e soluzioni + +| Problema | Causa | Soluzione | +|----------|-------|-----------| +| `Cannot access a closed Stream` nel merge | Gli stream dei `PdfLoadedDocument` venivano chiusi prima del `Save()` finale | `PdfMergerService` tiene tutti gli stream aperti fino al salvataggio, poi fa cleanup nel `finally` | +| `Color does not contain a constructor that takes 3 arguments` | Syncfusion v33 ha rimosso `new Color(r,g,b)` | Usare `Color.FromArgb(255, r, g, b)` | +| `Syncfusion.Drawing.Net.Core` non trovato | Pacchetto rimosso nella v33, inglobato in `Syncfusion.Pdf.Net.Core` | Rimuovere dal `.csproj` | +| `Unexpected character '{'` in `FormatPercent` | Interpolazione annidata non supportata in C# | Usare `value.Value.ToString($"N{decimals}") + " %"` | +| Connessione DB via Named Pipes | `Microsoft.Data.SqlClient` v5 tenta Named Pipes di default | Aggiungere `Encrypt=False;` e/o prefisso `tcp:` nella connection string | +| Connection string ignorata | `appsettings.Development.json` sovrascriveva `appsettings.json` | Rimuovere `ConnectionStrings` da `appsettings.Development.json` | +| Scenario mai generato | SP sbagliata (`rpt_AnalisiRischio_ISIN` con 11 colonne) e check troppo restrittivo | Usare `rpt_AnalisiScenario_ISIN` (13 colonne, label in `Descrizione`), condizione `Rows.Count > 0` | +| Namespace conflict `Syncfusion.Pdf` in `ChartController` | `CertReports.Syncfusion.Pdf` confuso con `Syncfusion.Pdf` | Aggiungere `using Syncfusion.Pdf;` esplicito, non usare riferimenti fully-qualified | +| `PdfStandardFont` non è IDisposable | Syncfusion v33 non implementa `IDisposable` su questo tipo | Non usare `using` nella dichiarazione | +| SkiaSharp `DrawText` obsoleto | `SKPaint.TextSize/Typeface` deprecati in SkiaSharp 2.x | Usare `SKFont` + `canvas.DrawText(text, x, y, SKTextAlign, font, paint)` | +| Bordi tabelle troppo spessi | `PdfGrid` usa bordi di default grossi | `PdfTheme.ApplyThinBorders(grid)` con `PdfPen` a 0.25pt prima di `Draw()` | +| `grid.Headers` non iterabile con foreach | Syncfusion v33 non espone `IEnumerable` sugli headers | Usare `for (int r = 0; r < grid.Headers.Count; r++)` con accesso indicizzato | +| `grid.Draw()` restituisce void | Syncfusion v33 ha rimosso il valore di ritorno | Stimare altezza con `(grid.Rows.Count + 1) * RowHeight` | + +--- + +## 14. Cronologia modifiche + +| Data | Modifica | +|------|----------| +| 18/03/2026 | Creazione progetto iniziale con struttura ASP.NET Core 8 + Syncfusion | +| 18/03/2026 | Implementazione completa `CryptoHelper.DecryptCombined` (v1 TripleDES + v2 AES) | +| 18/03/2026 | Mapping SP reali: `rpt_Master_CFT_ISIN`, `rpt_Details_UL_ISIN`, `rpt_Events_CFT_ISIN` | +| 18/03/2026 | Fix compatibilità Syncfusion v33: `Color.FromArgb`, rimozione `Syncfusion.Drawing.Net.Core` | +| 18/03/2026 | Fix `PdfMergerService`: stream mantenuti aperti fino al `Save()` finale | +| 18/03/2026 | Fix connection string: `Data Source` + `Encrypt=False` per `Microsoft.Data.SqlClient` v5 | +| 18/03/2026 | Fix `appsettings.Development.json` che sovrascriveva la connection string | +| 18/03/2026 | Prima generazione PDF riuscita con dati reali | +| 18/03/2026 | Porting grafico da DevExpress `ChartFSWeb.aspx` a SkiaSharp interno | +| 18/03/2026 | Aggiunta `ChartDataService` (SP: `FSWeb_Chart_UL`, `FSWeb_Chart_DailyCTF`, `FSWeb_Chart_DailyUL`) | +| 18/03/2026 | Aggiunta `SkiaChartRenderer` con serie, barriere, legenda, auto-scaling | +| 18/03/2026 | Aggiunta `ChartController` per API chart standalone (`/api/chart/{isin}`) | +| 18/03/2026 | Fix scenario: SP corretta `rpt_AnalisiScenario_ISIN` con 13 colonne e label `Descrizione` | +| 18/03/2026 | Scenario: header con gradiente rosso→verde, valori negativi in rosso | +| 18/03/2026 | Rimozione colonne Trigger Capitale e Valore Capitale dalla tabella eventi | +| 18/03/2026 | Fix bordi tabelle: `PdfTheme.ApplyThinBorders()` con linee 0.25pt | +| 18/03/2026 | Fix SkiaSharp: migrazione a API moderna `SKFont` (rimozione warning obsoleti) | diff --git a/CertReports.Syncfusion/Dockerfile b/CertReports.Syncfusion/Dockerfile new file mode 100644 index 0000000..8ac29c0 --- /dev/null +++ b/CertReports.Syncfusion/Dockerfile @@ -0,0 +1,36 @@ +# ── Build stage ────────────────────────────────────────────────────── +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src + +COPY *.csproj . +RUN dotnet restore + +COPY . . +RUN dotnet publish -c Release -o /app/publish --no-restore + +# ── Runtime stage ──────────────────────────────────────────────────── +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime +WORKDIR /app + +# Installa font per il rendering PDF (Syncfusion ne ha bisogno) +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + libfontconfig1 \ + fonts-dejavu-core && \ + rm -rf /var/lib/apt/lists/* + +# Crea directory per i log +RUN mkdir -p /app/Logs + +COPY --from=build /app/publish . + +# Non-root user per sicurezza +RUN groupadd -r appuser && useradd -r -g appuser appuser && \ + chown -R appuser:appuser /app +USER appuser + +EXPOSE 8080 +ENV ASPNETCORE_URLS=http://+:8080 +ENV ASPNETCORE_ENVIRONMENT=Production + +ENTRYPOINT ["dotnet", "CertReports.Syncfusion.dll"] diff --git a/CertReports.Syncfusion/GlobalUsings.cs b/CertReports.Syncfusion/GlobalUsings.cs new file mode 100644 index 0000000..f08ebdc --- /dev/null +++ b/CertReports.Syncfusion/GlobalUsings.cs @@ -0,0 +1,4 @@ +// GlobalUsings.cs +// Importazioni globali usate in tutto il progetto +global using Syncfusion.Pdf; +global using PdfLoadedDocument = Syncfusion.Pdf.Parsing.PdfLoadedDocument; diff --git a/CertReports.Syncfusion/Helpers/CryptoHelper.cs b/CertReports.Syncfusion/Helpers/CryptoHelper.cs new file mode 100644 index 0000000..d32f5e8 --- /dev/null +++ b/CertReports.Syncfusion/Helpers/CryptoHelper.cs @@ -0,0 +1,133 @@ +using System.Security.Cryptography; +using System.Text; + +namespace CertReports.Syncfusion.Helpers; + +/// +/// Helper per decodifica ISIN cifrato. +/// Compatibile con SQL Server ENCRYPTBYPASSPHRASE (versione 1 = TripleDES, versione 2 = AES). +/// Portato da CommonClass.DecryptCombined. +/// +public class CryptoHelper +{ + private readonly string _passphrase; + + public CryptoHelper(IConfiguration config) + { + _passphrase = config["CryptoSettings:Passphrase"] + ?? throw new InvalidOperationException("CryptoSettings:Passphrase non configurata."); + } + + /// + /// Decodifica una stringa esadecimale cifrata con SQL Server ENCRYPTBYPASSPHRASE. + /// Formato input: "0x0200000047..." (stringa hex con prefisso 0x). + /// + public string DecryptIsin(string fromSql) + { + if (string.IsNullOrWhiteSpace(fromSql)) + return string.Empty; + + return DecryptCombined(fromSql, _passphrase); + } + + /// + /// Implementazione completa della decodifica ENCRYPTBYPASSPHRASE di SQL Server. + /// Supporta versione 1 (TripleDES/SHA1) e versione 2 (AES/SHA256). + /// + private static string DecryptCombined(string fromSql, string password) + { + // Password codificata come UTF16-LE (come fa SQL Server internamente) + byte[] passwordBytes = Encoding.Unicode.GetBytes(password); + + // Rimuove il prefisso "0x" + if (fromSql.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + fromSql = fromSql[2..]; + + // I primi 4 byte (8 caratteri hex) indicano la versione + int version = BitConverter.ToInt32(HexToBytes(fromSql[..8]), 0); + + byte[] encrypted; + HashAlgorithm hashAlgo; + SymmetricAlgorithm cryptoAlgo; + int keySize; + + if (version == 1) + { + // Versione 1: TripleDES + SHA1 + keySize = 16; + hashAlgo = SHA1.Create(); + cryptoAlgo = TripleDES.Create(); + cryptoAlgo.IV = HexToBytes(fromSql.Substring(8, 16)); // 8 byte IV = 16 hex chars + encrypted = HexToBytes(fromSql[24..]); // Resto = dati cifrati + } + else if (version == 2) + { + // Versione 2: AES + SHA256 + keySize = 32; + hashAlgo = SHA256.Create(); + cryptoAlgo = Aes.Create(); + cryptoAlgo.IV = HexToBytes(fromSql.Substring(8, 32)); // 16 byte IV = 32 hex chars + encrypted = HexToBytes(fromSql[40..]); // Resto = dati cifrati + } + else + { + throw new CryptographicException($"Versione di cifratura non supportata: {version}"); + } + + cryptoAlgo.Padding = PaddingMode.PKCS7; + cryptoAlgo.Mode = CipherMode.CBC; + + // Genera la chiave dall'hash della password + hashAlgo.TransformFinalBlock(passwordBytes, 0, passwordBytes.Length); + cryptoAlgo.Key = hashAlgo.Hash!.Take(keySize).ToArray(); + + // Decrittazione + byte[] decrypted = cryptoAlgo.CreateDecryptor() + .TransformFinalBlock(encrypted, 0, encrypted.Length); + + // Validazione magic number (i primi 4 byte devono essere 0xBAADF00D) + uint magic = BitConverter.ToUInt32(decrypted, 0); + if (magic != 0xBAADF00D) + { + throw new CryptographicException( + "Decrittazione fallita: magic number non valido. Password errata?"); + } + + // I byte 4-5 riservati, byte 6-7 = lunghezza, byte 8+ = dati + byte[] decryptedData = decrypted[8..]; + + // Rileva encoding: se contiene byte 0x00 è UTF-16, altrimenti UTF-8 + bool isUtf16 = Array.IndexOf(decryptedData, (byte)0) != -1; + string result = isUtf16 + ? Encoding.Unicode.GetString(decryptedData) + : Encoding.UTF8.GetString(decryptedData); + + // Cleanup risorse crittografiche + hashAlgo.Dispose(); + cryptoAlgo.Dispose(); + + return result; + } + + /// + /// Converte una stringa esadecimale in byte array. + /// + private static byte[] HexToBytes(string hex) + { + byte[] bytes = new byte[hex.Length / 2]; + for (int i = 0; i < bytes.Length; i++) + { + bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16); + } + return bytes; + } + + /// + /// Genera una stringa casuale sicura. + /// + public static string RandomString(int length) + { + const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + return RandomNumberGenerator.GetString(chars, length); + } +} diff --git a/CertReports.Syncfusion/Helpers/GlobalExceptionMiddleware.cs b/CertReports.Syncfusion/Helpers/GlobalExceptionMiddleware.cs new file mode 100644 index 0000000..6f4b7b3 --- /dev/null +++ b/CertReports.Syncfusion/Helpers/GlobalExceptionMiddleware.cs @@ -0,0 +1,84 @@ +using System.Net; +using System.Text.Json; + +namespace CertReports.Syncfusion.Helpers; + +/// +/// Middleware globale per la gestione degli errori. +/// Cattura tutte le eccezioni non gestite e restituisce una risposta JSON coerente. +/// +public class GlobalExceptionMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public GlobalExceptionMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context); + } + catch (ArgumentException ex) + { + _logger.LogWarning(ex, "Richiesta non valida: {Message}", ex.Message); + await WriteErrorResponse(context, HttpStatusCode.BadRequest, ex.Message); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Operazione non valida: {Message}", ex.Message); + await WriteErrorResponse(context, HttpStatusCode.BadRequest, ex.Message); + } + catch (FileNotFoundException ex) + { + _logger.LogWarning(ex, "Risorsa non trovata: {Message}", ex.Message); + await WriteErrorResponse(context, HttpStatusCode.NotFound, "Risorsa non trovata."); + } + catch (TimeoutException ex) + { + _logger.LogError(ex, "Timeout durante la generazione del report"); + await WriteErrorResponse(context, HttpStatusCode.GatewayTimeout, + "Timeout nella generazione del report. Riprovare."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Errore non gestito: {Message}", ex.Message); + await WriteErrorResponse(context, HttpStatusCode.InternalServerError, + "Errore interno del server. Contattare l'assistenza."); + } + } + + private static async Task WriteErrorResponse(HttpContext context, HttpStatusCode statusCode, string message) + { + context.Response.ContentType = "application/json"; + context.Response.StatusCode = (int)statusCode; + + var errorResponse = new + { + status = (int)statusCode, + error = statusCode.ToString(), + message, + timestamp = DateTime.UtcNow + }; + + var json = JsonSerializer.Serialize(errorResponse, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + await context.Response.WriteAsync(json); + } +} + +public static class GlobalExceptionMiddlewareExtensions +{ + public static IApplicationBuilder UseGlobalExceptionHandler(this IApplicationBuilder app) + { + return app.UseMiddleware(); + } +} diff --git a/CertReports.Syncfusion/Helpers/HealthChecks.cs b/CertReports.Syncfusion/Helpers/HealthChecks.cs new file mode 100644 index 0000000..7794dbe --- /dev/null +++ b/CertReports.Syncfusion/Helpers/HealthChecks.cs @@ -0,0 +1,69 @@ +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace CertReports.Syncfusion.Helpers; + +/// +/// Verifica connettività al database SQL Server. +/// +public class DatabaseHealthCheck : IHealthCheck +{ + private readonly string _connectionString; + + public DatabaseHealthCheck(IConfiguration config) + { + _connectionString = config.GetConnectionString("CertDb") ?? ""; + } + + public async Task CheckHealthAsync( + HealthCheckContext context, CancellationToken cancellationToken = default) + { + try + { + await using var conn = new SqlConnection(_connectionString); + await conn.OpenAsync(cancellationToken); + await using var cmd = new SqlCommand("SELECT 1", conn); + await cmd.ExecuteScalarAsync(cancellationToken); + return HealthCheckResult.Healthy("SQL Server raggiungibile."); + } + catch (Exception ex) + { + return HealthCheckResult.Unhealthy("SQL Server non raggiungibile.", ex); + } + } +} + +/// +/// Verifica connettività al servizio chart esterno. +/// +public class ChartServiceHealthCheck : IHealthCheck +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly string _baseUrl; + + public ChartServiceHealthCheck(IHttpClientFactory httpClientFactory, IConfiguration config) + { + _httpClientFactory = httpClientFactory; + _baseUrl = config["ChartService:BaseUrl"] ?? ""; + } + + public async Task CheckHealthAsync( + HealthCheckContext context, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(_baseUrl)) + return HealthCheckResult.Degraded("ChartService:BaseUrl non configurato."); + + try + { + var client = _httpClientFactory.CreateClient("ChartService"); + var response = await client.GetAsync(_baseUrl, cancellationToken); + return response.IsSuccessStatusCode + ? HealthCheckResult.Healthy("Chart service raggiungibile.") + : HealthCheckResult.Degraded($"Chart service risponde con status {response.StatusCode}."); + } + catch (Exception ex) + { + return HealthCheckResult.Degraded("Chart service non raggiungibile.", ex); + } + } +} diff --git a/CertReports.Syncfusion/Helpers/PdfTheme.cs b/CertReports.Syncfusion/Helpers/PdfTheme.cs new file mode 100644 index 0000000..f48e20e --- /dev/null +++ b/CertReports.Syncfusion/Helpers/PdfTheme.cs @@ -0,0 +1,126 @@ +using Syncfusion.Drawing; +using Syncfusion.Pdf; +using Syncfusion.Pdf.Graphics; +using Syncfusion.Pdf.Grid; + +namespace CertReports.Syncfusion.Helpers; + +/// +/// Tema centralizzato per tutti i PDF generati. +/// Modifica qui colori, font e dimensioni per aggiornare l'intero report. +/// +public static class PdfTheme +{ + // ─── Colori ──────────────────────────────────────────────────────── + public static readonly Color HeaderBackground = Color.FromArgb(255, 46, 80, 144); // #2E5090 + public static readonly Color HeaderText = Color.FromArgb(255, 255, 255, 255); + public static readonly Color AlternateRow = Color.FromArgb(255, 242, 246, 250); // #F2F6FA + public static readonly Color BorderColor = Color.FromArgb(255, 200, 210, 220); + public static readonly Color TextPrimary = Color.FromArgb(255, 33, 37, 41); + public static readonly Color TextSecondary = Color.FromArgb(255, 108, 117, 125); + public static readonly Color PositiveValue = Color.FromArgb(255, 40, 167, 69); // verde + public static readonly Color NegativeValue = Color.FromArgb(255, 220, 53, 69); // rosso + public static readonly Color SectionTitle = Color.FromArgb(255, 46, 80, 144); + + // ─── Font ────────────────────────────────────────────────────────── + private static readonly PdfStandardFont _fontRegular = new(PdfFontFamily.Helvetica, 8f); + private static readonly PdfStandardFont _fontBold = new(PdfFontFamily.Helvetica, 8f, PdfFontStyle.Bold); + private static readonly PdfStandardFont _fontSmall = new(PdfFontFamily.Helvetica, 6.5f); + private static readonly PdfStandardFont _fontSmallBold = new(PdfFontFamily.Helvetica, 6.5f, PdfFontStyle.Bold); + private static readonly PdfStandardFont _fontTitle = new(PdfFontFamily.Helvetica, 14f, PdfFontStyle.Bold); + private static readonly PdfStandardFont _fontSectionTitle = new(PdfFontFamily.Helvetica, 10f, PdfFontStyle.Bold); + private static readonly PdfStandardFont _fontHeader = new(PdfFontFamily.Helvetica, 7f, PdfFontStyle.Bold); + + public static PdfFont Regular => _fontRegular; + public static PdfFont Bold => _fontBold; + public static PdfFont Small => _fontSmall; + public static PdfFont SmallBold => _fontSmallBold; + public static PdfFont Title => _fontTitle; + public static PdfFont SectionTitleFont => _fontSectionTitle; + public static PdfFont Header => _fontHeader; + + // ─── Margini & Layout ────────────────────────────────────────────── + public const float PageMargin = 40f; + public const float RowHeight = 18f; + public const float HeaderRowHeight = 22f; + public const float CellPadding = 4f; + + // ─── Brushes ─────────────────────────────────────────────────────── + public static PdfBrush HeaderBrush => new PdfSolidBrush(HeaderBackground); + public static PdfBrush HeaderTextBrush => new PdfSolidBrush(HeaderText); + public static PdfBrush AlternateRowBrush => new PdfSolidBrush(AlternateRow); + public static PdfBrush TextBrush => new PdfSolidBrush(TextPrimary); + public static PdfBrush TextSecondaryBrush => new PdfSolidBrush(TextSecondary); + public static PdfBrush PositiveBrush => new PdfSolidBrush(PositiveValue); + public static PdfBrush NegativeBrush => new PdfSolidBrush(NegativeValue); + public static PdfPen BorderPen => new PdfPen(BorderColor, 0.5f); + + // ─── Utility ─────────────────────────────────────────────────────── + public static PdfBrush ValueBrush(decimal? value) + { + if (value == null) return TextBrush; + return value >= 0 ? PositiveBrush : NegativeBrush; + } + + /// + /// Crea una pagina A4 con margini standard + /// + public static PdfPage AddA4Page(PdfDocument doc, PdfPageOrientation orientation = PdfPageOrientation.Portrait) + { + doc.PageSettings.Size = PdfPageSize.A4; + doc.PageSettings.Orientation = orientation; + doc.PageSettings.Margins.All = PageMargin; + return doc.Pages.Add(); + } + + /// + /// Formatta un decimale come percentuale + /// + public static string FormatPercent(decimal? value, int decimals = 2) + { + if (value == null) return "-"; + return value.Value.ToString($"N{decimals}") + " %"; + } + + /// + /// Formatta un decimale come numero + /// + public static string FormatNumber(decimal? value, int decimals = 2) + { + if (value == null) return "-"; + return value.Value.ToString($"N{decimals}"); + } + + /// + /// Formatta una data in formato italiano + /// + public static string FormatDate(DateTime? date) + { + return date?.ToString("dd/MM/yyyy") ?? "-"; + } + + /// + /// Applica bordi sottili a tutte le celle di una PdfGrid + /// + public static void ApplyThinBorders(PdfGrid grid, float thickness = 0.25f) + { + var pen = new PdfPen(BorderColor, thickness); + var borders = new PdfBorders + { + Top = pen, + Bottom = pen, + Left = pen, + Right = pen + }; + + // Header + for (int r = 0; r < grid.Headers.Count; r++) + for (int c = 0; c < grid.Headers[r].Cells.Count; c++) + grid.Headers[r].Cells[c].Style.Borders = borders; + + // Righe dati + for (int r = 0; r < grid.Rows.Count; r++) + for (int c = 0; c < grid.Rows[r].Cells.Count; c++) + grid.Rows[r].Cells[c].Style.Borders = borders; + } +} diff --git a/CertReports.Syncfusion/Models/CertificateModels.cs b/CertReports.Syncfusion/Models/CertificateModels.cs new file mode 100644 index 0000000..c311bee --- /dev/null +++ b/CertReports.Syncfusion/Models/CertificateModels.cs @@ -0,0 +1,158 @@ +namespace CertReports.Syncfusion.Models; + +// ═══════════════════════════════════════════════════════════════════════ +// Dati anagrafici del certificato (Sezione 1) +// SP: rpt_Master_CFT_ISIN +// ═══════════════════════════════════════════════════════════════════════ +public class CertificateInfo +{ + // Identificazione + public int Id { get; set; } + public string Isin { get; set; } = string.Empty; + public string Emittente { get; set; } = string.Empty; + public string Nome { get; set; } = string.Empty; + public string Categoria { get; set; } = string.Empty; + + // Struttura + public string Mercato { get; set; } = string.Empty; + public string Valuta { get; set; } = string.Empty; + public string Direzione { get; set; } = string.Empty; + public string BasketType { get; set; } = string.Empty; + public string BarrierType { get; set; } = string.Empty; + public string Memory { get; set; } = string.Empty; // "SI"/"NO"/"N/A" + public string Airbag { get; set; } = string.Empty; // "SI"/"NO"/"N/A" + public string FrequenzaCedole { get; set; } = string.Empty; // "mensile","trimestrale",... + + // Date (arrivano come stringhe formattate dalla SP) + public string Scadenza { get; set; } = string.Empty; + public string DataEmissione { get; set; } = string.Empty; + public string DataRimborso { get; set; } = string.Empty; + public string NextAutocallDate { get; set; } = string.Empty; + public string LastPriceDate { get; set; } = string.Empty; + + // Prezzi + public string Bid { get; set; } = string.Empty; + public string Ask { get; set; } = string.Empty; + public decimal? NominalValue { get; set; } + public decimal? PrezzoEmissione { get; set; } + + // Rendimenti e protezioni (arrivano formattati dalla SP) + public string NominalAnnualYield { get; set; } = string.Empty; + public string CouponYield { get; set; } = string.Empty; + public string PotentialCouponYield { get; set; } = string.Empty; + public string MinimumYield { get; set; } = string.Empty; + public string CapitalReturnAtMaturity { get; set; } = string.Empty; + public string CapitalAnnualReturnAtMaturity { get; set; } = string.Empty; + public string AutocallReturn { get; set; } = string.Empty; + public string AutocallAnnualReturn { get; set; } = string.Empty; + public string TriggerAutocallDistance { get; set; } = string.Empty; + public string IRR { get; set; } = string.Empty; + public string RendimentoTotale { get; set; } = string.Empty; + public string RendimentoAttuale { get; set; } = string.Empty; + + // Valori calcolati (arrivano formattati) + public string IntrinsicValue { get; set; } = string.Empty; + public string Premium { get; set; } = string.Empty; + public string CapitalValue { get; set; } = string.Empty; + public string AutocallValue { get; set; } = string.Empty; + public string ValoreRimborso { get; set; } = string.Empty; + public string RTS { get; set; } = string.Empty; + public string Var95 { get; set; } = string.Empty; + + // Protezioni (arrivano formattate) + public string BufferKProt { get; set; } = string.Empty; + public string BufferCPNProt { get; set; } = string.Empty; + public string LivelloBarriera { get; set; } = string.Empty; + + // Cedole + public decimal? CpnPagati { get; set; } + public decimal? CpnDaPagare { get; set; } + public decimal? CpnInMemoria { get; set; } + + // Vari + public string Leva { get; set; } = string.Empty; + public string FattoreAirbag { get; set; } = string.Empty; + public string TriggerOneStar { get; set; } = string.Empty; + public string Note { get; set; } = string.Empty; + + // Sottostanti (da SP separata: rpt_Details_UL_ISIN) + public List Sottostanti { get; set; } = new(); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Sottostanti del certificato +// SP: rpt_Details_UL_ISIN +// ═══════════════════════════════════════════════════════════════════════ +public class Sottostante +{ + public string Nome { get; set; } = string.Empty; + public string Strike { get; set; } = string.Empty; + public string LastPrice { get; set; } = string.Empty; + public string Performance { get; set; } = string.Empty; + public string CapitalBarrier { get; set; } = string.Empty; + public string CouponBarrier { get; set; } = string.Empty; + public string TriggerAutocall { get; set; } = string.Empty; + public string ULCapitalBarrierBuffer { get; set; } = string.Empty; + public string ULCouponBarrierBuffer { get; set; } = string.Empty; + public string ULTriggerAutocallDistance { get; set; } = string.Empty; + public string DividendExDate { get; set; } = string.Empty; + public string DividendPayDate { get; set; } = string.Empty; + public string DividendAmount { get; set; } = string.Empty; + public string DividendYield { get; set; } = string.Empty; + public string DividendFutAmount { get; set; } = string.Empty; + public string DividendFutYield { get; set; } = string.Empty; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Eventi del certificato (Sezione 2) +// SP: rpt_Events_CFT_ISIN +// ═══════════════════════════════════════════════════════════════════════ +public class CertificateEvent +{ + public string ObservationDate { get; set; } = string.Empty; + public string ExDate { get; set; } = string.Empty; + public string RecordDate { get; set; } = string.Empty; + public string PaymentDate { get; set; } = string.Empty; + public string CouponTrigger { get; set; } = string.Empty; + public string CouponValue { get; set; } = string.Empty; + public string Paid { get; set; } = string.Empty; + public string Memory { get; set; } = string.Empty; + public string AmountPaid { get; set; } = string.Empty; + public string CapitalTrigger { get; set; } = string.Empty; + public string CapitalValue { get; set; } = string.Empty; + public string AutocallTrigger { get; set; } = string.Empty; + public string AutocallValue { get; set; } = string.Empty; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Analisi scenario / Analisi rischio (Sezione 3) +// SP: rpt_AnalisiRischio_ISIN +// Restituisce righe pivot con col0..col11 (tutte varchar) +// ═══════════════════════════════════════════════════════════════════════ +public class ScenarioRow +{ + /// col0: "Prezzo Sottostante", "Rimborso Certificato", "P&L % certificato" + public string Label { get; set; } = string.Empty; + + /// col1..col11: valori per variazione -70%, -50%, -30%, -20%, -10%, 0%, +10%, +20%, +30%, +50%, +70% + public List Values { get; set; } = new(); +} + +public class ScenarioAnalysis +{ + public static readonly string[] VariationHeaders = + { "-90%", "-80%", "-70%", "-60%", "-50%", "-40%", "-30%", "-20%", "-10%", "0%", "+10%", "+20%", "+30%" }; + + public List Rows { get; set; } = new(); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Modello aggregato per generazione report +// ═══════════════════════════════════════════════════════════════════════ +public class CertificateReportData +{ + public CertificateInfo Info { get; set; } = new(); + public List Eventi { get; set; } = new(); + public ScenarioAnalysis Scenario { get; set; } = new(); + public byte[]? ChartImage { get; set; } +} diff --git a/CertReports.Syncfusion/Models/ChartModels.cs b/CertReports.Syncfusion/Models/ChartModels.cs new file mode 100644 index 0000000..6030246 --- /dev/null +++ b/CertReports.Syncfusion/Models/ChartModels.cs @@ -0,0 +1,53 @@ +namespace CertReports.Syncfusion.Models; + +// ═══════════════════════════════════════════════════════════════════════ +// Dati per la generazione del grafico (Sezione 4) +// SP: FSWeb_Chart_UL, FSWeb_Chart_DailyCTF, FSWeb_Chart_DailyUL +// ═══════════════════════════════════════════════════════════════════════ + +/// +/// Informazioni sui sottostanti per il grafico. +/// Da SP: FSWeb_Chart_UL +/// +public class ChartUnderlyingInfo +{ + public int IDCertificates { get; set; } + public int IDUnderlyings { get; set; } + public DateTime StartDate { get; set; } + public decimal Strike { get; set; } + public decimal BarrieraCoupon { get; set; } + public decimal BarrieraCapitale { get; set; } + public string Sottostante { get; set; } = string.Empty; +} + +/// +/// Singolo punto dati di una serie temporale (data + performance %). +/// Da SP: FSWeb_Chart_DailyCTF e FSWeb_Chart_DailyUL +/// +public class ChartDataPoint +{ + public DateTime Date { get; set; } + public decimal Performance { get; set; } +} + +/// +/// Una serie completa da disegnare nel grafico. +/// +public class ChartSeries +{ + public string Name { get; set; } = string.Empty; + public List Points { get; set; } = new(); + public bool IsCertificate { get; set; } +} + +/// +/// Tutti i dati necessari per disegnare il grafico. +/// +public class ChartRenderData +{ + public string Isin { get; set; } = string.Empty; + public List Series { get; set; } = new(); + public decimal BarrieraCapitale { get; set; } + public decimal BarrieraCoupon { get; set; } + public decimal Strike { get; set; } = 100; +} diff --git a/CertReports.Syncfusion/Program.cs b/CertReports.Syncfusion/Program.cs new file mode 100644 index 0000000..da0dd86 --- /dev/null +++ b/CertReports.Syncfusion/Program.cs @@ -0,0 +1,62 @@ +using CertReports.Syncfusion.Helpers; +using CertReports.Syncfusion.Services.Implementations; +using CertReports.Syncfusion.Services.Interfaces; +using Serilog; + +var builder = WebApplication.CreateBuilder(args); + +// ── Serilog ──────────────────────────────────────────────────────────── +Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(builder.Configuration) + .Enrich.FromLogContext() + .WriteTo.Console() + .WriteTo.File("Logs/certreports-.log", rollingInterval: RollingInterval.Day) + .CreateLogger(); +builder.Host.UseSerilog(); + +// ── Syncfusion Community License ─────────────────────────────────────── +Syncfusion.Licensing.SyncfusionLicenseProvider + .RegisterLicense(builder.Configuration["Syncfusion:LicenseKey"] ?? ""); + +// ── Services (DI) ────────────────────────────────────────────────────── +builder.Services.AddControllers(); + +// Memory cache per i report PDF generati +builder.Services.AddMemoryCache(options => +{ + options.SizeLimit = 200 * 1024 * 1024; // 200 MB max +}); + +// HttpClient factory per chiamate al chart service +builder.Services.AddHttpClient("ChartService", client => +{ + client.Timeout = TimeSpan.FromSeconds(30); +}); + +// Health checks +builder.Services.AddHealthChecks() + .AddCheck("database", tags: new[] { "db", "ready" }) + .AddCheck("chart-service", tags: new[] { "external", "ready" }); + +// Registra i servizi applicativi +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +var app = builder.Build(); + +// ── Middleware Pipeline ──────────────────────────────────────────────── +app.UseGlobalExceptionHandler(); // Gestione errori centralizzata (primo!) +app.UseSerilogRequestLogging(); +app.UseRouting(); +app.MapControllers(); +app.MapHealthChecks("/health"); // GET /health per monitoring + +app.Run(); diff --git a/CertReports.Syncfusion/Properties/launchSettings.json b/CertReports.Syncfusion/Properties/launchSettings.json new file mode 100644 index 0000000..16e8cee --- /dev/null +++ b/CertReports.Syncfusion/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "CertReports.Syncfusion": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:51308;http://localhost:51309" + } + } +} \ No newline at end of file diff --git a/CertReports.Syncfusion/README.md b/CertReports.Syncfusion/README.md new file mode 100644 index 0000000..aa4685a --- /dev/null +++ b/CertReports.Syncfusion/README.md @@ -0,0 +1,164 @@ +# CertReports.Syncfusion + +Progetto ASP.NET Core 8 per la generazione di report PDF per certificati finanziari, +basato su **Syncfusion PDF Library** (Community License). + +Sostituisce il vecchio progetto WebForms con DevExpress + PdfSharp. + +--- + +## Architettura + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ ReportController │ +│ GET /api/report?p={encrypted} | GET /api/report?alias={id} │ +└──────────────────────┬───────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ ReportOrchestrator │ +│ │ +│ 1. CertificateDataService ──→ SQL Server (stored procedures) │ +│ 2. AnagraficaSectionRenderer ──→ PDF Sezione 1 │ +│ 3. EventiSectionRenderer ──→ PDF Sezione 2 │ +│ 4. ScenarioSectionRenderer ──→ PDF Sezione 3 (se non Protect.) │ +│ 5. ChartSectionRenderer ──→ PDF Sezione 4 (ext. service) │ +│ 6. PdfMergerService ──→ PDF Finale unificato │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### Struttura file + +``` +CertReports.Syncfusion/ +├── Controllers/ +│ └── ReportController.cs # API endpoint (sostituisce WebForm) +├── Helpers/ +│ ├── CryptoHelper.cs # Decodifica ISIN cifrato +│ └── PdfTheme.cs # Colori, font, stili centralizzati +├── Models/ +│ └── CertificateModels.cs # Modelli dati (Info, Eventi, Scenario) +├── Services/ +│ ├── Interfaces/ +│ │ └── IServices.cs # Contratti per tutti i servizi +│ └── Implementations/ +│ ├── CertificateDataService.cs # Accesso DB via stored procedures +│ ├── AnagraficaSectionRenderer.cs # Sezione 1: dati + sottostanti +│ ├── EventiSectionRenderer.cs # Sezione 2: tabella eventi +│ ├── ScenarioSectionRenderer.cs # Sezione 3: matrice scenario +│ ├── ChartSectionRenderer.cs # Sezione 4: grafico +│ ├── PdfMergerService.cs # Merge PDF Syncfusion +│ └── ReportOrchestrator.cs # Coordinatore principale +├── GlobalUsings.cs +├── Program.cs # Entry point + DI setup +├── appsettings.json # Configurazione +└── CertReports.Syncfusion.csproj # Progetto + NuGet packages +``` + +--- + +## Mapping Vecchio → Nuovo + +| Vecchio (DevExpress/WebForms) | Nuovo (Syncfusion/ASP.NET Core) | +|----------------------------------------------|-------------------------------------------| +| `ReportFSSiteCrypt.aspx` (Page_Load) | `ReportController.cs` (API REST) | +| `XtraReport` + `.repx` template | `IPdfSectionRenderer` implementations | +| `report.ExportToPdf()` per ogni sezione | Ogni renderer restituisce `PdfDocument` | +| `PdfSharp.PdfReader.Open` + `CopyPages` | `Syncfusion PdfMergerService` | +| `CommonClass.DecryptCombined()` | `CryptoHelper.DecryptIsin()` | +| `CommonClass.execSP_Scalar()` | `CertificateDataService` (async) | +| `CallWebApi()` per chart | `ChartSectionRenderer` (HttpClientFactory)| +| File temp su disco + `File.Delete` | Tutto in memoria (MemoryStream) | +| `Response.BinaryWrite()` | `return File(bytes, "application/pdf")` | + +--- + +## Setup e Configurazione + +### 1. Prerequisiti +- .NET 8 SDK +- SQL Server con le stored procedures esistenti +- Syncfusion Community License Key (gratuita per < $1M fatturato) + +### 2. Configurazione `appsettings.json` + +```json +{ + "ConnectionStrings": { + "CertDb": "Server=...;Database=...;User Id=...;Password=...;" + }, + "Syncfusion": { + "LicenseKey": "LA_TUA_CHIAVE_QUI" + }, + "ChartService": { + "BaseUrl": "https://reports.smart-roots.net:4004", + "ChartEndpoint": "/ChartFSWeb.aspx?width=800&height=600&isin={0}&tipo=pdf" + } +} +``` + +### 3. Avvio + +```bash +dotnet restore +dotnet run +``` + +### 4. Test + +``` +# Con ISIN cifrato (compatibile vecchie URL) +GET https://localhost:5001/api/report?p=0x0200000047... + +# Con Alias ID +GET https://localhost:5001/api/report?alias=MY_ALIAS + +# Diretto (debug/interno) +GET https://localhost:5001/api/report/by-isin/CH1277653163 + +# Download come file +GET https://localhost:5001/api/report/download?alias=MY_ALIAS +``` + +--- + +## Stored Procedures Utilizzate + +| SP | Sezione Report | Descrizione | +|----|----------------|-------------| +| `rpt_Master_CFT_ISIN(@ISIN)` | 1 - Anagrafica | Dati certificato (1 record, campi pre-formattati) | +| `rpt_Details_UL_ISIN(@ISIN)` | 1 - Sottostanti | N sottostanti con strike, barriere, dividendi | +| `rpt_Events_CFT_ISIN(@ISIN)` | 2 - Eventi | Lista eventi con date, cedole, autocall | +| `rpt_AnalisiRischio_ISIN(@ISIN)` | 3 - Scenario | Tabella pivot 3×11 (variazioni -70%..+70%) | +| `rpt_FindIsinbyAliasID(@AliasID)` | Risoluzione | Alias ID → ISIN | + +--- + +## TODO Rimasti + +### Completato ✓ +- ✓ CryptoHelper con logica `DecryptCombined` completa (v1 TripleDES, v2 AES) +- ✓ Mapping colonne SP reali (`rpt_Master_CFT_ISIN`, `rpt_Details_UL_ISIN`, ecc.) +- ✓ Modelli aggiornati per ricevere stringhe pre-formattate dalla SP +- ✓ Analisi scenario adattata al formato pivot `col0..col11` + +### Da Verificare/Completare +1. **Test con DB reale** — Verificare che i nomi colonna nel `SqlDataReader` corrispondano + esattamente all'output delle SP (usa `GetStringSafe` che gestisce colonne mancanti) +2. **Layout PDF fine-tuning** — Posizioni, margini, colori per replicare il look originale +3. **Chart service** — L'endpoint ChartFSWeb.aspx funziona ancora? Altrimenti + implementare la generazione interna con SkiaSharp in `ChartSectionRenderer` +4. **SP `rpt_FindIsinbyAliasID`** — Verificare nome esatto e parametro + +--- + +## Vantaggi rispetto al Vecchio Progetto + +- **Nessun file temporaneo su disco**: tutto in MemoryStream +- **Dependency Injection**: ogni servizio è testabile e sostituibile +- **Async/await**: niente più `WebRequest` bloccanti +- **Logging strutturato**: Serilog con log rotanti +- **Sezioni modulari**: aggiungere un nuovo tipo di report = aggiungere un `IPdfSectionRenderer` +- **Tema centralizzato**: un cambio in `PdfTheme.cs` aggiorna tutti i PDF +- **API REST**: può essere consumata da qualsiasi client (non solo browser) diff --git a/CertReports.Syncfusion/Services/Implementations/AnagraficaSectionRenderer.cs b/CertReports.Syncfusion/Services/Implementations/AnagraficaSectionRenderer.cs new file mode 100644 index 0000000..bb69c58 --- /dev/null +++ b/CertReports.Syncfusion/Services/Implementations/AnagraficaSectionRenderer.cs @@ -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; + +/// +/// Sezione 1: Dati anagrafici del certificato + sottostanti. +/// Dati da: rpt_Master_CFT_ISIN + rpt_Details_UL_ISIN +/// +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 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()) + { + 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()) + 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 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; + } +} diff --git a/CertReports.Syncfusion/Services/Implementations/CertificateDataService.cs b/CertReports.Syncfusion/Services/Implementations/CertificateDataService.cs new file mode 100644 index 0000000..88924cc --- /dev/null +++ b/CertReports.Syncfusion/Services/Implementations/CertificateDataService.cs @@ -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; + +/// +/// 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. +/// +public class CertificateDataService : ICertificateDataService +{ + private readonly string _connectionString; + private readonly ILogger _logger; + + public CertificateDataService(IConfiguration config, ILogger logger) + { + _connectionString = config.GetConnectionString("CertDb") + ?? throw new InvalidOperationException("ConnectionString 'CertDb' non configurata."); + _logger = logger; + } + + // ─── Anagrafica + Sottostanti ────────────────────────────────────── + + public async Task 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("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> GetCertificateEventsAsync(string isin) + { + var events = new List(); + + 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 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 + { + 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 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 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 +{ + /// + /// Legge un campo stringa, restituisce string.Empty se NULL o colonna inesistente. + /// + 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; + } + } + + /// + /// Legge un campo decimal nullable. + /// + 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; + } + } + + /// + /// Legge un campo generico con tipo specifico. + /// + public static T GetSafe(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; + } + } +} diff --git a/CertReports.Syncfusion/Services/Implementations/ChartDataService.cs b/CertReports.Syncfusion/Services/Implementations/ChartDataService.cs new file mode 100644 index 0000000..444d2a6 --- /dev/null +++ b/CertReports.Syncfusion/Services/Implementations/ChartDataService.cs @@ -0,0 +1,130 @@ +using CertReports.Syncfusion.Models; +using Microsoft.Data.SqlClient; +using System.Data; + +namespace CertReports.Syncfusion.Services.Implementations; + +/// +/// 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) +/// +public interface IChartDataService +{ + Task GetChartDataAsync(string isin); +} + +public class ChartDataService : IChartDataService +{ + private readonly string _connectionString; + private readonly ILogger _logger; + + public ChartDataService(IConfiguration config, ILogger logger) + { + _connectionString = config.GetConnectionString("CertDb") + ?? throw new InvalidOperationException("ConnectionString 'CertDb' non configurata."); + _logger = logger; + } + + public async Task GetChartDataAsync(string isin) + { + await using var conn = new SqlConnection(_connectionString); + await conn.OpenAsync(); + + // ── 1. Carica info sottostanti (FSWeb_Chart_UL) ──────────────── + var ulInfos = new List(); + + 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; + } +} diff --git a/CertReports.Syncfusion/Services/Implementations/ChartSectionRenderer.cs b/CertReports.Syncfusion/Services/Implementations/ChartSectionRenderer.cs new file mode 100644 index 0000000..493f5b3 --- /dev/null +++ b/CertReports.Syncfusion/Services/Implementations/ChartSectionRenderer.cs @@ -0,0 +1,87 @@ +using CertReports.Syncfusion.Services.Interfaces; +using Syncfusion.Drawing; +using Syncfusion.Pdf; +using Syncfusion.Pdf.Graphics; + +namespace CertReports.Syncfusion.Services.Implementations; + +/// +/// 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. +/// +public class ChartSectionRenderer : IChartSectionRenderer +{ + private readonly IChartDataService _chartDataService; + private readonly ILogger _logger; + + public ChartSectionRenderer( + IChartDataService chartDataService, + ILogger logger) + { + _chartDataService = chartDataService; + _logger = logger; + } + + public async Task 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; + } + } +} diff --git a/CertReports.Syncfusion/Services/Implementations/EventiSectionRenderer.cs b/CertReports.Syncfusion/Services/Implementations/EventiSectionRenderer.cs new file mode 100644 index 0000000..d07d390 --- /dev/null +++ b/CertReports.Syncfusion/Services/Implementations/EventiSectionRenderer.cs @@ -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; + +/// +/// Sezione 2: Lista eventi del certificato (tabella multi-pagina con paginazione automatica). +/// Dati da: rpt_Events_CFT_ISIN +/// +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()) + { + cell.Style.Font = PdfTheme.Small; + cell.StringFormat = new PdfStringFormat(PdfTextAlignment.Center); + } + + // Righe alternate + if (i % 2 == 1) + foreach (var cell in row.Cells.OfType()) + 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; + } +} diff --git a/CertReports.Syncfusion/Services/Implementations/PdfCacheService.cs b/CertReports.Syncfusion/Services/Implementations/PdfCacheService.cs new file mode 100644 index 0000000..14da394 --- /dev/null +++ b/CertReports.Syncfusion/Services/Implementations/PdfCacheService.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.Caching.Memory; + +namespace CertReports.Syncfusion.Services.Implementations; + +/// +/// 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). +/// +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 _logger; + + public PdfCacheService(IMemoryCache cache, IConfiguration config, ILogger 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}"; +} diff --git a/CertReports.Syncfusion/Services/Implementations/PdfMergerService.cs b/CertReports.Syncfusion/Services/Implementations/PdfMergerService.cs new file mode 100644 index 0000000..d3ef3d1 --- /dev/null +++ b/CertReports.Syncfusion/Services/Implementations/PdfMergerService.cs @@ -0,0 +1,58 @@ +using CertReports.Syncfusion.Services.Interfaces; +using Syncfusion.Pdf; + +namespace CertReports.Syncfusion.Services.Implementations; + +/// +/// 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. +/// +public class PdfMergerService : IPdfMergerService +{ + public byte[] Merge(IEnumerable documents) + { + var finalDoc = new PdfDocument(); + var tempStreams = new List(); + var loadedDocs = new List(); + + 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 { } + } + } +} diff --git a/CertReports.Syncfusion/Services/Implementations/ReportOrchestrator.cs b/CertReports.Syncfusion/Services/Implementations/ReportOrchestrator.cs new file mode 100644 index 0000000..06b38d0 --- /dev/null +++ b/CertReports.Syncfusion/Services/Implementations/ReportOrchestrator.cs @@ -0,0 +1,119 @@ +using CertReports.Syncfusion.Models; +using CertReports.Syncfusion.Services.Interfaces; +using Syncfusion.Pdf; + +namespace CertReports.Syncfusion.Services.Implementations; + +/// +/// 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 +/// +public class ReportOrchestrator : IReportOrchestrator +{ + private readonly ICertificateDataService _dataService; + private readonly IEnumerable _sectionRenderers; + private readonly IChartSectionRenderer _chartRenderer; + private readonly IPdfMergerService _merger; + private readonly IPdfCacheService _cache; + private readonly ILogger _logger; + + public ReportOrchestrator( + ICertificateDataService dataService, + IEnumerable sectionRenderers, + IChartSectionRenderer chartRenderer, + IPdfMergerService merger, + IPdfCacheService cache, + ILogger logger) + { + _dataService = dataService; + _sectionRenderers = sectionRenderers; + _chartRenderer = chartRenderer; + _merger = merger; + _cache = cache; + _logger = logger; + } + + public async Task 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(); + + 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; + } +} diff --git a/CertReports.Syncfusion/Services/Implementations/ScenarioSectionRenderer.cs b/CertReports.Syncfusion/Services/Implementations/ScenarioSectionRenderer.cs new file mode 100644 index 0000000..6bb958b --- /dev/null +++ b/CertReports.Syncfusion/Services/Implementations/ScenarioSectionRenderer.cs @@ -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; + } +} \ No newline at end of file diff --git a/CertReports.Syncfusion/Services/Implementations/SkiaChartRenderer.cs b/CertReports.Syncfusion/Services/Implementations/SkiaChartRenderer.cs new file mode 100644 index 0000000..742f931 --- /dev/null +++ b/CertReports.Syncfusion/Services/Implementations/SkiaChartRenderer.cs @@ -0,0 +1,361 @@ +using CertReports.Syncfusion.Models; +using SkiaSharp; + +namespace CertReports.Syncfusion.Services.Implementations; + +/// +/// 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% +/// +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); + + /// + /// Genera il grafico come immagine PNG. + /// + 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)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); + } +} diff --git a/CertReports.Syncfusion/Services/Interfaces/IServices.cs b/CertReports.Syncfusion/Services/Interfaces/IServices.cs new file mode 100644 index 0000000..cd16c8e --- /dev/null +++ b/CertReports.Syncfusion/Services/Interfaces/IServices.cs @@ -0,0 +1,50 @@ +using CertReports.Syncfusion.Models; +using Syncfusion.Pdf; + +namespace CertReports.Syncfusion.Services.Interfaces; + +/// +/// Recupera i dati del certificato dal database SQL Server (stored procedures) +/// +public interface ICertificateDataService +{ + Task GetCertificateInfoAsync(string isin); + Task> GetCertificateEventsAsync(string isin); + Task GetScenarioAnalysisAsync(string isin); + Task IsScenarioAnalysisAllowedAsync(string isin); + Task FindIsinByAliasIdAsync(string aliasId); +} + +/// +/// Genera una sezione PDF del report (anagrafica, eventi, scenario) +/// +public interface IPdfSectionRenderer +{ + string SectionName { get; } + int Order { get; } + PdfDocument Render(CertificateReportData data); +} + +/// +/// Genera la sezione grafico (può fallire, gestita separatamente) +/// +public interface IChartSectionRenderer +{ + Task RenderAsync(string isin); +} + +/// +/// Unisce più PdfDocument in un unico PDF +/// +public interface IPdfMergerService +{ + byte[] Merge(IEnumerable documents); +} + +/// +/// Orchestratore principale: coordina data retrieval, rendering e merge +/// +public interface IReportOrchestrator +{ + Task GenerateReportAsync(string isin); +} diff --git a/CertReports.Syncfusion/appsettings.Development.json b/CertReports.Syncfusion/appsettings.Development.json new file mode 100644 index 0000000..31cfc23 --- /dev/null +++ b/CertReports.Syncfusion/appsettings.Development.json @@ -0,0 +1,18 @@ +{ + "ConnectionStrings": { + "CertDb": "Data Source=26.69.45.60;Database=FirstSolutionDB;User Id=sa;Password=Skyline72;TrustServerCertificate=True;" + }, + "Serilog": { + "MinimumLevel": { + "Default": "Debug", + "Override": { + "Microsoft": "Information", + "System": "Warning", + "Microsoft.AspNetCore": "Warning" + } + } + }, + "ReportSettings": { + "CacheMinutes": 1 + } +} diff --git a/CertReports.Syncfusion/appsettings.json b/CertReports.Syncfusion/appsettings.json new file mode 100644 index 0000000..a076f41 --- /dev/null +++ b/CertReports.Syncfusion/appsettings.json @@ -0,0 +1,33 @@ +{ + "ConnectionStrings": { + "CertDb": "Data Source=26.69.45.60;Database=FirstSolutionDB;User Id=sa;Password=Skyline72;TrustServerCertificate=True;" + }, + "Syncfusion": { + "LicenseKey": "Ngo9BigBOggjGyl/VkV+XU9AclRDX3xKf0x/TGpQb19xflBPallYVBYiSV9jS3hTdURrWH9ccXRUQmhUUE91XA==" + }, + "ChartService": { + "BaseUrl": "https://reports.smart-roots.net:4004", + "ChartEndpoint": "/ChartFSWeb.aspx?width=800&height=600&isin={0}&tipo=pdf" + }, + "CryptoSettings": { + "Passphrase": "ddCE3hM9BNJXgwtj" + }, + "ReportSettings": { + "TempFolder": "wwwroot/temp", + "FontFamily": "Arial", + "PageMargin": 40, + "HeaderColor": "#2E5090", + "AlternateRowColor": "#F2F6FA", + "CacheMinutes": 5 + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "System": "Warning" + } + } + }, + "AllowedHosts": "*" +} diff --git a/CertReports.Syncfusion/docker-compose.yml b/CertReports.Syncfusion/docker-compose.yml new file mode 100644 index 0000000..101eca6 --- /dev/null +++ b/CertReports.Syncfusion/docker-compose.yml @@ -0,0 +1,30 @@ +version: '3.8' + +services: + certreports: + build: . + ports: + - "5080:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ConnectionStrings__CertDb=Server=sqlserver;Database=CertDB;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True; + - Syncfusion__LicenseKey=${SYNCFUSION_LICENSE_KEY} + - ChartService__BaseUrl=https://reports.smart-roots.net:4004 + depends_on: + - sqlserver + restart: unless-stopped + + # Opzionale: SQL Server locale per sviluppo + # Commentare se si usa un server SQL esterno + sqlserver: + image: mcr.microsoft.com/mssql/server:2022-latest + environment: + - ACCEPT_EULA=Y + - MSSQL_SA_PASSWORD=YourStrong!Passw0rd + ports: + - "1433:1433" + volumes: + - sqldata:/var/opt/mssql + +volumes: + sqldata: