Renommage : TrajetKmTracker -> SchotzIadKmTracker (backend, specs, frontend)

- Backend : projets, solution, namespaces et références
  TrajetKmTracker.Api/.Tests -> SchotzIadKmTracker.Api/.Tests
  (dossiers, .csproj, .sln, .http, namespaces C#). GUID UserSecretsId préservé.
- Specs : dossier .kiro/specs/trajet-km-tracker -> schotziad-km-tracker
  et mise à jour des références internes (chemins, tags de propriétés).
- Frontend : projet Angular trajet-km-tracker -> schotziad-km-tracker
  (package.json, angular.json, chemin dist, Dockerfile), titre applicatif
  'SchotzIAD KM Tracker'.
- Docker et scripts (backend/Dockerfile, frontend/Dockerfile, run-dev.sh) mis à jour.
- Suppression du fichier résiduel package.json.bak.

Backend (solution) et frontend (build production) compilent sans erreur.
This commit is contained in:
2026-09-14 07:50:26 +02:00
parent e3f946f7d4
commit 0e6a97ca61
133 changed files with 462 additions and 503 deletions
@@ -0,0 +1,745 @@
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 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;
}
/// <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 octets = RendreMensuel(modele, token);
var nomFichier = $"trajets_{mois:D2}_{annee:D4}.pdf";
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 octets = RendreAnnuel(modele, token);
var nomFichier = $"trajets_{annee:D4}.pdf";
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, 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();
}
/// <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)
{
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);
});
}
/// <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)
{
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!;
var itineraire = $"{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);
}
/// <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)
{
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();
}
/// <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)
{
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);
});
}
/// <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)").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)
{
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);
}
/// <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)
{
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 : "—";
}
/// <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>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);
}