diff --git a/.kiro/specs/schotziad-km-tracker/design.md b/.kiro/specs/schotziad-km-tracker/design.md index 7961fae..f0aba87 100644 --- a/.kiro/specs/schotziad-km-tracker/design.md +++ b/.kiro/specs/schotziad-km-tracker/design.md @@ -20,7 +20,7 @@ | 4 | Vue annuelle | 12 résumés mensuels + total annuel | | 5 | Modification / suppression | Édition avec recalcul conditionnel de distance | | 6 | Persistance et cohérence | Base relationnelle avec invariants d'agrégation et transactions | -| 7 | Export PDF | Fichiers `trajets_MM_AAAA.pdf` et `trajets_AAAA.pdf` | +| 7 | Export PDF | Fichiers `AAAAMM_Trajets_MATRICULE_PLAQUE.pdf` et `AAAA_Trajets_MATRICULE_PLAQUE.pdf` | | 8 | Visualisation carte | Tracés des itinéraires d'une journée sur carte Leaflet + OpenStreetMap | | 9 | Cache adresses | Stockage des coordonnées BAN des adresses saisies, autocomplétion BAN + cache local, politique LRU (1 000 entrées) | | 10 | Profil / adresse domicile | Saisie du domicile, bouton "Utiliser mon domicile" dans le formulaire | @@ -248,14 +248,16 @@ src/ - Si l'utilisateur tape une adresse sans passer par BAN (pas de sélection), les coordonnées sont absentes et un avertissement discret s'affiche **`ResumeMensuelComponent`** -- Sélecteurs mois (1–12) et année (2000–2100) -- Affiche la liste des trajets et le total mensuel (2 décimales) -- Bouton "Export PDF mensuel" +- Réutilise le même `SelecteurTemporelComponent` que `ConsultationJourComponent` (sélection successive année puis mois, surbrillance des périodes ayant des données) au lieu de sélecteurs distincts mois/année ; la sélection d'un mois (événement `moisSelectionne`) déclenche automatiquement le chargement du Résumé_Mensuel, sans bouton "Afficher" sur cet écran +- Affiche la liste des trajets, avec une colonne **Commentaire** entre les colonnes Date et Départ (valorisée avec le commentaire du Trajet, ou « — » si absent), et le total mensuel +- Bouton "Export PDF mensuel" positionné à droite du `SelecteurTemporelComponent` +- La Distance de chaque Trajet et le total mensuel sont formatés selon `formatKm()` (pipe `number` alimenté par le signal `precisionDistance` de `ParametresApplicationService`), reflétant Precision_Distance (0, 1 ou 2 décimales) au lieu d'un affichage fixe à 2 décimales — voir spec `precision-distances-km` **`ResumeAnnuelComponent`** -- Sélecteur année (2000–2100) -- Tableau 12 lignes (mois, total km) + ligne total annuel (2 décimales, max 999 999,99) -- Bouton "Export PDF annuel" +- Sélecteur année (2000–2100) et bouton "Afficher" **inchangés** (pas de remplacement par `SelecteurTemporelComponent` sur cet écran — décision explicite de l'utilisateur, à la différence du Résumé_Mensuel et de la Vue_Consultation) +- Tableau 12 lignes (mois, total km) + ligne total annuel (max 999 999,99) +- Bouton "Export PDF annuel" positionné à droite du formulaire sélecteur/bouton "Afficher" (même agencement visuel que sur le Résumé_Mensuel), sans changement de comportement +- Le total kilométrique de chaque mois et le total annuel sont formatés selon `formatKm()`, reflétant Precision_Distance au lieu d'un affichage fixe à 2 décimales — voir spec `precision-distances-km` **`TrajetListComponent`** - Affiche TOUS les trajets du mois, regroupés par journée : chaque groupe/jour a un **en-tête repliable** (jour de la semaine avant la date, ex. « Lundi 15/09/2026 », puis nombre de trajets) ; par défaut tout est replié sauf le dernier jour du mois ayant des trajets. @@ -326,7 +328,8 @@ src/ - Affiche trois totaux : total du jour ciblé par la navigation, total du mois sélectionné, Total_Annuel_Partiel (cumul de l'année jusqu'au mois sélectionné inclus) - Reçoit en entrée la date sélectionnée et recalcule les totaux à chaque changement - Tous les totaux affichés à 2 décimales, calculés par sommation de distances déjà arrondies selon Precision_Distance (voir spec `precision-distances-km`, Requirement 5) -- Affiche également le Montant_Previsionnel_Mensuel (80%, désormais calculé sur le seul mois affiché) et, en dessous, une nouvelle ligne « Montant prévisionnel annuel » présentant le Montant_Previsionnel_Annuel (100%, sur le Total_Annuel_Partiel) — détail complet du calcul dans la spec `precision-distances-km`, Requirements 7 et 8 +- Affiche le libellé **« Remboursement indicatif »** (renommé, anciennement « Indication de versement (80%) ») présentant le Montant_Previsionnel_Mensuel (80%, calculé sur le seul mois affiché) et, en dessous, une ligne « Montant prévisionnel annuel » présentant le Montant_Previsionnel_Annuel (100%, sur le Total_Annuel_Partiel) — détail complet du calcul dans la spec `precision-distances-km`, Requirements 7 et 8 +- **Infobulles d'aide** : le texte explicatif de chacune de ces deux lignes (« Remboursement indicatif » et « Montant prévisionnel annuel »), auparavant affiché en permanence sous le montant, est retiré du flux visuel et remplacé par un bouton icône (SVG inline `help-circle`, 16×16, `aria-label` dédié) affiché juste après le libellé ; le texte explicatif est porté par l'attribut HTML `title` de ce bouton et s'affiche via l'infobulle native du navigateur au survol ou au focus clavier de l'icône **`ParametresApplicationComponent`** - Nouveau composant du Sous_Menu_Application de la Page_Parametrage, positionné entre « Compte » et « Véhicules » @@ -1537,19 +1540,17 @@ QuestPDF est une bibliothèque .NET open-source (licence MIT pour usage communau ### Structure des documents PDF -#### PDF Mensuel (`trajets_MM_AAAA.pdf`) +#### PDF Mensuel (`AAAAMM_Trajets_MATRICULE_PLAQUE.pdf`) ``` ┌────────────────────────────────────────────────────────────────────────────┐ -│ FEUILLE D'INDEMNITÉ KILOMÉTRIQUE MENSUELLE │ -│ Cécile DOUCET │ -├────────────────────────────────────────────────────────────────────────────┤ -│ Véhicule : GOLF (ES-036-YF) — 6 CV (Coef. : 0,447 / Base : 0) │ -│ Période : JANVIER 2025 Total parcouru sur le mois : 931 Kms │ -│ Coef. : 0,447 Total parcouru sur l'année : 931 Kms │ -│ Base : 0 TOTAL IK du mois : 416,16 € │ -│ Adresse de départ : 29 route des Cascades, 23400 Saint-Pardoux-Morterolles │ -│ Réglé : 500,00 € │ +│ [Logo] Rapport de trajets │ +│ Feuille d'indemnité kilométrique JANVIER 2025 │ +│ Utilisateur : Cécile DOUCET — Matricule : AB12CD34 │ +│ Domicile : 29 route des Cascades, 23400 Saint-Pardoux-Morterolles │ +│ Véhicule : GOLF (ES-036-YF) — 6 CV (Coef. : 0,447 / Base : 0) │ +│ Total parcouru sur le mois : 931 Kms TOTAL IK du mois : 416,16 € │ +│ Total parcouru sur l'année : 931 Kms Réglé : 500,00 € │ ├────────────┬──────────────────────────┬──────────────────────────┬─────────┤ │ Date │ Motifs (commentaire) │ Adresse départ → arrivée │ Kms │ ├────────────┼──────────────────────────┼──────────────────────────┼─────────┤ @@ -1558,20 +1559,25 @@ QuestPDF est une bibliothèque .NET open-source (licence MIT pour usage communau └────────────┴──────────────────────────┴──────────────────────────┴─────────┘ ``` -> Le « TOTAL IK du mois » = kilomètres du mois × coefficient de la tranche annuelle applicable. Le « Réglé » = Montant_Regle saisi pour le mois. Le motif affiché est le commentaire du Trajet. +> Le titre du document intègre désormais le mois et l'année (« Feuille d'indemnité kilométrique JANVIER 2025 ») ; il n'y a plus de ligne « Période » distincte dans l'en-tête. Le libellé « Adresse de départ » devient « Domicile » et est positionné juste après la ligne Utilisateur/Matricule (au lieu d'être après la ligne Véhicule). Le « TOTAL IK du mois » = kilomètres du mois × coefficient de la tranche annuelle applicable. Le « Réglé » = Montant_Regle saisi pour le mois. Le motif affiché est le commentaire du Trajet. Les kilomètres (mois, année, table des trajets) sont formatés selon Precision_Distance. -#### PDF Annuel (`trajets_AAAA.pdf`) +#### PDF Annuel (`AAAA_Trajets_MATRICULE_PLAQUE.pdf`) ``` ┌──────────────────────────────────────────────────────────────────────────────┐ -│ SYNTHÈSE 2025 D'INDEMNITÉ KILOMÉTRIQUE │ -│ Nom : Cécile DOUCET Année : 2025 │ -│ Domicile : 29 route des Cascades, 23400 Saint-Pardoux-Morterolles │ +│ [Logo] Rapport de trajets │ +│ Synthèse 2025 d'indemnité kilométrique │ +│ Utilisateur : Cécile DOUCET — Matricule : AB12CD34 │ +│ Domicile : 29 route des Cascades, 23400 Saint-Pardoux-Morterolles │ +│ Véhicule : GOLF (ES-036-YF) — 6 CV │ +│ Barème applicable : 5001–20000 │ +│ Total kilométrique de l'année : 12 241 km │ ├──────────────────────────────────────────────────────────────────────────────┤ -│ Barème applicable pour l'année (par puissance fiscale) : │ -│ PF │ ≤5000 (coef/base) │ 5001–20000 (coef/base) │ >20000 (coef/base) │ -│ 6 CV │ 0,665 / 0 │ 0,374 / 1457 │ 0,447 / 0 │ -│ Véhicule : GOLF (ES-036-YF) Total Kms : 12 241 Barème applicable : D2 │ +│ Barème applicable pour l'année (6 CV) : │ +│ Tranche (km) │ Coefficient │ Base (€) │ +│ 0 à 5000 │ 0,665 │ 0 │ +│ 5001 à 20000 │ 0,374 │ 1457 │ +│ 20000 et plus │ 0,447 │ 0 │ ├────────────┬───────────────┬───────────────────┬────────────────────────────┤ │ Mois │ Kms parcourus │ Frais payé (réglé)│ Frais réel │ ├────────────┼───────────────┼───────────────────┼────────────────────────────┤ @@ -1586,28 +1592,45 @@ QuestPDF est une bibliothèque .NET open-source (licence MIT pour usage communau └──────────────────────────────────────────────────────────────────────────────┘ ``` -> Le « Frais réel » mensuel = kilomètres du mois × coefficient de la tranche annuelle. Le « Frais payé » = Montant_Regle saisi. La minoration 80% n'apparaît pas dans la synthèse : elle sert uniquement d'indication de versement en cours d'année. +> La ligne « Année : AAAA », auparavant présente dans l'en-tête, est retirée : elle est redondante avec le titre « Synthèse {Année} d'indemnité kilométrique ». Le « Frais réel » mensuel = kilomètres du mois × coefficient de la tranche annuelle. Le « Frais payé » = Montant_Regle saisi. La minoration 80% n'apparaît pas dans la synthèse : elle sert uniquement d'indication de versement en cours d'année. Les kilomètres (total annuel, table des mois, formule réelle) sont formatés selon Precision_Distance. > **Rapports distincts par véhicule** : si la période demandée (mois ou année) contient des Trajets associés à plusieurs Véhicules, le backend génère un fichier PDF distinct par Véhicule. Chaque PDF ne contient que les Trajets et montants du Véhicule concerné. ### En-tête obligatoire (Exigence 7.6) Tous les PDF incluent en en-tête : -1. Titre fixe : **"Rapport de trajets"** -2. Période couverte : `Mois AAAA` pour le mensuel, `AAAA` pour l'annuel -3. Total kilométrique de la période +1. Le **logo carré** de l'Application, en haut à gauche (voir « Logo dans l'en-tête » ci-dessous) +2. Titre fixe : **"Rapport de trajets"**, suivi du titre spécifique intégrant la période (`Feuille d'indemnité kilométrique {Mois} {AAAA}` pour le mensuel, `Synthèse {AAAA} d'indemnité kilométrique` pour l'annuel) +3. Total kilométrique de la période, formaté selon Precision_Distance -### Nommage des fichiers +### Logo dans l'en-tête (Exigences 7.13) -- Mensuel : `trajets_MM_AAAA.pdf` (ex. `trajets_01_2025.pdf`) -- Annuel : `trajets_AAAA.pdf` (ex. `trajets_2025.pdf`) +Le logo carré (`logo-carre.png`, cf. section Charte graphique) est **embarqué comme ressource** dans l'assembly du backend (`Assets/logo-carre.png`, copié depuis les assets du frontend au moment du build) plutôt que lu depuis le système de fichiers, pour rester indépendant du répertoire de travail au runtime. `PdfService` charge ces octets une seule fois via `Assembly.GetManifestResourceStream`, en cache statique, et les insère via `Image().FitWidth()` dans une colonne fixe (45 pt) à gauche de la colonne de texte de l'en-tête, pour les deux rapports (mensuel et annuel). Si la ressource est introuvable (tableau vide), le logo est simplement omis sans faire échouer la génération du PDF. + +### Precision_Distance dans les PDF (Exigence 7.12) + +`PdfService` récupère la valeur courante de Precision_Distance via `IPrecisionDistanceService` avant le rendu, et l'applique à tous les kilomètres affichés (`FormatKm(valeur, precision)`) : totaux d'en-tête (mois, année), table des Trajets, table de synthèse annuelle, et formule réelle. Comme pour l'affichage Frontend, seul le rendu est affecté ; la Distance brute stockée en base n'est jamais modifiée par ce paramètre (voir spec `precision-distances-km`). + +### Restructuration des en-têtes (Exigence 7.9) + +- **Mensuel** : le libellé « Adresse de départ » devient **« Domicile »** et est déplacé juste après la ligne Utilisateur/Matricule (même position que sur le rapport annuel), au lieu d'être positionné après la ligne Véhicule. La ligne « Période : MOIS ANNEE » est retirée : la période est désormais intégrée au titre du document (« Feuille d'indemnité kilométrique {MOIS} {ANNEE} », au lieu du titre fixe « Feuille d'indemnité kilométrique mensuelle »). +- **Annuel** : la ligne « Année : AAAA » est retirée de l'en-tête, l'année figurant déjà dans le titre « Synthèse {Année} d'indemnité kilométrique ». + +### Nommage des fichiers (Exigence 7.4) + +- Mensuel : `AAAAMM_Trajets_MATRICULE_PLAQUE.pdf` (ex. `202501_Trajets_AB12CD34_ES036YF.pdf`) +- Annuel : `AAAA_Trajets_MATRICULE_PLAQUE.pdf` (ex. `2025_Trajets_AB12CD34_ES036YF.pdf`) +- La **plaque** est nettoyée de ses espaces et de ses tirets avant insertion dans le nom de fichier (ex. `ES-036-YF` → `ES036YF`). +- Les segments **Matricule** et **Plaque** (et le séparateur `_` qui les précède) sont **omis** lorsque la donnée correspondante est vide ou absente (Profil sans matricule, ou Véhicule sans plaque) — voir `PdfService.ConstruireNomFichier`. Le header HTTP de réponse inclut : ``` -Content-Disposition: attachment; filename="trajets_01_2025.pdf" +Content-Disposition: attachment; filename="202501_Trajets_AB12CD34_ES036YF.pdf" Content-Type: application/pdf ``` +> **Exposition CORS de `Content-Disposition`** : la politique CORS du backend (`Program.cs`) déclare explicitement `WithExposedHeaders("Content-Disposition")` — sans cet appel, `AllowAnyHeader()` (qui ne couvre que les en-têtes de requête) ne suffit pas à rendre cet en-tête de réponse lisible en cross-origin par le Frontend, qui en a besoin pour proposer le nom de fichier suggéré lors du téléchargement d'un Export_PDF. + ### Timeout PDF (Exigence 7.5) La génération PDF est enveloppée dans un `CancellationToken` avec délai de 15 secondes. En cas de dépassement, le backend retourne `503 Service Unavailable`. @@ -1984,7 +2007,7 @@ catch ### Property 9: Nommage des fichiers PDF exportés -*Pour tout* export PDF généré — qu'il soit mensuel ou annuel — le nom de fichier inclus dans l'en-tête `Content-Disposition` doit correspondre exactement au format `trajets_MM_AAAA.pdf` (mensuel) ou `trajets_AAAA.pdf` (annuel), où MM et AAAA correspondent aux paramètres de la requête. +*Pour tout* export PDF généré — qu'il soit mensuel ou annuel — le nom de fichier inclus dans l'en-tête `Content-Disposition` doit correspondre exactement au format `AAAAMM_Trajets_MATRICULE_PLAQUE.pdf` (mensuel) ou `AAAA_Trajets_MATRICULE_PLAQUE.pdf` (annuel), où AAAA et MM correspondent aux paramètres de la requête, PLAQUE est la plaque du Véhicule nettoyée de ses espaces et de ses tirets, et les segments MATRICULE / PLAQUE (avec leur séparateur `_`) sont omis lorsque la donnée correspondante est vide ou absente. **Validates: Requirements 7.4** @@ -2359,7 +2382,7 @@ public Property EtatProfilConfigureIffNomPrenomEtVehicule() { ... } - `DistanceCalculatorService`: BAN indisponible → saisie manuelle autorisée, distance null stockée - `ResumeService`: total mois vide = 0, résumé annuel avec mois manquants = 0 - `ResumeService` / `TotauxService`: calcul TotalAnnuelPartiel (cumul jusqu'au mois inclus, exclusion des mois postérieurs), années disponibles (données + année courante), mois/jours disponibles -- `PdfService`: génération mensuelle, génération annuelle, nommage des fichiers, timeout +- `PdfService`: génération mensuelle, génération annuelle, nommage des fichiers (avec/sans matricule, avec/sans plaque, nettoyage espaces/tirets de la plaque), formatage des km selon Precision_Distance, présence du logo (et comportement si ressource absente), timeout - `AdresseCacheService`: insertion nouvelle adresse, réutilisation adresse existante, éviction LRU à 1 001 entrées - `ProfilService`: sauvegarde avec géocodage réussi, sauvegarde avec géocodage échoué (warning sans erreur), mise à jour - `ProfilService`: validation matricule <= 8 car. diff --git a/.kiro/specs/schotziad-km-tracker/requirements.md b/.kiro/specs/schotziad-km-tracker/requirements.md index 1f99415..2cdcdbb 100644 --- a/.kiro/specs/schotziad-km-tracker/requirements.md +++ b/.kiro/specs/schotziad-km-tracker/requirements.md @@ -127,12 +127,15 @@ L'application permet de gérer un profil (identité, matricule, adresse de domic #### Acceptance Criteria 1. WHEN l'utilisateur sélectionne un mois (valeur entre 1 et 12 inclus) et une année (valeur entre 2000 et 2100 inclus), THE Frontend SHALL afficher la liste des Trajets enregistrés pour ce mois ainsi que le Résumé_Mensuel correspondant. -2. WHEN le Backend reçoit une requête de Résumé_Mensuel pour un mois (entre 1 et 12 inclus) et une année (entre 2000 et 2100 inclus) donnés, THE Backend SHALL retourner la liste des Trajets de ce mois triés par date croissante puis par Position croissante au sein de chaque journée, avec leur Distance respective et le total kilométrique du mois. -3. WHEN le Backend reçoit une requête de Résumé_Mensuel, THE Backend SHALL calculer le total kilométrique du Résumé_Mensuel en additionnant les Distances, exprimées en kilomètres à deux décimales, de tous les Trajets du mois concerné. +2. WHEN le Backend reçoit une requête de Résumé_Mensuel pour un mois (entre 1 et 12 inclus) et une année (entre 2000 et 2100 inclus) donnés, THE Backend SHALL retourner la liste des Trajets de ce mois triés par date croissante puis par Position croissante au sein de chaque journée, avec leur Distance respective, leur Commentaire et le total kilométrique du mois. +3. WHEN le Backend reçoit une requête de Résumé_Mensuel, THE Backend SHALL calculer le total kilométrique du Résumé_Mensuel en additionnant les Distances, arrondies selon Precision_Distance (voir spec `precision-distances-km`, Requirement 6), de tous les Trajets du mois concerné. 4. IF aucun Trajet n'est enregistré pour le mois sélectionné, THEN THE Backend SHALL retourner un Résumé_Mensuel avec une liste vide et un total kilométrique de zéro. -5. WHEN le Frontend affiche le Résumé_Mensuel, THE Frontend SHALL afficher le total kilométrique mensuel avec une précision de deux décimales. +5. WHEN le Frontend affiche le Résumé_Mensuel, THE Frontend SHALL afficher la Distance de chaque Trajet et le total kilométrique mensuel avec le nombre de décimales défini par Precision_Distance (voir spec `precision-distances-km`, Requirements 1, 3, 4, 5). 6. IF le Backend reçoit une requête de Résumé_Mensuel avec un mois hors de la plage 1–12 ou une année hors de la plage 2000–2100, THEN THE Backend SHALL refuser la requête et retourner un message descriptif. 7. IF le Backend retourne une erreur lors d'une requête de Résumé_Mensuel, THEN THE Frontend SHALL afficher un message d'erreur indiquant que le chargement des données a échoué et ne pas afficher de données kilométriques partielles. +8. THE Frontend SHALL afficher, dans la liste des Trajets du Résumé_Mensuel, une colonne "Commentaire" positionnée entre la colonne "Date" et la colonne "Départ", affichant le Commentaire du Trajet ou « — » lorsqu'il est absent. +9. THE Frontend SHALL utiliser le Sélecteur_Temporel (défini à l'Exigence 12) pour choisir l'année puis le mois du Résumé_Mensuel, au lieu de sélecteurs distincts de mois et d'année et d'un bouton de validation séparé. +10. WHEN l'utilisateur sélectionne un mois via le Sélecteur_Temporel sur l'écran de Résumé_Mensuel, THE Frontend SHALL charger et afficher automatiquement le Résumé_Mensuel correspondant, sans action supplémentaire de l'utilisateur. --- @@ -146,7 +149,7 @@ L'application permet de gérer un profil (identité, matricule, adresse de domic 2. WHEN le Backend reçoit une requête de Résumé_Annuel pour une année donnée, THE Backend SHALL retourner les douze Résumés_Mensuels de cette année, chacun avec son total kilométrique. 3. WHEN le Backend reçoit une requête de Résumé_Annuel pour une année donnée, THE Backend SHALL calculer le total kilométrique annuel en additionnant les totaux kilométriques des douze Résumés_Mensuels de l'année concernée. 4. IF aucun Trajet n'est enregistré pour un mois d'une année, THEN THE Backend SHALL inclure ce mois dans le Résumé_Annuel avec un total kilométrique de zéro. -5. WHEN le Frontend affiche le Résumé_Annuel, THE Frontend SHALL afficher chaque total kilométrique mensuel et le total annuel avec une précision de deux décimales et une valeur maximale affichable de 999 999,99 km. +5. WHEN le Frontend affiche le Résumé_Annuel, THE Frontend SHALL afficher chaque total kilométrique mensuel et le total annuel avec le nombre de décimales défini par Precision_Distance (voir spec `precision-distances-km`, Requirements 1, 3, 4, 5) et une valeur maximale affichable de 999 999,99 km. 6. IF le Backend retourne une erreur lors d'une requête de Résumé_Annuel, THEN THE Frontend SHALL afficher un message d'erreur indiquant que le chargement des données a échoué et ne pas afficher de données kilométriques partielles. 7. THE Backend SHALL calculer le total kilométrique annuel exclusivement en additionnant les totaux kilométriques des douze Résumés_Mensuels de l'année concernée ; aucun kilométrage non rattaché à un mois ne peut être inclus dans le total annuel. @@ -203,14 +206,16 @@ L'application permet de gérer un profil (identité, matricule, adresse de domic 1. WHEN l'utilisateur demande l'export PDF d'un Résumé_Mensuel, THE Backend SHALL générer un fichier PDF contenant la liste de tous les Trajets du mois (date, adresse de départ, adresse d'arrivée, distance en km) ainsi que le total kilométrique mensuel. 2. WHEN l'utilisateur demande l'export PDF d'un Résumé_Annuel, THE Backend SHALL générer un fichier PDF contenant le tableau des douze mois (mois, total kilométrique) ainsi que le total kilométrique annuel. 3. WHEN le Backend génère un Export_PDF avec succès, THE Frontend SHALL déclencher le téléchargement automatique du fichier PDF dans le navigateur de l'utilisateur. IF le mécanisme de téléchargement échoue après une génération réussie, THEN THE Frontend SHALL afficher un message d'erreur indiquant que le téléchargement a échoué. -4. THE Backend SHALL nommer le fichier PDF exporté selon le format suivant : `trajets_MM_AAAA.pdf` pour un export mensuel et `trajets_AAAA.pdf` pour un export annuel. +4. THE Backend SHALL nommer le fichier PDF exporté selon le format suivant : `AAAAMM_Trajets_MATRICULE_PLAQUE.pdf` pour un export mensuel et `AAAA_Trajets_MATRICULE_PLAQUE.pdf` pour un export annuel, où PLAQUE désigne la plaque d'immatriculation du Véhicule nettoyée de ses espaces et de ses tirets ; les segments MATRICULE et PLAQUE, ainsi que le séparateur `_` qui les précède, SHALL être omis lorsque la donnée correspondante (matricule du Profil ou plaque du Véhicule) est vide ou absente. 5. WHEN le Backend génère un Export_PDF, THE Backend SHALL inclure dans le PDF : le titre "Rapport de trajets", la période couverte (mois/année ou année), et le total kilométrique de la période en en-tête du document. 6. WHEN l'utilisateur demande un export PDF (mensuel ou annuel), THE Backend SHALL n'inclure que les Trajets associés au Véhicule sélectionné et SHALL indiquer dans l'en-tête du PDF la plaque d'immatriculation et la Puissance_Fiscale du Véhicule concerné. 7. WHEN le Backend génère un Export_PDF (mensuel ou annuel), THE Backend SHALL inclure dans l'en-tête du document : le nom, le prénom, le matricule de l'utilisateur, ainsi que la plaque d'immatriculation et la Puissance_Fiscale du Véhicule concerné. 8. IF des Trajets de plusieurs Véhicules existent pour la période (mois ou année) demandée, THEN THE Backend SHALL générer un rapport PDF distinct par Véhicule. -9. WHEN le Backend génère un Export_PDF mensuel, THE Backend SHALL faire figurer dans l'en-tête : le Véhicule (plaque, puissance fiscale, coefficient et base de la tranche applicable), la période, l'adresse de départ du Profil, le total kilométrique du mois, le total kilométrique annuel cumulé, le TOTAL IK du mois (kilomètres du mois × coefficient de la tranche annuelle) et le Montant_Regle du mois. +9. WHEN le Backend génère un Export_PDF mensuel, THE Backend SHALL faire figurer dans l'en-tête, à la suite de la ligne Utilisateur/Matricule : le Véhicule (plaque, puissance fiscale, coefficient et base de la tranche applicable), l'Adresse_Domicile du Profil sous le libellé "Domicile", le total kilométrique du mois, le total kilométrique annuel cumulé, le TOTAL IK du mois (kilomètres du mois × coefficient de la tranche annuelle) et le Montant_Regle du mois ; le titre du document SHALL intégrer le mois et l'année de la période couverte (ex. "Feuille d'indemnité kilométrique Janvier 2025") au lieu d'un titre fixe, et l'en-tête SHALL ne pas comporter de ligne distincte "Période". 10. WHEN le Backend génère un Export_PDF mensuel, THE Backend SHALL faire figurer la liste des Trajets du mois avec, pour chaque Trajet : la date, le motif (commentaire), l'adresse de départ, l'adresse d'arrivée et la distance en kilomètres. -11. WHEN le Backend génère un Export_PDF annuel (synthèse), THE Backend SHALL faire figurer : le nom et le prénom de l'utilisateur, l'année, l'adresse de domicile, le Bareme_Kilometrique applicable pour l'année, le Véhicule et sa puissance fiscale, la tranche applicable ; puis pour chacun des douze mois : les kilomètres parcourus, le Montant_Regle (frais payé) et le Frais_Reel_Mensuel (frais réel) ; et enfin les totaux : total kilométrique annuel, total réglé, Montant_Reel, et Regularisation (Montant_Reel − total réglé). +11. WHEN le Backend génère un Export_PDF annuel (synthèse), THE Backend SHALL faire figurer : le nom et le prénom de l'utilisateur, l'adresse de domicile, le Bareme_Kilometrique applicable pour l'année, le Véhicule et sa puissance fiscale, la tranche applicable ; puis pour chacun des douze mois : les kilomètres parcourus, le Montant_Regle (frais payé) et le Frais_Reel_Mensuel (frais réel) ; et enfin les totaux : total kilométrique annuel, total réglé, Montant_Reel, et Regularisation (Montant_Reel − total réglé) ; le titre du document SHALL indiquer l'année couverte (ex. "Synthèse 2025 d'indemnité kilométrique"), et l'en-tête SHALL ne pas comporter de ligne distincte "Année" dès lors que l'année figure déjà dans ce titre. +12. WHEN le Backend génère un Export_PDF (mensuel ou annuel), THE Backend SHALL exprimer tous les kilomètres affichés dans le document (totaux d'en-tête, table des Trajets, table de synthèse annuelle, formule réelle) avec le nombre de décimales défini par Precision_Distance (voir spec `precision-distances-km`, Requirements 1, 3, 4, 5), au lieu d'une précision fixe à deux décimales. +13. WHEN le Backend génère un Export_PDF (mensuel ou annuel), THE Backend SHALL afficher le logo carré de l'Application en haut à gauche de l'en-tête du document. --- diff --git a/backend/SchotzIadKmTracker.Api/Assets/logo-carre.png b/backend/SchotzIadKmTracker.Api/Assets/logo-carre.png new file mode 100644 index 0000000..7127725 Binary files /dev/null and b/backend/SchotzIadKmTracker.Api/Assets/logo-carre.png differ diff --git a/backend/SchotzIadKmTracker.Api/Controllers/ExportsPdfController.cs b/backend/SchotzIadKmTracker.Api/Controllers/ExportsPdfController.cs index 63aa19e..e1e3adb 100644 --- a/backend/SchotzIadKmTracker.Api/Controllers/ExportsPdfController.cs +++ b/backend/SchotzIadKmTracker.Api/Controllers/ExportsPdfController.cs @@ -9,7 +9,8 @@ namespace SchotzIadKmTracker.Api.Controllers; /// Endpoints d'Export_PDF : rapport mensuel et synthèse annuelle (Exigences 7.3, 7.4, 7.5). /// Chaque réponse renvoie un unique fichier application/pdf avec l'en-tête /// Content-Disposition: attachment et le nom de fichier suggéré par le service -/// (trajets_MM_AAAA.pdf pour le mensuel, trajets_AAAA.pdf pour l'annuel). +/// (AAAAMM_Trajets_MATRICULE_PLAQUE.pdf pour le mensuel, AAAA_Trajets_MATRICULE_PLAQUE.pdf +/// pour l'annuel — les segments Matricule/Plaque sont omis s'ils sont vides). /// /// Le filtre par véhicule est optionnel : si vehiculeId est omis, le Véhicule_Courant /// est utilisé par défaut. @@ -46,7 +47,7 @@ public class ExportsPdfController : ControllerBase /// Génère et télécharge le rapport PDF mensuel d'un (année, mois) pour le véhicule indiqué /// (Véhicule_Courant si est omis). Retourne les octets du PDF /// avec Content-Type: application/pdf et Content-Disposition: attachment; - /// filename="trajets_MM_AAAA.pdf" (Exigences 7.3, 7.4). + /// filename="AAAAMM_Trajets_MATRICULE_PLAQUE.pdf" (Exigences 7.3, 7.4). /// [HttpGet("mensuel/{annee:int}/{mois:int}")] [Produces(MediaTypeNames.Application.Pdf)] @@ -76,7 +77,7 @@ public class ExportsPdfController : ControllerBase /// Génère et télécharge la synthèse PDF annuelle d'une année pour le véhicule indiqué /// (Véhicule_Courant si est omis). Retourne les octets du PDF /// avec Content-Type: application/pdf et Content-Disposition: attachment; - /// filename="trajets_AAAA.pdf" (Exigences 7.3, 7.4). + /// filename="AAAA_Trajets_MATRICULE_PLAQUE.pdf" (Exigences 7.3, 7.4). /// [HttpGet("annuel/{annee:int}")] [Produces(MediaTypeNames.Application.Pdf)] diff --git a/backend/SchotzIadKmTracker.Api/Program.cs b/backend/SchotzIadKmTracker.Api/Program.cs index ace870c..a4afe6f 100644 --- a/backend/SchotzIadKmTracker.Api/Program.cs +++ b/backend/SchotzIadKmTracker.Api/Program.cs @@ -214,7 +214,12 @@ builder.Services.AddCors(options => { policy.WithOrigins(allowedOrigins) .AllowAnyHeader() - .AllowAnyMethod(); + .AllowAnyMethod() + // Expose Content-Disposition en lecture côté client (JS) : sans cet appel, les + // en-têtes de RÉPONSE ne sont pas lisibles en cross-origin même avec + // AllowAnyHeader() (qui ne couvre que les en-têtes de REQUÊTE). Nécessaire pour + // que le Frontend récupère le nom de fichier suggéré des Export_PDF. + .WithExposedHeaders("Content-Disposition"); }); }); diff --git a/backend/SchotzIadKmTracker.Api/SchotzIadKmTracker.Api.csproj b/backend/SchotzIadKmTracker.Api/SchotzIadKmTracker.Api.csproj index 1f91e6c..0ebf34c 100644 --- a/backend/SchotzIadKmTracker.Api/SchotzIadKmTracker.Api.csproj +++ b/backend/SchotzIadKmTracker.Api/SchotzIadKmTracker.Api.csproj @@ -22,4 +22,9 @@ + + + + + diff --git a/backend/SchotzIadKmTracker.Api/Services/PdfService.cs b/backend/SchotzIadKmTracker.Api/Services/PdfService.cs index 36371bb..7185838 100644 --- a/backend/SchotzIadKmTracker.Api/Services/PdfService.cs +++ b/backend/SchotzIadKmTracker.Api/Services/PdfService.cs @@ -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; + /// Logo carré, chargé une seule fois depuis la ressource embarquée (Assets/logo-carre.png). + private static readonly byte[] LogoCarre = ChargerLogoCarre(); + public PdfService( IResumeService resumeService, IProfilService profilService, @@ -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; } + /// Charge les octets du logo carré depuis la ressource embarquée de l'assembly. + private static byte[] ChargerLogoCarre() + { + var assembly = typeof(PdfService).Assembly; + var nomRessource = assembly.GetManifestResourceNames() + .FirstOrDefault(n => n.EndsWith("logo-carre.png", StringComparison.OrdinalIgnoreCase)); + if (nomRessource is null) + { + return Array.Empty(); + } + + using var flux = assembly.GetManifestResourceStream(nomRessource); + if (flux is null) + { + return Array.Empty(); + } + + using var memoire = new MemoryStream(); + flux.CopyTo(memoire); + return memoire.ToArray(); + } + /// public async Task GenererRapportMensuelAsync( int annee, int mois, int vehiculeId, CancellationToken cancellationToken = default) @@ -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 /// /// Rend le document mensuel en octets PDF selon la maquette « PDF Mensuel » du design. /// - 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. /// - 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. /// - 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 /// /// Rend le document de synthèse annuelle en octets PDF selon la maquette « PDF Annuel » du design. /// - 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. /// - 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 . /// - 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 /// /// Compose le bloc « formule réelle » et la régularisation (réel − payé) — Exigence 7.12. /// - 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 : "—"; } - /// Kilomètres : séparateur de milliers, 2 décimales. - private static string FormatKm(decimal km) => - Math.Round(km, 2, MidpointRounding.AwayFromZero).ToString("N2", FrCulture); + /// + /// Kilomètres : séparateur de milliers, nombre de décimales selon Precision_Distance + /// (0, 1 ou 2 — voir la spec precision-distances-km). + /// + private static string FormatKm(decimal km, int precision) => + Math.Round(km, precision, MidpointRounding.AwayFromZero).ToString("N" + precision, FrCulture); + + /// + /// Construit le nom de fichier d'export au format `AAAAMM_Trajets_MATRICULE_PLAQUE.pdf` + /// (sans le segment mois pour l'export annuel, `periode` valant alors `AAAA`). Les segments + /// Matricule et Plaque sont omis (avec leur séparateur) lorsqu'ils sont vides. + /// + private static string ConstruireNomFichier(string periode, string? matricule, string? plaque) + { + var segments = new List { periode, "Trajets" }; + + if (!string.IsNullOrWhiteSpace(matricule)) + { + segments.Add(NettoyerSegmentNomFichier(matricule)); + } + if (!string.IsNullOrWhiteSpace(plaque)) + { + segments.Add(NettoyerSegmentNomFichier(plaque)); + } + + return string.Join("_", segments) + ".pdf"; + } + + /// Retire les espaces et tirets d'un segment de nom de fichier (ex. plaque « AB-123-CD » → « AB123CD »). + private static string NettoyerSegmentNomFichier(string valeur) => + valeur.Replace(" ", "").Replace("-", "").Trim(); /// Montant : séparateur de milliers, 2 décimales. private static string FormatMontant(decimal montant) => diff --git a/backend/SchotzIadKmTracker.Tests/PdfExport/PdfServiceIntegrationTests.cs b/backend/SchotzIadKmTracker.Tests/PdfExport/PdfServiceIntegrationTests.cs new file mode 100644 index 0000000..f500dc3 --- /dev/null +++ b/backend/SchotzIadKmTracker.Tests/PdfExport/PdfServiceIntegrationTests.cs @@ -0,0 +1,169 @@ +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using SchotzIadKmTracker.Api.Data; +using SchotzIadKmTracker.Api.Models.DTOs; +using SchotzIadKmTracker.Api.Models.Entities; +using SchotzIadKmTracker.Api.Repositories; +using SchotzIadKmTracker.Api.Services; +using Xunit; + +namespace SchotzIadKmTracker.Tests.PdfExport; + +/// +/// Tests d'intégration de (vérification manuelle demandée après les +/// changements de nommage de fichier, logo, précision des distances et restructuration des +/// en-têtes mensuel/annuel). Branche les services réels sur une base SQLite en mémoire ; +/// seul est mocké (jamais appelé ici, aucune adresse de domicile +/// à géocoder dans les scénarios testés). +/// +public class PdfServiceIntegrationTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly AppDbContext _context; + private readonly PdfService _pdfService; + + public PdfServiceIntegrationTests() + { + // Licence QuestPDF (usage communautaire, gratuit) — requise avant toute génération de + // PDF ; configurée normalement au démarrage de l'application (Program.cs), reproduite + // ici car ce test instancie PdfService directement sans démarrer le host. + QuestPDF.Settings.License = QuestPDF.Infrastructure.LicenseType.Community; + + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder().UseSqlite(_connection).Options; + _context = new AppDbContext(options); + _context.Database.EnsureCreated(); + + var precisionDistanceService = new PrecisionDistanceService(_context); + var baremeService = new BaremeService(_context); + var resumeService = new ResumeService(_context, precisionDistanceService); + var montantRegleService = new MontantRegleService(_context, baremeService, precisionDistanceService); + var remboursementService = new RemboursementService( + _context, baremeService, montantRegleService, precisionDistanceService); + + var vehiculeRepository = new VehiculeRepository(_context); + var vehiculeService = new VehiculeService(vehiculeRepository, _context); + + var adresseCacheRepository = new AdresseCacheRepository(_context); + var adresseCacheService = new AdresseCacheService(adresseCacheRepository, _context); + var banClientMock = new Mock(); + var profilService = new ProfilService( + _context, banClientMock.Object, adresseCacheService, NullLogger.Instance); + + _pdfService = new PdfService( + resumeService, + profilService, + vehiculeService, + baremeService, + montantRegleService, + remboursementService, + precisionDistanceService, + _context); + } + + /// Prépare un profil, un véhicule, un trajet et une tranche de barème de test. + private async Task PreparerScenarioAsync(string matricule, string plaque, int precision) + { + _context.Profil.Add(new Profil + { + Id = 1, + Nom = "Dupont", + Prenom = "Jean", + Matricule = matricule, + AdresseDomicile = null + }); + + var vehicule = new Vehicule + { + Denomination = "Véhicule de test", + Plaque = plaque, + PuissanceFiscale = 5, + DateEffet = new DateOnly(2020, 1, 1) + }; + _context.Vehicules.Add(vehicule); + + _context.TranchesBareme.Add(new TrancheBareme + { + Annee = 2026, + PuissanceFiscale = 5, + KmMin = 0m, + KmMax = null, + Coefficient = 0.350m, + Base = 0m + }); + + await _context.SaveChangesAsync(); + + _context.Trajets.Add(new Trajet + { + Date = new DateOnly(2026, 3, 15), + AdresseDepart = "Départ test", + AdresseArrivee = "Arrivée test", + Distance = 12.3456m, + AllerRetour = false, + Position = 1m, + VehiculeId = vehicule.Id, + PuissanceFiscale = 5 + }); + await _context.SaveChangesAsync(); + + await precisionServiceDefinir(precision); + + return vehicule.Id; + } + + private async Task precisionServiceDefinir(int precision) + { + var service = new PrecisionDistanceService(_context); + await service.DefinirAsync(precision, CancellationToken.None); + } + + [Fact] + public async Task GenererRapportMensuelAsync_produit_un_pdf_et_un_nom_de_fichier_conforme() + { + var vehiculeId = await PreparerScenarioAsync("AB1234", "AB-123-CD", precision: 0); + + var resultat = await _pdfService.GenererRapportMensuelAsync(2026, 3, vehiculeId, CancellationToken.None); + + resultat.Contenu.Should().NotBeEmpty(); + // En-tête PDF standard : les octets générés doivent être un vrai document PDF. + System.Text.Encoding.ASCII.GetString(resultat.Contenu, 0, 4).Should().Be("%PDF"); + resultat.NomFichier.Should().Be("202603_Trajets_AB1234_AB123CD.pdf"); + } + + [Fact] + public async Task GenererRapportAnnuelAsync_produit_un_pdf_et_un_nom_de_fichier_conforme() + { + var vehiculeId = await PreparerScenarioAsync("XY9876", "ZZ-999-ZZ", precision: 2); + + var resultat = await _pdfService.GenererRapportAnnuelAsync(2026, vehiculeId, CancellationToken.None); + + resultat.Contenu.Should().NotBeEmpty(); + System.Text.Encoding.ASCII.GetString(resultat.Contenu, 0, 4).Should().Be("%PDF"); + resultat.NomFichier.Should().Be("2026_Trajets_XY9876_ZZ999ZZ.pdf"); + } + + [Fact] + public async Task GenererRapportMensuelAsync_omet_les_segments_matricule_et_plaque_absents() + { + // Matricule vide (chaîne vide) : le segment doit être omis du nom de fichier. + var vehiculeId = await PreparerScenarioAsync(string.Empty, "CD-456-EF", precision: 1); + + var resultat = await _pdfService.GenererRapportMensuelAsync(2026, 3, vehiculeId, CancellationToken.None); + + resultat.NomFichier.Should().Be("202603_Trajets_CD456EF.pdf"); + } + + public void Dispose() + { + _context.Dispose(); + _connection.Dispose(); + } +} diff --git a/frontend/src/app/core/services/trajet.service.ts b/frontend/src/app/core/services/trajet.service.ts index 5099620..d14210d 100644 --- a/frontend/src/app/core/services/trajet.service.ts +++ b/frontend/src/app/core/services/trajet.service.ts @@ -1,4 +1,4 @@ -import { HttpClient, HttpParams } from '@angular/common/http'; +import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; import { Observable } from 'rxjs'; @@ -77,22 +77,46 @@ export class TrajetService { }); } - /** Export PDF mensuel (GET /api/exports/mensuel/{annee}/{mois}). */ - exportPdfMensuel(annee: number, mois: number, vehiculeId?: number): Observable { + /** + * Export PDF mensuel (GET /api/exports/mensuel/{annee}/{mois}). Réponse complète + * (et non le seul Blob) afin que l'appelant puisse lire le nom de fichier suggéré + * par le backend depuis l'en-tête `Content-Disposition`. + */ + exportPdfMensuel(annee: number, mois: number, vehiculeId?: number): Observable> { return this.http.get(`${this.baseUrl}/exports/mensuel/${annee}/${mois}`, { params: this.vehiculeParams(vehiculeId), - responseType: 'blob' + responseType: 'blob', + observe: 'response' }); } - /** Export PDF annuel (GET /api/exports/annuel/{annee}). */ - exportPdfAnnuel(annee: number, vehiculeId?: number): Observable { + /** + * Export PDF annuel (GET /api/exports/annuel/{annee}). Réponse complète (et non le + * seul Blob) afin que l'appelant puisse lire le nom de fichier suggéré par le backend + * depuis l'en-tête `Content-Disposition`. + */ + exportPdfAnnuel(annee: number, vehiculeId?: number): Observable> { return this.http.get(`${this.baseUrl}/exports/annuel/${annee}`, { params: this.vehiculeParams(vehiculeId), - responseType: 'blob' + responseType: 'blob', + observe: 'response' }); } + /** + * Extrait le nom de fichier suggéré par le backend depuis l'en-tête + * `Content-Disposition: attachment; filename="..."` d'une réponse HTTP. + * Retourne `null` si l'en-tête est absent ou ne contient pas de nom de fichier. + */ + extraireNomFichier(reponse: HttpResponse): string | null { + const entete = reponse.headers.get('Content-Disposition'); + if (!entete) { + return null; + } + const correspondance = /filename\*?=(?:UTF-8'')?"?([^;"]+)"?/i.exec(entete); + return correspondance ? decodeURIComponent(correspondance[1]) : null; + } + /** * Trajets d'une journée avec géométries pour la carte * (GET /api/trajets/journee/{date}). La date est au format ISO (yyyy-MM-dd). diff --git a/frontend/src/app/features/panneau-totaux/panneau-totaux.component.html b/frontend/src/app/features/panneau-totaux/panneau-totaux.component.html index f70b0a3..7e8d7b7 100644 --- a/frontend/src/app/features/panneau-totaux/panneau-totaux.component.html +++ b/frontend/src/app/features/panneau-totaux/panneau-totaux.component.html @@ -24,32 +24,46 @@
- Indication de versement (80%) + Remboursement indicatif + {{ montantPrevisionnel.montantPrevisionnelMensuel | number: '1.2-2' }} €
-

- Simple indication de versement, calculée à 80% du barème sur le mois - affiché. Le montant réellement versé est le montant réglé que vous - saisissez. -

Montant prévisionnel annuel + {{ montantPrevisionnel.montantPrevisionnelAnnuel | number: '1.2-2' }} €
-

- Projection à 100% du barème sur le total annuel partiel, sans - minoration. Cette projection ne se substitue pas au montant réel - calculé en fin d'année. -

} diff --git a/frontend/src/app/features/panneau-totaux/panneau-totaux.component.scss b/frontend/src/app/features/panneau-totaux/panneau-totaux.component.scss index 7215d71..8378466 100644 --- a/frontend/src/app/features/panneau-totaux/panneau-totaux.component.scss +++ b/frontend/src/app/features/panneau-totaux/panneau-totaux.component.scss @@ -13,6 +13,8 @@ margin: 0 0 12px; font-size: 1.05rem; color: #333; + text-align: center; + text-transform: uppercase; } &__erreur { @@ -67,17 +69,32 @@ padding-top: 12px; border-top: 1px solid #e0e0e0; + &-libelle { + display: inline-flex; + align-items: center; + gap: 6px; + } + &-montant { font-variant-numeric: tabular-nums; color: #1565c0; } + } - &-note { - margin: 8px 0 0; - font-size: 0.8rem; - color: #777; - font-style: italic; - line-height: 1.35; + &__aide { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + background: none; + color: #888; + cursor: help; + line-height: 0; + + &:hover, + &:focus-visible { + color: #1565c0; } } } diff --git a/frontend/src/app/features/resume-annuel/resume-annuel.component.html b/frontend/src/app/features/resume-annuel/resume-annuel.component.html index 43927b5..9727f21 100644 --- a/frontend/src/app/features/resume-annuel/resume-annuel.component.html +++ b/frontend/src/app/features/resume-annuel/resume-annuel.component.html @@ -2,22 +2,25 @@

Résumé annuel

-
- - -
+
+
+ + +
-
+
+ +
- +
@if (chargement()) {

Chargement du résumé…

@@ -45,6 +26,7 @@ Date + Commentaire Départ Arrivée Distance (km) @@ -54,22 +36,23 @@ @for (trajet of resume.trajets; track trajet.id) { {{ trajet.date | date: 'dd/MM/yyyy' }} + {{ trajet.commentaire || '—' }} {{ trajet.adresseDepart }} {{ trajet.adresseArrivee }} - {{ trajet.distance != null ? (trajet.distance | number: '1.2-2') : '—' }} + {{ trajet.distance != null ? (trajet.distance | number: formatKm()) : '—' }} } @empty { - Aucun trajet pour ce mois. + Aucun trajet pour ce mois. } - Total mensuel - {{ resume.totalKm | number: '1.2-2' }} km + Total mensuel + {{ resume.totalKm | number: formatKm() }} km diff --git a/frontend/src/app/features/resume-mensuel/resume-mensuel.component.scss b/frontend/src/app/features/resume-mensuel/resume-mensuel.component.scss index d9585e1..97ef8b9 100644 --- a/frontend/src/app/features/resume-mensuel/resume-mensuel.component.scss +++ b/frontend/src/app/features/resume-mensuel/resume-mensuel.component.scss @@ -12,29 +12,12 @@ h1 { .selecteurs { display: flex; flex-wrap: wrap; - align-items: flex-end; + align-items: flex-start; + justify-content: space-between; gap: 1rem; margin-bottom: 1.5rem; } -.champ { - display: flex; - flex-direction: column; -} - -.champ label { - font-weight: 600; - margin-bottom: 0.25rem; -} - -.champ select, -.champ input { - padding: 0.5rem; - border: 1px solid #bbb; - border-radius: 6px; - font-size: 0.95rem; -} - .actions { display: flex; gap: 0.75rem; diff --git a/frontend/src/app/features/resume-mensuel/resume-mensuel.component.ts b/frontend/src/app/features/resume-mensuel/resume-mensuel.component.ts index c858be9..0881760 100644 --- a/frontend/src/app/features/resume-mensuel/resume-mensuel.component.ts +++ b/frontend/src/app/features/resume-mensuel/resume-mensuel.component.ts @@ -3,8 +3,10 @@ import { Component, OnInit, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { NotificationService } from '../../core/services/notification.service'; +import { ParametresApplicationService } from '../../core/services/parametres-application.service'; import { TrajetService } from '../../core/services/trajet.service'; import { ResumeMensuel } from '../../core/models'; +import { MoisSelectionne, SelecteurTemporelComponent } from '../navigation-temporelle/selecteur-temporel.component'; /** * Résumé mensuel (Exigences 3.1, 3.5, 3.7, 7.3). @@ -22,25 +24,45 @@ import { ResumeMensuel } from '../../core/models'; @Component({ selector: 'app-resume-mensuel', standalone: true, - imports: [CommonModule, FormsModule], + imports: [CommonModule, FormsModule, SelecteurTemporelComponent], templateUrl: './resume-mensuel.component.html', styleUrl: './resume-mensuel.component.scss' }) export class ResumeMensuelComponent implements OnInit { private readonly trajetService = inject(TrajetService); private readonly notification = inject(NotificationService); + readonly parametresService = inject(ParametresApplicationService); /** Bornes de saisie de l'année (cohérentes avec la validation backend). */ readonly anneeMin = 2000; readonly anneeMax = 2100; - /** Liste des mois pour le sélecteur (1–12). */ - readonly moisDisponibles = Array.from({ length: 12 }, (_, i) => i + 1); - /** Sélection courante, initialisée au mois/année en cours. */ annee: number = new Date().getFullYear(); mois: number = new Date().getMonth() + 1; + /** Date ISO (`yyyy-MM-dd`) transmise au sélecteur temporel (jour arbitraire du mois sélectionné). */ + get dateSelecteur(): string { + const mm = String(this.mois).padStart(2, '0'); + return `${this.annee}-${mm}-01`; + } + + /** Réagit à la sélection d'un mois via le sélecteur temporel (même composant que la page trajets). */ + onMoisSelectionne(selection: MoisSelectionne): void { + this.annee = selection.annee; + this.mois = selection.mois; + this.charger(); + } + + /** + * Chaîne de format `number` (`1.{p}-{p}`) basée sur la précision courante des + * distances, pour un affichage dynamique des distances en km. + */ + formatKm(): string { + const precision = this.parametresService.precisionDistance(); + return `1.${precision}-${precision}`; + } + /** Résumé chargé (null tant qu'aucun chargement réussi ou après une erreur). */ readonly resume = signal(null); @@ -76,7 +98,11 @@ export class ResumeMensuelComponent implements OnInit { }); } - /** Déclenche l'export PDF mensuel et le téléchargement navigateur (Exigence 7.3). */ + /** + * Déclenche l'export PDF mensuel et le téléchargement navigateur (Exigence 7.3). Le nom de + * fichier utilisé est celui suggéré par le backend (en-tête `Content-Disposition`, format + * `AAAAMM_Trajets_MATRICULE_PLAQUE.pdf`) ; à défaut (en-tête absent), un repli local est utilisé. + */ exporterPdf(): void { if (!this.selectionValide() || this.export()) { return; @@ -84,10 +110,10 @@ export class ResumeMensuelComponent implements OnInit { this.export.set(true); this.trajetService.exportPdfMensuel(this.annee, this.mois).subscribe({ - next: (blob) => { + next: (reponse) => { this.export.set(false); - const nom = this.nomFichierParDefaut(); - this.declencherTelechargement(blob, nom); + const nom = this.trajetService.extraireNomFichier(reponse) ?? this.nomFichierParDefaut(); + this.declencherTelechargement(reponse.body!, nom); }, error: () => { this.export.set(false); @@ -96,10 +122,10 @@ export class ResumeMensuelComponent implements OnInit { }); } - /** Nom de fichier de repli au format `trajets_MM_AAAA.pdf`. */ + /** Nom de fichier de repli si le backend n'a pas fourni de `Content-Disposition`. */ private nomFichierParDefaut(): string { const mm = String(this.mois).padStart(2, '0'); - return `trajets_${mm}_${this.annee}.pdf`; + return `${this.annee}${mm}_Trajets.pdf`; } /**