- Totaux : rafraîchissement immédiat du panneau (jour/mois/année + montant prévisionnel) après création/modification/suppression/réordonnancement d'un trajet, sans changer de jour (ViewChild + méthode publique rafraichir()). - Trajets Aller/Retour : case à cocher au formulaire ; la distance comptée est doublée (distance simple stockée + booléen AllerRetour, distance effective utilisée dans tous les agrégats : totaux, résumés, barème, régularisation, PDF). Migration TrajetAllerRetour. Badge « A/R » dans une colonne dédiée en début de ligne pour garder date/adresses alignées. - Mise en page de la page Trajets : vue à hauteur fixe où seule la liste défile ; colonne gauche figée (sélecteur de date + navigation, totaux, carte) et bande fixe (véhicule + bouton d'ajout) au-dessus de la liste. Layout applicatif global en pleine hauteur (app.component). - run-dev.sh : chargement de la clé JWT depuis .env (ou secret de dev aléatoire) et Auth__FrontendBaseUrl sur le frontend dev ; rappel du lien de reset journalisé. Build backend (solution) et frontend (production) OK.
750 lines
31 KiB
C#
750 lines
31 KiB
C#
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!;
|
||
// Suffixe « (A/R) » pour les trajets aller/retour ; la distance affichée est
|
||
// déjà la distance effective (doublée) portée par le TrajetDto.
|
||
var itineraire = trajet.AllerRetour
|
||
? $"{trajet.AdresseDepart} → {trajet.AdresseArrivee} (A/R)"
|
||
: $"{trajet.AdresseDepart} → {trajet.AdresseArrivee}";
|
||
var km = trajet.Distance ?? 0m;
|
||
|
||
table.Cell().Element(Cellule).Text(trajet.Date.ToString("dd/MM/yyyy", FrCulture));
|
||
table.Cell().Element(Cellule).Text(motif);
|
||
table.Cell().Element(Cellule).Text(itineraire);
|
||
table.Cell().Element(Cellule).AlignRight().Text(FormatKm(km));
|
||
}
|
||
|
||
// Ligne de total du mois.
|
||
table.Cell().ColumnSpan(3).Element(CelluleTotal).AlignRight().Text("Total du mois :").SemiBold();
|
||
table.Cell().Element(CelluleTotal).AlignRight().Text($"{FormatKm(modele.TotalKmMois)}").SemiBold();
|
||
}
|
||
});
|
||
|
||
static IContainer CelluleEntete(IContainer c) =>
|
||
c.Background(Colors.Grey.Lighten2).BorderBottom(1).BorderColor(Colors.Grey.Medium)
|
||
.PaddingVertical(4).PaddingHorizontal(3);
|
||
|
||
static IContainer Cellule(IContainer c) =>
|
||
c.BorderBottom(1).BorderColor(Colors.Grey.Lighten2).PaddingVertical(3).PaddingHorizontal(3);
|
||
|
||
static IContainer CelluleTotal(IContainer c) =>
|
||
c.BorderTop(1).BorderColor(Colors.Grey.Medium).PaddingVertical(4).PaddingHorizontal(3);
|
||
}
|
||
|
||
/// <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);
|
||
}
|