Refonte export PDF, colonne commentaire et UX résumés
- Export PDF: nouveau nommage AAAAMM/AAAA_Trajets_MATRICULE_PLAQUE.pdf, logo carré embarqué en en-tête, km formatés selon Precision_Distance, restructuration en-têtes (Domicile déplacé, période/année fusionnées au titre) - Correction bug: le nom de fichier suggéré au téléchargement ne remontait pas au frontend (CORS WithExposedHeaders manquant + composants résumé ne lisaient pas Content-Disposition) - Résumé mensuel: colonne Commentaire, sélecteur temporel unifié (auto-chargement), km selon précision - Résumé annuel: bouton Export PDF repositionné, km selon précision - Panneau totaux: renommage "Remboursement indicatif" + infobulles d'aide (icône) au lieu du texte permanent - Ajout de PdfServiceIntegrationTests (3 tests), 65/65 tests backend OK - Mise à jour requirements.md et design.md en conséquence
This commit is contained in:
@@ -20,7 +20,7 @@
|
|||||||
| 4 | Vue annuelle | 12 résumés mensuels + total annuel |
|
| 4 | Vue annuelle | 12 résumés mensuels + total annuel |
|
||||||
| 5 | Modification / suppression | Édition avec recalcul conditionnel de distance |
|
| 5 | Modification / suppression | Édition avec recalcul conditionnel de distance |
|
||||||
| 6 | Persistance et cohérence | Base relationnelle avec invariants d'agrégation et transactions |
|
| 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 |
|
| 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) |
|
| 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 |
|
| 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
|
- 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`**
|
**`ResumeMensuelComponent`**
|
||||||
- Sélecteurs mois (1–12) et année (2000–2100)
|
- 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 et le total mensuel (2 décimales)
|
- 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"
|
- 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`**
|
**`ResumeAnnuelComponent`**
|
||||||
- Sélecteur année (2000–2100)
|
- 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 (2 décimales, max 999 999,99)
|
- Tableau 12 lignes (mois, total km) + ligne total annuel (max 999 999,99)
|
||||||
- Bouton "Export PDF annuel"
|
- 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`**
|
**`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.
|
- 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)
|
- 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
|
- 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)
|
- 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`**
|
**`ParametresApplicationComponent`**
|
||||||
- Nouveau composant du Sous_Menu_Application de la Page_Parametrage, positionné entre « Compte » et « Véhicules »
|
- 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
|
### Structure des documents PDF
|
||||||
|
|
||||||
#### PDF Mensuel (`trajets_MM_AAAA.pdf`)
|
#### PDF Mensuel (`AAAAMM_Trajets_MATRICULE_PLAQUE.pdf`)
|
||||||
|
|
||||||
```
|
```
|
||||||
┌────────────────────────────────────────────────────────────────────────────┐
|
┌────────────────────────────────────────────────────────────────────────────┐
|
||||||
│ FEUILLE D'INDEMNITÉ KILOMÉTRIQUE MENSUELLE │
|
│ [Logo] Rapport de trajets │
|
||||||
│ Cécile DOUCET │
|
│ Feuille d'indemnité kilométrique JANVIER 2025 │
|
||||||
├────────────────────────────────────────────────────────────────────────────┤
|
│ Utilisateur : Cécile DOUCET — Matricule : AB12CD34 │
|
||||||
│ Véhicule : GOLF (ES-036-YF) — 6 CV (Coef. : 0,447 / Base : 0) │
|
│ Domicile : 29 route des Cascades, 23400 Saint-Pardoux-Morterolles │
|
||||||
│ Période : JANVIER 2025 Total parcouru sur le mois : 931 Kms │
|
│ Véhicule : GOLF (ES-036-YF) — 6 CV (Coef. : 0,447 / Base : 0) │
|
||||||
│ Coef. : 0,447 Total parcouru sur l'année : 931 Kms │
|
│ Total parcouru sur le mois : 931 Kms TOTAL IK du mois : 416,16 € │
|
||||||
│ Base : 0 TOTAL IK du mois : 416,16 € │
|
│ Total parcouru sur l'année : 931 Kms Réglé : 500,00 € │
|
||||||
│ Adresse de départ : 29 route des Cascades, 23400 Saint-Pardoux-Morterolles │
|
|
||||||
│ Réglé : 500,00 € │
|
|
||||||
├────────────┬──────────────────────────┬──────────────────────────┬─────────┤
|
├────────────┬──────────────────────────┬──────────────────────────┬─────────┤
|
||||||
│ Date │ Motifs (commentaire) │ Adresse départ → arrivée │ Kms │
|
│ 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 │
|
│ [Logo] Rapport de trajets │
|
||||||
│ Nom : Cécile DOUCET Année : 2025 │
|
│ Synthèse 2025 d'indemnité kilométrique │
|
||||||
│ Domicile : 29 route des Cascades, 23400 Saint-Pardoux-Morterolles │
|
│ 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) : │
|
│ Barème applicable pour l'année (6 CV) : │
|
||||||
│ PF │ ≤5000 (coef/base) │ 5001–20000 (coef/base) │ >20000 (coef/base) │
|
│ Tranche (km) │ Coefficient │ Base (€) │
|
||||||
│ 6 CV │ 0,665 / 0 │ 0,374 / 1457 │ 0,447 / 0 │
|
│ 0 à 5000 │ 0,665 │ 0 │
|
||||||
│ Véhicule : GOLF (ES-036-YF) Total Kms : 12 241 Barème applicable : D2 │
|
│ 5001 à 20000 │ 0,374 │ 1457 │
|
||||||
|
│ 20000 et plus │ 0,447 │ 0 │
|
||||||
├────────────┬───────────────┬───────────────────┬────────────────────────────┤
|
├────────────┬───────────────┬───────────────────┬────────────────────────────┤
|
||||||
│ Mois │ Kms parcourus │ Frais payé (réglé)│ Frais réel │
|
│ 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é.
|
> **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)
|
### En-tête obligatoire (Exigence 7.6)
|
||||||
|
|
||||||
Tous les PDF incluent en en-tête :
|
Tous les PDF incluent en en-tête :
|
||||||
1. Titre fixe : **"Rapport de trajets"**
|
1. Le **logo carré** de l'Application, en haut à gauche (voir « Logo dans l'en-tête » ci-dessous)
|
||||||
2. Période couverte : `Mois AAAA` pour le mensuel, `AAAA` pour l'annuel
|
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
|
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`)
|
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.
|
||||||
- Annuel : `trajets_AAAA.pdf` (ex. `trajets_2025.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 :
|
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
|
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)
|
### 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`.
|
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
|
### 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**
|
**Validates: Requirements 7.4**
|
||||||
|
|
||||||
@@ -2359,7 +2382,7 @@ public Property EtatProfilConfigureIffNomPrenomEtVehicule() { ... }
|
|||||||
- `DistanceCalculatorService`: BAN indisponible → saisie manuelle autorisée, distance null stockée
|
- `DistanceCalculatorService`: BAN indisponible → saisie manuelle autorisée, distance null stockée
|
||||||
- `ResumeService`: total mois vide = 0, résumé annuel avec mois manquants = 0
|
- `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
|
- `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
|
- `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`: sauvegarde avec géocodage réussi, sauvegarde avec géocodage échoué (warning sans erreur), mise à jour
|
||||||
- `ProfilService`: validation matricule <= 8 car.
|
- `ProfilService`: validation matricule <= 8 car.
|
||||||
|
|||||||
@@ -127,12 +127,15 @@ L'application permet de gérer un profil (identité, matricule, adresse de domic
|
|||||||
#### Acceptance Criteria
|
#### 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.
|
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.
|
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, exprimées en kilomètres à deux décimales, de tous les Trajets du mois concerné.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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é.
|
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.
|
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é.
|
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é.
|
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.
|
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.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
@@ -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).
|
/// Endpoints d'Export_PDF : rapport mensuel et synthèse annuelle (Exigences 7.3, 7.4, 7.5).
|
||||||
/// Chaque réponse renvoie un unique fichier <c>application/pdf</c> avec l'en-tête
|
/// Chaque réponse renvoie un unique fichier <c>application/pdf</c> avec l'en-tête
|
||||||
/// <c>Content-Disposition: attachment</c> et le nom de fichier suggéré par le service
|
/// <c>Content-Disposition: attachment</c> et le nom de fichier suggéré par le service
|
||||||
/// (<c>trajets_MM_AAAA.pdf</c> pour le mensuel, <c>trajets_AAAA.pdf</c> pour l'annuel).
|
/// (<c>AAAAMM_Trajets_MATRICULE_PLAQUE.pdf</c> pour le mensuel, <c>AAAA_Trajets_MATRICULE_PLAQUE.pdf</c>
|
||||||
|
/// pour l'annuel — les segments Matricule/Plaque sont omis s'ils sont vides).
|
||||||
///
|
///
|
||||||
/// Le filtre par véhicule est optionnel : si <c>vehiculeId</c> est omis, le Véhicule_Courant
|
/// Le filtre par véhicule est optionnel : si <c>vehiculeId</c> est omis, le Véhicule_Courant
|
||||||
/// est utilisé par défaut.
|
/// 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é
|
/// 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 <paramref name="vehiculeId"/> est omis). Retourne les octets du PDF
|
/// (Véhicule_Courant si <paramref name="vehiculeId"/> est omis). Retourne les octets du PDF
|
||||||
/// avec <c>Content-Type: application/pdf</c> et <c>Content-Disposition: attachment;
|
/// avec <c>Content-Type: application/pdf</c> et <c>Content-Disposition: attachment;
|
||||||
/// filename="trajets_MM_AAAA.pdf"</c> (Exigences 7.3, 7.4).
|
/// filename="AAAAMM_Trajets_MATRICULE_PLAQUE.pdf"</c> (Exigences 7.3, 7.4).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("mensuel/{annee:int}/{mois:int}")]
|
[HttpGet("mensuel/{annee:int}/{mois:int}")]
|
||||||
[Produces(MediaTypeNames.Application.Pdf)]
|
[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é
|
/// 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 <paramref name="vehiculeId"/> est omis). Retourne les octets du PDF
|
/// (Véhicule_Courant si <paramref name="vehiculeId"/> est omis). Retourne les octets du PDF
|
||||||
/// avec <c>Content-Type: application/pdf</c> et <c>Content-Disposition: attachment;
|
/// avec <c>Content-Type: application/pdf</c> et <c>Content-Disposition: attachment;
|
||||||
/// filename="trajets_AAAA.pdf"</c> (Exigences 7.3, 7.4).
|
/// filename="AAAA_Trajets_MATRICULE_PLAQUE.pdf"</c> (Exigences 7.3, 7.4).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("annuel/{annee:int}")]
|
[HttpGet("annuel/{annee:int}")]
|
||||||
[Produces(MediaTypeNames.Application.Pdf)]
|
[Produces(MediaTypeNames.Application.Pdf)]
|
||||||
|
|||||||
@@ -214,7 +214,12 @@ builder.Services.AddCors(options =>
|
|||||||
{
|
{
|
||||||
policy.WithOrigins(allowedOrigins)
|
policy.WithOrigins(allowedOrigins)
|
||||||
.AllowAnyHeader()
|
.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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,4 +22,9 @@
|
|||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.22.0" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.22.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Logo carré embarqué dans l'assembly, utilisé dans l'en-tête des Export_PDF (PdfService). -->
|
||||||
|
<EmbeddedResource Include="Assets\logo-carre.png" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -29,8 +29,12 @@ public class PdfService : IPdfService
|
|||||||
private readonly IBaremeService _baremeService;
|
private readonly IBaremeService _baremeService;
|
||||||
private readonly IMontantRegleService _montantRegleService;
|
private readonly IMontantRegleService _montantRegleService;
|
||||||
private readonly IRemboursementService _remboursementService;
|
private readonly IRemboursementService _remboursementService;
|
||||||
|
private readonly IPrecisionDistanceService _precisionDistanceService;
|
||||||
private readonly AppDbContext _context;
|
private readonly AppDbContext _context;
|
||||||
|
|
||||||
|
/// <summary>Logo carré, chargé une seule fois depuis la ressource embarquée (Assets/logo-carre.png).</summary>
|
||||||
|
private static readonly byte[] LogoCarre = ChargerLogoCarre();
|
||||||
|
|
||||||
public PdfService(
|
public PdfService(
|
||||||
IResumeService resumeService,
|
IResumeService resumeService,
|
||||||
IProfilService profilService,
|
IProfilService profilService,
|
||||||
@@ -38,6 +42,7 @@ public class PdfService : IPdfService
|
|||||||
IBaremeService baremeService,
|
IBaremeService baremeService,
|
||||||
IMontantRegleService montantRegleService,
|
IMontantRegleService montantRegleService,
|
||||||
IRemboursementService remboursementService,
|
IRemboursementService remboursementService,
|
||||||
|
IPrecisionDistanceService precisionDistanceService,
|
||||||
AppDbContext context)
|
AppDbContext context)
|
||||||
{
|
{
|
||||||
_resumeService = resumeService;
|
_resumeService = resumeService;
|
||||||
@@ -46,9 +51,32 @@ public class PdfService : IPdfService
|
|||||||
_baremeService = baremeService;
|
_baremeService = baremeService;
|
||||||
_montantRegleService = montantRegleService;
|
_montantRegleService = montantRegleService;
|
||||||
_remboursementService = remboursementService;
|
_remboursementService = remboursementService;
|
||||||
|
_precisionDistanceService = precisionDistanceService;
|
||||||
_context = context;
|
_context = context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Charge les octets du logo carré depuis la ressource embarquée de l'assembly.</summary>
|
||||||
|
private static byte[] ChargerLogoCarre()
|
||||||
|
{
|
||||||
|
var assembly = typeof(PdfService).Assembly;
|
||||||
|
var nomRessource = assembly.GetManifestResourceNames()
|
||||||
|
.FirstOrDefault(n => n.EndsWith("logo-carre.png", StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (nomRessource is null)
|
||||||
|
{
|
||||||
|
return Array.Empty<byte>();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var flux = assembly.GetManifestResourceStream(nomRessource);
|
||||||
|
if (flux is null)
|
||||||
|
{
|
||||||
|
return Array.Empty<byte>();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var memoire = new MemoryStream();
|
||||||
|
flux.CopyTo(memoire);
|
||||||
|
return memoire.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<PdfResult> GenererRapportMensuelAsync(
|
public async Task<PdfResult> GenererRapportMensuelAsync(
|
||||||
int annee, int mois, int vehiculeId, CancellationToken cancellationToken = default)
|
int annee, int mois, int vehiculeId, CancellationToken cancellationToken = default)
|
||||||
@@ -62,9 +90,10 @@ public class PdfService : IPdfService
|
|||||||
return await ExecuterAvecTimeoutAsync(cancellationToken, async token =>
|
return await ExecuterAvecTimeoutAsync(cancellationToken, async token =>
|
||||||
{
|
{
|
||||||
var modele = await AssemblerModeleMensuelAsync(annee, mois, vehiculeId, 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);
|
return new PdfResult(octets, nomFichier);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -76,9 +105,11 @@ public class PdfService : IPdfService
|
|||||||
return await ExecuterAvecTimeoutAsync(cancellationToken, async token =>
|
return await ExecuterAvecTimeoutAsync(cancellationToken, async token =>
|
||||||
{
|
{
|
||||||
var modele = await AssemblerModeleAnnuelAsync(annee, vehiculeId, 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);
|
return new PdfResult(octets, nomFichier);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -274,7 +305,7 @@ public class PdfService : IPdfService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Rend le document mensuel en octets PDF selon la maquette « PDF Mensuel » du design.
|
/// Rend le document mensuel en octets PDF selon la maquette « PDF Mensuel » du design.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static byte[] RendreMensuel(ModeleRapportMensuel modele, CancellationToken cancellationToken)
|
private static byte[] RendreMensuel(ModeleRapportMensuel modele, int precision, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
@@ -286,8 +317,8 @@ public class PdfService : IPdfService
|
|||||||
page.Margin(30);
|
page.Margin(30);
|
||||||
page.DefaultTextStyle(x => x.FontSize(9).FontColor(Colors.Black));
|
page.DefaultTextStyle(x => x.FontSize(9).FontColor(Colors.Black));
|
||||||
|
|
||||||
page.Header().Element(c => ComposerEntete(c, modele));
|
page.Header().Element(c => ComposerEntete(c, modele, precision));
|
||||||
page.Content().PaddingTop(10).Element(c => ComposerTableTrajets(c, modele));
|
page.Content().PaddingTop(10).Element(c => ComposerTableTrajets(c, modele, precision));
|
||||||
page.Footer().AlignCenter().Text(txt =>
|
page.Footer().AlignCenter().Text(txt =>
|
||||||
{
|
{
|
||||||
txt.Span("Page ");
|
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,
|
/// 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.
|
/// période, adresse de départ, totaux mois/année, TOTAL IK et Montant_Regle.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void ComposerEntete(IContainer container, ModeleRapportMensuel modele)
|
private static void ComposerEntete(IContainer container, ModeleRapportMensuel modele, int precision)
|
||||||
{
|
{
|
||||||
var periode = $"{NomMois(modele.Mois).ToUpper(FrCulture)} {modele.Annee}";
|
var periode = $"{NomMois(modele.Mois).ToUpper(FrCulture)} {modele.Annee}";
|
||||||
var identite = FormatIdentite(modele.Profil);
|
var identite = FormatIdentite(modele.Profil);
|
||||||
var matricule = string.IsNullOrWhiteSpace(modele.Profil.Matricule)
|
var matricule = string.IsNullOrWhiteSpace(modele.Profil.Matricule)
|
||||||
? "—"
|
? "—"
|
||||||
: modele.Profil.Matricule!;
|
: modele.Profil.Matricule!;
|
||||||
var adresseDepart = string.IsNullOrWhiteSpace(modele.Profil.AdresseDomicile)
|
var domicile = string.IsNullOrWhiteSpace(modele.Profil.AdresseDomicile)
|
||||||
? "—"
|
? "—"
|
||||||
: modele.Profil.AdresseDomicile!;
|
: modele.Profil.AdresseDomicile!;
|
||||||
var vehiculeLibelle =
|
var vehiculeLibelle =
|
||||||
$"{modele.Vehicule.Plaque} — {modele.Vehicule.PuissanceFiscale} CV " +
|
$"{modele.Vehicule.Plaque} — {modele.Vehicule.PuissanceFiscale} CV " +
|
||||||
$"(Coef. : {FormatDecimal(modele.Coefficient, 3)} / Base : {FormatDecimal(modele.Base, 0)})";
|
$"(Coef. : {FormatDecimal(modele.Coefficient, 3)} / Base : {FormatDecimal(modele.Base, 0)})";
|
||||||
|
|
||||||
container.Column(col =>
|
container.Row(ligne =>
|
||||||
{
|
{
|
||||||
col.Spacing(2);
|
// Logo carré en haut à gauche.
|
||||||
|
if (LogoCarre.Length > 0)
|
||||||
// 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();
|
ligne.ConstantItem(45).Image(LogoCarre).FitWidth();
|
||||||
txt.Span($"{identite} — Matricule : {matricule}");
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Véhicule (plaque, puissance fiscale, coefficient/base de la tranche annuelle) — Exigences 7.8/7.10.
|
ligne.RelativeItem().PaddingLeft(LogoCarre.Length > 0 ? 10 : 0).Column(col =>
|
||||||
col.Item().Text(txt =>
|
|
||||||
{
|
{
|
||||||
txt.Span("Véhicule : ").SemiBold();
|
col.Spacing(2);
|
||||||
txt.Span(vehiculeLibelle);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Période et adresse de départ du profil — Exigences 7.6/7.10.
|
// Titre fixe imposé par l'Exigence 7.6, période intégrée (remplace la ligne « Période »).
|
||||||
col.Item().Text(txt =>
|
col.Item().Text("Rapport de trajets").FontSize(16).Bold();
|
||||||
{
|
col.Item().Text($"Feuille d'indemnité kilométrique {periode}").FontSize(11).SemiBold();
|
||||||
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.
|
// Identité (nom, prénom, matricule) — Exigence 7.8.
|
||||||
col.Item().PaddingTop(6).Row(row =>
|
col.Item().PaddingTop(4).Text(txt =>
|
||||||
{
|
|
||||||
row.RelativeItem().Column(c =>
|
|
||||||
{
|
{
|
||||||
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();
|
c.Item().Text(txt =>
|
||||||
txt.Span($"{FormatKm(modele.TotalKmMois)} km");
|
{
|
||||||
|
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();
|
c.Item().Text(txt =>
|
||||||
txt.Span($"{FormatKm(modele.TotalKmAnnuel)} km");
|
{
|
||||||
|
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),
|
/// Compose la table des trajets du mois (Exigence 7.11) : date, motif (commentaire),
|
||||||
/// adresse de départ → arrivée, kilomètres.
|
/// adresse de départ → arrivée, kilomètres.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void ComposerTableTrajets(IContainer container, ModeleRapportMensuel modele)
|
private static void ComposerTableTrajets(IContainer container, ModeleRapportMensuel modele, int precision)
|
||||||
{
|
{
|
||||||
container.Table(table =>
|
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(trajet.Date.ToString("dd/MM/yyyy", FrCulture));
|
||||||
table.Cell().Element(Cellule).Text(motif);
|
table.Cell().Element(Cellule).Text(motif);
|
||||||
table.Cell().Element(Cellule).Text(itineraire);
|
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.
|
// Ligne de total du mois.
|
||||||
table.Cell().ColumnSpan(3).Element(CelluleTotal).AlignRight().Text("Total du mois :").SemiBold();
|
table.Cell().ColumnSpan(3).Element(CelluleTotal).AlignRight().Text("Total du mois :").SemiBold();
|
||||||
table.Cell().Element(CelluleTotal).AlignRight().Text($"{FormatKm(modele.TotalKmMois)}").SemiBold();
|
table.Cell().Element(CelluleTotal).AlignRight().Text($"{FormatKm(modele.TotalKmMois, precision)}").SemiBold();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -456,7 +491,7 @@ public class PdfService : IPdfService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Rend le document de synthèse annuelle en octets PDF selon la maquette « PDF Annuel » du design.
|
/// Rend le document de synthèse annuelle en octets PDF selon la maquette « PDF Annuel » du design.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static byte[] RendreAnnuel(ModeleRapportAnnuel modele, CancellationToken cancellationToken)
|
private static byte[] RendreAnnuel(ModeleRapportAnnuel modele, int precision, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
@@ -468,13 +503,13 @@ public class PdfService : IPdfService
|
|||||||
page.Margin(30);
|
page.Margin(30);
|
||||||
page.DefaultTextStyle(x => x.FontSize(9).FontColor(Colors.Black));
|
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 =>
|
page.Content().PaddingTop(10).Column(col =>
|
||||||
{
|
{
|
||||||
col.Spacing(10);
|
col.Spacing(10);
|
||||||
col.Item().Element(c => ComposerTableBaremeAnnuel(c, modele));
|
col.Item().Element(c => ComposerTableBaremeAnnuel(c, modele));
|
||||||
col.Item().Element(c => ComposerTableSyntheseAnnuelle(c, modele));
|
col.Item().Element(c => ComposerTableSyntheseAnnuelle(c, modele, precision));
|
||||||
col.Item().Element(c => ComposerTotauxAnnuels(c, modele));
|
col.Item().Element(c => ComposerTotauxAnnuels(c, modele, precision));
|
||||||
});
|
});
|
||||||
page.Footer().AlignCenter().Text(txt =>
|
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,
|
/// 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.
|
/// identité, année, domicile, véhicule (plaque, puissance fiscale) et tranche annuelle applicable.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void ComposerEnteteAnnuel(IContainer container, ModeleRapportAnnuel modele)
|
private static void ComposerEnteteAnnuel(IContainer container, ModeleRapportAnnuel modele, int precision)
|
||||||
{
|
{
|
||||||
var reg = modele.Regularisation;
|
var reg = modele.Regularisation;
|
||||||
var identite = FormatIdentite(modele.Profil);
|
var identite = FormatIdentite(modele.Profil);
|
||||||
@@ -505,55 +540,58 @@ public class PdfService : IPdfService
|
|||||||
? "—"
|
? "—"
|
||||||
: modele.Profil.AdresseDomicile!;
|
: modele.Profil.AdresseDomicile!;
|
||||||
|
|
||||||
container.Column(col =>
|
container.Row(ligne =>
|
||||||
{
|
{
|
||||||
col.Spacing(2);
|
// Logo carré en haut à gauche.
|
||||||
|
if (LogoCarre.Length > 0)
|
||||||
// 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();
|
ligne.ConstantItem(45).Image(LogoCarre).FitWidth();
|
||||||
txt.Span($"{identite} — Matricule : {matricule}");
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Année (période couverte) — Exigence 7.6.
|
ligne.RelativeItem().PaddingLeft(LogoCarre.Length > 0 ? 10 : 0).Column(col =>
|
||||||
col.Item().Text(txt =>
|
|
||||||
{
|
{
|
||||||
txt.Span("Année : ").SemiBold();
|
col.Spacing(2);
|
||||||
txt.Span(modele.Annee.ToString(CultureInfo.InvariantCulture));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Adresse de domicile — Exigence 7.12.
|
// Titre fixe imposé par l'Exigence 7.6.
|
||||||
col.Item().Text(txt =>
|
col.Item().Text("Rapport de trajets").FontSize(16).Bold();
|
||||||
{
|
col.Item().Text($"Synthèse {modele.Annee} d'indemnité kilométrique").FontSize(11).SemiBold();
|
||||||
txt.Span("Domicile : ").SemiBold();
|
|
||||||
txt.Span(domicile);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Véhicule (plaque, puissance fiscale) et tranche annuelle applicable — Exigences 7.7/7.8/7.12.
|
// Identité (nom, prénom, matricule) — Exigence 7.8.
|
||||||
col.Item().Text(txt =>
|
col.Item().PaddingTop(4).Text(txt =>
|
||||||
{
|
{
|
||||||
txt.Span("Véhicule : ").SemiBold();
|
txt.Span("Utilisateur : ").SemiBold();
|
||||||
txt.Span($"{reg.Plaque} — {reg.PuissanceFiscale} CV");
|
txt.Span($"{identite} — Matricule : {matricule}");
|
||||||
});
|
});
|
||||||
col.Item().Text(txt =>
|
|
||||||
{
|
|
||||||
txt.Span("Barème applicable : ").SemiBold();
|
|
||||||
txt.Span(reg.TrancheLibelle);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Total kilométrique de la période (Exigence 7.6).
|
// Adresse de domicile — Exigence 7.12. (La ligne « Année » a été retirée : la
|
||||||
col.Item().Text(txt =>
|
// période figure déjà dans le titre « Synthèse {Année} d'indemnité kilométrique ».)
|
||||||
{
|
col.Item().Text(txt =>
|
||||||
txt.Span("Total kilométrique de l'année : ").SemiBold();
|
{
|
||||||
txt.Span($"{FormatKm(reg.TotalKmAnnuel)} km");
|
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.
|
/// 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"/>.
|
/// Les données proviennent de <see cref="RegularisationAnnuelleDto.Mois"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void ComposerTableSyntheseAnnuelle(IContainer container, ModeleRapportAnnuel modele)
|
private static void ComposerTableSyntheseAnnuelle(IContainer container, ModeleRapportAnnuel modele, int precision)
|
||||||
{
|
{
|
||||||
var reg = modele.Regularisation;
|
var reg = modele.Regularisation;
|
||||||
|
|
||||||
@@ -643,14 +681,14 @@ public class PdfService : IPdfService
|
|||||||
foreach (var ligne in reg.Mois)
|
foreach (var ligne in reg.Mois)
|
||||||
{
|
{
|
||||||
table.Cell().Element(Cellule).Text(NomMois(ligne.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.FraisPaye)} €");
|
||||||
table.Cell().Element(Cellule).AlignRight().Text($"{FormatMontant(ligne.FraisReelMensuel)} €");
|
table.Cell().Element(Cellule).AlignRight().Text($"{FormatMontant(ligne.FraisReelMensuel)} €");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ligne de total annuel.
|
// Ligne de total annuel.
|
||||||
table.Cell().Element(CelluleTotal).Text("TOTAL").SemiBold();
|
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.TotalPaye)} €").SemiBold();
|
||||||
table.Cell().Element(CelluleTotal).AlignRight().Text($"{FormatMontant(reg.MontantReel)} €").SemiBold();
|
table.Cell().Element(CelluleTotal).AlignRight().Text($"{FormatMontant(reg.MontantReel)} €").SemiBold();
|
||||||
});
|
});
|
||||||
@@ -669,13 +707,13 @@ public class PdfService : IPdfService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compose le bloc « formule réelle » et la régularisation (réel − payé) — Exigence 7.12.
|
/// Compose le bloc « formule réelle » et la régularisation (réel − payé) — Exigence 7.12.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void ComposerTotauxAnnuels(IContainer container, ModeleRapportAnnuel modele)
|
private static void ComposerTotauxAnnuels(IContainer container, ModeleRapportAnnuel modele, int precision)
|
||||||
{
|
{
|
||||||
var reg = modele.Regularisation;
|
var reg = modele.Regularisation;
|
||||||
|
|
||||||
// Formule réelle : total_km × coefficient + base = Montant_Reel (tranche annuelle).
|
// Formule réelle : total_km × coefficient + base = Montant_Reel (tranche annuelle).
|
||||||
var formule =
|
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)} € " +
|
$"+ {FormatDecimal(reg.BaseTranche, 0)} = {FormatMontant(reg.MontantReel)} € " +
|
||||||
$"(tranche {reg.TrancheLibelle}, {reg.PuissanceFiscale} CV)";
|
$"(tranche {reg.TrancheLibelle}, {reg.PuissanceFiscale} CV)";
|
||||||
|
|
||||||
@@ -703,9 +741,37 @@ public class PdfService : IPdfService
|
|||||||
return identite.Length > 0 ? identite : "—";
|
return identite.Length > 0 ? identite : "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Kilomètres : séparateur de milliers, 2 décimales.</summary>
|
/// <summary>
|
||||||
private static string FormatKm(decimal km) =>
|
/// Kilomètres : séparateur de milliers, nombre de décimales selon Precision_Distance
|
||||||
Math.Round(km, 2, MidpointRounding.AwayFromZero).ToString("N2", FrCulture);
|
/// (0, 1 ou 2 — voir la spec precision-distances-km).
|
||||||
|
/// </summary>
|
||||||
|
private static string FormatKm(decimal km, int precision) =>
|
||||||
|
Math.Round(km, precision, MidpointRounding.AwayFromZero).ToString("N" + precision, FrCulture);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Construit le nom de fichier d'export au format `AAAAMM_Trajets_MATRICULE_PLAQUE.pdf`
|
||||||
|
/// (sans le segment mois pour l'export annuel, `periode` valant alors `AAAA`). Les segments
|
||||||
|
/// Matricule et Plaque sont omis (avec leur séparateur) lorsqu'ils sont vides.
|
||||||
|
/// </summary>
|
||||||
|
private static string ConstruireNomFichier(string periode, string? matricule, string? plaque)
|
||||||
|
{
|
||||||
|
var segments = new List<string> { periode, "Trajets" };
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(matricule))
|
||||||
|
{
|
||||||
|
segments.Add(NettoyerSegmentNomFichier(matricule));
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(plaque))
|
||||||
|
{
|
||||||
|
segments.Add(NettoyerSegmentNomFichier(plaque));
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join("_", segments) + ".pdf";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Retire les espaces et tirets d'un segment de nom de fichier (ex. plaque « AB-123-CD » → « AB123CD »).</summary>
|
||||||
|
private static string NettoyerSegmentNomFichier(string valeur) =>
|
||||||
|
valeur.Replace(" ", "").Replace("-", "").Trim();
|
||||||
|
|
||||||
/// <summary>Montant : séparateur de milliers, 2 décimales.</summary>
|
/// <summary>Montant : séparateur de milliers, 2 décimales.</summary>
|
||||||
private static string FormatMontant(decimal montant) =>
|
private static string FormatMontant(decimal montant) =>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests d'intégration de <see cref="PdfService"/> (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 <see cref="IBANClient"/> est mocké (jamais appelé ici, aucune adresse de domicile
|
||||||
|
/// à géocoder dans les scénarios testés).
|
||||||
|
/// </summary>
|
||||||
|
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<AppDbContext>().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<IBANClient>();
|
||||||
|
var profilService = new ProfilService(
|
||||||
|
_context, banClientMock.Object, adresseCacheService, NullLogger<ProfilService>.Instance);
|
||||||
|
|
||||||
|
_pdfService = new PdfService(
|
||||||
|
resumeService,
|
||||||
|
profilService,
|
||||||
|
vehiculeService,
|
||||||
|
baremeService,
|
||||||
|
montantRegleService,
|
||||||
|
remboursementService,
|
||||||
|
precisionDistanceService,
|
||||||
|
_context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Prépare un profil, un véhicule, un trajet et une tranche de barème de test.</summary>
|
||||||
|
private async Task<int> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { Injectable, inject } from '@angular/core';
|
||||||
import { Observable } from 'rxjs';
|
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<Blob> {
|
* 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<HttpResponse<Blob>> {
|
||||||
return this.http.get(`${this.baseUrl}/exports/mensuel/${annee}/${mois}`, {
|
return this.http.get(`${this.baseUrl}/exports/mensuel/${annee}/${mois}`, {
|
||||||
params: this.vehiculeParams(vehiculeId),
|
params: this.vehiculeParams(vehiculeId),
|
||||||
responseType: 'blob'
|
responseType: 'blob',
|
||||||
|
observe: 'response'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Export PDF annuel (GET /api/exports/annuel/{annee}). */
|
/**
|
||||||
exportPdfAnnuel(annee: number, vehiculeId?: number): Observable<Blob> {
|
* 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<HttpResponse<Blob>> {
|
||||||
return this.http.get(`${this.baseUrl}/exports/annuel/${annee}`, {
|
return this.http.get(`${this.baseUrl}/exports/annuel/${annee}`, {
|
||||||
params: this.vehiculeParams(vehiculeId),
|
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<Blob>): 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
|
* 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).
|
* (GET /api/trajets/journee/{date}). La date est au format ISO (yyyy-MM-dd).
|
||||||
|
|||||||
@@ -24,32 +24,46 @@
|
|||||||
<div class="panneau-totaux__previsionnel">
|
<div class="panneau-totaux__previsionnel">
|
||||||
<div class="panneau-totaux__ligne panneau-totaux__ligne--fort">
|
<div class="panneau-totaux__ligne panneau-totaux__ligne--fort">
|
||||||
<span class="panneau-totaux__previsionnel-libelle">
|
<span class="panneau-totaux__previsionnel-libelle">
|
||||||
Indication de versement (80%)
|
Remboursement indicatif
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="panneau-totaux__aide"
|
||||||
|
aria-label="En savoir plus sur le remboursement indicatif"
|
||||||
|
title="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."
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>
|
||||||
|
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
<span class="panneau-totaux__previsionnel-montant">
|
<span class="panneau-totaux__previsionnel-montant">
|
||||||
{{ montantPrevisionnel.montantPrevisionnelMensuel | number: '1.2-2' }} €
|
{{ montantPrevisionnel.montantPrevisionnelMensuel | number: '1.2-2' }} €
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="panneau-totaux__previsionnel-note">
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="panneau-totaux__previsionnel">
|
<div class="panneau-totaux__previsionnel">
|
||||||
<div class="panneau-totaux__ligne panneau-totaux__ligne--fort">
|
<div class="panneau-totaux__ligne panneau-totaux__ligne--fort">
|
||||||
<span class="panneau-totaux__previsionnel-libelle">
|
<span class="panneau-totaux__previsionnel-libelle">
|
||||||
Montant prévisionnel annuel
|
Montant prévisionnel annuel
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="panneau-totaux__aide"
|
||||||
|
aria-label="En savoir plus sur le montant prévisionnel annuel"
|
||||||
|
title="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."
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>
|
||||||
|
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
<span class="panneau-totaux__previsionnel-montant">
|
<span class="panneau-totaux__previsionnel-montant">
|
||||||
{{ montantPrevisionnel.montantPrevisionnelAnnuel | number: '1.2-2' }} €
|
{{ montantPrevisionnel.montantPrevisionnelAnnuel | number: '1.2-2' }} €
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="panneau-totaux__previsionnel-note">
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
color: #333;
|
color: #333;
|
||||||
|
text-align: center;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__erreur {
|
&__erreur {
|
||||||
@@ -67,17 +69,32 @@
|
|||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
border-top: 1px solid #e0e0e0;
|
border-top: 1px solid #e0e0e0;
|
||||||
|
|
||||||
|
&-libelle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
&-montant {
|
&-montant {
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
color: #1565c0;
|
color: #1565c0;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&-note {
|
&__aide {
|
||||||
margin: 8px 0 0;
|
display: inline-flex;
|
||||||
font-size: 0.8rem;
|
align-items: center;
|
||||||
color: #777;
|
justify-content: center;
|
||||||
font-style: italic;
|
padding: 0;
|
||||||
line-height: 1.35;
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: #888;
|
||||||
|
cursor: help;
|
||||||
|
line-height: 0;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
color: #1565c0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,22 +2,25 @@
|
|||||||
<h1>Résumé annuel</h1>
|
<h1>Résumé annuel</h1>
|
||||||
|
|
||||||
<form class="selecteurs" (ngSubmit)="charger()">
|
<form class="selecteurs" (ngSubmit)="charger()">
|
||||||
<div class="champ">
|
<div class="selecteurs__groupe">
|
||||||
<label for="annee">Année</label>
|
<div class="champ">
|
||||||
<input
|
<label for="annee">Année</label>
|
||||||
id="annee"
|
<input
|
||||||
name="annee"
|
id="annee"
|
||||||
type="number"
|
name="annee"
|
||||||
[(ngModel)]="annee"
|
type="number"
|
||||||
[min]="anneeMin"
|
[(ngModel)]="annee"
|
||||||
[max]="anneeMax"
|
[min]="anneeMin"
|
||||||
/>
|
[max]="anneeMax"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<button type="submit" class="btn-principal" [disabled]="chargement()">
|
<button type="submit" class="btn-principal" [disabled]="chargement()">
|
||||||
Afficher
|
Afficher
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-secondaire"
|
class="btn-secondaire"
|
||||||
@@ -43,14 +46,14 @@
|
|||||||
@for (ligne of lignes(); track ligne.mois) {
|
@for (ligne of lignes(); track ligne.mois) {
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ ligne.libelle }}</td>
|
<td>{{ ligne.libelle }}</td>
|
||||||
<td class="num">{{ ligne.totalKm | number: '1.2-2' }}</td>
|
<td class="num">{{ ligne.totalKm | number: formatKm() }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Total annuel</td>
|
<td>Total annuel</td>
|
||||||
<td class="num total">{{ totalAnnuel() | number: '1.2-2' }} km</td>
|
<td class="num total">{{ totalAnnuel() | number: formatKm() }} km</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -13,10 +13,18 @@ h1 {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selecteurs__groupe {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.champ {
|
.champ {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Component, OnInit, inject, signal } from '@angular/core';
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
|
||||||
import { NotificationService } from '../../core/services/notification.service';
|
import { NotificationService } from '../../core/services/notification.service';
|
||||||
|
import { ParametresApplicationService } from '../../core/services/parametres-application.service';
|
||||||
import { TrajetService } from '../../core/services/trajet.service';
|
import { TrajetService } from '../../core/services/trajet.service';
|
||||||
import { ResumeAnnuel } from '../../core/models';
|
import { ResumeAnnuel } from '../../core/models';
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ export interface LigneAnnuelle {
|
|||||||
export class ResumeAnnuelComponent implements OnInit {
|
export class ResumeAnnuelComponent implements OnInit {
|
||||||
private readonly trajetService = inject(TrajetService);
|
private readonly trajetService = inject(TrajetService);
|
||||||
private readonly notification = inject(NotificationService);
|
private readonly notification = inject(NotificationService);
|
||||||
|
readonly parametresService = inject(ParametresApplicationService);
|
||||||
|
|
||||||
/** Bornes de saisie de l'année (cohérentes avec la validation backend). */
|
/** Bornes de saisie de l'année (cohérentes avec la validation backend). */
|
||||||
readonly anneeMin = 2000;
|
readonly anneeMin = 2000;
|
||||||
@@ -104,7 +106,11 @@ export class ResumeAnnuelComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Déclenche l'export PDF annuel et le téléchargement navigateur (Exigences 4.6, 7.3). */
|
/**
|
||||||
|
* Déclenche l'export PDF annuel et le téléchargement navigateur (Exigences 4.6, 7.3). Le nom
|
||||||
|
* de fichier utilisé est celui suggéré par le backend (en-tête `Content-Disposition`, format
|
||||||
|
* `AAAA_Trajets_MATRICULE_PLAQUE.pdf`) ; à défaut (en-tête absent), un repli local est utilisé.
|
||||||
|
*/
|
||||||
exporterPdf(): void {
|
exporterPdf(): void {
|
||||||
if (!this.anneeValide() || this.export()) {
|
if (!this.anneeValide() || this.export()) {
|
||||||
return;
|
return;
|
||||||
@@ -112,9 +118,10 @@ export class ResumeAnnuelComponent implements OnInit {
|
|||||||
|
|
||||||
this.export.set(true);
|
this.export.set(true);
|
||||||
this.trajetService.exportPdfAnnuel(this.annee).subscribe({
|
this.trajetService.exportPdfAnnuel(this.annee).subscribe({
|
||||||
next: (blob) => {
|
next: (reponse) => {
|
||||||
this.export.set(false);
|
this.export.set(false);
|
||||||
this.declencherTelechargement(blob, `trajets_${this.annee}.pdf`);
|
const nom = this.trajetService.extraireNomFichier(reponse) ?? `${this.annee}_Trajets.pdf`;
|
||||||
|
this.declencherTelechargement(reponse.body!, nom);
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.export.set(false);
|
this.export.set(false);
|
||||||
@@ -156,6 +163,15 @@ export class ResumeAnnuelComponent implements OnInit {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chaîne de format `number` (`1.{p}-{p}`) basée sur la précision courante des
|
||||||
|
* distances, pour un affichage dynamique des totaux en km.
|
||||||
|
*/
|
||||||
|
formatKm(): string {
|
||||||
|
const precision = this.parametresService.precisionDistance();
|
||||||
|
return `1.${precision}-${precision}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Vérifie que l'année sélectionnée est dans les bornes attendues. */
|
/** Vérifie que l'année sélectionnée est dans les bornes attendues. */
|
||||||
private anneeValide(): boolean {
|
private anneeValide(): boolean {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,32 +1,13 @@
|
|||||||
<section class="resume-mensuel">
|
<section class="resume-mensuel">
|
||||||
<h1>Résumé mensuel</h1>
|
<h1>Résumé mensuel</h1>
|
||||||
|
|
||||||
<form class="selecteurs" (ngSubmit)="charger()">
|
<div class="selecteurs">
|
||||||
<div class="champ">
|
<app-selecteur-temporel
|
||||||
<label for="mois">Mois</label>
|
[date]="dateSelecteur"
|
||||||
<select id="mois" name="mois" [(ngModel)]="mois">
|
(moisSelectionne)="onMoisSelectionne($event)"
|
||||||
@for (m of moisDisponibles; track m) {
|
></app-selecteur-temporel>
|
||||||
<option [ngValue]="m">{{ m }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="champ">
|
|
||||||
<label for="annee">Année</label>
|
|
||||||
<input
|
|
||||||
id="annee"
|
|
||||||
name="annee"
|
|
||||||
type="number"
|
|
||||||
[(ngModel)]="annee"
|
|
||||||
[min]="anneeMin"
|
|
||||||
[max]="anneeMax"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="submit" class="btn-principal" [disabled]="chargement()">
|
|
||||||
Afficher
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-secondaire"
|
class="btn-secondaire"
|
||||||
@@ -36,7 +17,7 @@
|
|||||||
Export PDF mensuel
|
Export PDF mensuel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
|
|
||||||
@if (chargement()) {
|
@if (chargement()) {
|
||||||
<p class="info">Chargement du résumé…</p>
|
<p class="info">Chargement du résumé…</p>
|
||||||
@@ -45,6 +26,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
|
<th>Commentaire</th>
|
||||||
<th>Départ</th>
|
<th>Départ</th>
|
||||||
<th>Arrivée</th>
|
<th>Arrivée</th>
|
||||||
<th class="num">Distance (km)</th>
|
<th class="num">Distance (km)</th>
|
||||||
@@ -54,22 +36,23 @@
|
|||||||
@for (trajet of resume.trajets; track trajet.id) {
|
@for (trajet of resume.trajets; track trajet.id) {
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ trajet.date | date: 'dd/MM/yyyy' }}</td>
|
<td>{{ trajet.date | date: 'dd/MM/yyyy' }}</td>
|
||||||
|
<td>{{ trajet.commentaire || '—' }}</td>
|
||||||
<td>{{ trajet.adresseDepart }}</td>
|
<td>{{ trajet.adresseDepart }}</td>
|
||||||
<td>{{ trajet.adresseArrivee }}</td>
|
<td>{{ trajet.adresseArrivee }}</td>
|
||||||
<td class="num">
|
<td class="num">
|
||||||
{{ trajet.distance != null ? (trajet.distance | number: '1.2-2') : '—' }}
|
{{ trajet.distance != null ? (trajet.distance | number: formatKm()) : '—' }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
} @empty {
|
} @empty {
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="4" class="vide">Aucun trajet pour ce mois.</td>
|
<td colspan="5" class="vide">Aucun trajet pour ce mois.</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="3">Total mensuel</td>
|
<td colspan="4">Total mensuel</td>
|
||||||
<td class="num total">{{ resume.totalKm | number: '1.2-2' }} km</td>
|
<td class="num total">{{ resume.totalKm | number: formatKm() }} km</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -12,29 +12,12 @@ h1 {
|
|||||||
.selecteurs {
|
.selecteurs {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: flex-end;
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-bottom: 1.5rem;
|
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 {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import { Component, OnInit, inject, signal } from '@angular/core';
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
|
||||||
import { NotificationService } from '../../core/services/notification.service';
|
import { NotificationService } from '../../core/services/notification.service';
|
||||||
|
import { ParametresApplicationService } from '../../core/services/parametres-application.service';
|
||||||
import { TrajetService } from '../../core/services/trajet.service';
|
import { TrajetService } from '../../core/services/trajet.service';
|
||||||
import { ResumeMensuel } from '../../core/models';
|
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).
|
* Résumé mensuel (Exigences 3.1, 3.5, 3.7, 7.3).
|
||||||
@@ -22,25 +24,45 @@ import { ResumeMensuel } from '../../core/models';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-resume-mensuel',
|
selector: 'app-resume-mensuel',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule],
|
imports: [CommonModule, FormsModule, SelecteurTemporelComponent],
|
||||||
templateUrl: './resume-mensuel.component.html',
|
templateUrl: './resume-mensuel.component.html',
|
||||||
styleUrl: './resume-mensuel.component.scss'
|
styleUrl: './resume-mensuel.component.scss'
|
||||||
})
|
})
|
||||||
export class ResumeMensuelComponent implements OnInit {
|
export class ResumeMensuelComponent implements OnInit {
|
||||||
private readonly trajetService = inject(TrajetService);
|
private readonly trajetService = inject(TrajetService);
|
||||||
private readonly notification = inject(NotificationService);
|
private readonly notification = inject(NotificationService);
|
||||||
|
readonly parametresService = inject(ParametresApplicationService);
|
||||||
|
|
||||||
/** Bornes de saisie de l'année (cohérentes avec la validation backend). */
|
/** Bornes de saisie de l'année (cohérentes avec la validation backend). */
|
||||||
readonly anneeMin = 2000;
|
readonly anneeMin = 2000;
|
||||||
readonly anneeMax = 2100;
|
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. */
|
/** Sélection courante, initialisée au mois/année en cours. */
|
||||||
annee: number = new Date().getFullYear();
|
annee: number = new Date().getFullYear();
|
||||||
mois: number = new Date().getMonth() + 1;
|
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). */
|
/** Résumé chargé (null tant qu'aucun chargement réussi ou après une erreur). */
|
||||||
readonly resume = signal<ResumeMensuel | null>(null);
|
readonly resume = signal<ResumeMensuel | null>(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 {
|
exporterPdf(): void {
|
||||||
if (!this.selectionValide() || this.export()) {
|
if (!this.selectionValide() || this.export()) {
|
||||||
return;
|
return;
|
||||||
@@ -84,10 +110,10 @@ export class ResumeMensuelComponent implements OnInit {
|
|||||||
|
|
||||||
this.export.set(true);
|
this.export.set(true);
|
||||||
this.trajetService.exportPdfMensuel(this.annee, this.mois).subscribe({
|
this.trajetService.exportPdfMensuel(this.annee, this.mois).subscribe({
|
||||||
next: (blob) => {
|
next: (reponse) => {
|
||||||
this.export.set(false);
|
this.export.set(false);
|
||||||
const nom = this.nomFichierParDefaut();
|
const nom = this.trajetService.extraireNomFichier(reponse) ?? this.nomFichierParDefaut();
|
||||||
this.declencherTelechargement(blob, nom);
|
this.declencherTelechargement(reponse.body!, nom);
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.export.set(false);
|
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 {
|
private nomFichierParDefaut(): string {
|
||||||
const mm = String(this.mois).padStart(2, '0');
|
const mm = String(this.mois).padStart(2, '0');
|
||||||
return `trajets_${mm}_${this.annee}.pdf`;
|
return `${this.annee}${mm}_Trajets.pdf`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user