using System.Globalization;
using Microsoft.EntityFrameworkCore;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
using SchotzIadKmTracker.Api.Data;
using SchotzIadKmTracker.Api.Models.DTOs;
using SchotzIadKmTracker.Api.Services.Exceptions;
namespace SchotzIadKmTracker.Api.Services;
///
/// Génération des Export_PDF via QuestPDF. Ce service compose les services métier existants
/// (résumé, profil, véhicule, barème, montants réglés) et n'accède pas directement à la base.
/// Le rapport mensuel est produit par ; la synthèse annuelle
/// par . Les variantes « par véhicule » génèrent un PDF distinct
/// pour chaque véhicule ayant des trajets sur la période (Exigence 7.9).
///
public class PdfService : IPdfService
{
/// Délai maximal de génération d'un PDF (Exigence 7.5).
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(15);
private static readonly CultureInfo FrCulture = CultureInfo.GetCultureInfo("fr-FR");
private readonly IResumeService _resumeService;
private readonly IProfilService _profilService;
private readonly IVehiculeService _vehiculeService;
private readonly IBaremeService _baremeService;
private readonly IMontantRegleService _montantRegleService;
private readonly IRemboursementService _remboursementService;
private readonly AppDbContext _context;
public PdfService(
IResumeService resumeService,
IProfilService profilService,
IVehiculeService vehiculeService,
IBaremeService baremeService,
IMontantRegleService montantRegleService,
IRemboursementService remboursementService,
AppDbContext context)
{
_resumeService = resumeService;
_profilService = profilService;
_vehiculeService = vehiculeService;
_baremeService = baremeService;
_montantRegleService = montantRegleService;
_remboursementService = remboursementService;
_context = context;
}
///
public async Task GenererRapportMensuelAsync(
int annee, int mois, int vehiculeId, CancellationToken cancellationToken = default)
{
// Enveloppe la génération dans un délai de 15 s (Exigence 7.5) — le dépassement ou toute
// erreur de rendu se traduit par une PdfGenerationException que le controller mappe en 503.
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(Timeout);
var token = timeoutCts.Token;
return await ExecuterAvecTimeoutAsync(cancellationToken, async token =>
{
var modele = await AssemblerModeleMensuelAsync(annee, mois, vehiculeId, token);
var octets = RendreMensuel(modele, token);
var nomFichier = $"trajets_{mois:D2}_{annee:D4}.pdf";
return new PdfResult(octets, nomFichier);
});
}
///
public async Task GenererRapportAnnuelAsync(
int annee, int vehiculeId, CancellationToken cancellationToken = default)
{
return await ExecuterAvecTimeoutAsync(cancellationToken, async token =>
{
var modele = await AssemblerModeleAnnuelAsync(annee, vehiculeId, token);
var octets = RendreAnnuel(modele, token);
var nomFichier = $"trajets_{annee:D4}.pdf";
return new PdfResult(octets, nomFichier);
});
}
///
public async Task> GenererRapportsAnnuelsParVehiculeAsync(
int annee, CancellationToken cancellationToken = default)
{
var vehiculeIds = await VehiculesAvecTrajetsAnneeAsync(annee, cancellationToken);
var resultats = new List(vehiculeIds.Count);
foreach (var vehiculeId in vehiculeIds)
{
resultats.Add(await GenererRapportAnnuelAsync(annee, vehiculeId, cancellationToken));
}
return resultats;
}
///
public async Task> GenererRapportsMensuelsParVehiculeAsync(
int annee, int mois, CancellationToken cancellationToken = default)
{
var vehiculeIds = await VehiculesAvecTrajetsMoisAsync(annee, mois, cancellationToken);
var resultats = new List(vehiculeIds.Count);
foreach (var vehiculeId in vehiculeIds)
{
resultats.Add(await GenererRapportMensuelAsync(annee, mois, vehiculeId, cancellationToken));
}
return resultats;
}
///
/// Enveloppe une opération de génération PDF dans un délai de 15 s (Exigence 7.5). Tout
/// dépassement du délai propre au PDF (et non une annulation demandée par l'appelant) ou toute
/// erreur de rendu est traduit en — que le controller mappe
/// vers 503 Service Unavailable.
///
private static async Task ExecuterAvecTimeoutAsync(
CancellationToken cancellationToken, Func> operation)
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(Timeout);
var token = timeoutCts.Token;
try
{
return await operation(token);
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
// Dépassement du délai propre à la génération PDF (et non annulation par l'appelant).
throw new PdfGenerationException(
"La génération du PDF a échoué : délai de 15 secondes dépassé.");
}
catch (PdfGenerationException)
{
throw;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Annulation demandée par l'appelant : propagée telle quelle.
throw;
}
catch (Exception ex)
{
throw new PdfGenerationException(
"La génération du PDF a échoué.", ex);
}
}
///
/// Retourne, triés par identifiant, les véhicules ayant au moins un Trajet sur l'année.
///
private async Task> VehiculesAvecTrajetsAnneeAsync(
int annee, CancellationToken cancellationToken)
{
return await _context.Trajets
.AsNoTracking()
.Where(t => t.Date.Year == annee)
.Select(t => t.VehiculeId)
.Distinct()
.OrderBy(id => id)
.ToListAsync(cancellationToken);
}
///
/// Retourne, triés par identifiant, les véhicules ayant au moins un Trajet sur le mois donné.
///
private async Task> VehiculesAvecTrajetsMoisAsync(
int annee, int mois, CancellationToken cancellationToken)
{
return await _context.Trajets
.AsNoTracking()
.Where(t => t.Date.Year == annee && t.Date.Month == mois)
.Select(t => t.VehiculeId)
.Distinct()
.OrderBy(id => id)
.ToListAsync(cancellationToken);
}
///
/// Agrège, via les services métier existants, toutes les données nécessaires au rapport mensuel.
///
private async Task AssemblerModeleMensuelAsync(
int annee, int mois, int vehiculeId, CancellationToken cancellationToken)
{
// Identité et adresse de départ (profil).
var profil = await _profilService.GetProfilAsync(cancellationToken);
// Véhicule concerné (plaque, puissance fiscale).
var vehicule = await ResoudreVehiculeAsync(vehiculeId, cancellationToken);
// Résumé mensuel filtré sur le véhicule (trajets triés + total km du mois).
var resumeMensuel = await _resumeService.GetResumeMensuelAsync(annee, mois, vehiculeId, cancellationToken);
// Total kilométrique annuel cumulé (tous mois de l'année, même véhicule).
var resumeAnnuel = await _resumeService.GetResumeAnnuelAsync(annee, vehiculeId, cancellationToken);
var totalKmAnnuel = resumeAnnuel.TotalKm;
// Coefficient et base de la tranche ANNUELLE applicable (déterminée par le total annuel).
var bareme = await _baremeService.CalculerMontantAsync(
annee, vehicule.PuissanceFiscale, totalKmAnnuel, cancellationToken);
// TOTAL IK du mois = km du mois × coefficient de la tranche annuelle applicable.
var totalIkMois = Math.Round(resumeMensuel.TotalKm * bareme.Coefficient, 2, MidpointRounding.AwayFromZero);
// Montant_Regle saisi pour ce (année, mois, véhicule) — 0 si absent.
var montantsRegles = await _montantRegleService.GetMontantsReglesAsync(annee, vehiculeId, cancellationToken);
var montantRegle = montantsRegles
.Where(m => m.Mois == mois)
.Select(m => m.Montant)
.FirstOrDefault();
return new ModeleRapportMensuel(
Annee: annee,
Mois: mois,
Profil: profil,
Vehicule: vehicule,
Trajets: resumeMensuel.Trajets,
TotalKmMois: resumeMensuel.TotalKm,
TotalKmAnnuel: totalKmAnnuel,
Coefficient: bareme.Coefficient,
Base: bareme.Base,
TotalIkMois: totalIkMois,
MontantRegle: montantRegle);
}
///
/// Agrège, via les services métier existants, les données nécessaires à la synthèse annuelle :
/// profil (identité + domicile), régularisation annuelle (12 lignes + totaux + tranche) et les
/// trois tranches du barème de l'année pour la puissance fiscale du véhicule.
///
private async Task AssemblerModeleAnnuelAsync(
int annee, int vehiculeId, CancellationToken cancellationToken)
{
// Identité et adresse de domicile (profil).
var profil = await _profilService.GetProfilAsync(cancellationToken);
// Synthèse annuelle de régularisation (barème plein, 12 lignes, totaux, tranche applicable).
var regularisation = await _remboursementService.GetRegularisationAnnuelleAsync(
annee, vehiculeId, cancellationToken);
// Barème de l'année filtré sur la puissance fiscale du véhicule (les trois tranches).
var toutesTranches = await _baremeService.GetBaremeAsync(annee, cancellationToken);
var tranchesVehicule = toutesTranches
.Where(t => t.PuissanceFiscale == regularisation.PuissanceFiscale)
.OrderBy(t => t.KmMin)
.ToList();
return new ModeleRapportAnnuel(
Annee: annee,
Profil: profil,
Regularisation: regularisation,
TranchesBareme: tranchesVehicule);
}
///
/// Résout le véhicule ciblé par son identifiant à partir de l'historique.
/// Lève si aucun véhicule ne correspond.
///
private async Task ResoudreVehiculeAsync(int vehiculeId, CancellationToken cancellationToken)
{
var historique = await _vehiculeService.GetHistoriqueAsync(cancellationToken);
var vehicule = historique.FirstOrDefault(v => v.Id == vehiculeId);
return vehicule
?? throw new VehiculeNotFoundException($"Le véhicule d'identifiant {vehiculeId} est introuvable.");
}
///
/// Rend le document mensuel en octets PDF selon la maquette « PDF Mensuel » du design.
///
private static byte[] RendreMensuel(ModeleRapportMensuel modele, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(30);
page.DefaultTextStyle(x => x.FontSize(9).FontColor(Colors.Black));
page.Header().Element(c => ComposerEntete(c, modele));
page.Content().PaddingTop(10).Element(c => ComposerTableTrajets(c, modele));
page.Footer().AlignCenter().Text(txt =>
{
txt.Span("Page ");
txt.CurrentPageNumber();
txt.Span(" / ");
txt.TotalPages();
});
});
});
cancellationToken.ThrowIfCancellationRequested();
return document.GeneratePdf();
}
///
/// Compose l'en-tête obligatoire (Exigences 7.6, 7.8, 7.10) : titre fixe, identité, véhicule,
/// période, adresse de départ, totaux mois/année, TOTAL IK et Montant_Regle.
///
private static void ComposerEntete(IContainer container, ModeleRapportMensuel modele)
{
var periode = $"{NomMois(modele.Mois).ToUpper(FrCulture)} {modele.Annee}";
var identite = FormatIdentite(modele.Profil);
var matricule = string.IsNullOrWhiteSpace(modele.Profil.Matricule)
? "—"
: modele.Profil.Matricule!;
var adresseDepart = string.IsNullOrWhiteSpace(modele.Profil.AdresseDomicile)
? "—"
: modele.Profil.AdresseDomicile!;
var vehiculeLibelle =
$"{modele.Vehicule.Plaque} — {modele.Vehicule.PuissanceFiscale} CV " +
$"(Coef. : {FormatDecimal(modele.Coefficient, 3)} / Base : {FormatDecimal(modele.Base, 0)})";
container.Column(col =>
{
col.Spacing(2);
// Titre fixe imposé par l'Exigence 7.6.
col.Item().Text("Rapport de trajets").FontSize(16).Bold();
col.Item().Text("Feuille d'indemnité kilométrique mensuelle").FontSize(11).SemiBold();
// Identité (nom, prénom, matricule) — Exigence 7.8.
col.Item().PaddingTop(4).Text(txt =>
{
txt.Span("Utilisateur : ").SemiBold();
txt.Span($"{identite} — Matricule : {matricule}");
});
// Véhicule (plaque, puissance fiscale, coefficient/base de la tranche annuelle) — Exigences 7.8/7.10.
col.Item().Text(txt =>
{
txt.Span("Véhicule : ").SemiBold();
txt.Span(vehiculeLibelle);
});
// Période et adresse de départ du profil — Exigences 7.6/7.10.
col.Item().Text(txt =>
{
txt.Span("Période : ").SemiBold();
txt.Span(periode);
});
col.Item().Text(txt =>
{
txt.Span("Adresse de départ : ").SemiBold();
txt.Span(adresseDepart);
});
// Totaux : mois, année cumulée, TOTAL IK du mois, Montant_Regle — Exigences 7.6/7.10.
col.Item().PaddingTop(6).Row(row =>
{
row.RelativeItem().Column(c =>
{
c.Item().Text(txt =>
{
txt.Span("Total parcouru sur le mois : ").SemiBold();
txt.Span($"{FormatKm(modele.TotalKmMois)} km");
});
c.Item().Text(txt =>
{
txt.Span("Total parcouru sur l'année : ").SemiBold();
txt.Span($"{FormatKm(modele.TotalKmAnnuel)} km");
});
});
row.RelativeItem().Column(c =>
{
c.Item().Text(txt =>
{
txt.Span("TOTAL IK du mois : ").SemiBold();
txt.Span($"{FormatMontant(modele.TotalIkMois)} €");
});
c.Item().Text(txt =>
{
txt.Span("Réglé : ").SemiBold();
txt.Span($"{FormatMontant(modele.MontantRegle)} €");
});
});
});
col.Item().PaddingTop(6).LineHorizontal(1).LineColor(Colors.Grey.Medium);
});
}
///
/// Compose la table des trajets du mois (Exigence 7.11) : date, motif (commentaire),
/// adresse de départ → arrivée, kilomètres.
///
private static void ComposerTableTrajets(IContainer container, ModeleRapportMensuel modele)
{
container.Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.ConstantColumn(70); // Date
columns.RelativeColumn(2); // Motif (commentaire)
columns.RelativeColumn(3); // Adresse départ → arrivée
columns.ConstantColumn(55); // Kms
});
// En-tête de table.
table.Header(header =>
{
header.Cell().Element(CelluleEntete).Text("Date");
header.Cell().Element(CelluleEntete).Text("Motifs");
header.Cell().Element(CelluleEntete).Text("Départ → Arrivée");
header.Cell().Element(CelluleEntete).AlignRight().Text("Kms");
});
if (modele.Trajets.Count == 0)
{
table.Cell().ColumnSpan(4).Element(Cellule).Text("Aucun trajet pour ce mois.").Italic();
}
else
{
foreach (var trajet in modele.Trajets)
{
var motif = string.IsNullOrWhiteSpace(trajet.Commentaire) ? "—" : trajet.Commentaire!;
// Suffixe « (A/R) » pour les trajets aller/retour ; la distance affichée est
// déjà la distance effective (doublée) portée par le TrajetDto.
var itineraire = trajet.AllerRetour
? $"{trajet.AdresseDepart} → {trajet.AdresseArrivee} (A/R)"
: $"{trajet.AdresseDepart} → {trajet.AdresseArrivee}";
var km = trajet.Distance ?? 0m;
table.Cell().Element(Cellule).Text(trajet.Date.ToString("dd/MM/yyyy", FrCulture));
table.Cell().Element(Cellule).Text(motif);
table.Cell().Element(Cellule).Text(itineraire);
table.Cell().Element(Cellule).AlignRight().Text(FormatKm(km));
}
// Ligne de total du mois.
table.Cell().ColumnSpan(3).Element(CelluleTotal).AlignRight().Text("Total du mois :").SemiBold();
table.Cell().Element(CelluleTotal).AlignRight().Text($"{FormatKm(modele.TotalKmMois)}").SemiBold();
}
});
static IContainer CelluleEntete(IContainer c) =>
c.Background(Colors.Grey.Lighten2).BorderBottom(1).BorderColor(Colors.Grey.Medium)
.PaddingVertical(4).PaddingHorizontal(3);
static IContainer Cellule(IContainer c) =>
c.BorderBottom(1).BorderColor(Colors.Grey.Lighten2).PaddingVertical(3).PaddingHorizontal(3);
static IContainer CelluleTotal(IContainer c) =>
c.BorderTop(1).BorderColor(Colors.Grey.Medium).PaddingVertical(4).PaddingHorizontal(3);
}
///
/// Rend le document de synthèse annuelle en octets PDF selon la maquette « PDF Annuel » du design.
///
private static byte[] RendreAnnuel(ModeleRapportAnnuel modele, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(30);
page.DefaultTextStyle(x => x.FontSize(9).FontColor(Colors.Black));
page.Header().Element(c => ComposerEnteteAnnuel(c, modele));
page.Content().PaddingTop(10).Column(col =>
{
col.Spacing(10);
col.Item().Element(c => ComposerTableBaremeAnnuel(c, modele));
col.Item().Element(c => ComposerTableSyntheseAnnuelle(c, modele));
col.Item().Element(c => ComposerTotauxAnnuels(c, modele));
});
page.Footer().AlignCenter().Text(txt =>
{
txt.Span("Page ");
txt.CurrentPageNumber();
txt.Span(" / ");
txt.TotalPages();
});
});
});
cancellationToken.ThrowIfCancellationRequested();
return document.GeneratePdf();
}
///
/// Compose l'en-tête de la synthèse annuelle (Exigences 7.6, 7.7, 7.8, 7.12) : titre fixe,
/// identité, année, domicile, véhicule (plaque, puissance fiscale) et tranche annuelle applicable.
///
private static void ComposerEnteteAnnuel(IContainer container, ModeleRapportAnnuel modele)
{
var reg = modele.Regularisation;
var identite = FormatIdentite(modele.Profil);
var matricule = string.IsNullOrWhiteSpace(modele.Profil.Matricule)
? "—"
: modele.Profil.Matricule!;
var domicile = string.IsNullOrWhiteSpace(modele.Profil.AdresseDomicile)
? "—"
: modele.Profil.AdresseDomicile!;
container.Column(col =>
{
col.Spacing(2);
// Titre fixe imposé par l'Exigence 7.6.
col.Item().Text("Rapport de trajets").FontSize(16).Bold();
col.Item().Text($"Synthèse {modele.Annee} d'indemnité kilométrique").FontSize(11).SemiBold();
// Identité (nom, prénom, matricule) — Exigence 7.8.
col.Item().PaddingTop(4).Text(txt =>
{
txt.Span("Utilisateur : ").SemiBold();
txt.Span($"{identite} — Matricule : {matricule}");
});
// Année (période couverte) — Exigence 7.6.
col.Item().Text(txt =>
{
txt.Span("Année : ").SemiBold();
txt.Span(modele.Annee.ToString(CultureInfo.InvariantCulture));
});
// Adresse de domicile — Exigence 7.12.
col.Item().Text(txt =>
{
txt.Span("Domicile : ").SemiBold();
txt.Span(domicile);
});
// Véhicule (plaque, puissance fiscale) et tranche annuelle applicable — Exigences 7.7/7.8/7.12.
col.Item().Text(txt =>
{
txt.Span("Véhicule : ").SemiBold();
txt.Span($"{reg.Plaque} — {reg.PuissanceFiscale} CV");
});
col.Item().Text(txt =>
{
txt.Span("Barème applicable : ").SemiBold();
txt.Span(reg.TrancheLibelle);
});
// Total kilométrique de la période (Exigence 7.6).
col.Item().Text(txt =>
{
txt.Span("Total kilométrique de l'année : ").SemiBold();
txt.Span($"{FormatKm(reg.TotalKmAnnuel)} km");
});
col.Item().PaddingTop(6).LineHorizontal(1).LineColor(Colors.Grey.Medium);
});
}
///
/// Compose la table du barème applicable de l'année pour la puissance fiscale du véhicule
/// (les trois tranches coefficient/base) — Exigence 7.12.
///
private static void ComposerTableBaremeAnnuel(IContainer container, ModeleRapportAnnuel modele)
{
container.Column(col =>
{
col.Spacing(3);
col.Item().Text(txt =>
{
txt.Span("Barème applicable pour l'année ").SemiBold();
txt.Span($"({modele.Regularisation.PuissanceFiscale} CV)").SemiBold();
});
if (modele.TranchesBareme.Count == 0)
{
col.Item().Text("Barème non configuré pour cette année et cette puissance fiscale.").Italic();
return;
}
col.Item().Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.RelativeColumn(3); // Tranche (bornes km)
columns.RelativeColumn(2); // Coefficient
columns.RelativeColumn(2); // Base
});
table.Header(header =>
{
header.Cell().Element(CelluleEnteteBareme).Text("Tranche (km)");
header.Cell().Element(CelluleEnteteBareme).AlignRight().Text("Coefficient");
header.Cell().Element(CelluleEnteteBareme).AlignRight().Text("Base (€)");
});
foreach (var tranche in modele.TranchesBareme)
{
var bornes = tranche.KmMax.HasValue
? $"{FormatDecimal(tranche.KmMin, 0)} à {FormatDecimal(tranche.KmMax.Value, 0)}"
: $"{FormatDecimal(tranche.KmMin, 0)} et plus";
table.Cell().Element(CelluleBareme).Text(bornes);
table.Cell().Element(CelluleBareme).AlignRight().Text(FormatDecimal(tranche.Coefficient, 3));
table.Cell().Element(CelluleBareme).AlignRight().Text(FormatDecimal(tranche.Base, 0));
}
});
});
static IContainer CelluleEnteteBareme(IContainer c) =>
c.Background(Colors.Grey.Lighten2).BorderBottom(1).BorderColor(Colors.Grey.Medium)
.PaddingVertical(4).PaddingHorizontal(3);
static IContainer CelluleBareme(IContainer c) =>
c.BorderBottom(1).BorderColor(Colors.Grey.Lighten2).PaddingVertical(3).PaddingHorizontal(3);
}
///
/// Compose la table des douze mois (kilomètres parcourus, frais payé, frais réel) — Exigence 7.12.
/// Les données proviennent de .
///
private static void ComposerTableSyntheseAnnuelle(IContainer container, ModeleRapportAnnuel modele)
{
var reg = modele.Regularisation;
container.Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.RelativeColumn(2); // Mois
columns.RelativeColumn(2); // Kms parcourus
columns.RelativeColumn(2); // Frais payé (réglé)
columns.RelativeColumn(2); // Frais réel
});
table.Header(header =>
{
header.Cell().Element(CelluleEntete).Text("Mois");
header.Cell().Element(CelluleEntete).AlignRight().Text("Kms parcourus");
header.Cell().Element(CelluleEntete).AlignRight().Text("Frais payé");
header.Cell().Element(CelluleEntete).AlignRight().Text("Frais réel");
});
foreach (var ligne in reg.Mois)
{
table.Cell().Element(Cellule).Text(NomMois(ligne.Mois));
table.Cell().Element(Cellule).AlignRight().Text($"{FormatKm(ligne.KmMois)}");
table.Cell().Element(Cellule).AlignRight().Text($"{FormatMontant(ligne.FraisPaye)} €");
table.Cell().Element(Cellule).AlignRight().Text($"{FormatMontant(ligne.FraisReelMensuel)} €");
}
// Ligne de total annuel.
table.Cell().Element(CelluleTotal).Text("TOTAL").SemiBold();
table.Cell().Element(CelluleTotal).AlignRight().Text($"{FormatKm(reg.TotalKmAnnuel)}").SemiBold();
table.Cell().Element(CelluleTotal).AlignRight().Text($"{FormatMontant(reg.TotalPaye)} €").SemiBold();
table.Cell().Element(CelluleTotal).AlignRight().Text($"{FormatMontant(reg.MontantReel)} €").SemiBold();
});
static IContainer CelluleEntete(IContainer c) =>
c.Background(Colors.Grey.Lighten2).BorderBottom(1).BorderColor(Colors.Grey.Medium)
.PaddingVertical(4).PaddingHorizontal(3);
static IContainer Cellule(IContainer c) =>
c.BorderBottom(1).BorderColor(Colors.Grey.Lighten2).PaddingVertical(3).PaddingHorizontal(3);
static IContainer CelluleTotal(IContainer c) =>
c.BorderTop(1).BorderColor(Colors.Grey.Medium).PaddingVertical(4).PaddingHorizontal(3);
}
///
/// Compose le bloc « formule réelle » et la régularisation (réel − payé) — Exigence 7.12.
///
private static void ComposerTotauxAnnuels(IContainer container, ModeleRapportAnnuel modele)
{
var reg = modele.Regularisation;
// Formule réelle : total_km × coefficient + base = Montant_Reel (tranche annuelle).
var formule =
$"{FormatKm(reg.TotalKmAnnuel)} km × {FormatDecimal(reg.CoefficientTranche, 3)} " +
$"+ {FormatDecimal(reg.BaseTranche, 0)} = {FormatMontant(reg.MontantReel)} € " +
$"(tranche {reg.TrancheLibelle}, {reg.PuissanceFiscale} CV)";
container.Column(col =>
{
col.Spacing(3);
col.Item().Text(txt =>
{
txt.Span("Formule réelle : ").SemiBold();
txt.Span(formule);
});
col.Item().Text(txt =>
{
txt.Span("Régularisation (réel − payé) : ").SemiBold();
txt.Span($"{FormatMontant(reg.Regularisation)} €");
});
});
}
private static string FormatIdentite(ProfilDto profil)
{
var prenom = (profil.Prenom ?? string.Empty).Trim();
var nom = (profil.Nom ?? string.Empty).Trim();
var identite = string.Join(" ", new[] { prenom, nom }.Where(s => s.Length > 0));
return identite.Length > 0 ? identite : "—";
}
/// Kilomètres : séparateur de milliers, 2 décimales.
private static string FormatKm(decimal km) =>
Math.Round(km, 2, MidpointRounding.AwayFromZero).ToString("N2", FrCulture);
/// Montant : séparateur de milliers, 2 décimales.
private static string FormatMontant(decimal montant) =>
Math.Round(montant, 2, MidpointRounding.AwayFromZero).ToString("N2", FrCulture);
private static string FormatDecimal(decimal valeur, int decimales) =>
Math.Round(valeur, decimales, MidpointRounding.AwayFromZero)
.ToString("N" + decimales, FrCulture);
private static string NomMois(int mois)
{
if (mois < 1 || mois > 12)
{
return mois.ToString(CultureInfo.InvariantCulture);
}
var nom = FrCulture.DateTimeFormat.GetMonthName(mois);
return nom.Length > 0 ? char.ToUpper(nom[0], FrCulture) + nom[1..] : nom;
}
/// Modèle de données agrégé pour le rendu du rapport mensuel.
private sealed record ModeleRapportMensuel(
int Annee,
int Mois,
ProfilDto Profil,
VehiculeDto Vehicule,
IReadOnlyList Trajets,
decimal TotalKmMois,
decimal TotalKmAnnuel,
decimal Coefficient,
decimal Base,
decimal TotalIkMois,
decimal MontantRegle);
/// Modèle de données agrégé pour le rendu de la synthèse annuelle.
private sealed record ModeleRapportAnnuel(
int Annee,
ProfilDto Profil,
RegularisationAnnuelleDto Regularisation,
IReadOnlyList TranchesBareme);
}