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 IPrecisionDistanceService _precisionDistanceService; private readonly AppDbContext _context; /// Logo carré, chargé une seule fois depuis la ressource embarquée (Assets/logo-carre.png). private static readonly byte[] LogoCarre = ChargerLogoCarre(); public PdfService( IResumeService resumeService, IProfilService profilService, IVehiculeService vehiculeService, IBaremeService baremeService, IMontantRegleService montantRegleService, IRemboursementService remboursementService, IPrecisionDistanceService precisionDistanceService, AppDbContext context) { _resumeService = resumeService; _profilService = profilService; _vehiculeService = vehiculeService; _baremeService = baremeService; _montantRegleService = montantRegleService; _remboursementService = remboursementService; _precisionDistanceService = precisionDistanceService; _context = context; } /// Charge les octets du logo carré depuis la ressource embarquée de l'assembly. private static byte[] ChargerLogoCarre() { var assembly = typeof(PdfService).Assembly; var nomRessource = assembly.GetManifestResourceNames() .FirstOrDefault(n => n.EndsWith("logo-carre.png", StringComparison.OrdinalIgnoreCase)); if (nomRessource is null) { return Array.Empty(); } using var flux = assembly.GetManifestResourceStream(nomRessource); if (flux is null) { return Array.Empty(); } using var memoire = new MemoryStream(); flux.CopyTo(memoire); return memoire.ToArray(); } /// 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 precision = await _precisionDistanceService.ObtenirAsync(token); var octets = RendreMensuel(modele, precision, token); var nomFichier = ConstruireNomFichier($"{annee:D4}{mois:D2}", modele.Profil.Matricule, modele.Vehicule.Plaque); 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 precision = await _precisionDistanceService.ObtenirAsync(token); var octets = RendreAnnuel(modele, precision, token); var nomFichier = ConstruireNomFichier( $"{annee:D4}", modele.Profil.Matricule, modele.Regularisation.Plaque); 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, int precision, 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, precision)); page.Content().PaddingTop(10).Element(c => ComposerTableTrajets(c, modele, precision)); 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, int precision) { 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 domicile = 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.Row(ligne => { // Logo carré en haut à gauche. if (LogoCarre.Length > 0) { ligne.ConstantItem(45).Image(LogoCarre).FitWidth(); } ligne.RelativeItem().PaddingLeft(LogoCarre.Length > 0 ? 10 : 0).Column(col => { col.Spacing(2); // Titre fixe imposé par l'Exigence 7.6, période intégrée (remplace la ligne « Période »). col.Item().Text("Rapport de trajets").FontSize(16).Bold(); col.Item().Text($"Feuille d'indemnité kilométrique {periode}").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}"); }); // Domicile, juste après l'identité (même position que sur le rapport annuel). col.Item().Text(txt => { txt.Span("Domicile : ").SemiBold(); txt.Span(domicile); }); // 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); }); // 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, precision)} km"); }); c.Item().Text(txt => { txt.Span("Total parcouru sur l'année : ").SemiBold(); txt.Span($"{FormatKm(modele.TotalKmAnnuel, precision)} 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, int precision) { 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, precision)); } // 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, precision)}").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, int precision, 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, precision)); page.Content().PaddingTop(10).Column(col => { col.Spacing(10); col.Item().Element(c => ComposerTableBaremeAnnuel(c, modele)); col.Item().Element(c => ComposerTableSyntheseAnnuelle(c, modele, precision)); col.Item().Element(c => ComposerTotauxAnnuels(c, modele, precision)); }); 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, int precision) { 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.Row(ligne => { // Logo carré en haut à gauche. if (LogoCarre.Length > 0) { ligne.ConstantItem(45).Image(LogoCarre).FitWidth(); } ligne.RelativeItem().PaddingLeft(LogoCarre.Length > 0 ? 10 : 0).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}"); }); // Adresse de domicile — Exigence 7.12. (La ligne « Année » a été retirée : la // période figure déjà dans le titre « Synthèse {Année} d'indemnité kilométrique ».) 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, precision)} 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, int precision) { 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, precision)}"); 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, precision)}").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, int precision) { var reg = modele.Regularisation; // Formule réelle : total_km × coefficient + base = Montant_Reel (tranche annuelle). var formule = $"{FormatKm(reg.TotalKmAnnuel, precision)} 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, nombre de décimales selon Precision_Distance /// (0, 1 ou 2 — voir la spec precision-distances-km). /// private static string FormatKm(decimal km, int precision) => Math.Round(km, precision, MidpointRounding.AwayFromZero).ToString("N" + precision, FrCulture); /// /// Construit le nom de fichier d'export au format `AAAAMM_Trajets_MATRICULE_PLAQUE.pdf` /// (sans le segment mois pour l'export annuel, `periode` valant alors `AAAA`). Les segments /// Matricule et Plaque sont omis (avec leur séparateur) lorsqu'ils sont vides. /// private static string ConstruireNomFichier(string periode, string? matricule, string? plaque) { var segments = new List { periode, "Trajets" }; if (!string.IsNullOrWhiteSpace(matricule)) { segments.Add(NettoyerSegmentNomFichier(matricule)); } if (!string.IsNullOrWhiteSpace(plaque)) { segments.Add(NettoyerSegmentNomFichier(plaque)); } return string.Join("_", segments) + ".pdf"; } /// Retire les espaces et tirets d'un segment de nom de fichier (ex. plaque « AB-123-CD » → « AB123CD »). private static string NettoyerSegmentNomFichier(string valeur) => valeur.Replace(" ", "").Replace("-", "").Trim(); /// 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); }