Files
SmartReports/docs/superpowers/plans/2026-03-21-expired-certificate-report.md

25 KiB

Expired Certificate Report — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Aggiungere un secondo template PDF per certificati non in quotazione (Scaduto/Rimborsato/Revocato), auto-rilevato dal campo Stato della SP esistente, senza modificare endpoint né renderer esistenti.

Architecture: Il campo Stato viene aggiunto al modello e mappato dal DB. L'orchestratore legge Stato dopo il caricamento dati e sceglie il flusso: attivo (flusso esistente invariato) oppure expired (nuovo ExpiredAnagraficaSectionRenderer + EventiSectionRenderer riusato + Chart). Il nuovo renderer è iniettato direttamente nell'orchestratore, non via IEnumerable<IPdfSectionRenderer>, per evitare inclusione nel ciclo normale.

Tech Stack: ASP.NET Core 8, Syncfusion PDF (Community), SkiaSharp, Microsoft.Data.SqlClient, dotnet build per verifica compilazione.

Nota: Non esistono test automatici nel progetto. La verifica avviene tramite dotnet build (compilazione) e richiesta HTTP manuale con un ISIN expired reale.


File Map

File Operazione
CertReports.Syncfusion/Models/CertificateModels.cs Modifica — aggiunge campo Stato
CertReports.Syncfusion/Services/Implementations/CertificateDataService.cs Modifica — mappa Stato dal reader
CertReports.Syncfusion/Services/Implementations/ExpiredAnagraficaSectionRenderer.cs Nuovo file
CertReports.Syncfusion/Services/Implementations/ReportOrchestrator.cs Modifica — branching + cache keys + inject ExpiredRenderer
CertReports.Syncfusion/Program.cs Modifica — registra ExpiredAnagraficaSectionRenderer

Task 1: Aggiungere campo Stato al modello

Files:

  • Modify: CertReports.Syncfusion/Models/CertificateModels.cs

  • Step 1: Aggiungere la proprietà Stato a CertificateInfo

Aprire CertificateModels.cs. Alla fine della sezione // Vari (dopo TriggerOneStar e prima di Note), aggiungere:

// Stato quotazione (da SP rpt_Master_CFT_ISIN)
// Valori: "Quotazione" | "Revocato" | "Scaduto" | "Rimborsato"
public string Stato { get; set; } = string.Empty;

La sezione interessata è intorno a riga 73-76:

    // Vari
    public string Leva { get; set; } = string.Empty;
    public string FattoreAirbag { get; set; } = string.Empty;
    public string TriggerOneStar { get; set; } = string.Empty;
    public string Note { get; set; } = string.Empty;

Diventa:

    // Vari
    public string Leva { get; set; } = string.Empty;
    public string FattoreAirbag { get; set; } = string.Empty;
    public string TriggerOneStar { get; set; } = string.Empty;
    public string Note { get; set; } = string.Empty;

    // Stato quotazione (da SP rpt_Master_CFT_ISIN)
    // Valori: "Quotazione" | "Revocato" | "Scaduto" | "Rimborsato"
    public string Stato { get; set; } = string.Empty;
  • Step 2: Verificare compilazione
cd "CertReports.Syncfusion" && dotnet build

Atteso: Build succeeded (0 errori).

  • Step 3: Commit
git add CertReports.Syncfusion/Models/CertificateModels.cs
git commit -m "feat: add Stato field to CertificateInfo model"

Task 2: Mappare Stato nel data service

Files:

  • Modify: CertReports.Syncfusion/Services/Implementations/CertificateDataService.cs

  • Step 1: Aggiungere la mappatura nel reader di rpt_Master_CFT_ISIN

In GetCertificateInfoAsync, dopo la riga che mappa RendimentoAttuale (riga ~100), aggiungere:

info.RendimentoAttuale = r.GetStringSafe("RendimentoAttuale");
info.Stato = r.GetStringSafe("Stato");   // ← aggiungere questa riga

Il blocco risultante sarà:

                info.CpnInMemoria = r.GetNullableDecimal("CpnInMemoria");
                info.CpnDaPagare = r.GetNullableDecimal("CpnDaPagare");
                info.CpnPagati = r.GetNullableDecimal("CpnPagati");
                info.RendimentoAttuale = r.GetStringSafe("RendimentoAttuale");
                info.Stato = r.GetStringSafe("Stato");

Nota: GetStringSafe è già nel progetto (extension method su SqlDataReader). Se la SP non restituisce il campo Stato, il metodo restituisce string.Empty senza eccezioni — sicuro.

  • Step 2: Verificare compilazione
dotnet build

Atteso: Build succeeded.

  • Step 3: Commit
git add CertReports.Syncfusion/Services/Implementations/CertificateDataService.cs
git commit -m "feat: map Stato field from rpt_Master_CFT_ISIN SP"

Task 3: Creare ExpiredAnagraficaSectionRenderer

Files:

  • Create: CertReports.Syncfusion/Services/Implementations/ExpiredAnagraficaSectionRenderer.cs

  • Step 1: Creare il file con il renderer completo

Creare il file CertReports.Syncfusion/Services/Implementations/ExpiredAnagraficaSectionRenderer.cs con il seguente contenuto:

using CertReports.Syncfusion.Helpers;
using CertReports.Syncfusion.Models;
using CertReports.Syncfusion.Services.Interfaces;
using Syncfusion.Drawing;
using Syncfusion.Pdf;
using Syncfusion.Pdf.Graphics;

namespace CertReports.Syncfusion.Services.Implementations;

/// <summary>
/// Sezione 1 — Prima pagina del report per certificati non in quotazione
/// (Scaduto, Rimborsato, Revocato). Struttura semplificata rispetto al report attivo:
/// solo Titolo + Caratteristiche Prodotto + Analisi. Niente Bid/Ask, niente Sottostanti.
/// </summary>
public class ExpiredAnagraficaSectionRenderer : IPdfSectionRenderer
{
    public string SectionName => "ExpiredAnagrafica";
    public int Order => 1;

    private const float PageW = 595f - 2 * PdfTheme.PageMargin;
    private const float PageH = 842f - 2 * PdfTheme.PageMargin - PdfTheme.FooterHeight;
    private const float ColGap = 10f;
    private const float ColW = (PageW - ColGap) / 2f;
    private const float SectionGap = 10f;

    public PdfDocument Render(CertificateReportData data)
    {
        var doc = new PdfDocument();
        var page = PdfTheme.AddA4Page(doc);
        var g = page.Graphics;
        var info = data.Info;
        float y = 0f;

        // ── TITOLO ────────────────────────────────────────────────────
        y = DrawTitle(g, info, PageW, y);

        // ── SEZIONE A: CARATTERISTICHE PRODOTTO ───────────────────────
        y = DrawSectionLabel(g, "Caratteristiche Prodotto", y);
        y = DrawCaratteristiche(g, info, y);
        y += SectionGap;

        // ── SEZIONE B: ANALISI ────────────────────────────────────────
        y = DrawSectionLabel(g, "Analisi", y);
        DrawAnalisi(g, info, y);

        PdfTheme.DrawFooter(g, PageW, PageH, 1, data.ShowBranding);

        return doc;
    }

    // ═══════════════════════════════════════════════════════════════
    // TITOLO — Solo Tipologia box (niente Data/Bid/Ask)
    // ═══════════════════════════════════════════════════════════════

    private float DrawTitle(PdfGraphics g, CertificateInfo info, float w, float y)
    {
        g.DrawString($"Scheda Prodotto   {info.Isin}",
            PdfTheme.SectionTitleFont,
            new PdfSolidBrush(PdfTheme.AccentBlue),
            new RectangleF(0, y, w, 20f),
            new PdfStringFormat(PdfTextAlignment.Center));
        y += 24f;

        g.DrawLine(PdfTheme.AccentBluePen, 0, y, w, y);
        y += 8f;

        // Solo box Tipologia, niente Data/Bid/Ask
        const float boxH = 28f;
        if (!string.IsNullOrEmpty(info.Categoria))
        {
            DrawInfoBox(g, 0, y, w, boxH,
                "TIPOLOGIA", info.Categoria,
                PdfTheme.BoxLightBlueBgBrush, PdfTheme.AccentBlueBrush,
                PdfTheme.AccentBlueBrush, PdfTheme.AccentBlueDarkBrush,
                PdfTheme.Bold);
        }

        y += boxH + 10f;
        return y;
    }

    // ═══════════════════════════════════════════════════════════════
    // INTESTAZIONE DI SEZIONE
    // ═══════════════════════════════════════════════════════════════

    private float DrawSectionLabel(PdfGraphics g, string title, float y)
    {
        g.DrawRectangle(PdfTheme.AccentBlueBrush, new RectangleF(0, y, 3f, 14f));
        g.DrawString(title, PdfTheme.Bold,
            new PdfSolidBrush(PdfTheme.AccentBlue),
            new RectangleF(6f, y, PageW, 14f));
        return y + 16f;
    }

    // ═══════════════════════════════════════════════════════════════
    // INFO BOX (identico ad AnagraficaSectionRenderer)
    // ═══════════════════════════════════════════════════════════════

    private void DrawInfoBox(
        PdfGraphics g,
        float x, float y, float w, float boxH,
        string label, string value,
        PdfBrush bgBrush, PdfBrush accentBrush,
        PdfBrush labelBrush, PdfBrush valueBrush,
        PdfFont valueFont)
    {
        g.DrawRectangle(bgBrush, new RectangleF(x, y, w, boxH));
        g.DrawRectangle(accentBrush, new RectangleF(x, y, 4f, boxH));
        float innerX = x + 4f + 6f;
        float innerW = w - 4f - 6f - 4f;
        g.DrawString(label, PdfTheme.Small, labelBrush,
            new RectangleF(innerX, y + 4f, innerW, 10f));
        g.DrawString(value, valueFont, valueBrush,
            new RectangleF(innerX, y + 14f, innerW, 14f));
    }

    // ═══════════════════════════════════════════════════════════════
    // SEZIONE A: CARATTERISTICHE PRODOTTO
    // Sinistra: tabella emittente con campi rimborso
    // ═══════════════════════════════════════════════════════════════

    private float DrawCaratteristiche(PdfGraphics g, CertificateInfo info, float y)
    {
        // Tabella occupa la colonna sinistra; destra libera
        return DrawEmittenteTable(g, info, 0, ColW, y);
    }

    private float DrawEmittenteTable(PdfGraphics g, CertificateInfo info, float x, float w, float y)
    {
        float rh = PdfTheme.RowHeight;
        float pad = PdfTheme.CellPadding;

        // Header blu
        g.DrawRectangle(PdfTheme.TableHeaderBrush, new RectangleF(x, y, w, rh));
        string headerText = string.IsNullOrEmpty(info.Emittente)
            ? "EMITTENTE"
            : $"EMITTENTE   {info.Emittente.ToUpper()}";
        g.DrawString(headerText, PdfTheme.TableBold,
            PdfTheme.HeaderTextBrush,
            new RectangleF(x + pad, y + 3f, w - pad * 2, rh));
        y += rh;

        var rows = new[]
        {
            ("ISIN",            info.Isin),
            ("Mercato",         info.Mercato),
            ("Valuta",          info.Valuta),
            ("Data Emissione",  info.DataEmissione),
            ("Valore Rimborso", info.ValoreRimborso),
            ("Data Rimborso",   info.DataRimborso),
        };

        for (int i = 0; i < rows.Length; i++)
        {
            var (label, value) = rows[i];
            if (string.IsNullOrWhiteSpace(value)) continue;

            bool alt = i % 2 == 1;
            if (alt)
                g.DrawRectangle(new PdfSolidBrush(PdfTheme.TableAltRow),
                    new RectangleF(x, y, w, rh));

            g.DrawLine(PdfTheme.TableBorderPen, x, y, x + w, y);

            g.DrawString(label, PdfTheme.TableFont,
                new PdfSolidBrush(PdfTheme.TextSecondary),
                new RectangleF(x + pad, y + 2f, w * 0.5f, rh));

            var valueBrush = label == "ISIN"
                ? new PdfSolidBrush(PdfTheme.AccentBlue)
                : new PdfSolidBrush(PdfTheme.TextPrimary);

            g.DrawString(value, PdfTheme.TableBold, valueBrush,
                new RectangleF(x + w * 0.5f, y + 2f, w * 0.5f - pad, rh),
                new PdfStringFormat(PdfTextAlignment.Right));

            y += rh;
        }

        g.DrawLine(PdfTheme.TableBorderPen, x, y, x + w, y);
        return y + 2f;
    }

    // ═══════════════════════════════════════════════════════════════
    // SEZIONE B: ANALISI — KV list colonna destra
    // ═══════════════════════════════════════════════════════════════

    private float DrawAnalisi(PdfGraphics g, CertificateInfo info, float y)
    {
        var items = new (string Label, string Value)[]
        {
            ("Importo Cedola (p.a.)", info.NominalAnnualYield),
            ("Frequenza Cedola",      info.FrequenzaCedole),
            ("Valore Nominale",       info.NominalValue?.ToString("N0") ?? string.Empty),
            ("Memoria Cedola",        info.Memory),
            ("Tipo Barriera",         info.BarrierType),
            ("Tipo Basket",           info.BasketType),
            ("Rendimento Totale",     info.RendimentoTotale),
        };

        return DrawKVList(g, items, ColW + ColGap, ColW, y);
    }

    private float DrawKVList(PdfGraphics g, (string Label, string Value)[] items, float x, float w, float y)
    {
        float rh = PdfTheme.RowHeight;
        float pad = PdfTheme.CellPadding;
        float labelW = w * 0.58f;
        float valueW = w * 0.42f;

        foreach (var (label, value) in items)
        {
            if (string.IsNullOrWhiteSpace(value)) continue;

            g.DrawString(label, PdfTheme.TableFont,
                new PdfSolidBrush(PdfTheme.TextSecondary),
                new RectangleF(x + pad, y + 1f, labelW, rh));

            bool isNeg = value.TrimStart().StartsWith('-');
            bool isKey = label == "Rendimento Totale";

            var brush = isNeg ? new PdfSolidBrush(PdfTheme.NegativeRed)
                      : isKey ? new PdfSolidBrush(PdfTheme.AccentBlue)
                      : new PdfSolidBrush(PdfTheme.TextPrimary);

            g.DrawString(value, PdfTheme.TableBold, brush,
                new RectangleF(x + labelW, y + 1f, valueW - pad, rh),
                new PdfStringFormat(PdfTextAlignment.Right));

            g.DrawLine(new PdfPen(PdfTheme.SeparatorLine, 0.3f),
                x, y + rh, x + w, y + rh);

            y += rh;
        }
        return y;
    }
}
  • Step 2: Verificare compilazione
dotnet build

Atteso: Build succeeded. Se manca un using, aggiungerlo. Riferimento per i using necessari: guardare AnagraficaSectionRenderer.cs (stessi namespace).

  • Step 3: Commit
git add CertReports.Syncfusion/Services/Implementations/ExpiredAnagraficaSectionRenderer.cs
git commit -m "feat: add ExpiredAnagraficaSectionRenderer for non-quoted certificates"

Task 4: Aggiornare ReportOrchestrator — branching + cache + injection

Files:

  • Modify: CertReports.Syncfusion/Services/Implementations/ReportOrchestrator.cs

  • Step 1: Aggiungere ExpiredAnagraficaSectionRenderer al costruttore

Il costruttore attuale riceve IEnumerable<IPdfSectionRenderer> sectionRenderers. Aggiungere ExpiredAnagraficaSectionRenderer expiredAnagraficaRenderer come parametro diretto:

private readonly ExpiredAnagraficaSectionRenderer _expiredAnagraficaRenderer;

public ReportOrchestrator(
    ICertificateDataService dataService,
    IEnumerable<IPdfSectionRenderer> sectionRenderers,
    IChartSectionRenderer chartRenderer,
    IPdfMergerService merger,
    IPdfCacheService cache,
    ILogger<ReportOrchestrator> logger,
    ExpiredAnagraficaSectionRenderer expiredAnagraficaRenderer)  // ← aggiunto
{
    _dataService = dataService;
    _sectionRenderers = sectionRenderers;
    _chartRenderer = chartRenderer;
    _merger = merger;
    _cache = cache;
    _logger = logger;
    _expiredAnagraficaRenderer = expiredAnagraficaRenderer;      // ← aggiunto
}
  • Step 2: Aggiornare GenerateReportAsync — cache key e branching

Sostituire la logica GenerateReportAsync (dal var cacheKey in poi) con questa:

public async Task<byte[]> GenerateReportAsync(string isin, bool showBranding = false)
{
    // ── Cache check ────────────────────────────────────────────────
    // La chiave include branding E tipo report. Il tipo verrà determinato
    // dopo il caricamento dati, ma usiamo una chiave provvisoria per
    // il primo lookup — se c'è cache, non leggiamo nemmeno il tipo.
    // Strategia: chiave separata per expired dopo determinazione tipo.
    // Per semplicità: tentativo cache con chiave base prima del DB,
    // poi con chiave tipizzata dopo la lettura Stato.

    // Prima proviamo con la chiave "standard" — hit solo se già cached
    var baseCacheKey = showBranding ? $"{isin}:branded" : isin;
    var expiredCacheKey = showBranding ? $"{isin}:expired:branded" : $"{isin}:expired";

    // Controlla entrambe le chiavi (una sarà un miss, l'altra un possibile hit)
    var cached = _cache.Get(baseCacheKey) ?? _cache.Get(expiredCacheKey);
    if (cached != null)
    {
        _logger.LogInformation("Report per ISIN {Isin} servito da cache ({Size} bytes)", isin, cached.Length);
        return cached;
    }

    _logger.LogInformation("Inizio generazione report per ISIN {Isin}", isin);

    // ── 1. Recupera tutti i dati ───────────────────────────────────
    var reportData = new CertificateReportData
    {
        Info = await _dataService.GetCertificateInfoAsync(isin),
        Eventi = await _dataService.GetCertificateEventsAsync(isin),
        Scenario = await _dataService.GetScenarioAnalysisAsync(isin),
        ShowBranding = showBranding,
    };

    // ── 2. Determina il tipo di report ────────────────────────────
    bool isExpired = !string.IsNullOrEmpty(reportData.Info.Stato)
                     && reportData.Info.Stato != "Quotazione";

    var cacheKey = isExpired ? expiredCacheKey : baseCacheKey;

    _logger.LogInformation(
        "Dati recuperati per {Isin}: Stato={Stato}, isExpired={IsExpired}, {EventiCount} eventi",
        isin, reportData.Info.Stato, isExpired, reportData.Eventi.Count);

    // ── 3. Genera le sezioni PDF ──────────────────────────────────
    var pdfSections = new List<PdfDocument>();

    if (isExpired)
    {
        // Flusso expired: ExpiredAnagrafica + Eventi + Chart
        pdfSections.Add(_expiredAnagraficaRenderer.Render(reportData));
        _logger.LogInformation("Sezione 'ExpiredAnagrafica' generata per {Isin}", isin);

        var eventiRenderer = _sectionRenderers.First(r => r.SectionName == "Eventi");
        pdfSections.Add(eventiRenderer.Render(reportData));
        _logger.LogInformation("Sezione 'Eventi' generata per {Isin}", isin);
    }
    else
    {
        // Flusso attuale: Anagrafica + Eventi + Scenario (condizionale)
        bool isScenarioAllowed = reportData.Scenario.Rows.Count > 0;

        foreach (var renderer in _sectionRenderers.OrderBy(r => r.Order))
        {
            if (renderer.SectionName == "Scenario" && !isScenarioAllowed)
            {
                _logger.LogInformation("Sezione Scenario saltata per {Isin}", isin);
                continue;
            }

            try
            {
                pdfSections.Add(renderer.Render(reportData));
                _logger.LogInformation("Sezione '{Section}' generata per {Isin}", renderer.SectionName, isin);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Errore nella sezione '{Section}' per {Isin}", renderer.SectionName, isin);
                throw;
            }
        }
    }

    // ── 4. Grafico (entrambi i flussi) ────────────────────────────
    var chartPdf = await _chartRenderer.RenderAsync(isin);
    if (chartPdf != null)
    {
        pdfSections.Add(chartPdf);
        _logger.LogInformation("Sezione Grafico aggiunta per {Isin}", isin);
    }

    // ── 5. Unisci tutto ────────────────────────────────────────────
    var finalPdf = _merger.Merge(pdfSections);

    _logger.LogInformation("Report generato per {Isin}: {Size} bytes, {Sections} sezioni",
        isin, finalPdf.Length, pdfSections.Count);

    _cache.Set(cacheKey, finalPdf);

    foreach (var doc in pdfSections)
        doc.Close(true);

    return finalPdf;
}
  • Step 3: Verificare compilazione
dotnet build

Atteso: Build succeeded. Se appare errore su _expiredAnagraficaRenderer, verificare che il campo privato e il costruttore siano entrambi aggiornati.

  • Step 4: Commit
git add CertReports.Syncfusion/Services/Implementations/ReportOrchestrator.cs
git commit -m "feat: add expired certificate report branching in orchestrator"

Task 5: Registrare ExpiredAnagraficaSectionRenderer in DI

Files:

  • Modify: CertReports.Syncfusion/Program.cs

  • Step 1: Aggiungere la registrazione

In Program.cs, nel blocco dove sono registrati gli altri renderer (dopo le registrazioni AddScoped<IPdfSectionRenderer, ...>), aggiungere:

// Registrazione diretta (non come IPdfSectionRenderer) —
// l'orchestratore la inietta direttamente per il flusso expired.
builder.Services.AddScoped<ExpiredAnagraficaSectionRenderer>();

La sezione registrazioni renderer in Program.cs sarà simile a:

builder.Services.AddScoped<IPdfSectionRenderer, AnagraficaSectionRenderer>();
builder.Services.AddScoped<IPdfSectionRenderer, EventiSectionRenderer>();
builder.Services.AddScoped<IPdfSectionRenderer, ScenarioSectionRenderer>();
builder.Services.AddScoped<ExpiredAnagraficaSectionRenderer>();   // ← aggiunta
builder.Services.AddScoped<IChartSectionRenderer, ChartSectionRenderer>();
  • Step 2: Verificare compilazione finale
dotnet build

Atteso: Build succeeded senza warning critici.

  • Step 3: Commit
git add CertReports.Syncfusion/Program.cs
git commit -m "feat: register ExpiredAnagraficaSectionRenderer in DI"

Task 6: Verifica manuale end-to-end

  • Step 1: Avviare l'applicazione
dotnet run --project CertReports.Syncfusion
  • Step 2: Testare con un ISIN expired

Aprire nel browser o usare curl con un ISIN reale nello stato "Scaduto"/"Rimborsato"/"Revocato":

GET https://localhost:{porta}/api/report/by-isin/IT0006745704

Atteso:

  • PDF a 3 pagine (non 4)

  • Pagina 1: titolo + solo box Tipologia (niente Bid/Ask) + tabella con Valore Rimborso e Data Rimborso + KV Analisi con 7 campi

  • Pagina 2: Lista eventi (identica all'attuale)

  • Pagina 3: Grafico

  • Step 3: Testare branding

GET https://localhost:{porta}/api/report/by-isin/IT0006745704?branding=true

Atteso: footer con "Powered by Smart Roots" su tutte le 3 pagine.

  • Step 4: Testare che il report attivo non sia rotto
GET https://localhost:{porta}/api/report/by-isin/{ISIN_ATTIVO}

Atteso: report attivo invariato a 4 pagine (o 3 senza scenario).

  • Step 5: Commit finale
git tag v-expired-report-complete

Note di implementazione

Brush e font disponibili in PdfTheme (già esistenti, non creare nuovi):

  • PdfTheme.AccentBlue, PdfTheme.AccentBlueBrush, PdfTheme.AccentBluePen
  • PdfTheme.AccentBlueDarkBrush, PdfTheme.BoxLightBlueBgBrush
  • PdfTheme.TableHeaderBrush, PdfTheme.HeaderTextBrush, PdfTheme.TableAltRowBrush
  • PdfTheme.TableAltRow (Color), PdfTheme.TextSecondary, PdfTheme.TextPrimary
  • PdfTheme.NegativeRed, PdfTheme.SeparatorLine
  • PdfTheme.TableBorderPen
  • Font: PdfTheme.SectionTitleFont, PdfTheme.Bold, PdfTheme.Small, PdfTheme.TableFont, PdfTheme.TableBold
  • PdfTheme.RowHeight, PdfTheme.CellPadding, PdfTheme.PageMargin, PdfTheme.FooterHeight
  • PdfTheme.AddA4Page(doc), PdfTheme.DrawFooter(g, w, h, pageNum, showBranding)

Cache lookup doppio: Il lookup _cache.Get(baseCacheKey) ?? _cache.Get(expiredCacheKey) nel nuovo orchestratore è necessario perché non conosciamo il tipo del certificato prima di leggere il DB. Il caching avviene poi con la chiave corretta basata su Stato. Questo comporta al più 2 miss di cache prima della prima generazione, poi 1 hit per le richieste successive — accettabile.