Refonte export PDF, colonne commentaire et UX résumés

- Export PDF: nouveau nommage AAAAMM/AAAA_Trajets_MATRICULE_PLAQUE.pdf,
  logo carré embarqué en en-tête, km formatés selon Precision_Distance,
  restructuration en-têtes (Domicile déplacé, période/année fusionnées
  au titre)
- Correction bug: le nom de fichier suggéré au téléchargement ne
  remontait pas au frontend (CORS WithExposedHeaders manquant +
  composants résumé ne lisaient pas Content-Disposition)
- Résumé mensuel: colonne Commentaire, sélecteur temporel unifié
  (auto-chargement), km selon précision
- Résumé annuel: bouton Export PDF repositionné, km selon précision
- Panneau totaux: renommage "Remboursement indicatif" + infobulles
  d'aide (icône) au lieu du texte permanent
- Ajout de PdfServiceIntegrationTests (3 tests), 65/65 tests backend OK
- Mise à jour requirements.md et design.md en conséquence
This commit is contained in:
2026-09-28 08:58:23 +02:00
parent 9db3c91471
commit 61298d6384
17 changed files with 614 additions and 266 deletions
@@ -29,8 +29,12 @@ public class PdfService : IPdfService
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,
@@ -38,6 +42,7 @@ public class PdfService : IPdfService
IBaremeService baremeService,
IMontantRegleService montantRegleService,
IRemboursementService remboursementService,
IPrecisionDistanceService precisionDistanceService,
AppDbContext context)
{
_resumeService = resumeService;
@@ -46,9 +51,32 @@ public class PdfService : IPdfService
_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)
@@ -62,9 +90,10 @@ public class PdfService : IPdfService
return await ExecuterAvecTimeoutAsync(cancellationToken, async token =>
{
var modele = await AssemblerModeleMensuelAsync(annee, mois, vehiculeId, token);
var octets = RendreMensuel(modele, token);
var precision = await _precisionDistanceService.ObtenirAsync(token);
var octets = RendreMensuel(modele, precision, token);
var nomFichier = $"trajets_{mois:D2}_{annee:D4}.pdf";
var nomFichier = ConstruireNomFichier($"{annee:D4}{mois:D2}", modele.Profil.Matricule, modele.Vehicule.Plaque);
return new PdfResult(octets, nomFichier);
});
}
@@ -76,9 +105,11 @@ public class PdfService : IPdfService
return await ExecuterAvecTimeoutAsync(cancellationToken, async token =>
{
var modele = await AssemblerModeleAnnuelAsync(annee, vehiculeId, token);
var octets = RendreAnnuel(modele, token);
var precision = await _precisionDistanceService.ObtenirAsync(token);
var octets = RendreAnnuel(modele, precision, token);
var nomFichier = $"trajets_{annee:D4}.pdf";
var nomFichier = ConstruireNomFichier(
$"{annee:D4}", modele.Profil.Matricule, modele.Regularisation.Plaque);
return new PdfResult(octets, nomFichier);
});
}
@@ -274,7 +305,7 @@ public class PdfService : IPdfService
/// <summary>
/// Rend le document mensuel en octets PDF selon la maquette « PDF Mensuel » du design.
/// </summary>
private static byte[] RendreMensuel(ModeleRapportMensuel modele, CancellationToken cancellationToken)
private static byte[] RendreMensuel(ModeleRapportMensuel modele, int precision, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -286,8 +317,8 @@ public class PdfService : IPdfService
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.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 ");
@@ -306,86 +337,90 @@ public class PdfService : IPdfService
/// 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)
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 adresseDepart = string.IsNullOrWhiteSpace(modele.Profil.AdresseDomicile)
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(col =>
container.Row(ligne =>
{
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 =>
// Logo carré en haut à gauche.
if (LogoCarre.Length > 0)
{
txt.Span("Utilisateur : ").SemiBold();
txt.Span($"{identite} — Matricule : {matricule}");
});
ligne.ConstantItem(45).Image(LogoCarre).FitWidth();
}
// Véhicule (plaque, puissance fiscale, coefficient/base de la tranche annuelle) — Exigences 7.8/7.10.
col.Item().Text(txt =>
ligne.RelativeItem().PaddingLeft(LogoCarre.Length > 0 ? 10 : 0).Column(col =>
{
txt.Span("Véhicule : ").SemiBold();
txt.Span(vehiculeLibelle);
});
col.Spacing(2);
// 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);
});
// 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();
// 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 =>
// Identité (nom, prénom, matricule) — Exigence 7.8.
col.Item().PaddingTop(4).Text(txt =>
{
c.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 =>
{
txt.Span("Total parcouru sur le mois : ").SemiBold();
txt.Span($"{FormatKm(modele.TotalKmMois)} km");
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");
});
});
c.Item().Text(txt =>
row.RelativeItem().Column(c =>
{
txt.Span("Total parcouru sur l'année : ").SemiBold();
txt.Span($"{FormatKm(modele.TotalKmAnnuel)} km");
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)} €");
});
});
});
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);
col.Item().PaddingTop(6).LineHorizontal(1).LineColor(Colors.Grey.Medium);
});
});
}
@@ -393,7 +428,7 @@ public class PdfService : IPdfService
/// 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)
private static void ComposerTableTrajets(IContainer container, ModeleRapportMensuel modele, int precision)
{
container.Table(table =>
{
@@ -433,12 +468,12 @@ public class PdfService : IPdfService
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));
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)}").SemiBold();
table.Cell().Element(CelluleTotal).AlignRight().Text($"{FormatKm(modele.TotalKmMois, precision)}").SemiBold();
}
});
@@ -456,7 +491,7 @@ public class PdfService : IPdfService
/// <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, CancellationToken cancellationToken)
private static byte[] RendreAnnuel(ModeleRapportAnnuel modele, int precision, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -468,13 +503,13 @@ public class PdfService : IPdfService
page.Margin(30);
page.DefaultTextStyle(x => x.FontSize(9).FontColor(Colors.Black));
page.Header().Element(c => ComposerEnteteAnnuel(c, modele));
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));
col.Item().Element(c => ComposerTotauxAnnuels(c, modele));
col.Item().Element(c => ComposerTableSyntheseAnnuelle(c, modele, precision));
col.Item().Element(c => ComposerTotauxAnnuels(c, modele, precision));
});
page.Footer().AlignCenter().Text(txt =>
{
@@ -494,7 +529,7 @@ public class PdfService : IPdfService
/// 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)
private static void ComposerEnteteAnnuel(IContainer container, ModeleRapportAnnuel modele, int precision)
{
var reg = modele.Regularisation;
var identite = FormatIdentite(modele.Profil);
@@ -505,55 +540,58 @@ public class PdfService : IPdfService
? "—"
: modele.Profil.AdresseDomicile!;
container.Column(col =>
container.Row(ligne =>
{
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 =>
// Logo carré en haut à gauche.
if (LogoCarre.Length > 0)
{
txt.Span("Utilisateur : ").SemiBold();
txt.Span($"{identite} — Matricule : {matricule}");
});
ligne.ConstantItem(45).Image(LogoCarre).FitWidth();
}
// Année (période couverte) — Exigence 7.6.
col.Item().Text(txt =>
ligne.RelativeItem().PaddingLeft(LogoCarre.Length > 0 ? 10 : 0).Column(col =>
{
txt.Span("Année : ").SemiBold();
txt.Span(modele.Annee.ToString(CultureInfo.InvariantCulture));
});
col.Spacing(2);
// Adresse de domicile — Exigence 7.12.
col.Item().Text(txt =>
{
txt.Span("Domicile : ").SemiBold();
txt.Span(domicile);
});
// 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();
// 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);
});
// Identité (nom, prénom, matricule) — Exigence 7.8.
col.Item().PaddingTop(4).Text(txt =>
{
txt.Span("Utilisateur : ").SemiBold();
txt.Span($"{identite} — Matricule : {matricule}");
});
// 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");
});
// 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);
});
col.Item().PaddingTop(6).LineHorizontal(1).LineColor(Colors.Grey.Medium);
// 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);
});
});
}
@@ -618,7 +656,7 @@ public class PdfService : IPdfService
/// 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)
private static void ComposerTableSyntheseAnnuelle(IContainer container, ModeleRapportAnnuel modele, int precision)
{
var reg = modele.Regularisation;
@@ -643,14 +681,14 @@ public class PdfService : IPdfService
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($"{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)}").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();
});
@@ -669,13 +707,13 @@ public class PdfService : IPdfService
/// <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)
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)} km × {FormatDecimal(reg.CoefficientTranche, 3)} " +
$"{FormatKm(reg.TotalKmAnnuel, precision)} km × {FormatDecimal(reg.CoefficientTranche, 3)} " +
$"+ {FormatDecimal(reg.BaseTranche, 0)} = {FormatMontant(reg.MontantReel)} € " +
$"(tranche {reg.TrancheLibelle}, {reg.PuissanceFiscale} CV)";
@@ -703,9 +741,37 @@ public class PdfService : IPdfService
return identite.Length > 0 ? identite : "—";
}
/// <summary>Kilomètres : séparateur de milliers, 2 décimales.</summary>
private static string FormatKm(decimal km) =>
Math.Round(km, 2, MidpointRounding.AwayFromZero).ToString("N2", FrCulture);
/// <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) =>