# Document de Conception Technique — SchotzIAD KM Tracker --- ## Overview **SchotzIAD KM Tracker** est une application web mono-utilisateur, protégée par un **compte unique authentifié** (e-mail + mot de passe, session JWT), permettant de saisir, calculer et consulter l'historique kilométrique de déplacements professionnels ou personnels. L'application calcule automatiquement les distances routières via l'API OpenRouteService (ORS), agrège les données par mois et par année, et permet l'export en PDF des résumés. > **Documents d'exigences associés** : le besoin fonctionnel métier est décrit dans `requirements.md` ; les exigences techniques dérivées (API externes, codes HTTP, délais, sécurité, cache, rendu visuel) sont détaillées dans `design-requirements.md`. La présente conception technique met en œuvre ces exigences. > **Précision des distances** : le paramètre de précision d'affichage des distances (Precision_Distance, 0/1/2 décimales) et son impact sur les calculs de l'application (résumés mensuels/annuels, Panneau_Totaux, barème kilométrique, carte, Export_PDF) sont documentés intégralement dans la spec séparée `precision-distances-km` (`requirements.md` et `design.md`), qui complète et prime, en cas de divergence, sur les points de conception ci-dessous relatifs au calcul des distances et du Montant_Previsionnel. ### Périmètre fonctionnel | # | Fonctionnalité | Description | |---|----------------|-------------| | 1 | Saisie d'un trajet | Formulaire avec date (JJ/MM/AAAA), adresse départ, adresse arrivée, commentaire | | 2 | Calcul automatique de distance | Via ORS (routier), fallback orthodromique | | 3 | Synthèse Kms (mensuel) | Total km par mois agrégé dans l'écran unique Synthèse Kms (ex-« Résumé mensuel », fusionné) ; le détail trajet par trajet reste sur l'écran « Trajets » | | 4 | Synthèse Kms (annuel) | 12 lignes mensuelles + ligne TOTAL annuel dans l'écran unique Synthèse Kms (ex-« Résumé annuel », fusionné) | | 5 | Modification / suppression | Édition avec recalcul conditionnel de distance | | 6 | Persistance et cohérence | Base relationnelle avec invariants d'agrégation et transactions | | 7 | Export PDF | Fichiers `AAAAMM_Trajets_MATRICULE_PLAQUE.pdf` et `AAAA_Trajets_MATRICULE_PLAQUE.pdf` | | 8 | Visualisation carte | Tracés des itinéraires d'une journée sur carte Leaflet + OpenStreetMap | | 9 | Cache adresses | Stockage des coordonnées BAN des adresses saisies, autocomplétion BAN + cache local, politique LRU (1 000 entrées) | | 10 | Profil / adresse domicile | Saisie du domicile, bouton "Utiliser mon domicile" dans le formulaire | | 11 | Commentaire par trajet | Champ optionnel texte libre (0–500 car.) associé à chaque Trajet | | 12 | Insertion entre deux trajets | Formulaire pré-rempli avec adresse d'arrivée du précédent / adresse de départ du suivant | | 13 | Navigation temporelle | Sélecteur année/mois (repliable) avec surbrillance des mois ayant des données, pas de mois futur | | 14 | Mise en page consultation | Liste mensuelle groupée par jour (repliable, scroll) + panneau totaux + carte du jour ciblé + navigation jour bornée au mois | | 15 | Profil enrichi & véhicules | Nom, prénom, matricule (8 car.), plaque, puissance fiscale ; historique des véhicules daté | | 16 | Association véhicule par trajet | Chaque trajet référence un véhicule + puissance fiscale ; totaux filtrés par véhicule | | 17 | Barème kilométrique annuel | Saisie par tranches (puissance fiscale, bornes km, coefficient, part fixe) ; montant prévisionnel minoré 80% | | 18 | Montants réglés & régularisation | Saisie du montant réglé par mois et régularisation intégrées à l'écran unique Synthèse Kms (ex-« Montants réglés », fusionné) ; rapport annuel avec montant réel, versé, dû et régularisation | | 19 | Carnet d'adresses nommées | Menu CRUD d'Adresses_Nommées (Dénomination unique + adresse BAN) ; réutilisation par Dénomination dans la saisie et l'insertion de Trajets | | 20 | Onboarding première utilisation | Présentation de la saisie du profil (identité + véhicule) si le Profil n'est pas configuré, avant tout accès à la saisie / consultation des trajets | | 21 | Authentification | Compte unique (e-mail + mot de passe), connexion par JWT (12 h par défaut), réinitialisation de mot de passe par e-mail, garde de session serveur | | 22 | Trajet aller/retour | Case à cocher A/R doublant la distance comptée (Distance_Effective) sans ligne supplémentaire ; badge A/R en liste et mention « (A/R) » sur la carte | | 23 | Barème multi-tranches & recopie | Tranches paramétrables par puissance fiscale (dernière tranche illimitée), coefficient sur 3 décimales, bouton « Recopier le dernier barème connu » | | 24 | Consultation des specs | Lien « Specs » vers une page dédiée (menu latéral Exigences / Conception / Tâches) affichant les documents Markdown rendus (dont les diagrammes Mermaid), en lecture seule et derrière l'authentification | | 25 | Changement de mot de passe | Page Compte utilisateur : formulaire dédié (mot de passe actuel + nouveau + confirmation) ; endpoint protégé distinct de la réinitialisation par e-mail | | 26 | Navigation utilisateur | Avatar (initiales) à droite de la barre, sous-menu au survol (Prénom Nom, Paramétrage, Maintenance, Se déconnecter) ; Page_Parametrage (Compte / Véhicules / Barème) et Page_Maintenance dédiées ; barre masquée sur les pages publiques ; contrôle afficher/masquer sur tous les champs mot de passe | | 27 | Synthèse Kms (écran unique) | Fusion des ex-écrans « Résumé mensuel » / « Résumé annuel » / « Montants réglés » (supprimés) : critères sur une ligne (Sélecteur_Temporel année seule + sélecteur Véhicule + bouton Afficher), barème appliqué, tableau 12 mois (km, Frais_Reel_Mensuel, indication 80%, Montant_Regle saisissable, Export PDF mensuel), ligne TOTAL (km annuel, Montant_Reel, total réglé, Export PDF annuel), bouton « Enregistrer » global (seuls les montants modifiés), auto-affichage initial, aucun nouvel endpoint | ### Contraintes techniques - **Mono-utilisateur authentifié** : un seul Compte (e-mail + mot de passe), session par jeton JWT signé HS256 (durée configurable, 12 h par défaut) ; tous les endpoints de données sont protégés - **Frontend** : Angular (TypeScript), compilé et servi en SPA - **Backend** : ASP.NET Core Web API (C#) - **Base de données** : SQLite (simplicité mono-utilisateur, zero-configuration, fichier unique portable) ; migrations EF Core appliquées automatiquement au démarrage - **Service externe — adresses** : API BAN (Base Adresses Nationale) — `https://api-adresse.data.gouv.fr` — autocomplétion et géocodage des adresses françaises, sans clé d'authentification - **Service externe — itinéraires** : OpenRouteService (ORS) — calcul de distance routière et géométrie d'itinéraire entre coordonnées géographiques connues - **Génération PDF** : côté backend, bibliothèque QuestPDF - **Hébergement** : l'application est packagée et hébergée via **Docker Compose** — un conteneur `frontend` (Angular servi par nginx en interne) publié sur le port hôte **2202**, et un conteneur `backend` (ASP.NET Core API) non exposé à l'hôte. Le fichier SQLite est persisté via un **bind mount** (`./data:/data`) dans le conteneur backend, ce qui rend `data/trajets.db` directement accessible sur l'hôte (inspection et sauvegarde sans entrer dans le conteneur). La terminaison **HTTPS est assurée par un reverse-proxy Caddy externe** (sur une autre machine) qui relaie vers `IP_hôte:2202` ; le backend ne fait donc pas de redirection HTTPS lui-même ### Charte graphique - **Palette officielle** (variables CSS globales dans `styles.scss`) : Orange `--couleur-orange: #F28C28`, Bleu IAD `--couleur-bleu: #0074B8` (couleur primaire), Gris anthracite `--couleur-anthracite: #343A40`, Gris clair `--couleur-gris-clair: #E7E9EA`, avec déclinaisons de survol `--couleur-bleu-fonce` et `--couleur-orange-fonce`. - **Convention d'interaction** : tous les boutons d'ACTION passent à l'orange plein au survol (texte blanc), qu'ils soient à fond bleu plein (ex. `.btn-principal`, `.btn-primaire`, bouton de soumission bleu) ou à fond blanc/clair avec texte bleu (actions secondaires, ex. « Utiliser mon domicile » `.btn-domicile`, « Export PDF » `.btn-secondaire` des résumés, « Recopier le dernier barème » `.btn-secondaire` du barème, « Modifier » et « Enregistrer/Ajouter » du carnet d'adresses). L'intention : harmoniser le feedback de survol de toutes les actions sur l'orange de la charte. Exceptions qui NE passent PAS à l'orange au survol : - Les boutons « Annuler » : rendu neutre uniformisé sur tout le site (fond blanc, bordure grise `#c4c9d1`, texte `#33475b`, survol gris clair `#f3f5f8`), aligné sur l'écran d'ajout/modification d'un trajet et repris à l'identique sur le barème (`.btn-annuler`), le carnet d'adresses (`.btn--annuler`) et le composant partagé `confirm-dialog`. - Les boutons de suppression (rouge) : restent rouges. - Les boutons à icône seule. - Les indicateurs d'état sélectionné/actif (lien de navigation actif, mois sélectionné, onglet actif) : restent en bleu plein, sans changement de teinte au survol. - **Assets graphiques** (`frontend/src/assets/images/`) : - `logo-icon.png` : icône seule, affichée en tête de la barre de navigation (fond blanc localisé pour ressortir sur la barre anthracite). - `logo-carre.png` : logo avec texte vertical, centré au-dessus du formulaire sur les 4 pages d'authentification (connexion, inscription, mot de passe oublié, réinitialisation). - `logo-horizontal.png` : logo avec texte horizontal, réservé à une utilisation future sur les documents PDF (non encore intégré). - `favicon.png` : favicon référencé en priorité (``) ; `src/favicon.ico` régénéré à partir de ce PNG (multi-résolutions 16/32/48) pour remplacer l'icône par défaut d'Angular CLI, avec un paramètre de cache-busting (`?v=`) à incrémenter en cas de remplacement futur du visuel. --- ## Architecture ### Architecture globale ```mermaid graph TB subgraph "Navigateur (SPA)" FE[Angular App
TypeScript] end subgraph "Backend (ASP.NET Core)" API[API REST
Controllers] SVC[Services métier] ORS_SVC[ORSClient
HttpClient] BAN_SVC[BANClient
HttpClient] PDF_SVC[PdfService
QuestPDF] DAL[Repository
Entity Framework Core] end subgraph "Stockage" DB[(SQLite
trajets.db)] end subgraph "Services externes" ORS[OpenRouteService
API] BAN[BAN API
api-adresse.data.gouv.fr] end FE -- "HTTP/JSON (REST)" --> API API --> SVC SVC --> ORS_SVC SVC --> BAN_SVC SVC --> PDF_SVC SVC --> DAL DAL --> DB ORS_SVC -- "HTTPS" --> ORS BAN_SVC -- "HTTPS" --> BAN[BAN API
api-adresse.data.gouv.fr] ``` ### Flux de création d'un trajet ```mermaid sequenceDiagram participant U as Utilisateur participant FE as Angular Frontend participant API as API Controller participant SVC as TrajetService participant ORS as ORSClient participant DB as SQLite U->>FE: Saisit formulaire + soumet FE->>FE: Validation locale (champs, date) FE->>API: POST /api/trajets API->>SVC: CreateTrajetAsync(dto) SVC->>ORS: GeocodeThenRouteAsync(depart, arrivee) ORS-->>SVC: distance (km) ou erreur alt ORS timeout ou erreur géocodage SVC-->>API: erreur descriptive API-->>FE: 400 / 503 + message FE-->>U: Message d'erreur else Succès SVC->>DB: INSERT Trajet (avec distance) DB-->>SVC: id créé SVC-->>API: TrajetCreatedDto API-->>FE: 201 Created + trajet FE-->>U: Message de succès, formulaire réinitialisé end ``` ### Décision d'architecture : SQLite > **Justification** : L'application est mono-utilisateur et n'a pas vocation à être multi-instances. SQLite élimine la complexité d'installation d'un serveur de base de données, offre des transactions ACID complètes via Entity Framework Core, et est parfaitement supporté par EF Core avec les migrations standard. Le fichier `trajets.db` est autoportant et facilement sauvegardable. > > **Emplacement en conteneur** : dans le déploiement Docker Compose, le fichier de base de données est stocké sous `/data/trajets.db`, monté via un **bind mount** depuis `./data` de l'hôte (voir la section « Déploiement (Docker Compose) » et « Accès à la base de données »). Le chemin est fourni au backend via la chaîne de connexion / variable d'environnement (`ConnectionStrings__Default=Data Source=/data/trajets.db`), ce qui garantit la persistance des données indépendamment du cycle de vie du conteneur. ### Décision d'architecture : BAN pour le géocodage, ORS pour l'itinéraire > **Justification** : L'API BAN (Base Adresses Nationale) est le référentiel officiel des adresses françaises, maintenu par l'État. Elle est gratuite, sans clé d'authentification, et propose une API de recherche (`/search`) retournant directement les coordonnées géographiques avec une autocomplétion précise. En l'utilisant côté frontend dès la saisie, on garantit que les coordonnées sont connues avant même la soumission du formulaire, éliminant ainsi les erreurs de géocodage au moment de la création du trajet. ORS reste utilisé uniquement pour le calcul de l'itinéraire routier (distance + géométrie) entre deux coordonnées déjà résolues, ce qui réduit sa surface de défaillance à la seule étape de routage. --- ## Components and Interfaces ### Frontend Angular #### Structure des modules ``` src/ ├── app/ │ ├── core/ │ │ ├── services/ │ │ │ ├── trajet.service.ts # HTTP + state management │ │ │ ├── notification.service.ts # Toast / snackbar │ │ │ ├── adresse-cache.service.ts # Autocomplétion adresses │ │ │ ├── adresse-nommee.service.ts # CRUD carnet d'adresses nommées │ │ │ ├── profil.service.ts # CRUD profil │ │ │ ├── auth.service.ts # Inscription, connexion, session, reset mot de passe │ │ │ └── spec.service.ts # Lecture des documents de spécification (specs) │ │ ├── models/ │ │ │ ├── trajet.model.ts │ │ │ ├── resume-mensuel.model.ts │ │ │ └── resume-annuel.model.ts │ │ └── interceptors/ │ │ ├── timeout.interceptor.ts # Timeout 10s + erreurs réseau │ │ └── auth.interceptor.ts # Ajoute le jeton JWT (Authorization: Bearer) │ ├── features/ │ │ ├── trajet-form/ │ │ │ ├── trajet-form.component.ts # Saisie + édition │ │ │ └── trajet-form.component.html │ │ ├── synthese-kms/ │ │ │ ├── synthese-kms.component.ts # Écran unique : synthèse annuelle par mois + saisie Montant_Regle + exports PDF │ │ │ └── synthese-kms.component.html │ │ ├── trajet-list/ │ │ │ ├── trajet-list.component.ts │ │ │ └── trajet-list.component.html │ │ ├── carte-trajets/ │ │ │ ├── carte-trajets.component.ts # Visualisation Leaflet │ │ │ └── carte-trajets.component.html │ │ ├── profil/ │ │ │ ├── profil.component.ts # Adresse domicile │ │ │ └── profil.component.html │ │ ├── vehicules/ │ │ │ ├── vehicules.component.ts # Gestion + historique véhicules │ │ │ ├── vehicules.component.html │ │ │ └── reaffectation-dialog.component.ts # Dialog de réaffectation │ │ ├── navigation-temporelle/ │ │ │ ├── selecteur-temporel.component.ts # Année / mois (repliable, sans jour) │ │ │ └── selecteur-temporel.component.html │ │ ├── consultation-jour/ │ │ │ ├── consultation-jour.component.ts # Layout principal │ │ │ └── consultation-jour.component.html │ │ ├── panneau-totaux/ │ │ │ ├── panneau-totaux.component.ts # Totaux jour/mois/année partiel │ │ │ └── panneau-totaux.component.html │ │ ├── bareme/ │ │ │ ├── bareme.component.ts # Saisie du barème par année/tranches │ │ │ └── bareme.component.html │ │ ├── carnet-adresses/ │ │ │ ├── carnet-adresses.component.ts # CRUD des Adresses_Nommées (liste + formulaire) │ │ │ └── carnet-adresses.component.html │ │ ├── onboarding/ │ │ │ ├── onboarding.component.ts # Saisie initiale du profil (identité + 1er véhicule) │ │ │ └── onboarding.component.html │ │ ├── auth/ │ │ │ ├── login.component.ts # Connexion (email + mot de passe) │ │ │ ├── register.component.ts # Création de l'unique compte (étape 1 de l'initialisation) │ │ │ ├── forgot-password.component.ts # Demande de réinitialisation par email │ │ │ └── reset-password.component.ts # Définition d'un nouveau mot de passe via jeton │ │ ├── specs/ │ │ │ ├── specs.component.ts # Rendu Markdown des documents de spécification (lazy-loadé) │ │ │ └── specs.component.html │ ├── core/ │ │ └── guards/ │ │ ├── auth.guard.ts # Valide la session serveur (GET /api/auth/moi) avant les vues protégées │ │ └── profil-configure.guard.ts # Redirige vers l'onboarding si le profil n'est pas configuré │ ├── shared/ │ │ ├── components/ │ │ │ └── confirm-dialog/ │ │ └── validators/ │ │ └── date-jj-mm-aaaa.validator.ts │ └── app-routing.module.ts ``` #### Composants principaux **`TrajetFormComponent`** - Reactive Form avec les champs : `date` (string JJ/MM/AAAA), `adresseDepart` (1–255 car.), `adresseArrivee` (1–255 car.), `commentaire` (optionnel, 0–500 car.), `allerRetour` (case à cocher — distance comptée doublée) - Validations synchrones : présence, longueur, format date via `DateJjMmAAAAValidator`, longueur commentaire - Mode création / édition : pré-remplissage en édition, recalcul distance uniquement si adresse modifiée, modification commentaire sans recalcul - **Mode insertion** : accepte en entrée `{ adresseDepart?: string, adresseArrivee?: string }` pour pré-remplir les champs depuis le Trajet_Précédent / Trajet_Suivant ; les champs restent modifiables - Boutons "Utiliser mon domicile" à côté de chaque champ adresse ; pour l'arrivée, si le Commentaire est vide, il est pré-rempli avec « Retour domicile » (jamais de remplacement d'un commentaire déjà saisi) - Date par défaut : en mode insertion, la date de la journée du trajet source (`prefill.date`) prime sur la date de la Carte (`dateParDefaut`) - Timeout de 10 secondes via `TimeoutInterceptor` - **Autocomplétion BAN** : appel à `https://api-adresse.data.gouv.fr/search/?q={saisie}&limit=5` dès 3 caractères saisis ; chaque suggestion affiche le libellé + code postal + commune ; à la sélection d'une suggestion, les coordonnées `(lat, lon)` sont stockées dans l'état local du composant et transmises avec le formulaire - 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 **`SyntheseKmsComponent`** (écran unique — Exigences 3, 4, 13, 14) - **Fusionne et remplace** les anciens composants `ResumeMensuelComponent`, `ResumeAnnuelComponent` et `MontantsReglesComponent` (tous supprimés), ainsi que leurs routes `/resume-mensuel`, `/resume-annuel`, `/montants-regles` et leurs liens de menu. Route unique : `/synthese-kms`, lien de menu « Synthèse Kms » - **En-tête sur une même ligne** : le titre « Synthèse Kms » et la zone de critères — `SelecteurTemporelComponent` en **mode « année seule »**, sélecteur de Véhicule, bouton « Afficher » - **Auto-affichage initial** : à l'initialisation, la synthèse est chargée automatiquement pour la pré-sélection (Véhicule_Courant + année courante), sans attendre d'action sur « Afficher » - **Barème appliqué** affiché au-dessus du tableau : tranche, Puissance_Fiscale, coefficient, base - **Tableau une ligne par mois** : libellé du mois, kilomètres du mois, Frais_Reel_Mensuel (remboursement réel propre au mois = km du mois × coefficient de la tranche annuelle), indication 80% (Montant_Previsionnel_Mensuel), Montant_Regle **saisissable**, et colonne « Action » avec un bouton « Export PDF » (rapport mensuel du mois) - **Barre d'action au-dessus du tableau** : un unique bouton « Enregistrer » qui persiste uniquement les Montant_Regle **modifiés** depuis le dernier chargement (plusieurs mois en une seule action) ; pas de bouton d'enregistrement par ligne - **Ligne TOTAL** : total kilométrique annuel, Montant_Reel (total du remboursement réel), **cellule « indication 80% » vide**, total réglé (somme des Montant_Regle, non modifiable), bouton « Export PDF » qui exporte le rapport **annuel** ; aucun bouton « Enregistrer » sur cette ligne - **Aucun nouvel endpoint backend** : composition d'endpoints existants — `GET /api/regularisation/{annee}` (barème, km mensuels, Frais_Reel_Mensuel, total annuel, Montant_Reel, total réglé), `GET /api/montant-previsionnel/{annee}/{mois}` (indication 80%), `PUT /api/montants-regles` (upsert à l'enregistrement), `GET /api/exports/mensuel/{annee}/{mois}` et `GET /api/exports/annuel/{annee}` (exports PDF) - **Pleine largeur** : l'écran occupe toute la largeur disponible (pas de largeur maximale centrée) - Tous les kilomètres sont formatés selon `formatKm()` (signal `precisionDistance` de `ParametresApplicationService`), reflétant Precision_Distance — voir spec `precision-distances-km` ; les montants en euros à 2 décimales - Le **détail des Trajets un par un n'est plus affiché** dans cet écran de synthèse : il reste consultable sur `ConsultationJourComponent` (écran « Trajets ») **`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. - **Bouton dédié « + » à droite de l'en-tête** (`Output ajouterSurJournee: string`) : ouvre le formulaire de création avec la date de CETTE journée, sans dépendre de la journée affichée sur la Carte (pilotée par le survol) — évite qu'un déplacement de souris à travers plusieurs journées ouvertes ne fasse dériver la date visée en se dirigeant vers le bouton principal d'ajout. Le conteneur fixe `date` sur cette valeur avant d'ouvrir la création (`ouvrirCreationSur`). - Ligne par trajet (triée par `Position` croissante au sein du jour) : les **boutons d'action** sont placés **en début de ligne** (après la poignée de glisser-déposer, avant la colonne A/R) et **alignés à droite au sein de leur colonne** pour conserver l'alignement vertical du bouton « insérer après » ; puis colonne dédiée **A/R** pour les allers/retours, commentaire (si présent), adresses (taille réduite), distance effective - Actions par trajet (regroupées en début de ligne) : modifier (→ `TrajetFormComponent` pré-rempli en < 500 ms), supprimer (→ dialog de confirmation), **insérer avant/après** — l'insérer après ouvre `TrajetFormComponent` en mode insertion avec `adresseDepart` = point de fin du trajet courant (son arrivée, ou son **départ** si le trajet est un aller/retour), la **date de la journée** du trajet, `positionApres` = Position du trajet courant - Si le trajet n'est pas le dernier de la journée : action **insérer avant le suivant** disponible, pré-remplissant l'adresse de départ avec le point de fin du trajet courant (départ si aller/retour) et l'adresse d'arrivée avec le départ du trajet suivant, en calculant la position d'insertion `(P_courant + P_suivant) / 2` - Méthode publique `ouvrirJour(dateIso)` : déplie une journée donnée (appelée par le conteneur après ajout/insertion pour rendre le trajet visible) ; `ouvrirToutesLesJournees()` déplie tous les groupes ; `aDesJourneesFermees()` conditionne l'affichage du bouton « Ouvrir toutes les journées » - `@Input jourAffiche` : la journée affichée sur la Carte ; le groupe correspondant est mis en surbrillance (`estJourAffiche`) - **Glisser-déposer** (drag & drop) limité à chaque journée : un `cdkDropList` par jour, listes **non connectées** entre elles (pas de déplacement inter-journées) → recalcul des positions du jour puis `PATCH /api/trajets/reordonner` - **Locale** : `registerLocaleData(localeFr)` et `LOCALE_ID: 'fr-FR'` enregistrés dans `app.config.ts`, nécessaires pour que le pipe `date` (format `EEEE`, jour de la semaine) affiche les noms en français. - Rafraîchissement du résumé mensuel en < 1 000 ms après modification/suppression/insertion **`CarteTrajetsComponent`** - Intègre Leaflet.js via `leaflet` package Angular - Sélecteur de date (JJ/MM/AAAA) - Affiche les tracés GeoJSON retournés par le backend, un tracé par trajet avec couleur distincte - Fallback ligne droite si géométrie indisponible (trait pointillé) - Infobulles au clic (popup) et au survol (tooltip) : commentaire en tête (si présent), adresse départ, adresse arrivée, distance suivie de « (A/R) » pour un aller/retour ; largeur bornée avec retour à la ligne pour ne pas déborder de la carte - Fond de carte : OpenStreetMap (tile layer `https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png`) **`ProfilComponent`** (page « Compte utilisateur » — Exigences 10, 20) - Titre affiché : « Compte utilisateur » (aligné avec les intitulés « Véhicules » et « Barème » du menu de la Page_Parametrage) - Formulaire d'identité : nom (1–100 car.), prénom (1–100 car.), matricule (max 8 car., limite de saisie stricte), adresse de domicile (1–255 car.) - Bouton "Enregistrer" ; message d'avertissement si le géocodage de l'adresse a échoué - **Formulaire de changement de mot de passe** (distinct, Exigence 20) : mot de passe actuel + nouveau + confirmation (validateur de groupe d'égalité, même pattern que `RegisterComponent`) ; `POST /api/auth/change-password` via `AuthService.changePassword` ; une erreur 400 affiche « Le mot de passe actuel est incorrect » sur le formulaire - La section « Maintenance » (recalcul des distances) a été extraite vers `MaintenanceComponent` (route `/maintenance`, accessible depuis l'Avatar_Utilisateur) - Route : `/parametrage/compte` (au sein de la Page_Parametrage) **`MaintenanceComponent`** (route `/maintenance` — Exigence 21) - Reprend l'action de recalcul des distances (`POST /api/trajets/recalculer-distances`) extraite de l'ancienne page Profil - Accessible depuis le sous-menu de l'Avatar_Utilisateur, `authGuard` seul (pas de dépendance au profil configuré) **`ParametrageComponent`** (route `/parametrage` — Exigence 21) - Layout menu latéral + contenu, sur le même modèle que `SpecsComponent` : colonne gauche (« Compte », « Véhicules », « Barème »), colonne droite = `` avec les routes enfants `parametrage/compte`, `parametrage/vehicules`, `parametrage/bareme` - Réutilise `ProfilComponent`, `VehiculesComponent`, `BaremeComponent` sans duplication ; seul le point d'entrée change - Gardes : `compte` et `vehicules` sur `authGuard` seul ; `bareme` ajoute `profilConfigureGuard` (Exigence 21.6) **`VehiculesComponent`** (route `/parametrage/vehicules`) - Liste de l'historique des véhicules (plaque, puissance fiscale, date d'effet), triée par date d'effet décroissante - Formulaire d'ajout d'un véhicule : plaque, puissance fiscale, date d'effet (<= date du jour, sélecteur bloquant les dates futures) - À l'enregistrement, si la réponse `VehiculeCreeDto.ReaffectationProposee` est true, ouvre `ReaffectationDialogComponent` **`ReaffectationDialogComponent`** - Affiche le nombre de trajets concernés (date >= date d'effet, puissance fiscale différente) - Deux actions : "Réaffecter au nouveau véhicule" (POST reaffectation accepter=true) / "Conserver l'existant" (accepter=false) **`SelecteurTemporelComponent`** - **Repliable** ; l'en-tête affiche la période sélectionnée (ex. « Septembre 2026 » en mode année + mois, « 2026 » en mode année seule) - **Deux modes** pilotés par un `@Input` : - **Mode « année + mois »** (par défaut, utilisé par `ConsultationJourComponent`) : choix successif année puis mois ; émet un événement `moisSelectionne: { annee: number, mois: number }` à chaque changement - **Mode « année seule »** (utilisé par `SyntheseKmsComponent`) : la grille des 12 mois **n'est pas affichée** ; seul le choix de l'année est proposé et le composant émet l'année choisie (`@Output` dédié) - Sélecteur d'année (dans les deux modes) : liste limitée à l'année courante + années ayant des données (endpoint `/api/annees-disponibles`) - Sélecteur de mois (mode année + mois uniquement) : 12 mois affichés, surbrillance des mois avec données (endpoint `/api/mois-disponibles/{annee}`), avec le même code couleur bleu que la journée mise en avant dans la liste ; le mois sélectionné reste en bleu plein ; désactivation de tout mois > mois courant - **Repli automatique** : lors d'un clic sur le niveau le plus bas (le mois en mode année + mois, l'année en mode année seule), le composant se referme automatiquement (collapse). Comportement appliqué partout où le sélecteur est utilisé **`ConsultationJourComponent`** (composant conteneur / layout principal) - **Pleine largeur** : l'écran « Trajets » occupe toute la largeur disponible (plus de largeur maximale centrée) - Deux états : un signal `mois` (`{ annee, mois }`) pilote la **liste mensuelle** ; un signal `date` (ISO) pilote la **carte** et la date par défaut d'ajout/insertion - Colonne gauche (fixe) : `SelecteurTemporelComponent`, puis `PanneauTotauxComponent`, puis `CarteTrajetsComponent` (plus de bande de navigation par jour) - Colonne droite : bande fixe (sélecteur de Véhicule + bouton « Ajouter un trajet »), puis `TrajetListComponent` du mois avec scroll vertical - La journée affichée sur la Carte est pilotée par le **survol** des Trajets de la liste : survoler un Trajet bascule la Carte sur sa journée (via un `Subject` debouncé ~80 ms pour éviter les rechargements en rafale) et met le tracé en évidence ; en fin de survol, la Carte **reste** sur la dernière journée survolée (seule la surbrillance du tracé est retirée) - Au chargement d'un mois, la Carte se positionne par défaut sur la dernière journée du mois comportant des Trajets - Bouton « Ouvrir toutes les journées » (dans la barre liste, avant « Ajouter un trajet » pour ne pas décaler ce dernier ; affiché seulement s'il reste des journées repliées) → `TrajetListComponent.ouvrirToutesLesJournees()` - Sélecteur de Véhicule (par défaut : véhicule courant) ; filtre la liste, le Panneau_Totaux et la Carte sur le véhicule sélectionné - Transmet la date affichée à la liste (`[jourAffiche]`) : le groupe de cette journée est mis en surbrillance (en-tête bleu accentué) pour la repérer - Coordonne le rafraîchissement (< 1 000 ms) ; un mois vide affiche des totaux à 0 et un unique message « Aucun trajet pour ce mois », sans alerte d'erreur - Après création/modification d'un trajet (`onFormEnregistre(trajet)`), si la date du trajet est dans un autre mois, se repositionne sur ce mois, cale la Carte sur la journée du trajet, puis déplie ce jour dans la liste (`TrajetListComponent.ouvrirJour`, différé au tick suivant pour passer après le recalcul de repliage du setter) **`PanneauTotauxComponent`** - Affiche trois totaux : total du jour ciblé par la navigation, total du mois sélectionné, Total_Annuel_Partiel (cumul de l'année jusqu'au mois sélectionné inclus) - Reçoit en entrée la date sélectionnée et recalcule les totaux à chaque changement - Tous les totaux affichés à 2 décimales, calculés par sommation de distances déjà arrondies selon Precision_Distance (voir spec `precision-distances-km`, Requirement 5) - Affiche le libellé **« Remboursement indicatif pour ce mois »** (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 - **Mise en forme (emphase)** : les informations relatives au mois affiché sont mises en avant en gras (classe `--fort`) — la ligne « Total du mois » et la ligne « Remboursement indicatif pour ce mois » ; à l'inverse, la ligne « Total annuel partiel » et la ligne « Montant prévisionnel annuel » ne sont PAS en gras - **Infobulles d'aide** : le texte explicatif de chacune de ces deux lignes (« Remboursement indicatif pour ce mois » et « Montant prévisionnel annuel »), auparavant affiché en permanence sous le montant, est retiré du flux visuel et remplacé par un bouton icône (SVG inline `help-circle`, 16×16, `aria-label` dédié) affiché juste après le libellé ; le texte explicatif est porté par l'attribut HTML `title` de ce bouton et s'affiche via l'infobulle native du navigateur au survol ou au focus clavier de l'icône **`ParametresApplicationComponent`** - Nouveau composant du Sous_Menu_Application de la Page_Parametrage, positionné entre « Compte » et « Véhicules » - Permet de sélectionner la valeur de Precision_Distance (0, 1 ou 2 décimales) ; la sélection courante est exposée à toute l'application via un signal partagé `precisionDistance` porté par `ParametresApplicationService` - Détail complet (endpoints, entité, formule d'arrondi, service, migration) dans la spec `precision-distances-km`, `design.md` **`BaremeComponent`** - Sélecteur d'année - Tableau éditable des tranches : puissance fiscale, km min, km max (**vide = illimité**), coefficient €/km (**3 décimales**, saisi à côté de la base), base € - Plusieurs tranches possibles pour une même puissance fiscale ; ajout / modification / suppression - Bouton **« Recopier le dernier barème connu »** (recopie l'année antérieure la plus récente, confirmation si l'année cible contient déjà des tranches, inaccessible sur l'année du dernier barème) - Validation du non-chevauchement des tranches pour une même puissance fiscale et année > **Saisie des Montant_Regle** : l'ancien composant `MontantsReglesComponent` (écran dédié « Montants réglés ») est supprimé ; la saisie des Montant_Regle par mois est désormais assurée directement dans le tableau mensuel du `SyntheseKmsComponent` ci-dessus, avec l'affichage en regard du Montant_Previsionnel_Mensuel (indication 80%, calculé sur le seul mois affiché — voir spec `precision-distances-km`, Requirement 7). **`CarnetAdressesComponent`** - Liste des Adresses_Nommées (Dénomination + adresse + indication si coordonnées disponibles), triée par Dénomination, avec un bouton « Ajouter une adresse » au-dessus de la liste - **Pleine largeur** : l'écran « Carnet d'adresses » occupe toute la largeur disponible (plus de largeur maximale centrée) - **Disposition en grille de cartes** : les adresses sont affichées les unes à côté des autres puis retournent à la ligne selon la largeur disponible (`display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr))`). Chaque carte a une taille homogène (colonnes de largeur égale, cartes étirées à la même hauteur par rangée via `align-items: stretch`), un contour (`border: 1px solid #e0e0e0; border-radius: 10px`) et un effet d'ombre (`box-shadow: 0 2px 8px rgba(0,0,0,0.1)`) la détachant du fond. La zone de contenu de la page occupe toute la largeur disponible pour laisser la place à plusieurs colonnes ; la dénomination et l'adresse sont en haut de la carte, les actions Modifier/Supprimer en bas à droite - Formulaire de création / modification présenté dans une **fenêtre modale** (overlay `.modale-overlay` + panneau `.modale-panneau`, motif partagé avec `ConfirmDialogComponent` / `ConsultationJourComponent`), ouvert via le bouton « Ajouter une adresse » (mode création) ou l'action « Modifier » d'une ligne (mode édition) ; fermée par enregistrement réussi, par le bouton « Annuler », ou par un clic sur l'arrière-plan. Le signal `modaleOuverte` pilote l'affichage - Champ `denomination` (1–100 car.) et champ `adresse` avec autocomplétion BAN (1–255 car.) ; dans la modale, les suggestions BAN sont rendues **dans le flux** du panneau (et non en position absolue) pour éviter toute troncature par le défilement du panneau - **Autocomplétion BAN** : appel à `https://api-adresse.data.gouv.fr/search/?q={saisie}&limit=5` dès 3 caractères saisis, avec debounce de 300 ms, maximum 5 suggestions ; à la sélection, le libellé normalisé remplit le champ et les coordonnées `(lat, lon)` sont conservées pour transmission - Actions par ligne : modifier (→ formulaire pré-rempli), supprimer (→ dialog de confirmation) - À chaque succès de création, modification ou suppression, affiche un message de confirmation via `NotificationService` - Avertissement discret si le géocodage BAN d'une adresse a échoué (coordonnées absentes) **`TrajetFormComponent` — sélecteur de Carnet d'adresses** - Pour le champ adresse de départ **et** le champ adresse d'arrivée, en mode création **comme** en mode insertion, le composant propose un choix entre : sélectionner une Adresse_Nommee pré-enregistrée du Carnet_Adresses (par sa Dénomination) via un menu déroulant alimenté par `AdresseNommeeService.getAdressesNommees()`, ou saisir une nouvelle adresse manuellement avec l'autocomplétion BAN - À la sélection d'une Adresse_Nommee, le champ concerné est rempli avec le texte d'adresse de l'Adresse_Nommee et ses coordonnées `(lat, lon)` sont associées pour transmission au backend (y compris lorsque le champ était pré-rempli depuis le Trajet_Précédent / Trajet_Suivant en mode insertion, auquel cas le contenu pré-rempli est remplacé) **`OnboardingComponent`** (première utilisation) - Présenté à l'utilisateur lorsque le Profil n'est pas encore configuré (voir `ProfilConfigureGuard`) - Formulaire de mise en route en une étape regroupant : - **Identité** : nom (1–100 car.), prénom (1–100 car.), matricule (max 8 car.) - **Adresse de domicile** : optionnelle (1–255 car.), avec autocomplétion BAN - **Premier véhicule** : plaque, puissance fiscale, date d'effet (<= date du jour) — au moins un véhicule requis pour finaliser - Bouton "Commencer" désactivé tant que les champs requis (nom, prénom, un véhicule valide) ne sont pas renseignés - À l'enregistrement réussi (`PUT /api/profil` + `POST /api/vehicules`), navigue vers la vue principale de consultation (`ConsultationJourComponent`) - Réutilise `ProfilService.saveProfil()` et `VehiculeService.createVehicule()` **Composants d'authentification** (`LoginComponent`, `RegisterComponent`, `ForgotPasswordComponent`, `ResetPasswordComponent`) - `RegisterComponent` : création de l'unique compte (email + mot de passe ≥ 8 car.), étape 1 de l'initialisation ; à la première ouverture sans compte, l'application y redirige directement. Après création, redirige vers la connexion. - `LoginComponent` : connexion (email + mot de passe) → `POST /api/auth/login` ; à la réussite, stocke le JWT et navigue vers la vue protégée. Propose un lien « Mot de passe oublié ». Propose également une case à cocher « Se souvenir de moi » placée sous le champ email : à une connexion réussie, si elle est cochée, l'email (identifiant uniquement, jamais le mot de passe) est conservé dans le `localStorage` du navigateur sous la clé `schotziad.login.emailMemorise` et pré-rempli (case cochée) aux visites suivantes sur le même navigateur ; si elle est décochée, la clé est supprimée. Le stockage `localStorage` étant propre à l'origine et au navigateur, aucun pré-remplissage n'a lieu sur une autre machine ou un autre navigateur, et les erreurs d'accès au stockage sont ignorées silencieusement. Détail dans `requirements.md` Requirement 17 (critères 14-18) et `design-requirements.md` Requirement 17 (critères 9-13). - `ForgotPasswordComponent` : saisie de l'email → `POST /api/auth/forgot-password` (lien envoyé par email, ou journalisé en repli). - `ResetPasswordComponent` : nouveau mot de passe via le jeton reçu → `POST /api/auth/reset-password`. **`AuthService` / `AuthInterceptor` / `authGuard`** - `AuthService` gère l'inscription, la connexion, la déconnexion et le stockage du JWT dans `localStorage`. - `AuthInterceptor` ajoute l'en-tête `Authorization: Bearer ` à chaque appel HTTP protégé. - `authGuard` valide la session **côté serveur** via `GET /api/auth/moi` avant d'afficher les vues protégées : un jeton résiduel dont le compte n'existe plus est ainsi rejeté. > **Décision — stockage du JWT dans `localStorage`** : retenu pour sa simplicité, malgré le risque XSS, acceptable pour une application personnelle mono-utilisateur. Le cookie HttpOnly a été rejeté pour éviter la complexité de protection CSRF. **`SpecsComponent`** (documents de spécification — Exigence 19) - La barre de navigation principale expose un menu déroulant « Specs » (survol ou clic, même mécanisme que l'Avatar_Utilisateur) listant directement les 3 documents ; le choix détaillé (disponibilité, etc.) reste géré par le **menu latéral** de la page elle-même. - Route paramétrée `/specs/:cle` ; **lazy-loadée** (`loadComponent`) pour sortir la librairie Markdown du bundle initial - Colonne gauche : menu listant les documents (`GET /api/specs`), le document indisponible étant signalé ; colonne droite : contenu rendu - Rendu : le Markdown (`GET /api/specs/{cle}`) est converti en HTML par **`marked`** (GFM : titres, listes, tableaux, code), puis **assaini par `DOMPurify`** avant injection (`[innerHTML]`) — Exigence 19.7 - **Diagrammes Mermaid** : un renderer `marked` transforme les blocs ` ```mermaid ` en `
` ; après injection du HTML (déclenchée via `setTimeout` pour laisser Angular résoudre le `@ViewChild`), **Mermaid.js** (importé dynamiquement) rend ces blocs en SVG (`securityLevel: 'loose'` pour accepter les libellés HTML type `
`). En cas d'échec de rendu d'un diagramme, le bloc de code brut reste visible. - Accès derrière `authGuard` (mêmes règles que les autres vues protégées) - **Largeur de lecture limitée** : à la différence des écrans « Trajets », « Carnet d'adresses » et « Synthèse Kms » (passés en pleine largeur), le `SpecsComponent` conserve une largeur maximale centrée adaptée à la lecture (comportement inchangé) **`ProfilConfigureGuard`** (garde de route Angular — `CanActivate` / `CanActivateFn`) - Au chargement de l'application, appelle `ProfilService.getEtat()` (`GET /api/profil/etat`) - Si `EstConfigure` est `false`, redirige vers la route d'onboarding (`/onboarding`) et bloque l'accès à la saisie / consultation des trajets tant que le profil n'est pas configuré - Si `EstConfigure` est `true`, laisse passer la navigation vers la vue principale - **Gestion d'erreur** : en cas d'échec de récupération de l'indicateur (backend injoignable / erreur réseau), le guard affiche un message d'erreur de connexion avec une action "Réessayer" plutôt que de rediriger arbitrairement, laissant l'utilisateur relancer la vérification - Déclaré dans `app-routing.module.ts` sur les routes protégées (consultation, saisie, résumés, etc.) ; la route `/onboarding` reste accessible sans garde **`AppComponent`** (coquille applicative — barre de navigation, Exigence 21) - Barre de navigation : logo (format horizontal, icône + texte), liens métier (Trajets, Carnet d'adresses, Synthèse Kms), menu déroulant « Specs », puis l'Avatar_Utilisateur poussé à l'extrémité droite (`margin-left: auto`). Les anciens liens « Montants réglés », « Résumé mensuel » et « Résumé annuel » sont retirés : ils sont remplacés par l'unique lien « Synthèse Kms » - **Menus déroulants (Specs, Avatar)** : s'ouvrent au survol (`mouseenter`) ET restent utilisables au clic (tactile, clavier) ; se ferment au `mouseleave` du bloc conteneur ou au clic extérieur (`@HostListener('document:click')`) - **Piège évité** : le panneau déroulant ne doit PAS être séparé du bouton par un espace (`top: calc(100% + Xpx)`), sous peine de déclencher un `mouseleave` prématuré lorsque le curseur traverse cet espace mort en descendant vers le panneau. Le panneau touche directement le bouton (`top: 100%`) et l'espacement visuel est reporté en `padding-top` À L'INTÉRIEUR du panneau (zone `__panneau-contenu` séparée pour le fond/l'ombre), de sorte que la zone de survol reste continue. - **Affichage conditionnel** : la barre de navigation (avatar inclus) est masquée non seulement en l'absence de jeton, mais aussi sur les pages PUBLIQUES (`/login`, `/register`, `/forgot-password`, `/reset-password`), détectées via les événements du `Router` (`NavigationEnd`). Ce deuxième critère est nécessaire car un jeton résiduel expiré ou invalide peut rester en `localStorage` sans qu'aucun appel HTTP protégé ne le purge sur ces pages (aucune requête vers un endpoint protégé n'y est faite) ; se fier uniquement à la présence du jeton ferait réapparaître la barre à tort. - **Avatar** : initiales calculées à partir du Profil (`ProfilService.getProfil()`, chargé via un `effect()` déclenché dès que l'utilisateur authentifié quitte une page publique) ; sous-menu = identité (Prénom Nom), « Paramétrage » (`/parametrage`), « Maintenance » (`/maintenance`), « Se déconnecter » (`AuthService.logout()`) **`PasswordInputComponent`** (`shared/components/password-input/` — Exigence 21.8) - Champ de mot de passe réutilisable avec bouton « œil » (SVG inline, pas d'icône externe) pour basculer l'affichage entre masqué et visible - Implémente `ControlValueAccessor` : s'utilise comme un `` classique avec `formControlName` (remplace directement `` dans les 4 pages d'authentification et la page Compte utilisateur, 8 occurrences au total) - **Style aligné sur les champs texte classiques** (`padding: 0.5rem`, `border: 1px solid #bbb`, `border-radius: 6px`, `font-size: 0.95rem`, variante rouge si `aria-invalid`) : porté par le composant lui-même (pas par la page hôte, qui ne peut pas styler l'intérieur d'un composant enfant à travers l'encapsulation de vue Angular) afin que le rendu soit identique à celui d'un champ texte normal quelle que soit la page #### Services Angular **`TrajetService`** ```typescript interface TrajetService { createTrajet(dto: CreateTrajetDto): Observable; updateTrajet(id: number, dto: UpdateTrajetDto): Observable; deleteTrajet(id: number): Observable; getResumeMensuel(annee: number, mois: number): Observable; getResumeAnnuel(annee: number): Observable; exportPdfMensuel(annee: number, mois: number): Observable; exportPdfAnnuel(annee: number): Observable; getTrajetsJournee(date: string): Observable; getAnneesDisponibles(): Observable; getMoisDisponibles(annee: number): Observable; getJoursDisponibles(annee: number, mois: number): Observable; getTotauxJour(date: string, vehiculeId: number): Observable; } interface VehiculeService { getVehicules(): Observable; getVehiculeCourant(): Observable; createVehicule(dto: CreateVehiculeDto): Observable; reaffecter(dto: ReaffectationDto): Observable; } interface BANService { searchAdresses(query: string): Observable; } interface AdresseCacheService { searchLocal(query: string): Observable; // recherche dans le cache local saveAdresse(adresse: AdresseCacheDto): Observable; // mise en cache après sélection BAN } interface AdresseNommeeService { getAdressesNommees(): Observable; // liste du carnet create(dto: SaveAdresseNommeeDto): Observable; // création update(id: number, dto: SaveAdresseNommeeDto): Observable; // modification delete(id: number): Observable; // suppression } interface ProfilService { getProfil(): Observable; saveProfil(dto: SaveProfilDto): Observable; getEtat(): Observable; // état de configuration (onboarding) } interface BaremeService { getBareme(annee: number): Observable; saveTranche(dto: SaveTrancheBaremeDto): Observable; updateTranche(id: number, dto: SaveTrancheBaremeDto): Observable; deleteTranche(id: number): Observable; getMontantPrevisionnel(annee: number, mois: number, vehiculeId: number): Observable; } interface RemboursementService { getMontantsRegles(annee: number, vehiculeId: number): Observable; saveMontantRegle(dto: MontantRegleDto): Observable; getRegularisation(annee: number, vehiculeId: number): Observable; } ``` #### Modèle BAN ```typescript // Réponse BAN (Feature GeoJSON) interface BANFeatureDto { properties: { label: string; // libellé complet normalisé score: number; // score de pertinence 0–1 postcode: string; city: string; context: string; type: string; // 'housenumber' | 'street' | 'locality' | 'municipality' x: number; // longitude y: number; // latitude }; geometry: { coordinates: [number, number]; // [longitude, latitude] }; } ``` > **Note** : BAN est la source primaire d'autocomplétion. Le cache local (`AdresseCacheService`) est un fallback/optimisation : il permet de retrouver instantanément des adresses déjà utilisées sans solliciter le réseau. #### Validateur de date personnalisé ```typescript // shared/validators/date-jj-mm-aaaa.validator.ts export function dateJjMmAAAAValidator(): ValidatorFn { // Vérifie le format et l'existence de la date (mois 1-12, jours cohérents avec le mois/année) } ``` --- ### Backend ASP.NET Core #### Structure du projet ``` SchotzIadKmTracker.Api/ ├── Controllers/ │ ├── TrajetsController.cs │ ├── ResumesMensuelsController.cs │ ├── ResumesAnnuelsController.cs │ ├── ExportsPdfController.cs │ └── AdressesNommeesController.cs # CRUD carnet d'adresses ├── Services/ │ ├── ITrajetService.cs / TrajetService.cs │ ├── IDistanceCalculator.cs / DistanceCalculatorService.cs │ ├── IORSClient.cs / ORSClient.cs # calcul itinéraire uniquement │ ├── IBANClient.cs / BANClient.cs # géocodage adresses françaises │ ├── IAdresseNommeeService.cs / AdresseNommeeService.cs # CRUD + normalisation + cache/BAN │ └── IPdfService.cs / PdfService.cs ├── Repositories/ │ ├── ITrajetRepository.cs / TrajetRepository.cs │ └── IResumeRepository.cs / ResumeRepository.cs ├── Models/ │ ├── Entities/ │ │ ├── Trajet.cs │ │ └── AdresseNommee.cs │ └── DTOs/ │ ├── CreateTrajetDto.cs │ ├── UpdateTrajetDto.cs │ ├── TrajetDto.cs │ ├── ResumeMensuelDto.cs │ ├── ResumeAnnuelDto.cs │ ├── AdresseNommeeDto.cs │ └── SaveAdresseNommeeDto.cs ├── Validators/ │ └── SaveAdresseNommeeDtoValidator.cs # FluentValidation (dénomination, adresse) ├── Data/ │ ├── AppDbContext.cs │ └── Migrations/ ├── Configuration/ │ ├── ORSOptions.cs │ └── BANOptions.cs └── Program.cs ``` --- ## Data Models ### Entité `Trajet` (base de données) ```csharp public class Trajet { public int Id { get; set; } [Required] public DateOnly Date { get; set; } [Required, MaxLength(255)] public string AdresseDepart { get; set; } = string.Empty; [Required, MaxLength(255)] public string AdresseArrivee { get; set; } = string.Empty; /// /// Distance SIMPLE en kilomètres (aller), arrondie à 2 décimales, strictement positive /// lorsqu'elle est connue. null lorsque la distance n'a pas pu être calculée (aucune /// coordonnée résoluble) : le Trajet est alors persisté avec une Distance nulle et un /// avertissement (Exigence 2.4). Une modification ultérieure peut la recalculer (Exigence 5.10). /// public decimal? Distance { get; set; } /// /// Trajet aller/retour (Exigence 18). Lorsqu'il vaut true, la distance COMPTÉE est le /// double de la distance simple, sans créer de ligne supplémentaire. Défaut false. /// public bool AllerRetour { get; set; } /// /// Distance COMPTÉE (effective), doublée si . Propriété calculée /// NON MAPPÉE (non persistée), dérivée de et ; /// null si la distance simple est inconnue. C'est cette valeur qui alimente tous les /// agrégats (totaux, résumés, barème, PDF) et l'affichage. /// [NotMapped] public decimal? DistanceEffective => Distance.HasValue ? Distance.Value * (AllerRetour ? 2m : 1m) : null; /// Commentaire optionnel (0–500 caractères). [MaxLength(500)] public string? Commentaire { get; set; } /// /// Géométrie GeoJSON (LineString) de l'itinéraire routier départ→arrivée renvoyée par ORS, /// pour le tracé exact sur la carte. null si l'itinéraire routier n'a pu être obtenu /// (fallback ligne droite). Portée par le Trajet (dépend de la paire départ→arrivée) et non /// par le Cache_Adresses. /// public string? GeometrieJson { get; set; } /// /// Rang du trajet dans la liste de la journée. Valeur décimale (ex. 1.0, 1.5, 2.0) /// permettant l'insertion sans réordonnancement systématique. /// Trié par ordre croissant pour l'affichage. /// [Required] public decimal Position { get; set; } /// Référence au véhicule associé au trajet (véhicule courant à la date de création par défaut). [Required] public int VehiculeId { get; set; } public Vehicule? Vehicule { get; set; } /// Puissance fiscale (CV) du véhicule au moment de l'association ; stockée pour l'historique. [Required] public int PuissanceFiscale { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } ``` > **Note** : Bien que les exigences métier limitent les adresses à 255 caractères à l'entrée (validation API), la colonne de base de données est définie à 255 caractères maximum. La contrainte de 100 caractères mentionnée en Exigence 6.2 est interprétée comme la valeur après nettoyage/normalisation stockée ; la validation API accepte jusqu'à 255 caractères en saisie. ### Schéma SQLite (via EF Core) ```sql CREATE TABLE Trajets ( Id INTEGER PRIMARY KEY AUTOINCREMENT, Date TEXT NOT NULL, AdresseDepart TEXT NOT NULL, AdresseArrivee TEXT NOT NULL, Distance REAL NULL, -- distance SIMPLE ; NULL si non calculable AllerRetour INTEGER NOT NULL DEFAULT 0, -- booléen ; distance comptée doublée si 1 GeometrieJson TEXT, -- GeoJSON LineString de l'itinéraire, ou NULL Commentaire TEXT, Position REAL NOT NULL DEFAULT 0, VehiculeId INTEGER NOT NULL REFERENCES Vehicules(Id), PuissanceFiscale INTEGER NOT NULL, CreatedAt TEXT NOT NULL DEFAULT (datetime('now')), UpdatedAt TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IX_Trajets_Date ON Trajets (Date); CREATE INDEX IX_Trajets_Date_Position ON Trajets (Date, Position); CREATE INDEX IX_Trajets_Vehicule ON Trajets (VehiculeId); -- filtrage par véhicule ``` > **Stratégie d'insertion sans réordonnancement** : la `Position` est un `REAL` (décimal) permettant d'insérer entre deux positions existantes P₁ et P₂ en assignant `(P₁ + P₂) / 2`. Un nouveau trajet ajouté en fin de journée reçoit `max(Position) + 1.0`. Lorsque la précision devient trop faible (différence < 0.001), le backend renumérote les positions de la journée concernée par pas de 1.0 en transaction. ### Entité `AdresseCache` (base de données) ```csharp /// Cache des adresses géocodées via ORS. public class AdresseCache { public int Id { get; set; } [Required, MaxLength(255)] public string Adresse { get; set; } = string.Empty; // texte normalisé public double Latitude { get; set; } // précision 6 décimales public double Longitude { get; set; } // précision 6 décimales /// Géométrie GeoJSON de l'itinéraire (null si indisponible). public string? GeometrieJson { get; set; } public DateTime DerniereUtilisation { get; set; } = DateTime.UtcNow; } ``` ### Entité `AdresseNommee` (base de données) ```csharp /// /// Adresse enregistrée dans le Carnet_Adresses, identifiée par une Dénomination unique /// et réutilisable par sa Dénomination lors de la saisie/insertion d'un Trajet. /// public class AdresseNommee { public int Id { get; set; } /// Libellé libre et unique identifiant l'adresse (1 à 100 caractères). [Required, MaxLength(100)] public string Denomination { get; set; } = string.Empty; /// Texte d'adresse résolu via l'autocomplétion BAN (1 à 255 caractères). [Required, MaxLength(255)] public string Adresse { get; set; } = string.Empty; /// Latitude (précision 6 décimales) ; null si le géocodage a échoué. public double? Latitude { get; set; } /// Longitude (précision 6 décimales) ; null si le géocodage a échoué. public double? Longitude { get; set; } /// Vrai si les coordonnées ont été obtenues (cache ou BAN) ; faux sinon. public bool GeocodageReussi { get; set; } = false; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } ``` > **Stratégie de normalisation de la Dénomination** : l'unicité de la Dénomination est évaluée sur une forme normalisée = suppression des espaces de début et de fin (`trim`), passage en minuscules (insensible à la casse) et suppression des diacritiques/accents (insensible aux accents). Cette forme normalisée est calculée par le backend et matérialisée dans une colonne dédiée `DenominationNormalisee` portant un index unique `UX_AdresseNommee_Denomination`, ce qui délègue la garantie d'unicité au moteur SQLite tout en conservant la Dénomination d'origine (casse/accents) pour l'affichage. Le même schéma de normalisation est réutilisé pour comparer un texte d'adresse au Cache_Adresses (Exigences 15.7, 15.10). ### Entité `Profil` (base de données) ```csharp /// Profil utilisateur (singleton — une seule ligne). public class Profil { public int Id { get; set; } = 1; // toujours 1 [MaxLength(100)] public string? Nom { get; set; } [MaxLength(100)] public string? Prenom { get; set; } /// Matricule (8 caractères maximum). [MaxLength(8)] public string? Matricule { get; set; } [MaxLength(255)] public string? AdresseDomicile { get; set; } public double? DomicileLatitude { get; set; } public double? DomicileLongitude { get; set; } public bool DomicileGeocode { get; set; } = false; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } /// /// Compte unique de l'application mono-utilisateur (singleton, comme le Profil). /// Protège l'accès aux données ; authentification par email + mot de passe (Exigence 17). /// public class Compte { /// Identifiant du compte. Valeur fixe (1) : le compte est un singleton. public int Id { get; set; } = 1; /// Adresse email, unique, servant d'identifiant de connexion (1–255 caractères). [Required, MaxLength(255)] public string Email { get; set; } = string.Empty; /// Hachage salé du mot de passe (PBKDF2 via PasswordHasher). Jamais exposé ni journalisé. [Required] public string MotDePasseHash { get; set; } = string.Empty; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; /// Mise à jour notamment lors d'une réinitialisation du mot de passe. public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } /// /// Jeton de réinitialisation de mot de passe à usage unique et à durée de vie limitée (Exigence 17.11–17.14). /// Seul le hachage du jeton est persisté ; le jeton en clair n'existe que dans l'email envoyé. /// public class ResetPasswordToken { public int Id { get; set; } /// Hachage SHA-256 du jeton (le jeton en clair n'est jamais persisté). [Required] public string TokenHash { get; set; } = string.Empty; /// Date d'expiration (UTC), par défaut création + 60 min (configurable). public DateTime Expiration { get; set; } /// Date d'utilisation (UTC) ; null tant qu'inutilisé (garantit l'usage unique). public DateTime? UsedAt { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; } /// Véhicule de l'utilisateur avec date d'effet. Historique conservé. public class Vehicule { public int Id { get; set; } /// Dénomination libre du véhicule (ex. « Marque, Modèle »), obligatoire (1–100 caractères). [Required, MaxLength(100)] public string Denomination { get; set; } = string.Empty; /// Plaque d'immatriculation (1–15 caractères). [Required, MaxLength(15)] public string Plaque { get; set; } = string.Empty; /// Puissance fiscale en chevaux fiscaux (entier positif). [Required] public int PuissanceFiscale { get; set; } /// Date à partir de laquelle ce véhicule devient le véhicule courant (<= date du jour). [Required] public DateOnly DateEffet { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public ICollection Trajets { get; set; } = new List(); } /// /// Tranche du barème kilométrique URSSAF pour une année et une puissance fiscale. Une puissance /// fiscale peut comporter PLUSIEURS tranches paramétrables (ex. 0–4999, 5000–19999, 20000+), /// la dernière pouvant être illimitée. Les bornes sont exclusives en haut : [KmMin, KmMax[. /// public class TrancheBareme { public int Id { get; set; } [Required] public int Annee { get; set; } [Required] public int PuissanceFiscale { get; set; } /// Borne minimale de kilométrage annuel (incluse). [Required] public decimal KmMin { get; set; } /// /// Borne maximale de kilométrage annuel, EXCLUSIVE : la tranche couvre [KmMin, KmMax[. /// null signifie « illimité » (dernière tranche, sans borne supérieure). /// public decimal? KmMax { get; set; } /// Coefficient en euros par kilomètre (3 décimales). [Required] public decimal Coefficient { get; set; } /// Base fixe en euros ajoutée au montant (souvent nulle). Formule : km × Coefficient + Base. public decimal Base { get; set; } = 0m; } /// Montant réglé (remboursement reçu) pour un mois, une année et un véhicule. public class MontantRegle { public int Id { get; set; } [Required] public int Annee { get; set; } [Required] public int Mois { get; set; } // 1–12 [Required] public int VehiculeId { get; set; } public Vehicule? Vehicule { get; set; } /// Montant réglé en euros (>= 0), deux décimales. [Required] public decimal Montant { get; set; } public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } ``` ### Schéma SQLite additionnel ```sql CREATE TABLE AdresseCache ( Id INTEGER PRIMARY KEY AUTOINCREMENT, Adresse TEXT NOT NULL UNIQUE, Latitude REAL NOT NULL, Longitude REAL NOT NULL, GeometrieJson TEXT, DerniereUtilisation TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE AdressesNommees ( Id INTEGER PRIMARY KEY AUTOINCREMENT, Denomination TEXT NOT NULL, -- 100 caractères maximum, casse/accents d'origine DenominationNormalisee TEXT NOT NULL, -- trim + minuscules + sans accents (clé d'unicité) Adresse TEXT NOT NULL, -- 255 caractères maximum Latitude REAL, -- null si géocodage échoué Longitude REAL, -- null si géocodage échoué GeocodageReussi INTEGER NOT NULL DEFAULT 0, CreatedAt TEXT NOT NULL DEFAULT (datetime('now')), UpdatedAt TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE UNIQUE INDEX UX_AdresseNommee_Denomination ON AdressesNommees (DenominationNormalisee); CREATE TABLE Profil ( Id INTEGER PRIMARY KEY DEFAULT 1, Nom TEXT, Prenom TEXT, Matricule TEXT, -- 8 caractères maximum AdresseDomicile TEXT, DomicileLatitude REAL, DomicileLongitude REAL, DomicileGeocode INTEGER NOT NULL DEFAULT 0, UpdatedAt TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE UNIQUE INDEX UX_Profil_Singleton ON Profil (Id); CREATE TABLE Comptes ( Id INTEGER PRIMARY KEY DEFAULT 1, -- singleton (un seul compte) Email TEXT NOT NULL UNIQUE, -- identifiant de connexion, 1–255 car. MotDePasseHash TEXT NOT NULL, -- hachage salé (PBKDF2), jamais en clair CreatedAt TEXT NOT NULL DEFAULT (datetime('now')), UpdatedAt TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE UNIQUE INDEX UX_Compte_Singleton ON Comptes (Id); CREATE TABLE ResetPasswordTokens ( Id INTEGER PRIMARY KEY AUTOINCREMENT, TokenHash TEXT NOT NULL, -- SHA-256 du jeton (jeton en clair jamais persisté) Expiration TEXT NOT NULL, -- UTC ; défaut création + 60 min UsedAt TEXT, -- NULL tant qu'inutilisé (usage unique) CreatedAt TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE Vehicules ( Id INTEGER PRIMARY KEY AUTOINCREMENT, Denomination TEXT NOT NULL, -- libellé libre (ex. « Marque, Modèle »), 1–100 car. Plaque TEXT NOT NULL, PuissanceFiscale INTEGER NOT NULL CHECK (PuissanceFiscale > 0), DateEffet TEXT NOT NULL, -- YYYY-MM-DD, <= date du jour CreatedAt TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IX_Vehicules_DateEffet ON Vehicules (DateEffet); ``` > **Détermination du Véhicule_Courant** : `SELECT * FROM Vehicules WHERE DateEffet <= date('now') ORDER BY DateEffet DESC, Id DESC LIMIT 1`. À la création d'un Trajet, on résout le véhicule dont la DateEffet est la plus récente <= date du Trajet, et on copie sa PuissanceFiscale sur le Trajet (dénormalisation pour figer l'historique même si le véhicule est modifié plus tard). ```sql CREATE TABLE TranchesBareme ( Id INTEGER PRIMARY KEY AUTOINCREMENT, Annee INTEGER NOT NULL, PuissanceFiscale INTEGER NOT NULL, KmMin REAL NOT NULL, -- borne minimale incluse (ex. 0, 5000, 20000) KmMax REAL NULL, -- borne maximale EXCLUSIVE ; NULL = illimité (dernière tranche) Coefficient REAL NOT NULL, -- €/km, 3 décimales Base REAL NOT NULL DEFAULT 0, CHECK (KmMax IS NULL OR KmMin < KmMax) ); -- Plusieurs tranches par (Annee, PuissanceFiscale) : bornes exclusives [KmMin, KmMax[. CREATE INDEX IX_TranchesBareme_Annee_PF ON TranchesBareme (Annee, PuissanceFiscale); CREATE TABLE MontantsRegles ( Id INTEGER PRIMARY KEY AUTOINCREMENT, Annee INTEGER NOT NULL, Mois INTEGER NOT NULL CHECK (Mois BETWEEN 1 AND 12), VehiculeId INTEGER NOT NULL REFERENCES Vehicules(Id), Montant REAL NOT NULL CHECK (Montant >= 0), UpdatedAt TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE UNIQUE INDEX UX_MontantsRegles_Periode_Vehicule ON MontantsRegles (Annee, Mois, VehiculeId); ``` > **Calcul du barème (URSSAF)** : pour un total kilométrique annuel, une année et une puissance fiscale donnés, on sélectionne la tranche dont l'intervalle `[KmMin, KmMax[` (KmMax exclusive, ou illimité si `KmMax` est NULL) contient le total annuel, puis `Montant_Reel = total_km_annuel × Coefficient + Base`. Le nombre de tranches par puissance fiscale est libre (paramétrable). La tranche est déterminée par le **total annuel** et non par les kilomètres d'un mois isolé. Le **frais réel mensuel** d'un mois = `km_du_mois × Coefficient de la tranche annuelle` ; la somme des douze frais réels mensuels + la Base de la tranche = Montant_Reel. Le **Montant_Previsionnel** (80% du barème sur le total partiel) est purement indicatif et n'entre pas dans le calcul de la régularisation. ### DTOs ```csharp // Création — les coordonnées sont transmises par le Frontend (issues de la sélection BAN) public record CreateTrajetDto( string Date, // JJ/MM/AAAA en entrée string AdresseDepart, double? DepartLatitude, // null si adresse saisie manuellement sans BAN double? DepartLongitude, string AdresseArrivee, double? ArriveeLatitude, // null si adresse saisie manuellement sans BAN double? ArriveeLongitude, string? Commentaire, // optionnel, max 500 car. int? VehiculeId, // optionnel ; null → véhicule courant à la date du trajet bool AllerRetour = false // true → distance comptée doublée (aller/retour) ); // Mise à jour public record UpdateTrajetDto( string Date, string AdresseDepart, double? DepartLatitude, double? DepartLongitude, string AdresseArrivee, double? ArriveeLatitude, double? ArriveeLongitude, string? Commentaire, int? VehiculeId, // optionnel ; null → véhicule courant à la date du trajet bool AllerRetour = false // true → distance comptée doublée (aller/retour) ); // Résultat de recherche BAN (proxied via backend si nécessaire) public record BANAdresseDto( string Label, double Latitude, double Longitude, string Postcode, string City ); // Réponse trajet — Distance = distance EFFECTIVE (doublée si aller/retour), null si non calculée public record TrajetDto( int Id, DateOnly Date, string AdresseDepart, string AdresseArrivee, decimal? Distance, // distance COMPTÉE (effective), km 2 décimales ; null si non calculée string? Commentaire, decimal Position, // rang dans la journée int VehiculeId, int PuissanceFiscale, bool AllerRetour // information aller/retour conservée par trajet ); // Réordonnancement manuel des trajets d'une journée public record ReordonnerTrajetsDto( IReadOnlyList Trajets ); public record TrajetPositionDto( int Id, decimal Position ); // Résumé mensuel public record ResumeMensuelDto( int Annee, int Mois, IReadOnlyList Trajets, decimal TotalKm // somme distances, 2 décimales ); // Résumé annuel public record ResumeAnnuelDto( int Annee, IReadOnlyList Mois, // toujours 12 éléments decimal TotalKm ); ``` ### DTOs — nouvelles fonctionnalités ```csharp // Trajet enrichi pour la carte public record TrajetCarteDto( int Id, DateOnly Date, string AdresseDepart, string AdresseArrivee, decimal Distance, // distance EFFECTIVE (doublée si aller/retour) double? DepartLatitude, double? DepartLongitude, double? ArriveeLatitude, double? ArriveeLongitude, string? GeometrieJson, // GeoJSON LineString ou null string? Commentaire = null, // affiché en tête de l'infobulle carte bool AllerRetour = false // mention « (A/R) » après la distance dans l'infobulle ); // Suggestion autocomplétion public record AdresseCacheDto( string Adresse, double Latitude, double Longitude ); // Carnet d'adresses — Adresse_Nommee retournée par le backend public record AdresseNommeeDto( int Id, string Denomination, string Adresse, double? Latitude, // null si le géocodage a échoué double? Longitude // null si le géocodage a échoué ); // Carnet d'adresses — création / mise à jour d'une Adresse_Nommee public record SaveAdresseNommeeDto( string Denomination, // 1 à 100 car., unique après normalisation string Adresse, // 1 à 255 car. double? Latitude, // fournies par le frontend si sélection BAN double? Longitude ); // Profil public record ProfilDto( string? Nom, string? Prenom, string? Matricule, string? AdresseDomicile, bool DomicileGeocode ); public record SaveProfilDto( string? Nom, string? Prenom, string? Matricule, // max 8 car. string AdresseDomicile ); // État de configuration du profil (onboarding première utilisation) public record EtatProfilDto( bool EstConfigure // true ssi Profil.Nom et Profil.Prenom non vides ET au moins un Vehicule existe ); // Véhicules public record VehiculeDto( int Id, string Denomination, // libellé libre (ex. « Marque, Modèle ») string Plaque, int PuissanceFiscale, DateOnly DateEffet ); public record CreateVehiculeDto( string Denomination, // 1 à 100 car. string Plaque, int PuissanceFiscale, string DateEffet // JJ/MM/AAAA, <= date du jour ); // Réponse à la création d'un véhicule : indique s'il faut proposer une réaffectation public record VehiculeCreeDto( VehiculeDto Vehicule, bool ReaffectationProposee, // true si des trajets >= DateEffet ont une autre puissance fiscale int NombreTrajetsConcernes ); // Requête de réaffectation public record ReaffectationDto( int VehiculeId, // nouveau véhicule bool Accepter // true = réaffecter les trajets >= DateEffet ; false = ne rien changer ); // Totaux affichés dans le Panneau_Totaux public record TotauxDto( DateOnly Date, int VehiculeId, // véhicule sur lequel portent les totaux decimal TotalJour, decimal TotalMois, decimal TotalAnnuelPartiel ); // Barème public record TrancheBaremeDto( int Id, int Annee, int PuissanceFiscale, decimal KmMin, decimal? KmMax, // borne exclusive ; null = illimité (dernière tranche) decimal Coefficient, // €/km, 3 décimales decimal Base ); public record SaveTrancheBaremeDto( int Annee, int PuissanceFiscale, decimal KmMin, decimal? KmMax, // borne exclusive ; null = illimité decimal Coefficient, decimal Base ); // Montant réglé public record MontantRegleDto( int Annee, int Mois, int VehiculeId, decimal Montant ); // Indication de versement mensuel (informatif) affichée à la saisie public record MontantPrevisionnelDto( int Annee, int Mois, int VehiculeId, decimal TotalAnnuelPartielKm, decimal MontantBaremePlein, // barème plein sur le total partiel decimal MontantPrevisionnel // = 80% (indicatif uniquement) ); // Ligne mensuelle de la synthèse annuelle (aligne sur la synthèse réelle) public record LigneSyntheseMoisDto( int Mois, decimal KmMois, // kilomètres parcourus dans le mois decimal FraisPaye, // Montant_Regle du mois (saisi) decimal FraisReelMensuel // km_du_mois × coefficient de la tranche annuelle ); // Synthèse annuelle de régularisation pour un véhicule (page 1 du rapport) public record RegularisationAnnuelleDto( int Annee, int VehiculeId, string Plaque, int PuissanceFiscale, decimal CoefficientTranche, // coefficient de la tranche annuelle applicable decimal BaseTranche, // base de la tranche annuelle applicable string TrancheLibelle, // ex. "5 001 à 20 000 km — D2" IReadOnlyList Mois, // 12 lignes decimal TotalKmAnnuel, decimal TotalPaye, // somme des Montant_Regle decimal MontantReel, // barème plein : total_km × coef + base decimal Regularisation // MontantReel − TotalPaye ); // --- Authentification (Exigence 17) --- // Inscription : création de l'unique compte public record RegisterDto( string Email, // identifiant de connexion, 1–255 car. string MotDePasse // >= 8 caractères ); // Connexion public record LoginDto( string Email, string MotDePasse ); // Réponse de connexion : jeton JWT + expiration public record LoginResultDto( string Token, // JWT signé HS256 DateTime Expiration // date d'expiration (UTC) ); // Demande de réinitialisation de mot de passe public record ForgotPasswordDto( string Email ); // Réinitialisation effective à partir du jeton reçu par email public record ResetPasswordDto( string Token, // jeton en clair reçu par email string MotDePasse // nouveau mot de passe, >= 8 caractères ); // Changement de mot de passe par un utilisateur authentifié (Exigence 20). À la // différence de ResetPasswordDto (jeton reçu par email), exige l'ancien mot de passe. public record ChangePasswordDto( string MotDePasseActuel, string NouveauMotDePasse // >= 8 caractères ); // --- Documents de spécification (Exigence 19) --- // Métadonnées d'un document (élément du sous-menu) public record SpecDocumentDto( string Cle, // whitelist : "requirements" | "design" | "tasks" string Titre, // libellé lisible (ex. « Exigences ») bool Disponible // fichier présent et lisible ); // Contenu Markdown d'un document public record SpecContenuDto( string Cle, string Titre, string Markdown // contenu brut, rendu en HTML côté frontend ); ``` --- ## Endpoints API REST ### Base URL : `/api` #### Trajets | Méthode | Route | Description | Corps | Réponse succès | |---------|-------|-------------|-------|----------------| | `POST` | `/api/trajets` | Créer un trajet | `CreateTrajetDto` | `201 Created` + `TrajetDto` | | `GET` | `/api/trajets/{id}` | Récupérer un trajet | — | `200 OK` + `TrajetDto` | | `PUT` | `/api/trajets/{id}` | Modifier un trajet | `UpdateTrajetDto` | `200 OK` + `TrajetDto` | | `DELETE` | `/api/trajets/{id}` | Supprimer un trajet | — | `204 No Content` | | `PATCH` | `/api/trajets/reordonner` | Mettre à jour l'ordre des trajets d'une journée | `ReordonnerTrajetsDto` | `204 No Content` | | `POST` | `/api/trajets/recalculer-distances` | Recalculer via ORS les trajets sans géométrie routière (limite les appels ORS aux trajets en mode dégradé) | — | `200 OK` + nombre recalculé | #### Authentification (Exigence 17) > Ces endpoints sont **anonymes** (`[AllowAnonymous]`). Tous les autres endpoints exigent un JWT valide et renvoient `401 Unauthorized` sans jeton (ou si le compte associé n'existe plus). | Méthode | Route | Description | Corps | Réponse succès | |---------|-------|-------------|-------|----------------| | `GET` | `/api/auth/compte-existe` | Indique si l'unique compte existe déjà (oriente inscription vs connexion) | — | `200 OK` + `{ existe: bool }` | | `POST` | `/api/auth/register` | Créer l'unique compte | `RegisterDto` | `200 OK` (compte créé ; `400/409` si déjà existant) | | `POST` | `/api/auth/login` | Se connecter | `LoginDto` | `200 OK` + `LoginResultDto` ; `401` si identifiants invalides | | `GET` | `/api/auth/moi` | Valider la session courante (jeton valide + compte existant) — endpoint **protégé** | — | `200 OK` ; `401` sinon | | `POST` | `/api/auth/forgot-password` | Demander un lien de réinitialisation (envoi email, sinon lien journalisé) | `ForgotPasswordDto` | `200 OK` | | `POST` | `/api/auth/reset-password` | Réinitialiser le mot de passe via le jeton reçu | `ResetPasswordDto` | `200 OK` ; `400` si jeton invalide/expiré/déjà utilisé | | `POST` | `/api/auth/change-password` | Changer le mot de passe d'un utilisateur **authentifié** (exige l'ancien mot de passe) — endpoint **protégé** | `ChangePasswordDto` | `200 OK` ; `400` si mot de passe actuel incorrect ; `401` sans jeton | > **Placement de `change-password`** : cet endpoint est déclaré dans `SessionController` (route `api/auth`, SANS `[AllowAnonymous]` au niveau classe) et non dans `AuthController` (qui porte `[AllowAnonymous]` au niveau classe). En ASP.NET Core, `[AllowAnonymous]` posé sur une classe s'applique à TOUTES ses actions et ne peut pas être contredit par un `[Authorize]` posé sur une seule action : un premier essai avec `[Authorize]` sur l'action dans `AuthController` a laissé l'endpoint accessible sans jeton (vérifié par test manuel), d'où le déplacement vers `SessionController`, protégé par l'autorisation globale de l'application. #### Résumés | Méthode | Route | Description | Réponse succès | |---------|-------|-------------|----------------| | `GET` | `/api/resumes/mensuel/{annee}/{mois}?vehiculeId={id}` | Résumé mensuel (filtre véhicule, courant si omis) | `200 OK` + `ResumeMensuelDto` | | `GET` | `/api/resumes/annuel/{annee}?vehiculeId={id}` | Résumé annuel (filtre véhicule, courant si omis) | `200 OK` + `ResumeAnnuelDto` | #### Exports PDF | Méthode | Route | Description | Réponse succès | |---------|-------|-------------|----------------| | `GET` | `/api/exports/mensuel/{annee}/{mois}?vehiculeId={id}` | PDF mensuel (filtre véhicule, courant si omis) | `200 OK` + `application/pdf` | | `GET` | `/api/exports/annuel/{annee}?vehiculeId={id}` | PDF annuel (filtre véhicule, courant si omis) | `200 OK` + `application/pdf` | #### Carte | Méthode | Route | Description | Réponse succès | |---------|-------|-------------|----------------| | `GET` | `/api/trajets/journee/{date}` | Trajets d'une journée avec géométries | `200 OK` + `TrajetCarteDto[]` | #### Adresses cache | Méthode | Route | Description | Réponse succès | |---------|-------|-------------|----------------| | `GET` | `/api/adresses/search?q={query}` | Recherche autocomplétion (min 3 car.) | `200 OK` + `AdresseCacheDto[]` | #### Carnet d'adresses | Méthode | Route | Description | Corps | Réponse succès | |---------|-------|-------------|-------|----------------| | `GET` | `/api/adresses-nommees` | Lister les Adresses_Nommées (triées par Dénomination) | — | `200 OK` + `AdresseNommeeDto[]` | | `POST` | `/api/adresses-nommees` | Créer une Adresse_Nommee | `SaveAdresseNommeeDto` | `201 Created` + `AdresseNommeeDto` | | `PUT` | `/api/adresses-nommees/{id}` | Modifier une Adresse_Nommee (re-géocodage si l'adresse change et n'est pas en cache) | `SaveAdresseNommeeDto` | `200 OK` + `AdresseNommeeDto` | | `DELETE` | `/api/adresses-nommees/{id}` | Supprimer une Adresse_Nommee (sans impact sur les Trajets existants) | — | `204 No Content` | #### Profil | Méthode | Route | Description | Réponse succès | |---------|-------|-------------|----------------| | `GET` | `/api/profil` | Récupérer le profil | `200 OK` + `ProfilDto` | | `PUT` | `/api/profil` | Sauvegarder le profil | `200 OK` + `ProfilDto` | | `GET` | `/api/profil/etat` | État de configuration du profil (onboarding) : `EstConfigure = true` ssi le Profil a un Nom + Prénom non vides ET au moins un Véhicule existe | `200 OK` + `EtatProfilDto` | #### Véhicules | Méthode | Route | Description | Corps | Réponse succès | |---------|-------|-------------|-------|----------------| | `GET` | `/api/vehicules` | Historique des véhicules | — | `200 OK` + `VehiculeDto[]` | | `GET` | `/api/vehicules/courant` | Véhicule courant | — | `200 OK` + `VehiculeDto` | | `POST` | `/api/vehicules` | Enregistrer un véhicule | `CreateVehiculeDto` | `201 Created` + `VehiculeCreeDto` | | `POST` | `/api/vehicules/reaffectation` | Accepter/refuser la réaffectation | `ReaffectationDto` | `204 No Content` | #### Navigation temporelle | Méthode | Route | Description | Réponse succès | |---------|-------|-------------|----------------| | `GET` | `/api/annees-disponibles` | Années ayant des données + année courante | `200 OK` + `number[]` | | `GET` | `/api/mois-disponibles/{annee}` | Mois de l'année ayant des données | `200 OK` + `number[]` (1–12) | | `GET` | `/api/totaux/{date}?vehiculeId={id}` | Totaux jour/mois/année partiel du véhicule (courant si omis) | `200 OK` + `TotauxDto` | #### Barème et remboursements | Méthode | Route | Description | Corps | Réponse succès | |---------|-------|-------------|-------|----------------| | `GET` | `/api/bareme/{annee}` | Barème d'une année (toutes tranches) | — | `200 OK` + `TrancheBaremeDto[]` | | `POST` | `/api/bareme` | Ajouter une tranche | `SaveTrancheBaremeDto` | `201 Created` + `TrancheBaremeDto` | | `PUT` | `/api/bareme/{id}` | Modifier une tranche | `SaveTrancheBaremeDto` | `200 OK` + `TrancheBaremeDto` | | `DELETE` | `/api/bareme/{id}` | Supprimer une tranche | — | `204 No Content` | | `GET` | `/api/bareme/annees-disponibles` | Années ayant un barème défini | — | `200 OK` + `number[]` | | `POST` | `/api/bareme/copier/{anneeCible}` | Recopier le dernier barème antérieur vers l'année cible (annule et remplace) | — | `200 OK` + nombre de tranches copiées | | `GET` | `/api/montant-previsionnel/{annee}/{mois}?vehiculeId={id}` | Montant prévisionnel minoré 80% | — | `200 OK` + `MontantPrevisionnelDto` | | `GET` | `/api/montants-regles/{annee}?vehiculeId={id}` | Montants réglés de l'année | — | `200 OK` + `MontantRegleDto[]` | | `PUT` | `/api/montants-regles` | Enregistrer/mettre à jour un montant réglé | `MontantRegleDto` | `200 OK` + `MontantRegleDto` | | `GET` | `/api/regularisation/{annee}?vehiculeId={id}` | Synthèse annuelle de régularisation : `RegularisationAnnuelleDto` avec les 12 `LigneSyntheseMoisDto`, le coefficient et la base de la tranche annuelle, le total réel (barème plein) et la régularisation (réel − payé) | — | `200 OK` + `RegularisationAnnuelleDto` | #### Documents de spécification (Exigence 19) > Endpoints **protégés** (authentification requise). L'accès est restreint à une **whitelist** de clés (`requirements`, `design`, `tasks`) : aucun chemin de fichier n'est construit à partir d'une entrée libre (pas de path traversal). | Méthode | Route | Description | Réponse succès | |---------|-------|-------------|----------------| | `GET` | `/api/specs` | Lister les documents et leur disponibilité (sous-menu) | `200 OK` + `SpecDocumentDto[]` | | `GET` | `/api/specs/{cle}` | Contenu Markdown d'un document (clé whitelistée) | `200 OK` + `SpecContenuDto` ; `404` si clé inconnue / fichier absent | #### Codes d'erreur standardisés | Code HTTP | Cas | |-----------|-----| | `400 Bad Request` | Validation échouée (champ manquant, date invalide, paramètre hors plage) | | `401 Unauthorized` | Jeton JWT absent, invalide, expiré, ou compte associé inexistant | | `404 Not Found` | Trajet inexistant | | `503 Service Unavailable` | ORS indisponible ou timeout | | `500 Internal Server Error` | Erreur inattendue | Toutes les erreurs retournent un objet `ProblemDetails` (RFC 7807) : ```json { "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1", "title": "Adresse de départ introuvable", "status": 400, "detail": "L'adresse 'rue inconnue 999' n'a pas pu être géocodée par ORS.", "instance": "/api/trajets" } ``` --- ## Intégration BAN et ORS ### Rôles respectifs | Service | Rôle | Déclenchement | |---------|------|---------------| | **BAN** (`api-adresse.data.gouv.fr`) | Autocomplétion + géocodage des adresses françaises | Frontend : dès 3 caractères saisis dans un champ adresse | | **ORS** (`openrouteservice.org`) | Calcul de distance routière + géométrie d'itinéraire | Backend : uniquement si les coordonnées départ et arrivée sont connues | ### Flux de saisie et calcul — BAN en frontend ```mermaid sequenceDiagram participant U as Utilisateur participant FE as Angular Frontend participant BAN as API BAN participant API as Backend API participant ORS as ORS API participant DB as SQLite U->>FE: Saisit 3+ caractères dans champ adresse FE->>BAN: GET /search/?q={saisie}&limit=5 BAN-->>FE: Liste de suggestions (label + coords) FE-->>U: Affiche suggestions dropdown U->>FE: Sélectionne une suggestion FE->>FE: Stocke label + lat/lon en état local U->>FE: Soumet formulaire (adresses + coords) FE->>API: POST /api/trajets (avec coordonnées) alt Coordonnées départ ET arrivée présentes API->>ORS: POST /v2/directions/driving-car ORS-->>API: distance (mètres) + géométrie GeoJSON API->>DB: INSERT Trajet (distance routière) else Coordonnées partiellement absentes API->>BAN: GET /search/?q={adresse_manquante} alt BAN géocode avec succès API->>ORS: POST /v2/directions/driving-car API->>DB: INSERT Trajet (distance calculée) else BAN échoue aussi API->>DB: INSERT Trajet (distance = null, avertissement) end end API-->>FE: 201 Created + TrajetDto ``` ### Appel ORS (itinéraire uniquement) ORS est appelé **uniquement** avec des coordonnées déjà résolues. Plus aucun appel de géocodage ORS. > **Géométrie portée par le Trajet** : la géométrie GeoJSON de l'itinéraire dépend de la paire départ→arrivée ; elle est donc stockée sur le `Trajet` (`GeometrieJson`) et non dans le Cache_Adresses (indexé par adresse seule). Le cache sert de repli pour les trajets hérités sans géométrie. > **Recalcul ciblé des distances** : l'endpoint `POST /api/trajets/recalculer-distances` ne (re)sollicite ORS que pour les trajets **sans géométrie routière** (`GeometrieJson` NULL), afin de corriger les distances calculées en mode dégradé (ex. clé ORS invalide à la saisie) sans re-solliciter inutilement ORS pour les trajets déjà tracés. ```mermaid sequenceDiagram participant SVC as DistanceCalculatorService participant ORS as ORSClient participant API as ORS API SVC->>ORS: CalculateRouteAsync(lat1, lon1, lat2, lon2) ORS->>API: POST /v2/directions/driving-car Note over ORS,API: Body: { coordinates: [[lon1,lat1],[lon2,lat2]] } alt Route calculée API-->>ORS: distance (mètres) + géométrie GeoJSON ORS-->>SVC: RouteResult(distanceKm, geometrieJson) else Timeout (>10s) ou erreur réseau ORS-->>SVC: fallback Haversine déclenché SVC->>SVC: HaversineKm(lat1,lon1,lat2,lon2) SVC-->>SVC: distance orthodromique (pas de géométrie) end ``` ### Fallback Haversine (inchangé) ```csharp private static decimal HaversineKm(double lat1, double lon1, double lat2, double lon2) { const double R = 6371.0; var dLat = (lat2 - lat1) * Math.PI / 180; var dLon = (lon2 - lon1) * Math.PI / 180; var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) + Math.Cos(lat1 * Math.PI / 180) * Math.Cos(lat2 * Math.PI / 180) * Math.Sin(dLon / 2) * Math.Sin(dLon / 2); var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a)); return Math.Round((decimal)(R * c), 2); } ``` ### Configuration BAN et ORS ```json // appsettings.json { "BAN": { "BaseUrl": "https://api-adresse.data.gouv.fr", "TimeoutSeconds": 5 }, "ORS": { "BaseUrl": "https://api.openrouteservice.org", "ApiKey": "", "TimeoutSeconds": 10 } } ``` ```csharp // Configuration/BANOptions.cs public class BANOptions { public string BaseUrl { get; set; } = "https://api-adresse.data.gouv.fr"; public int TimeoutSeconds { get; set; } = 5; } // Configuration/ORSOptions.cs public class ORSOptions { public string BaseUrl { get; set; } = string.Empty; public string ApiKey { get; set; } = string.Empty; public int TimeoutSeconds { get; set; } = 10; } ``` > **Note** : L'API BAN ne nécessite pas de clé d'authentification. La clé ORS est injectée via User Secrets en développement et variables d'environnement en production — jamais commitée dans le dépôt. --- ## Génération PDF (QuestPDF) ### Bibliothèque retenue : QuestPDF QuestPDF est une bibliothèque .NET open-source (licence MIT pour usage communautaire) offrant une API fluent pour la génération de PDF, sans dépendance à Acrobat/PDFium/LibreOffice. Elle est bien maintenue et adaptée à la génération de rapports tabulaires. ### Structure des documents PDF #### PDF Mensuel (`AAAAMM_Trajets_MATRICULE_PLAQUE.pdf`) ``` ┌────────────────────────────────────────────────────────────────────────────┐ │ ┌───────┐ RAPPORT DE TRAJETS (bloc titre centré │ │ │ │ Feuille d'indemnité kilométrique à droite du logo) │ │ │ Logo │ JANVIER 2025 │ │ │ 120pt │ Utilisateur : Cécile DOUCET — Matricule : AB12CD34 │ │ │ │ Domicile : 29 route des Cascades, 23400 Saint-Pardoux-Morter. │ │ └───────┘ Véhicule : GOLF (ES-036-YF) — 6 CV (Coef. : 0,447 / Base : 0) │ │ Total parcouru sur le mois : 931 Kms TOTAL IK du mois : 416 € │ │ Total parcouru sur l'année : 931 Kms Réglé : 500,00 € │ ├──────────────────────────────────────────────────────────────────────────── (trait pleine largeur) ├────────────┬────────────────┬───────────────────────────────┬─────┬────────┤ │ Date │ Motifs │ Départ → Arrivée │ A/R │ Kms │ ├────────────┼────────────────┼───────────────────────────────┼─────┼────────┤ │ 03/01/2025 │ Rdv chez Marie │ 29 route des Cascades... │ X │ 94 │ │ │ │ → Aureil │ │ │ │ ... │ ... │ ... │ │ ... │ ├────────────┴────────────────┴───────────────────────────────┼─────┼────────┤ │ Total du mois : │ │ 931 │ └──────────────────────────────────────────────────────────────┴─────┴────────┘ (pied de page : Mention_Legale 5 pt justifiée, puis « Page X / Y ») ``` > Le titre « RAPPORT DE TRAJETS » est désormais en majuscules. L'en-tête est disposé sur une rangée : logo carré agrandi (120 pt) aligné en haut à gauche ; à droite du logo, un bloc titre (titre + sous-titre « Feuille d'indemnité kilométrique {MOIS} {ANNEE} ») centré sur l'espace restant ; sous le bloc titre, le bloc d'infos (Utilisateur/Matricule, Domicile, Véhicule, totaux). Un trait séparateur horizontal est tracé sur toute la largeur de la page sous l'en-tête. Le libellé « Adresse de départ » devient « Domicile », positionné juste après la ligne Utilisateur/Matricule. Dans la table des Trajets, l'adresse d'arrivée passe à la ligne sous l'adresse de départ, précédée de « → » ; une colonne dédiée **« A/R »** (juste avant « Kms ») affiche un marqueur « X » pour un Aller_Retour (le suffixe « (A/R) » dans la cellule itinéraire est supprimé). 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 (`AAAA_Trajets_MATRICULE_PLAQUE.pdf`) ``` ┌──────────────────────────────────────────────────────────────────────────────┐ │ ┌───────┐ RAPPORT DE TRAJETS (bloc titre centré │ │ │ │ Synthèse 2025 d'indemnité km à droite du logo) │ │ │ Logo │ │ │ │ 120pt │ Utilisateur : Cécile DOUCET — Matricule : AB12CD34 │ │ │ │ Domicile : 29 route des Cascades, 23400 Saint-Pardoux-Morter. │ │ └───────┘ Véhicule : GOLF (ES-036-YF) — 6 CV │ │ (espace après Véhicule)│ │ Barème applicable : 5001–20000 Total kilométrique de │ │ l'année : 12 241 km │ ├──────────────────────────────────────────────────────────────────────────── (trait pleine largeur) │ Barème applicable pour l'année (6 CV) — Kms parcourus × Coef + Base │ │ Tranche (km) │ Coefficient │ Base (€) │ │ 0 à 5000 │ 0,665 │ 0 │ │ 5001 à 20000 │ 0,374 │ 1457 │ │ 20000 et plus │ 0,447 │ 0 │ ├────────────┬───────────────┬───────────────────┬────────────────────────────┤ │ Mois │ Kms parcourus │ Frais payé │ Frais réel │ ├────────────┼───────────────┼───────────────────┼────────────────────────────┤ │ Janvier │ 931 │ 500,00 € │ 459,01 € │ │ ... │ ... │ ... │ ... │ │ Décembre │ 1 540 │ 500,00 € │ 759,26 € │ ├────────────┼───────────────┼───────────────────┼────────────────────────────┤ │ TOTAL │ 12 241 │ 6 000,00 € │ 6 035,13 € │ ├────────────┴───────────────┴───────────────────┴────────────────────────────┤ │ Formule réelle : 12241 km × 0,374 + 1457 = 6 035,13 € (tranche 5001–20000, 6 CV) │ │ │ │ Régularisation (réel − payé) : 35,13 € (ligne centrée) │ └──────────────────────────────────────────────────────────────────────────────┘ (pied de page : Mention_Legale 5 pt justifiée, puis « Page X / Y ») ``` > Le titre « RAPPORT DE TRAJETS » est en majuscules et son bloc titre (titre + sous-titre « Synthèse {Année} d'indemnité kilométrique ») est centré à droite du logo 120 pt ; le bloc d'infos est placé dessous. La ligne « Année : AAAA » reste retirée (redondante avec le titre). Un espace est ajouté après la ligne « Véhicule », puis « Barème applicable » et « Total kilométrique de l'année » sont présentés sur **deux colonnes côte à côte** (même agencement que le bloc totaux du rapport mensuel). Le titre du tableau barème rappelle le mode de calcul en toutes lettres : « Barème applicable pour l'année (N CV) — Kms parcourus × Coef + Base » (les valeurs coef/base effectivement appliquées apparaissent, elles, dans la « Formule réelle » en fin de document). En fin de document, un espace sépare la ligne « Formule réelle » (alignée à gauche) de la ligne « Régularisation (réel − payé) », désormais **centrée** sur la largeur de la page. Le « Frais réel » mensuel = kilomètres du mois × coefficient de la tranche annuelle. Le « Frais payé » = Montant_Regle saisi. La minoration 80% n'apparaît pas dans la synthèse : elle sert uniquement d'indication de versement en cours d'année. Les kilomètres (total annuel, table des mois, formule réelle) sont formatés selon Precision_Distance. > **Rapports distincts par véhicule** : si la période demandée (mois ou année) contient des Trajets associés à plusieurs Véhicules, le backend génère un fichier PDF distinct par Véhicule. Chaque PDF ne contient que les Trajets et montants du Véhicule concerné. ### En-tête obligatoire (Exigence 7.6) Tous les PDF incluent en en-tête : 1. Le **logo carré** de l'Application, aligné en haut à gauche (voir « Logo dans l'en-tête » ci-dessous) 2. Titre fixe : **"RAPPORT DE TRAJETS"** (en majuscules), 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) — titre et sous-titre formant un bloc titre **centré** sur l'espace restant à droite du logo 3. Total kilométrique de la période, formaté selon Precision_Distance ### Logo dans l'en-tête (Exigences 7.13) 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 de **120 pt** (au lieu de 45 pt), alignée en haut (`AlignTop`) à gauche 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. L'en-tête est désormais disposé **en rangée** (`Row`) : le logo à gauche, puis à sa droite une colonne contenant d'abord le **bloc titre centré** (titre « RAPPORT DE TRAJETS » 16 pt gras + sous-titre spécifique 11 pt, chacun en `AlignCenter`), puis, séparé par un espace (`PaddingTop`), le **bloc d'infos** (Utilisateur/Matricule, Domicile, Véhicule, totaux). Un **trait séparateur horizontal** (`LineHorizontal`) est tracé sur toute la largeur de la page sous l'en-tête. Cette disposition remplace l'ancienne superposition `Layers` où le titre était centré sur toute la largeur de la page avec un logo de 45 pt. ### Pied de page (Exigences 7.14) Le pied de page, commun aux deux rapports, est composé par `ComposerPiedDePage` et comporte deux éléments empilés : 1. La **Mention_Legale** indicative : « Les kilomètres restitués dans le présent document sont déclarés par l'utilisateur sur l'honneur et sous sa seule responsabilité. Le calcul de l'indemnité kilométrique est fourni à titre purement indicatif, sur la base du barème kilométrique officiel en vigueur. SchotzIAD ne saurait être tenue responsable d'une quelconque erreur ou omission dans les données saisies ni du montant résultant du calcul. Le présent document ne constitue pas un justificatif opposable aux administrations. » — rendue en **police 5 pt**, couleur **grise**, **justifiée** (`Justify`), de façon à tenir sur deux à trois lignes. 2. La **pagination** « Page X / Y » (`CurrentPageNumber` / `TotalPages`), centrée en 8 pt, placée **sous** la Mention_Legale. ### Table des Trajets du rapport mensuel (Exigences 7.10, 7.15, 7.16) La table des Trajets du rapport mensuel (`ComposerTableTrajets`) comporte cinq colonnes : Date (70 pt), Motifs (relative 2), **Départ → Arrivée** (relative 4, maximisée pour que départ et arrivée tiennent majoritairement sur deux lignes), **A/R** (constante 22 pt, largeur minimale) et **Kms** (constante 44 pt, dimensionnée pour afficher jusqu'à « 9 999 » avec séparateur de milliers). - **Itinéraire** : l'adresse de départ est rendue en première ligne, puis l'adresse d'arrivée **passe à la ligne** en dessous, précédée du symbole « → » (`txt.Line(départ)` suivi de `txt.Span("→ " + arrivée)`). - **Colonne A/R** : affiche le marqueur « **X** » lorsque le Trajet est un Aller_Retour, et reste vide sinon. Le marqueur « X » est préféré à une coche Unicode (« ✓ ») car la police par défaut Lato ne la rend pas. L'information Aller_Retour n'est plus signalée par le suffixe « (A/R) » dans la cellule itinéraire ; la distance affichée reste la Distance_Effective (déjà doublée) portée par le `TrajetDto`. - La ligne de total du mois s'étend sur les quatre premières colonnes (`ColumnSpan(4)`) et affiche le total dans la colonne Kms. ### En-tête et fin du rapport annuel (Exigences 7.11, 7.14) - **Bloc d'infos de l'en-tête** (`ComposerInfosAnnuel`) : un espace (`PaddingTop`) est ajouté après la ligne « Véhicule », puis « Barème applicable » et « Total kilométrique de l'année » sont présentés sur **deux colonnes côte à côte** (`Row` de deux `RelativeItem`), même agencement que le bloc totaux du rapport mensuel. - **Titre du tableau barème** (`ComposerTableBaremeAnnuel`) : complété pour rappeler le mode de calcul en toutes lettres — « Barème applicable pour l'année (N CV) — Kms parcourus × Coef + Base ». Les valeurs coef/base effectivement appliquées apparaissent, elles, dans la « Formule réelle » en fin de document. - **Fin du document** (`ComposerTotauxAnnuels`) : la ligne « Formule réelle : … » reste alignée à gauche ; un espace (`PaddingTop`) la sépare de la ligne « Régularisation (réel − payé) : … € », désormais **centrée** (`AlignCenter`) sur la largeur de la page. ### Precision_Distance dans les PDF (Exigence 7.12) `PdfService` récupère la valeur courante de Precision_Distance via `IPrecisionDistanceService` avant le rendu, et l'applique à tous les kilomètres affichés (`FormatKm(valeur, precision)`) : totaux d'en-tête (mois, année), table des Trajets, table de synthèse annuelle, et formule réelle. Comme pour l'affichage Frontend, seul le rendu est affecté ; la Distance brute stockée en base n'est jamais modifiée par ce paramètre (voir spec `precision-distances-km`). ### Restructuration des en-têtes (Exigence 7.9) - **Mensuel** : le libellé « Adresse de départ » devient **« Domicile »** et est déplacé juste après la ligne Utilisateur/Matricule (même position que sur le rapport annuel), au lieu d'être positionné après la ligne Véhicule. La ligne « Période : MOIS ANNEE » est retirée : la période est désormais intégrée au titre du document (« Feuille d'indemnité kilométrique {MOIS} {ANNEE} », au lieu du titre fixe « Feuille d'indemnité kilométrique mensuelle »). - **Annuel** : la ligne « Année : AAAA » est retirée de l'en-tête, l'année figurant déjà dans le titre « Synthèse {Année} d'indemnité kilométrique ». ### Nommage des fichiers (Exigence 7.4) - Mensuel : `AAAAMM_Trajets_MATRICULE_PLAQUE.pdf` (ex. `202501_Trajets_AB12CD34_ES036YF.pdf`) - Annuel : `AAAA_Trajets_MATRICULE_PLAQUE.pdf` (ex. `2025_Trajets_AB12CD34_ES036YF.pdf`) - La **plaque** est nettoyée de ses espaces et de ses tirets avant insertion dans le nom de fichier (ex. `ES-036-YF` → `ES036YF`). - Les segments **Matricule** et **Plaque** (et le séparateur `_` qui les précède) sont **omis** lorsque la donnée correspondante est vide ou absente (Profil sans matricule, ou Véhicule sans plaque) — voir `PdfService.ConstruireNomFichier`. Le header HTTP de réponse inclut : ``` Content-Disposition: attachment; filename="202501_Trajets_AB12CD34_ES036YF.pdf" Content-Type: application/pdf ``` > **Exposition CORS de `Content-Disposition`** : la politique CORS du backend (`Program.cs`) déclare explicitement `WithExposedHeaders("Content-Disposition")` — sans cet appel, `AllowAnyHeader()` (qui ne couvre que les en-têtes de requête) ne suffit pas à rendre cet en-tête de réponse lisible en cross-origin par le Frontend, qui en a besoin pour proposer le nom de fichier suggéré lors du téléchargement d'un Export_PDF. ### Timeout PDF (Exigence 7.5) La génération PDF est enveloppée dans un `CancellationToken` avec délai de 15 secondes. En cas de dépassement, le backend retourne `503 Service Unavailable`. --- ## Déploiement (Docker Compose) ### Architecture des conteneurs La solution est hébergée via **Docker Compose** avec deux services : - **`backend`** : API ASP.NET Core (.NET 8), exposant le port `8080` en interne. Le fichier SQLite est stocké sous `/data/trajets.db`, monté depuis `./data` de l'hôte via un bind mount (accès direct au fichier). - **`frontend`** : application Angular compilée et servie par **nginx**. Nginx sert la SPA (fallback vers `index.html` pour le routage côté client) et agit comme reverse-proxy en relayant les requêtes `/api` vers le service `backend`. C'est le seul service exposé à l'hôte (port **`2202`** de l'hôte → nginx). La terminaison HTTPS est assurée par un reverse-proxy **Caddy externe**. ```mermaid graph LR U[Navigateur] -- "Caddy HTTPS externe → http://hôte:2202" --> NGINX subgraph "Réseau Docker Compose" NGINX[frontend
nginx + SPA Angular] API[backend
ASP.NET Core :8080] NGINX -- "proxy /api → backend:8080" --> API API -- "/data/trajets.db" --> VOL[(Bind mount
./data sur l'hôte)] end ``` > **Approche retenue** : à l'intérieur de Compose, nginx (conteneur frontend) sert les fichiers statiques Angular et relaie `/api` vers le backend sur le réseau interne ; le backend n'est pas exposé directement à l'hôte. La terminaison **HTTPS est déléguée à un reverse-proxy Caddy externe** (sur une autre machine) qui expose l'application sur le net via une URL HTTPS et relaie vers `IP_hôte:2202`. Le port hôte 2202 est donc à restreindre par pare-feu à l'IP du proxy Caddy. Le backend ne réalise pas de redirection HTTPS (`UseHttpsRedirection` retiré), la terminaison TLS étant assurée en amont. ### Backend `Dockerfile` (multi-stage) ```dockerfile # ---- Étape build (SDK) ---- FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src COPY *.sln ./ COPY SchotzIadKmTracker.Api/*.csproj SchotzIadKmTracker.Api/ RUN dotnet restore SchotzIadKmTracker.Api/SchotzIadKmTracker.Api.csproj COPY . . RUN dotnet publish SchotzIadKmTracker.Api/SchotzIadKmTracker.Api.csproj -c Release -o /app/publish # ---- Étape runtime (ASP.NET) ---- FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime WORKDIR /app COPY --from=build /app/publish . # Répertoire du volume pour la base SQLite persistée RUN mkdir -p /data VOLUME ["/data"] # La base SQLite est stockée sur le volume monté ; le chemin est fourni via la chaîne de connexion ENV ConnectionStrings__Default="Data Source=/data/trajets.db" ENV ASPNETCORE_URLS="http://+:8080" EXPOSE 8080 ENTRYPOINT ["dotnet", "SchotzIadKmTracker.Api.dll"] ``` ### Frontend `Dockerfile` (multi-stage) ```dockerfile # ---- Étape build (Node) ---- FROM node:20-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build -- --configuration production # ---- Étape serve (nginx) ---- FROM nginx:1.27-alpine AS serve # Configuration nginx (SPA + reverse-proxy /api) COPY nginx.conf /etc/nginx/conf.d/default.conf # Artefacts Angular compilés COPY --from=build /app/dist/schotziad-km-tracker /usr/share/nginx/html EXPOSE 80 ``` ### Configuration nginx (`nginx.conf`) ```nginx server { listen 80; server_name _; root /usr/share/nginx/html; index index.html; # SPA Angular : toute route inconnue retombe sur index.html location / { try_files $uri $uri/ /index.html; } # Reverse-proxy des appels API vers le service backend (réseau interne Compose) location /api/ { proxy_pass http://backend:8080; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } ``` ### `docker-compose.yml` ```yaml services: backend: build: context: ./backend dockerfile: Dockerfile environment: - ASPNETCORE_URLS=http://+:8080 - ConnectionStrings__Default=Data Source=/data/trajets.db # La clé ORS est fournie par l'environnement / .env — jamais intégrée à l'image - ORS__ApiKey=${ORS_API_KEY} # Clé de signature JWT : OBLIGATOIRE (le backend refuse de démarrer si absente) - JWT__SecretKey=${JWT_SECRET_KEY:?JWT_SECRET_KEY requis} # URL publique du frontend (liens de réinitialisation de mot de passe), ex. https://mon-domaine - Auth__FrontendBaseUrl=${AUTH_FRONTEND_BASE_URL} volumes: # Bind mount : base accessible sur l'hôte dans ./data/trajets.db - ./data:/data restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/health"] interval: 30s timeout: 5s retries: 3 start_period: 20s frontend: build: context: ./frontend dockerfile: Dockerfile ports: - "2202:80" # hôte 2202 → nginx (frontend) ; 8080 évité (conflit hôte fréquent) depends_on: backend: condition: service_healthy restart: unless-stopped ``` > **Endpoint de santé** : le backend expose `GET /health` (via `AddHealthChecks()` / `MapHealthChecks("/health")`) utilisé par le `healthcheck` Compose et la condition `depends_on: service_healthy`. ### Configuration et secrets - La **clé ORS** est fournie exclusivement via variable d'environnement (`ORS_API_KEY`), typiquement à partir d'un fichier `.env` **non commité** (ajouté au `.gitignore`) et lu automatiquement par Docker Compose. Elle n'est **jamais** intégrée dans une image Docker. - La **clé secrète JWT** (`JWT_SECRET_KEY`) est **obligatoire** : le backend refuse de démarrer si elle est absente. Elle est fournie via `.env`, jamais commitée. - L'**URL publique du frontend** (`AUTH_FRONTEND_BASE_URL`, ex. `https://mon-domaine`) sert à construire les liens de réinitialisation de mot de passe envoyés par e-mail — et non le port interne. - La **configuration e-mail** (SMTP via MailKit) est optionnelle : si elle est absente, le lien de réinitialisation est journalisé en repli (fallback) au lieu d'être envoyé. - L'**API BAN** ne nécessite aucune clé — aucune configuration de secret n'est requise pour le géocodage. - Le **bind mount `./data:/data`** garantit que le fichier `trajets.db` persiste à travers les redémarrages, recréations et mises à jour des conteneurs, tout en le rendant directement accessible sur l'hôte. ### Migrations au démarrage Les **migrations EF Core sont appliquées automatiquement au démarrage du backend** (`await dbContext.Database.MigrateAsync()` dans `Program.cs` avant `app.Run()`). Ainsi, au premier lancement, la base `/data/trajets.db` est créée sur le volume monté ; aux lancements suivants, elle est mise à jour avec les migrations en attente sans intervention manuelle. > **Documents de spécification embarqués** : le `Dockerfile` backend a pour contexte la racine du dépôt et copie `.kiro/specs/schotziad-km-tracker/{requirements,design,tasks}.md` dans `./specs/` de l'image. Le `SpecDocumentService` lit en priorité ce dossier embarqué (`ContentRootPath/specs`), avec repli sur `.kiro/specs/...` en développement. L'application est ainsi autonome, sans montage du dépôt à l'exécution. ### Accès à la base de données La base SQLite est exposée à l'hôte via un **bind mount** (`./data:/data` dans `docker-compose.yml`) : le fichier `data/trajets.db` est directement lisible et sauvegardable **sans entrer dans le conteneur**, à côté du `docker-compose.yml`. **Inspection / lecture** : ouvrir `data/trajets.db` avec un outil SQLite (DB Browser for SQLite, `sqlite3`, extension VS Code…). La lecture pendant que l'application tourne est sûre. **Sauvegarde à froid (recommandée)** : SQLite tolère mal l'écriture concurrente. Pour un backup cohérent, arrêter le backend le temps de la copie, ou utiliser la commande de sauvegarde en ligne intégrée : ```bash # Option 1 — snapshot à froid (cohérence garantie) docker compose stop backend cp data/trajets.db "backup-$(date +%F).db" docker compose start backend # Option 2 — sauvegarde en ligne SQLite (sans arrêt), si sqlite3 est installé sur l'hôte sqlite3 data/trajets.db ".backup 'backup-$(date +%F).db'" ``` > **Concurrence** : éviter d'écrire dans `data/trajets.db` depuis l'extérieur pendant que l'application tourne (risque de corruption). La lecture reste sûre. **Migration depuis l'ancien volume nommé** : les déploiements antérieurs utilisaient un volume nommé Docker (`trajets-data`). Pour récupérer ces données dans le nouveau bind mount `./data` avant de basculer : ```bash # Extraire la base de l'ancien volume vers ./data (backend encore basé sur l'ancienne image) mkdir -p data docker compose cp backend:/data/trajets.db ./data/trajets.db # si le conteneur tourne encore # — ou, si le service est déjà recréé, copier depuis le volume nommé : docker run --rm -v trajets-data:/from -v "$PWD/data":/to alpine \ sh -c "cp -a /from/. /to/" # Puis recréer avec le bind mount : docker compose up -d --build ``` Après migration et vérification, l'ancien volume nommé peut être supprimé : `docker volume rm trajets-data`. > **Migrations notables** : `VehiculeDenomination` (dénomination du véhicule), `TrajetGeometrieJson` (géométrie d'itinéraire portée par le trajet), `TrancheBaremeKmMaxNullable` / bornes exclusives / coefficient numérique (barème multi-tranches à dernière tranche illimitée), `AjoutAuthentification` (comptes + jetons de réinitialisation), `TrajetAllerRetour` (indicateur aller/retour). ## Error Handling ### Stratégie globale Un middleware `GlobalExceptionHandlerMiddleware` (ou `IExceptionHandler` .NET 8) intercepte toutes les exceptions non gérées et retourne un `ProblemDetails` standardisé. ```mermaid graph LR REQ[Requête entrante] MW[GlobalExceptionHandler] CTRL[Controller] SVC[Service] ERR[ProblemDetails Response] REQ --> MW --> CTRL --> SVC SVC -- Exception --> MW MW --> ERR ``` ### Tableau des exceptions métier | Exception | HTTP | Message | |-----------|------|---------| | `TrajetNotFoundException` | 404 | "Le trajet {id} n'existe pas." | | `BANIndisponibleException` | 200 (warning dans réponse) | "L'autocomplétion d'adresse est temporairement indisponible. Vous pouvez saisir l'adresse manuellement." | | `DistanceNonCalculableException` | 201 (warning dans réponse) | "Le trajet a été enregistré mais la distance n'a pas pu être calculée faute de coordonnées géographiques." | | `ORSTimeoutException` | 200 (fallback Haversine appliqué) | "La distance routière est indisponible. La distance orthodromique (vol d'oiseau) a été utilisée." | | `DistanceCalculationException` | 503 | "Erreur lors du calcul de distance." | | `PdfGenerationException` | 503 | "La génération du PDF a échoué. Réessayez." | | `ValidationException` | 400 | Détails par champ (FluentValidation) | | `AdresseDomicileGeocodageException` | 200 (warning) | "L'adresse de domicile a été sauvegardée mais n'a pas pu être vérifiée via ORS." | | `MatriculeTropLongException` | 400 | "Le matricule ne peut pas dépasser 8 caractères." | | `DateEffetFutureException` | 400 | "La date d'effet du véhicule ne peut pas être dans le futur." | | `VehiculeNotFoundException` | 404 | "Le véhicule demandé n'existe pas." | | `TranchesBaremeChevauchantesException` | 400 | "Les tranches du barème pour cette puissance fiscale et cette année se chevauchent." | | `BaremeNonConfigureException` | 200 (montant zéro + avertissement) | "Aucun barème configuré pour cette puissance fiscale et cette année ; le montant de remboursement est nul." | | `MontantRegleNegatifException` | 400 | "Le montant réglé ne peut pas être négatif." | | `DenominationInvalideException` | 400 | "La dénomination est absente ou invalide (1 à 100 caractères)." | | `DenominationDupliqueeException` | 400 | "Cette dénomination est déjà utilisée dans le carnet d'adresses." | | `AdresseNommeeInvalideException` | 400 | "L'adresse est absente ou invalide (1 à 255 caractères)." | | `AdresseNommeeNotFoundException` | 404 | "L'adresse nommée demandée n'existe pas." | > **Onboarding — échec de récupération de l'état** : aucune nouvelle exception backend n'est requise pour l'endpoint `GET /api/profil/etat`. Le cas d'échec de récupération de l'indicateur `EstConfigure` (backend injoignable / erreur réseau) est géré **côté client** par `ProfilConfigureGuard`, qui affiche un message d'erreur de connexion assorti d'une action "Réessayer" au lieu de rediriger arbitrairement. ### Validation des requêtes entrantes (Backend) La validation est assurée par **FluentValidation** intégré à ASP.NET Core : ```csharp public class CreateTrajetDtoValidator : AbstractValidator { public CreateTrajetDtoValidator() { RuleFor(x => x.Date) .NotEmpty().WithMessage("La date est obligatoire.") .Matches(@"^\d{2}/\d{2}/\d{4}$").WithMessage("Format de date invalide (JJ/MM/AAAA).") .Must(BeAValidDate).WithMessage("La date n'est pas une date existante."); RuleFor(x => x.AdresseDepart) .NotEmpty().WithMessage("L'adresse de départ est obligatoire.") .MaximumLength(255).WithMessage("L'adresse de départ ne peut dépasser 255 caractères."); RuleFor(x => x.AdresseArrivee) .NotEmpty().WithMessage("L'adresse d'arrivée est obligatoire.") .MaximumLength(255).WithMessage("L'adresse d'arrivée ne peut dépasser 255 caractères."); } } public class SaveAdresseNommeeDtoValidator : AbstractValidator { public SaveAdresseNommeeDtoValidator() { RuleFor(x => x.Denomination) .NotEmpty().WithMessage("La dénomination est obligatoire.") .MaximumLength(100).WithMessage("La dénomination ne peut dépasser 100 caractères."); RuleFor(x => x.Adresse) .NotEmpty().WithMessage("L'adresse est obligatoire.") .MaximumLength(255).WithMessage("L'adresse ne peut dépasser 255 caractères."); } } ``` > **Unicité de la Dénomination** : le contrôle d'unicité (Exigence 15.7) ne peut pas être exprimé par FluentValidation seul car il dépend de l'état de la base. `AdresseNommeeService` calcule la forme normalisée (trim + minuscules + suppression des accents) et vérifie l'absence de collision avant persistance ; l'index unique `UX_AdresseNommee_Denomination` garantit l'unicité au niveau du moteur SQLite en dernier recours. En cas de collision, une `DenominationDupliqueeException` est levée et convertie en HTTP 400. ### Transactions (Exigence 6.7) Toutes les opérations d'écriture (création, modification, suppression) s'exécutent dans une transaction EF Core : ```csharp await using var transaction = await _context.Database.BeginTransactionAsync(); try { // opérations... await _context.SaveChangesAsync(); await transaction.CommitAsync(); } catch { await transaction.RollbackAsync(); throw; } ``` --- ## Correctness Properties *Une propriété est une caractéristique ou un comportement qui doit rester vrai pour toutes les exécutions valides d'un système — en essence, un énoncé formel sur ce que le système doit faire. Les propriétés servent de pont entre les spécifications lisibles par l'humain et les garanties de correction vérifiables par une machine.* ### Property 1: Cohérence du total mensuel *Pour toute* liste de trajets enregistrés dans un mois calendaire donné (année, mois), le total kilométrique retourné par le backend dans le `ResumeMensuelDto` doit être égal à la somme des distances individuelles de ces trajets, avec une tolérance de 0,01 km. **Validates: Requirements 3.3, 6.5** --- ### Property 2: Cohérence du total annuel *Pour tout* résumé annuel, le total kilométrique annuel doit être égal à la somme des totaux kilométriques des douze résumés mensuels de l'année, avec une tolérance de 0,01 km. **Validates: Requirements 4.3, 6.6** --- ### Property 3: Précision de stockage des distances *Pour tout* trajet créé avec une distance calculée, la valeur stockée en base de données doit correspondre à la valeur retournée arrondie à exactement deux décimales — jamais tronquée, jamais supérieure à 99 999,99 km. **Validates: Requirements 2.2, 2.5, 6.2** --- ### Property 4: Rejet systématique des données d'entrée invalides (backend) *Pour tout* `CreateTrajetDto` contenant au moins un champ invalide — que ce soit une date absente, un format de date non conforme à JJ/MM/AAAA, une date inexistante, une adresse absente, vide, ou dépassant 255 caractères — le backend doit retourner une erreur HTTP 400 sans créer aucune entrée en base de données. > **Réflexion de consolidation** : les critères 1.6 (date invalide) et 1.7 (adresses invalides) partagent la même garantie d'atomicité (rejet → pas de persistance) et sont testables par le même générateur FsCheck produisant des `CreateTrajetDto` avec des champs invalides. Ils sont donc consolidés en une seule propriété. **Validates: Requirements 1.5, 1.6, 1.7, 6.3** --- ### Property 5: Invariant de suppression *Pour tout* trajet supprimé, ce trajet ne doit apparaître dans aucun résumé mensuel ni annuel consultés après la suppression — le total kilométrique doit refléter l'état après suppression. **Validates: Requirements 5.5, 6.4** --- ### Property 6: Recalcul conditionnel de la distance *Pour toute* modification d'un trajet, si aucune adresse n'est modifiée, la distance stockée ne doit pas changer. Si au moins une adresse est modifiée, la nouvelle distance doit correspondre au recalcul ORS (ou fallback Haversine) avec les nouvelles adresses. **Validates: Requirements 5.2, 5.3** --- ### Property 7: Mois toujours présents dans le résumé annuel *Pour toute* année valide (2000–2100), le `ResumeAnnuelDto` retourné doit contenir exactement 12 entrées mensuelles (une par mois de 1 à 12), chaque mois sans trajet ayant un total kilométrique de 0,00. **Validates: Requirements 4.2, 4.4** --- ### Property 8: Rejet des paramètres de résumé hors plage (backend) *Pour toute* 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], le backend doit systématiquement retourner une erreur HTTP 400. **Validates: Requirements 3.6** --- ### 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 `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** --- ### Property 10: Cohérence du cache adresses après géocodage *Pour toute* adresse résolue avec succès par ORS lors de la création ou modification d'un Trajet, cette adresse doit être présente dans le Cache_Adresses à l'issue de l'opération, avec des coordonnées non nulles. Si l'adresse était déjà présente, ses coordonnées ne doivent pas être modifiées, mais sa date de dernière utilisation doit être mise à jour. **Validates: Requirements 9.1, 9.2** --- ### Property 11: Politique LRU du cache adresses *Pour tout* état du Cache_Adresses contenant exactement 1 001 entrées après une insertion, le Backend doit supprimer exactement 1 entrée — celle dont la date de dernière utilisation est la plus ancienne — de sorte que le Cache_Adresses ne contienne jamais plus de 1 000 entrées. **Validates: Requirements 9.5** --- ### Property 12: Cohérence du profil domicile *Pour toute* sauvegarde réussie d'une Adresse_Domicile (que le géocodage ait réussi ou échoué), le Backend doit persister l'adresse textuelle et retourner un ProfilDto dont le champ `AdresseDomicile` correspond exactement au texte soumis. Le champ `DomicileGeocode` doit être `true` si et seulement si ORS a résolu les coordonnées. **Validates: Requirements 10.2, 10.7** --- ### Property 13: Persistance optionnelle du commentaire *Pour tout* Trajet créé ou modifié avec un commentaire non nul et d'une longueur comprise entre 1 et 500 caractères, le Backend doit stocker ce commentaire et le retourner intact dans le `TrajetDto`. Pour tout Trajet créé sans commentaire (null ou absent), le `TrajetDto` retourné doit avoir `Commentaire` égal à `null`. **Validates: Requirements 1.11, 1.12** --- ### Property 14: Cohérence de l'ordre des trajets d'une journée *Pour toute* journée contenant N trajets, la liste retournée par le Backend doit être triée par valeur de `Position` croissante. *Pour tout* trajet inséré entre un Trajet_Précédent de position P₁ et un Trajet_Suivant de position P₂ (P₁ < P₂), la `Position` du trajet inséré doit être strictement comprise entre P₁ et P₂, de sorte que `P₁ < Position_inséré < P₂`. **Validates: Requirements 11.7, 11.8** --- ### Property 15: Unicité et cohérence des positions au sein d'une journée *Pour tout* ensemble de trajets d'une même journée, deux trajets distincts ne peuvent pas avoir la même valeur de `Position`. Après toute opération d'insertion ou de réordonnancement, l'ordre relatif des trajets non déplacés doit être préservé — aucun trajet non concerné par l'opération ne doit voir sa `Position` modifiée sauf lors d'une renumérotation explicite. **Validates: Requirements 11.7, 11.8, 11.9** --- ### Property 16: Priorité BAN sur ORS pour la résolution des coordonnées *Pour tout* Trajet dont les coordonnées d'une adresse sont absentes au moment de la création, le Backend doit tenter de les résoudre via l'API BAN avant tout appel ORS. L'appel ORS ne doit jamais être effectué avec des coordonnées nulles — si la BAN ne peut pas résoudre les coordonnées, le Trajet est enregistré avec distance nulle sans appel ORS. **Validates: Requirements 2.4** --- ### Property 17: Total annuel partiel borné au mois sélectionné *Pour toute* date sélectionnée (année A, mois M), le `TotalAnnuelPartiel` retourné doit être égal à la somme des distances de tous les Trajets de l'année A dont le mois est inférieur ou égal à M — aucun Trajet d'un mois strictement supérieur à M ne doit être inclus. Par conséquent, `TotalAnnuelPartiel >= TotalMois` toujours, et `TotalAnnuelPartiel <= total annuel complet`. **Validates: Requirements 12.8** --- ### Property 18: Aucune sélection de date future *Pour toute* date proposée à la sélection dans le Sélecteur_Temporel, cette date doit être inférieure ou égale à la date courante. Le bouton "Jour suivant" doit être désactivé si et seulement si la journée suivante est strictement postérieure à la date courante. **Validates: Requirements 12.5, 12.13** --- ### Property 19: Association du véhicule courant par défaut *Pour tout* Trajet créé sans VehiculeId explicite, le Backend doit l'associer au Véhicule dont la DateEffet est la plus récente parmi celles inférieures ou égales à la date du Trajet, et copier la PuissanceFiscale de ce véhicule sur le Trajet. **Validates: Requirements 10.13, 10.14** --- ### Property 20: Réaffectation bornée à la date d'effet *Pour toute* réaffectation acceptée d'un nouveau Véhicule de DateEffet D, seuls les Trajets dont la date est supérieure ou égale à D doivent voir leur VehiculeId et leur PuissanceFiscale mis à jour ; aucun Trajet de date strictement inférieure à D ne doit être modifié. En cas de refus, aucun Trajet existant n'est modifié. **Validates: Requirements 10.16, 10.17** --- ### Property 21: Totaux filtrés par véhicule *Pour tout* calcul de totaux (jour, mois, annuel partiel) pour un Véhicule V, seuls les Trajets dont le VehiculeId est égal à V doivent être additionnés ; aucun Trajet associé à un autre véhicule ne doit contribuer au total. **Validates: Requirements 12.19** --- ### Property 22: Conservation de l'historique des véhicules *Pour tout* enregistrement d'un nouveau Véhicule, tous les Véhicules précédemment enregistrés doivent rester présents en base ; aucun véhicule existant n'est supprimé ni écrasé. **Validates: Requirements 10.12, 10.18** --- ### Property 23: Indication de versement à 80% (informative) *Pour tout* Montant_Previsionnel affiché, sa valeur doit être exactement égale à 80% du Bareme_Kilometrique appliqué au Total_Annuel_Partiel, arrondie à deux décimales. Cette valeur est purement informative : elle ne doit jamais entrer dans le calcul du Montant_Reel ni de la Regularisation. **Validates: Requirements 13.5, 13.6** --- ### Property 24: Sélection de tranche du barème *Pour tout* total kilométrique K, année A et puissance fiscale P donnés, le montant barème appliqué doit utiliser exactement la Tranche_Bareme dont l'intervalle [KmMin, KmMax] contient K ; le montant est (K × Coefficient) + Base de cette tranche. Comme les tranches ne se chevauchent pas, au plus une tranche est sélectionnable. **Validates: Requirements 13.3, 13.4** --- ### Property 25: Cohérence de la régularisation annuelle (barème plein) *Pour tout* Véhicule et année, le Montant_Reel doit être égal à `total_km_annuel × coefficient + base` de la tranche dont l'intervalle contient le total kilométrique annuel (barème plein, 100%). La Regularisation doit être exactement égale à `Montant_Reel − somme des Montant_Regle de l'année`, arrondie à deux décimales. La somme des douze Frais_Reel_Mensuel (km_du_mois × coefficient de la tranche annuelle) plus la base de la tranche doit être égale au Montant_Reel, avec une tolérance de 0,01 €. **Validates: Requirements 13.3, 14.5, 14.6, 14.7** --- ### Property 26: Unicité du montant réglé par période et véhicule *Pour tout* couple (année, mois, Véhicule), il ne peut exister qu'au plus un Montant_Regle ; un second enregistrement pour la même clé met à jour le montant existant sans créer de doublon. **Validates: Requirements 14.3** --- ### Property 27: Sélection de tranche par le total annuel *Pour tout* total kilométrique annuel K, année A et puissance fiscale P, la tranche sélectionnée pour le calcul du Montant_Reel doit être celle dont [KmMin, KmMax] contient K. Le coefficient et la base utilisés pour tous les frais réels mensuels de l'année doivent provenir de cette même tranche annuelle, quel que soit le kilométrage de chaque mois pris isolément. **Validates: Requirements 13.3, 14.6** --- ### Property 28: Unicité normalisée de la dénomination *Pour tout* couple d'Adresses_Nommées enregistrées dans le Carnet_Adresses, leurs Dénominations normalisées (trim + minuscules + sans accents) doivent être distinctes. *Pour toute* tentative de création ou de modification produisant une Dénomination dont la forme normalisée est déjà présente, le Backend doit rejeter l'opération avec une erreur HTTP 400 sans persister ni modifier aucune Adresse_Nommee. **Validates: Requirements 15.6, 15.7** --- ### Property 29: Préservation des trajets à la suppression d'une adresse nommée *Pour tout* Trajet existant et *pour toute* Adresse_Nommee supprimée du Carnet_Adresses, le texte d'adresse et les coordonnées géographiques (départ et arrivée) stockés du Trajet doivent rester strictement inchangés après la suppression — la suppression d'une Adresse_Nommee ne modifie, ne supprime ni n'altère aucun Trajet. **Validates: Requirements 15.14** --- ### Property 30: Réutilisation du cache et cohérence du géocodage des adresses nommées *Pour toute* Adresse_Nommee dont le texte d'adresse correspond, après normalisation (trim + minuscules + sans accents), à une entrée du Cache_Adresses, le Backend doit réutiliser les coordonnées du cache sans appeler l'API BAN. *Pour toute* Adresse_Nommee persistée avec des coordonnées valides absentes du Cache_Adresses, ce couple (libellé normalisé, latitude, longitude) doit être présent dans le Cache_Adresses à l'issue de l'opération. *Pour toute* Adresse_Nommee dont le géocodage BAN échoue ou dépasse 5 secondes, l'entité doit être persistée avec des coordonnées nulles et `GeocodageReussi = false`. **Validates: Requirements 15.9, 15.10, 15.11, 15.12** --- ### Property 31: Détermination de l'état de configuration du profil *Pour tout* état de la base (Profil éventuel + ensemble de Véhicules), l'indicateur `EstConfigure` retourné par `GET /api/profil/etat` doit être `true` **si et seulement si** un Profil existe avec un `Nom` et un `Prenom` tous deux non vides (après trim) **ET** qu'au moins un Véhicule existe. Dans tous les autres cas — Profil absent, Nom vide, Prénom vide, ou aucun Véhicule — `EstConfigure` doit être `false`. **Validates: Requirements 16.1, 16.2** --- ## Testing Strategy ### Approche duale La stratégie combine deux niveaux de tests complémentaires : - **Tests unitaires (xUnit + FluentAssertions + Moq)** : comportements spécifiques, cas limites, gestion des erreurs - **Tests basés sur les propriétés (FsCheck + xUnit)** : propriétés universelles vérifiées sur des centaines de cas générés aléatoirement - **Tests d'intégration (xUnit + WebApplicationFactory)** : flux end-to-end sur une base SQLite en mémoire ### Tests backend C# #### Outil PBT retenu : FsCheck (intégration xUnit via `FsCheck.Xunit`) FsCheck est le framework de property-based testing standard pour .NET, inspiré de QuickCheck. Il s'intègre nativement avec xUnit via l'attribut `[Property]`. Configuration minimale : 100 itérations (valeur par défaut FsCheck ≥ 100). #### Implémentation des propriétés Chaque propriété de correction est couverte par un test FsCheck unique. La clé ORS est mockée dans tous les tests PBT pour éviter des appels réseau réels. ```csharp // Tag : Feature: schotziad-km-tracker, Property 1: Cohérence du total mensuel [Property(MaxTest = 200)] public Property TotalMensuelEgaleSommeDistances() { return Prop.ForAll( Arb.From(GenTrajetList()), trajets => { var total = _resumeService.CalculerTotalMensuel(trajets); var somme = trajets.Sum(t => t.Distance); return Math.Abs(total - somme) <= 0.01m; }); } // Tag : Feature: schotziad-km-tracker, Property 2: Cohérence du total annuel [Property(MaxTest = 200)] public Property TotalAnnuelEgaleSommeMensuels() { ... } // Tag : Feature: schotziad-km-tracker, Property 3: Précision de stockage des distances [Property(MaxTest = 200)] public Property DistanceArrondieDeuxDecimales() { ... } // Tag : Feature: schotziad-km-tracker, Property 4: Rejet systématique des données d'entrée invalides [Property(MaxTest = 500)] public Property EntreeInvalideRetourne400SansPersistance() { ... } // Génère des CreateTrajetDto avec dates invalides (mauvais format, date inexistante) // ET/OU adresses vides/null/> 255 caractères → vérifie HTTP 400 + aucune entrée créée en base // Tag : Feature: schotziad-km-tracker, Property 5: Invariant de suppression [Property(MaxTest = 200)] public Property TrajetSupprimePasInResume() { ... } // Tag : Feature: schotziad-km-tracker, Property 6: Recalcul conditionnel de la distance [Property(MaxTest = 200)] public Property ModificationSansAdressePreserveDistance() { ... } // Tag : Feature: schotziad-km-tracker, Property 7: Mois toujours présents dans le résumé annuel [Property(MaxTest = 200)] public Property ResumeAnnuelContient12Mois() { ... } // Tag : Feature: schotziad-km-tracker, Property 8: Rejet des paramètres de résumé hors plage [Property(MaxTest = 300)] public Property ParamsHorsPlageRetourne400() { ... } // Génère des mois hors [1,12] et des années hors [2000,2100] // Tag : Feature: schotziad-km-tracker, Property 9: Nommage des fichiers PDF exportés [Property(MaxTest = 200)] public Property NomFichierPdfConforme() { ... } // Génère des couples (mois 1-12, année 2000-2100) et des années valides, // vérifie que Content-Disposition correspond au format attendu // Tag : Feature: schotziad-km-tracker, Property 10: Cohérence du cache après géocodage [Property(MaxTest = 200)] public Property AdresseGecoodeePresenteDansCache() { ... } // Tag : Feature: schotziad-km-tracker, Property 11: Politique LRU [Property(MaxTest = 200)] public Property CacheNExcedePas1000Entrees() { ... } // Tag : Feature: schotziad-km-tracker, Property 12: Cohérence profil domicile [Property(MaxTest = 200)] public Property ProfilDomicileCoherent() { ... } // Property 13: Persistance optionnelle du commentaire [Property(MaxTest = 300)] public Property CommentaireStockeEtRetourneIntact() { ... } // Génère des commentaires de longueur 0 à 500 et vérifie round-trip, // génère aussi des commentaires > 500 et vérifie HTTP 400 // Property 14: Cohérence de l'ordre des trajets d'une journée [Property(MaxTest = 200)] public Property InsertionPositionStrictementEntreDeuxExistants() { // Génère une liste de trajets d'une journée avec positions P1 < P2, // insère un trajet entre les deux et vérifie P1 < Position_inséré < P2 } // Property 15: Unicité et cohérence des positions [Property(MaxTest = 200)] public Property PositionsUniquesParJournee() { // Vérifie qu'après N insertions, aucune position n'est dupliquée dans une journée } // Property 16: Priorité BAN sur ORS [Property(MaxTest = 200)] public Property BANAppeleeAvantORSQuandCoordManquantes() { ... } // Vérifie que ORS n'est jamais appelé avec des coordonnées null // Property 17: Total annuel partiel borné au mois [Property(MaxTest = 200)] public Property TotalAnnuelPartielBorneAuMois() { ... } // Vérifie TotalAnnuelPartiel = somme des trajets mois <= M, et >= TotalMois // Property 18: Aucune date future sélectionnable [Property(MaxTest = 200)] public Property AucuneDateFutureProposee() { ... } // Property 19: Association véhicule courant par défaut [Property(MaxTest = 200)] public Property TrajetAssocieVehiculeCourant() { ... } // Property 20: Réaffectation bornée à la date d'effet [Property(MaxTest = 200)] public Property ReaffectationBorneeDateEffet() { ... } // Property 21: Totaux filtrés par véhicule [Property(MaxTest = 200)] public Property TotauxFiltresParVehicule() { ... } // Property 22: Conservation historique véhicules [Property(MaxTest = 200)] public Property HistoriqueVehiculesConserve() { ... } // Property 23: Indication 80% informative [Property(MaxTest = 200)] public Property Previsionnel80IndicatifSansImpactRegularisation() { ... } // Property 24: Sélection de tranche [Property(MaxTest = 300)] public Property TrancheSelectionneeContientKm() { ... } // Property 25: Régularisation barème plein [Property(MaxTest = 200)] public Property RegularisationBaremePlein() { ... } // Vérifie Montant_Reel = km_annuel × coef + base, Regularisation = reel − total réglé, // et somme des frais réels mensuels + base = Montant_Reel (tolérance 0,01) // Property 26: Unicité montant réglé par période/véhicule [Property(MaxTest = 200)] public Property MontantRegleUniqueParPeriode() { ... } // Property 27: Sélection de tranche par le total annuel [Property(MaxTest = 300)] public Property TrancheSelectionneeParTotalAnnuel() { ... } // Tag : Feature: schotziad-km-tracker, Property 28: Unicité normalisée de la dénomination [Property(MaxTest = 300)] public Property DenominationUniqueApresNormalisation() { ... } // Génère des dénominations variant en casse/accents/espaces de bord ; // vérifie qu'une seconde dénomination normalisée identique est rejetée (HTTP 400, aucune persistance) // Tag : Feature: schotziad-km-tracker, Property 29: Préservation des trajets à la suppression [Property(MaxTest = 200)] public Property SuppressionAdresseNommeePreserveTrajets() { ... } // Génère des trajets + des adresses nommées, supprime une adresse nommée, // vérifie que les textes d'adresse et coordonnées des trajets sont inchangés // Tag : Feature: schotziad-km-tracker, Property 30: Réutilisation du cache et cohérence du géocodage [Property(MaxTest = 200)] public Property AdresseNommeeReutiliseCacheOuGeocode() { ... } // Adresse en cache (après normalisation) → coords réutilisées, BAN non appelé (mock BAN vérifié) ; // adresse valide absente du cache → couple présent en cache après opération ; // BAN indisponible/timeout → coordonnées null + GeocodageReussi = false // Tag : Feature: schotziad-km-tracker, Property 31: Détermination de l'état de configuration du profil [Property(MaxTest = 300)] public Property EtatProfilConfigureIffNomPrenomEtVehicule() { ... } // Génère des états variés (Profil absent/présent, Nom/Prénom vides ou remplis, 0..N véhicules) ; // vérifie EstConfigure == (Nom non vide && Prénom non vide && au moins un véhicule) ``` #### Tests unitaires spécifiques (xUnit) - `TrajetService`: création, modification avec/sans recalcul, suppression, transactions, rollback sur erreur ORS - `TrajetService`: création avec commentaire, création sans commentaire (null stocké), modification commentaire seul (pas de recalcul distance), validation commentaire > 500 car. → HTTP 400 - `BANClient`: résolution adresse réussie, adresse non trouvée (score < seuil), timeout BAN → avertissement discret - `DistanceCalculatorService`: coordonnées connues → appel ORS ; coordonnées inconnues → appel BAN d'abord, puis ORS si BAN OK ; ORS timeout → fallback Haversine (plus d'erreur bloquante) - `DistanceCalculatorService`: BAN indisponible → saisie manuelle autorisée, distance null stockée - `ResumeService`: total mois vide = 0, résumé annuel avec mois manquants = 0 - `ResumeService` / `TotauxService`: calcul TotalAnnuelPartiel (cumul jusqu'au mois inclus, exclusion des mois postérieurs), années disponibles (données + année courante), mois/jours disponibles - `PdfService`: génération mensuelle, génération annuelle, nommage des fichiers (avec/sans matricule, avec/sans plaque, nettoyage espaces/tirets de la plaque), formatage des km selon Precision_Distance, présence du logo (et comportement si ressource absente), timeout - `AdresseCacheService`: insertion nouvelle adresse, réutilisation adresse existante, éviction LRU à 1 001 entrées - `ProfilService`: sauvegarde avec géocodage réussi, sauvegarde avec géocodage échoué (warning sans erreur), mise à jour - `ProfilService`: validation matricule <= 8 car. - `ProfilService.getEtat()`: `EstConfigure = true` (Nom + Prénom non vides ET au moins un véhicule), `EstConfigure = false` (Profil absent, Nom vide, Prénom vide, ou aucun véhicule) - `VehiculeService`: enregistrement véhicule, détermination véhicule courant (DateEffet max <= aujourd'hui), rejet DateEffet future, conservation historique, détection trajets à réaffecter (date >= DateEffet & puissance différente) - `VehiculeService`: réaffectation acceptée (trajets >= DateEffet mis à jour), réaffectation refusée (aucun trajet modifié) - `TrajetService`: association véhicule courant par défaut à la création, PuissanceFiscale copiée sur le trajet - `TotauxService`: totaux filtrés par véhicule (exclusion des trajets d'autres véhicules) - Validators FluentValidation: tous les cas de validation (date, adresses) - `BaremeService`: saisie des 3 tranches (≤5000, 5001–20000, >20000) par puissance fiscale, sélection de tranche selon total annuel, formule `km × coef + base`, barème non configuré → montant zéro - `RemboursementService`: Montant_Reel = barème plein sur total annuel (tranche selon total annuel), Frais_Reel_Mensuel = km_mois × coef tranche annuelle, somme des frais réels + base = Montant_Reel, Montant_Previsionnel = 80% indicatif (jamais dans la régularisation), Regularisation = Montant_Reel − total réglé - `MontantRegleService`: upsert par (année, mois, véhicule), rejet montant négatif - `PdfService`: en-tête identité (nom, prénom, matricule, plaque, puissance fiscale), PDF distinct par véhicule si plusieurs véhicules sur la période, montants prévisionnel/réglé (mensuel), régularisation (annuel) - `AdresseNommeeService`: création avec coordonnées (mise en cache si absente), création dénomination dupliquée après normalisation (casse/accents/espaces) → HTTP 400, dénomination vide/> 100 car. → HTTP 400, adresse vide/> 255 car. → HTTP 400 - `AdresseNommeeService`: adresse correspondant au cache (après normalisation) → réutilisation coords sans appel BAN (mock BAN), modification du texte d'adresse absente du cache → re-géocodage BAN, BAN indisponible/timeout 5 s → persistance sans coordonnées + `GeocodageReussi = false` - `AdresseNommeeService`: suppression d'une Adresse_Nommee → aucun Trajet modifié (texte d'adresse et coordonnées inchangés) - `SaveAdresseNommeeDtoValidator`: cas de validation dénomination (vide, > 100) et adresse (vide, > 255) #### Tests d'intégration (WebApplicationFactory + SQLite en mémoire) - Flux complet POST `/api/trajets` → vérification en base - POST `/api/trajets` avec et sans commentaire - PUT `/api/trajets/{id}` modification commentaire seul (distance inchangée) - POST `/api/trajets` avec coordonnées transmises (BAN) → calcul ORS → distance stockée - POST `/api/trajets` sans coordonnées (saisie manuelle) → appel BAN backend → si OK : ORS ; si KO : distance null + 201 + warning - POST `/api/trajets` avec coordonnées + ORS timeout → fallback Haversine → 201 avec distance orthodromique - Insertion entre deux trajets : vérification position (Position_inséré strictement entre P₁ et P₂) - Insertion entre P=1.0 et P=2.0 : vérification que Position_inséré = 1.5 - Insertion répétée jusqu'au déclenchement de la renumérotation (précision < 0.001) : vérification que l'ordre relatif est préservé - PATCH `/api/trajets/reordonner` : vérification du nouvel ordre retourné, préservation des positions non modifiées - GET résumé mensuel après insertions (vérification tri par date puis Position croissante) - GET résumé annuel, y compris mois sans trajet - PUT modification avec recalcul (ORS mocké) - DELETE + vérification disparition du résumé - GET exports PDF (vérification Content-Type, Content-Disposition, taille > 0) - GET `/api/trajets/journee/{date}` avec géométries - GET `/api/adresses/search?q=...` - PUT `/api/profil` avec et sans géocodage réussi - GET `/api/profil/etat` → `EstConfigure = true` (profil complet + véhicule) ; `EstConfigure = false` (profil incomplet ou sans véhicule) - GET `/api/annees-disponibles`, `/api/mois-disponibles/{annee}` - GET `/api/totaux/{date}` : vérification TotalAnnuelPartiel borné au mois sélectionné - POST `/api/vehicules` → détermination réaffectation proposée si trajets >= DateEffet avec autre puissance - POST `/api/vehicules/reaffectation` accepter=true → trajets >= DateEffet réaffectés ; accepter=false → inchangés - GET `/api/vehicules` → historique complet conservé - GET `/api/totaux/{date}?vehiculeId=` → totaux filtrés par véhicule - POST `/api/trajets` sans VehiculeId → associé au véhicule courant - POST `/api/bareme` chevauchant → 400 - GET `/api/montant-previsionnel/{annee}/{mois}?vehiculeId=` → 80% du barème sur total partiel - PUT `/api/montants-regles` (upsert) puis re-PUT même période → mise à jour sans doublon - GET `/api/regularisation/{annee}?vehiculeId=` → dû − versé cohérent - GET export PDF annuel avec 2 véhicules → 2 fichiers distincts - POST `/api/adresses-nommees` avec coordonnées → 201 + entrée présente dans le Cache_Adresses - POST `/api/adresses-nommees` avec dénomination déjà utilisée (variante casse/accents/espaces) → 400 sans persistance - POST `/api/adresses-nommees` avec dénomination vide/> 100 car. ou adresse vide/> 255 car. → 400 - POST `/api/adresses-nommees` avec adresse déjà en cache → réutilisation coords (BAN mocké non appelé) - PUT `/api/adresses-nommees/{id}` modifiant le texte d'adresse hors cache → re-géocodage BAN (mocké) ; BAN timeout → 200/warning + coordonnées null - GET `/api/adresses-nommees` → liste triée par Dénomination - DELETE `/api/adresses-nommees/{id}` → 204 puis vérification que les Trajets existants conservent adresse et coordonnées - POST `/api/trajets` avec adresse issue d'une Adresse_Nommee sélectionnée (coordonnées associées) → distance calculée normalement ### Tests frontend Angular (Jest + Testing Library) - `TrajetFormComponent` : validation synchrone (champs vides, format date, longueur), soumission succès/erreur, mode édition pré-rempli, autocomplétion BAN à 3 caractères, sélection suggestion → coordonnées stockées, saisie manuelle sans BAN → avertissement affiché, BAN indisponible → mode dégradé, bouton "Utiliser mon domicile" actif/désactivé - `TrajetFormComponent` : saisie commentaire optionnel, validation longueur 500 car., mode insertion avec adresses pré-remplies depuis Trajet_Précédent/Trajet_Suivant, modification des adresses pré-remplies - `ResumeMensuelComponent` : affichage 2 décimales, message erreur si API en échec - `ResumeAnnuelComponent` : 12 lignes affichées, total correct, max 999 999,99 - `CarteTrajetsComponent` : affichage tracés, fallback ligne droite, infobulles - `ProfilComponent` : sauvegarde, avertissement géocodage échoué, saisie nom/prénom/matricule (limite 8 car.), validation - `OnboardingComponent` : bouton "Commencer" désactivé tant que nom/prénom/véhicule requis manquants, enregistrement réussi (profil + véhicule) → navigation vers la vue de consultation - `ProfilConfigureGuard` : `EstConfigure = false` → redirection vers `/onboarding` (accès trajets/consultation bloqué) ; `EstConfigure = true` → navigation autorisée ; échec de récupération de l'état → message d'erreur de connexion + action "Réessayer" (pas de redirection arbitraire) - `VehiculesComponent` : ajout véhicule, date effet future bloquée, historique trié par date effet décroissante - `ReaffectationDialogComponent` : affichage nombre trajets concernés, accepter/refuser - `TrajetListComponent` : affichage commentaire (icône + tooltip), bouton "insérer après", bouton "insérer avant le suivant", drag & drop réordonne la liste et appelle PATCH, liste triée par Position croissante après insertion - `SelecteurTemporelComponent` : années limitées (courante + données), surbrillance mois/jours avec données, désactivation dates futures - `ConsultationJourComponent` : layout deux colonnes, scroll zone trajets, navigation jour±1, bouton "jour suivant" désactivé si futur, rafraîchissement < 1 000 ms, sélecteur véhicule filtre trajets/totaux/carte - `PanneauTotauxComponent` : affichage 3 totaux à 2 décimales, recalcul au changement de date, affichage montant prévisionnel minoré 80% - `BaremeComponent` : saisie tranches, détection chevauchement - `MontantsReglesComponent` : saisie montant réglé, rejet négatif, affichage prévisionnel indicatif - `CarnetAdressesComponent` : liste triée par dénomination, création/modification (autocomplétion BAN à 3 car., debounce 300 ms, max 5 suggestions), confirmation à la suppression, message de confirmation sur succès CRUD, avertissement si géocodage échoué - `TrajetFormComponent` : sélecteur Adresse_Nommee (par Dénomination) pour départ et arrivée, en mode création et insertion ; sélection → champ rempli + coordonnées associées ; remplacement du contenu pré-rempli en mode insertion - `TrajetService` : mocking HttpClient, timeout interceptor - Tests E2E (Playwright, optionnel) : flux création → consultation → export PDF ### Couverture cible | Périmètre | Cible | |-----------|-------| | Backend services (unitaire + PBT) | ≥ 90 % | | Controllers / intégration | ≥ 80 % | | Frontend components | ≥ 75 % |