Files
SchotzIAD/backend/SchotzIadKmTracker.Api/Services/PdfService.cs
T
MadJax e9f34e59e8 Refonte mise en page des exports PDF (en-tête, pied de page, tables)
En-tête (mensuel + annuel) :
- Titre « RAPPORT DE TRAJETS » en majuscules
- Logo agrandi (120 pt) à gauche, bloc titre centré sur l'espace restant,
  bloc d'infos placé dessous, trait séparateur sur toute la largeur

Pied de page (mensuel + annuel) :
- Mention légale (déclaration sur l'honneur, calcul indicatif, non
  opposable aux administrations) en 5 pt justifiée, au-dessus de la
  pagination

Rapport mensuel :
- Adresse d'arrivée à la ligne sous le départ, précédée de « → »
- Colonne dédiée « A/R » (marqueur « X ») avant les Kms ; largeurs
  ajustées (A/R minimale, Kms jusqu'à « 9 999 », adresses maximisées)

Rapport annuel :
- Espace après « Véhicule » ; barème applicable et total km sur deux
  colonnes
- Titre du tableau barème complété « — Kms parcourus × Coef + Base »
- Espace avant la régularisation, régularisation centrée

Steering : nouvelle règle workflow (valider l'approche avant de coder,
l'utilisateur lance le dev).

Specs requirements.md / design-requirements.md / design.md synchronisées
(Exigence 7 : critères + maquettes PDF + glossaire Mention_Legale).
2026-10-10 10:33:23 +02:00

897 lines
39 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// 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 <see cref="GenererRapportMensuelAsync"/> ; la synthèse annuelle
/// par <see cref="GenererRapportAnnuelAsync"/>. 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).
/// </summary>
public class PdfService : IPdfService
{
/// <summary>Délai maximal de génération d'un PDF (Exigence 7.5).</summary>
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;
/// <summary>Logo carré, chargé une seule fois depuis la ressource embarquée (Assets/logo-carre.png).</summary>
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;
}
/// <summary>Charge les octets du logo carré depuis la ressource embarquée de l'assembly.</summary>
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<byte>();
}
using var flux = assembly.GetManifestResourceStream(nomRessource);
if (flux is null)
{
return Array.Empty<byte>();
}
using var memoire = new MemoryStream();
flux.CopyTo(memoire);
return memoire.ToArray();
}
/// <inheritdoc />
public async Task<PdfResult> 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);
});
}
/// <inheritdoc />
public async Task<PdfResult> 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);
});
}
/// <inheritdoc />
public async Task<IReadOnlyList<PdfResult>> GenererRapportsAnnuelsParVehiculeAsync(
int annee, CancellationToken cancellationToken = default)
{
var vehiculeIds = await VehiculesAvecTrajetsAnneeAsync(annee, cancellationToken);
var resultats = new List<PdfResult>(vehiculeIds.Count);
foreach (var vehiculeId in vehiculeIds)
{
resultats.Add(await GenererRapportAnnuelAsync(annee, vehiculeId, cancellationToken));
}
return resultats;
}
/// <inheritdoc />
public async Task<IReadOnlyList<PdfResult>> GenererRapportsMensuelsParVehiculeAsync(
int annee, int mois, CancellationToken cancellationToken = default)
{
var vehiculeIds = await VehiculesAvecTrajetsMoisAsync(annee, mois, cancellationToken);
var resultats = new List<PdfResult>(vehiculeIds.Count);
foreach (var vehiculeId in vehiculeIds)
{
resultats.Add(await GenererRapportMensuelAsync(annee, mois, vehiculeId, cancellationToken));
}
return resultats;
}
/// <summary>
/// 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 <see cref="PdfGenerationException"/> — que le controller mappe
/// vers <c>503 Service Unavailable</c>.
/// </summary>
private static async Task<PdfResult> ExecuterAvecTimeoutAsync(
CancellationToken cancellationToken, Func<CancellationToken, Task<PdfResult>> 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);
}
}
/// <summary>
/// Retourne, triés par identifiant, les véhicules ayant au moins un Trajet sur l'année.
/// </summary>
private async Task<IReadOnlyList<int>> 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);
}
/// <summary>
/// Retourne, triés par identifiant, les véhicules ayant au moins un Trajet sur le mois donné.
/// </summary>
private async Task<IReadOnlyList<int>> 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);
}
/// <summary>
/// Agrège, via les services métier existants, toutes les données nécessaires au rapport mensuel.
/// </summary>
private async Task<ModeleRapportMensuel> 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);
}
/// <summary>
/// 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.
/// </summary>
private async Task<ModeleRapportAnnuel> 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);
}
/// <summary>
/// Résout le véhicule ciblé par son identifiant à partir de l'historique.
/// Lève <see cref="VehiculeNotFoundException"/> si aucun véhicule ne correspond.
/// </summary>
private async Task<VehiculeDto> 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.");
}
/// <summary>
/// Rend le document mensuel en octets PDF selon la maquette « PDF Mensuel » du design.
/// </summary>
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().Element(ComposerPiedDePage);
});
});
cancellationToken.ThrowIfCancellationRequested();
return document.GeneratePdf();
}
/// <summary>
/// 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.
/// </summary>
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.Column(entete =>
{
// Logo à gauche ; à droite, bloc titre (centré) puis bloc d'infos en dessous.
entete.Item().Row(bandeau =>
{
if (LogoCarre.Length > 0)
{
bandeau.ConstantItem(120).AlignTop().Image(LogoCarre).FitWidth();
}
bandeau.RelativeItem().PaddingLeft(LogoCarre.Length > 0 ? 12 : 0).Column(droite =>
{
// Bloc titre, centré sur l'espace restant après le logo.
droite.Item().Column(titre =>
{
titre.Spacing(2);
titre.Item().AlignCenter().Text("RAPPORT DE TRAJETS").FontSize(16).Bold();
titre.Item().AlignCenter().Text($"Feuille d'indemnité kilométrique {periode}").FontSize(11).SemiBold();
});
// Bloc d'infos, sous le bloc titre (espace pour les séparer).
ComposerInfosMensuel(droite.Item().PaddingTop(10), modele, identite, matricule, domicile, vehiculeLibelle, precision);
});
});
// Trait séparateur sur toute la largeur de la page.
entete.Item().PaddingTop(6).LineHorizontal(1).LineColor(Colors.Grey.Medium);
});
}
/// <summary>
/// Compose le bloc d'informations du rapport mensuel (identité, domicile, véhicule, totaux),
/// affiché à droite du logo sous le bloc titre.
/// </summary>
private static void ComposerInfosMensuel(
IContainer container, ModeleRapportMensuel modele, string identite, string matricule,
string domicile, string vehiculeLibelle, int precision)
{
container.Column(col =>
{
col.Spacing(2);
// Identité (nom, prénom, matricule) — Exigence 7.8.
col.Item().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)} €");
});
});
});
});
}
/// <summary>
/// Compose la table des trajets du mois (Exigence 7.11) : date, motif (commentaire),
/// adresse de départ → arrivée, kilomètres.
/// </summary>
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(4); // Adresse départ → arrivée (maximisée pour tenir sur 2 lignes)
columns.ConstantColumn(22); // A/R (minimale)
columns.ConstantColumn(44); // Kms (jusqu'à « 9 999 » avec séparateur de milliers)
});
// 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).AlignCenter().Text("A/R").FontSize(8);
header.Cell().Element(CelluleEntete).AlignRight().Text("Kms");
});
if (modele.Trajets.Count == 0)
{
table.Cell().ColumnSpan(5).Element(Cellule).Text("Aucun trajet pour ce mois.").Italic();
}
else
{
foreach (var trajet in modele.Trajets)
{
var motif = string.IsNullOrWhiteSpace(trajet.Commentaire) ? "—" : trajet.Commentaire!;
var km = trajet.Distance ?? 0m;
table.Cell().Element(Cellule).Text(trajet.Date.ToString("dd/MM/yyyy", FrCulture));
table.Cell().Element(Cellule).Text(motif);
// Adresse de départ, puis adresse d'arrivée sur une nouvelle ligne, précédée de « → ».
// L'information aller/retour est désormais portée par la colonne A/R dédiée.
table.Cell().Element(Cellule).Text(txt =>
{
txt.Line(trajet.AdresseDepart);
txt.Span($"→ {trajet.AdresseArrivee}");
});
// Colonne A/R : « X » lorsque le trajet est un aller/retour, sinon vide
// (« X » plutôt qu'une coche Unicode, non rendue par la police par défaut Lato).
// La distance affichée est déjà la distance effective (doublée) portée par le TrajetDto.
table.Cell().Element(Cellule).AlignCenter().Text(trajet.AllerRetour ? "X" : string.Empty).SemiBold();
table.Cell().Element(Cellule).AlignRight().Text(FormatKm(km, precision));
}
// Ligne de total du mois.
table.Cell().ColumnSpan(4).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);
}
/// <summary>
/// Rend le document de synthèse annuelle en octets PDF selon la maquette « PDF Annuel » du design.
/// </summary>
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().Element(ComposerPiedDePage);
});
});
cancellationToken.ThrowIfCancellationRequested();
return document.GeneratePdf();
}
/// <summary>
/// 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.
/// </summary>
private static void ComposerEnteteAnnuel(IContainer container, ModeleRapportAnnuel modele, int precision)
{
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(entete =>
{
// Logo à gauche ; à droite, bloc titre (centré) puis bloc d'infos en dessous.
entete.Item().Row(bandeau =>
{
if (LogoCarre.Length > 0)
{
bandeau.ConstantItem(120).AlignTop().Image(LogoCarre).FitWidth();
}
bandeau.RelativeItem().PaddingLeft(LogoCarre.Length > 0 ? 12 : 0).Column(droite =>
{
// Bloc titre, centré sur l'espace restant après le logo.
droite.Item().Column(titre =>
{
titre.Spacing(2);
titre.Item().AlignCenter().Text("RAPPORT DE TRAJETS").FontSize(16).Bold();
titre.Item().AlignCenter().Text($"Synthèse {modele.Annee} d'indemnité kilométrique").FontSize(11).SemiBold();
});
// Bloc d'infos, sous le bloc titre (espace pour les séparer).
ComposerInfosAnnuel(droite.Item().PaddingTop(10), modele, identite, matricule, domicile, precision);
});
});
// Trait séparateur sur toute la largeur de la page.
entete.Item().PaddingTop(6).LineHorizontal(1).LineColor(Colors.Grey.Medium);
});
}
/// <summary>
/// Compose le bloc d'informations de la synthèse annuelle (identité, domicile, véhicule,
/// tranche applicable, total annuel), affiché à droite du logo sous le bloc titre.
/// </summary>
private static void ComposerInfosAnnuel(
IContainer container, ModeleRapportAnnuel modele, string identite, string matricule,
string domicile, int precision)
{
var reg = modele.Regularisation;
container.Column(col =>
{
col.Spacing(2);
// Identité (nom, prénom, matricule) — Exigence 7.8.
col.Item().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");
});
// Barème applicable et total kilométrique sur deux colonnes (gain d'une ligne,
// même agencement que le bloc totaux du rapport mensuel) — Exigences 7.6/7.7/7.12.
col.Item().PaddingTop(6).Row(row =>
{
row.RelativeItem().Text(txt =>
{
txt.Span("Barème applicable : ").SemiBold();
txt.Span(reg.TrancheLibelle);
});
row.RelativeItem().Text(txt =>
{
txt.Span("Total kilométrique de l'année : ").SemiBold();
txt.Span($"{FormatKm(reg.TotalKmAnnuel, precision)} km");
});
});
});
}
/// <summary>
/// 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.
/// </summary>
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) — Kms parcourus × Coef + Base").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);
}
/// <summary>
/// Compose la table des douze mois (kilomètres parcourus, frais payé, frais réel) — Exigence 7.12.
/// Les données proviennent de <see cref="RegularisationAnnuelleDto.Mois"/>.
/// </summary>
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);
}
/// <summary>
/// Compose le bloc « formule réelle » et la régularisation (réel − payé) — Exigence 7.12.
/// </summary>
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);
});
// Espace entre la formule réelle et la régularisation, puis régularisation centrée.
col.Item().PaddingTop(8).AlignCenter().Text(txt =>
{
txt.Span("Régularisation (réel − payé) : ").SemiBold();
txt.Span($"{FormatMontant(reg.Regularisation)} €");
});
});
}
/// <summary>
/// Pied de page commun aux deux rapports : mention indicative en petite taille, puis la
/// pagination en dessous.
/// </summary>
private static void ComposerPiedDePage(IContainer container)
{
container.Column(col =>
{
col.Spacing(2);
// Mention légale indicative, en très petit caractère pour tenir sur 2-3 lignes.
col.Item().Text(txt =>
{
txt.Justify();
txt.DefaultTextStyle(x => x.FontSize(5).FontColor(Colors.Grey.Darken1));
txt.Span(
"Les kilomètres restitués dans le présent document sont déclarés par l'utilisateur sur l'honneur " +
"et sous sa seule responsabilité. Le calcul de l'indemnité kilométrique est fourni à titre " +
"purement indicatif, sur la base du barème kilométrique officiel en vigueur. SchotzIAD ne saurait " +
"être tenue responsable d'une quelconque erreur ou omission dans les données saisies ni du montant " +
"résultant du calcul. Le présent document ne constitue pas un justificatif opposable " +
"aux administrations.");
});
col.Item().AlignCenter().Text(txt =>
{
txt.DefaultTextStyle(x => x.FontSize(8));
txt.Span("Page ");
txt.CurrentPageNumber();
txt.Span(" / ");
txt.TotalPages();
});
});
}
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 : "—";
}
/// <summary>
/// Kilomètres : séparateur de milliers, nombre de décimales selon Precision_Distance
/// (0, 1 ou 2 — voir la spec precision-distances-km).
/// </summary>
private static string FormatKm(decimal km, int precision) =>
Math.Round(km, precision, MidpointRounding.AwayFromZero).ToString("N" + precision, FrCulture);
/// <summary>
/// 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.
/// </summary>
private static string ConstruireNomFichier(string periode, string? matricule, string? plaque)
{
var segments = new List<string> { periode, "Trajets" };
if (!string.IsNullOrWhiteSpace(matricule))
{
segments.Add(NettoyerSegmentNomFichier(matricule));
}
if (!string.IsNullOrWhiteSpace(plaque))
{
segments.Add(NettoyerSegmentNomFichier(plaque));
}
return string.Join("_", segments) + ".pdf";
}
/// <summary>Retire les espaces et tirets d'un segment de nom de fichier (ex. plaque « AB-123-CD » → « AB123CD »).</summary>
private static string NettoyerSegmentNomFichier(string valeur) =>
valeur.Replace(" ", "").Replace("-", "").Trim();
/// <summary>Montant : séparateur de milliers, 2 décimales.</summary>
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;
}
/// <summary>Modèle de données agrégé pour le rendu du rapport mensuel.</summary>
private sealed record ModeleRapportMensuel(
int Annee,
int Mois,
ProfilDto Profil,
VehiculeDto Vehicule,
IReadOnlyList<TrajetDto> Trajets,
decimal TotalKmMois,
decimal TotalKmAnnuel,
decimal Coefficient,
decimal Base,
decimal TotalIkMois,
decimal MontantRegle);
/// <summary>Modèle de données agrégé pour le rendu de la synthèse annuelle.</summary>
private sealed record ModeleRapportAnnuel(
int Annee,
ProfilDto Profil,
RegularisationAnnuelleDto Regularisation,
IReadOnlyList<TrancheBaremeDto> TranchesBareme);
}