# Document de Conception Technique — SchotzIAD KM Tracker --- ## Overview **SchotzIAD KM Tracker** est une application web mono-utilisateur 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. ### 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 | Résumé mensuel | Liste des trajets + total km du mois | | 4 | Vue annuelle | 12 résumés mensuels + total annuel | | 5 | Modification / suppression | Édition avec recalcul conditionnel de distance | | 6 | Persistance et cohérence | Base relationnelle avec invariants d'agrégation et transactions | | 7 | Export PDF | Fichiers `trajets_MM_AAAA.pdf` et `trajets_AAAA.pdf` | | 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/jour avec surbrillance des périodes ayant des données, pas de date future | | 14 | Mise en page consultation | Zone principale (trajets du jour, scroll) + panneau latéral totaux + carte + navigation jour±1 | | 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 ; 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 | ### Contraintes techniques - **Mono-utilisateur** : pas d'authentification ni de gestion de comptes - **Frontend** : Angular (TypeScript), compilé et servi en SPA - **Backend** : ASP.NET Core Web API (C#) - **Base de données** : SQLite (choix retenu pour simplicité mono-utilisateur, zero-configuration, fichier unique portable) - **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) + un conteneur `backend` (ASP.NET Core API). Le fichier SQLite est persisté sur un volume nommé (ou bind mount) monté dans le conteneur backend, afin de survivre aux redémarrages et mises à jour des conteneurs --- ## 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 le chemin du volume monté `/data/trajets.db` (voir la section « Déploiement (Docker Compose) »). 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 │ │ ├── models/ │ │ │ ├── trajet.model.ts │ │ │ ├── resume-mensuel.model.ts │ │ │ └── resume-annuel.model.ts │ │ └── interceptors/ │ │ └── timeout.interceptor.ts # Timeout 10s + erreurs réseau │ ├── features/ │ │ ├── trajet-form/ │ │ │ ├── trajet-form.component.ts # Saisie + édition │ │ │ └── trajet-form.component.html │ │ ├── resume-mensuel/ │ │ │ ├── resume-mensuel.component.ts │ │ │ └── resume-mensuel.component.html │ │ ├── resume-annuel/ │ │ │ ├── resume-annuel.component.ts │ │ │ └── resume-annuel.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 / 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 │ │ ├── montants-regles/ │ │ │ ├── montants-regles.component.ts # Saisie montant réglé par mois │ │ │ └── montants-regles.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 │ ├── core/ │ │ └── guards/ │ │ └── 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.) - 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 - 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 **`ResumeMensuelComponent`** - Sélecteurs mois (1–12) et année (2000–2100) - Affiche la liste des trajets et le total mensuel (2 décimales) - Bouton "Export PDF mensuel" **`ResumeAnnuelComponent`** - Sélecteur année (2000–2100) - Tableau 12 lignes (mois, total km) + ligne total annuel (2 décimales, max 999 999,99) - Bouton "Export PDF annuel" **`TrajetListComponent`** - Ligne par trajet triée par `Position` croissante au sein de chaque journée : date, adresse départ, adresse arrivée, distance, commentaire (icône + tooltip si présent) - Actions par trajet : modifier (→ `TrajetFormComponent` pré-rempli en < 500 ms), supprimer (→ dialog de confirmation), **insérer après** (→ `TrajetFormComponent` en mode insertion avec `adresseDepart` = adresse d'arrivée du trajet courant, `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 les deux adresses et calculant la position d'insertion `(P_courant + P_suivant) / 2` - **Glisser-déposer** (drag & drop) pour réordonner manuellement les trajets d'une journée → appel `PATCH /api/trajets/reordonner` - 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/survol : adresse départ, adresse arrivée, distance - Fond de carte : OpenStreetMap (tile layer `https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png`) **`ProfilComponent`** - Champs : 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é **`VehiculesComponent`** - 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`** - Sélecteur d'année : liste limitée à l'année courante + années ayant des données (endpoint `/api/annees-disponibles`) - Sélecteur de mois : 12 mois affichés, surbrillance des mois avec données (endpoint `/api/mois-disponibles/{annee}`) - Sélecteur de jour : jours du mois affichés, surbrillance des jours avec données (endpoint `/api/jours-disponibles/{annee}/{mois}`) - Désactivation de toute date > date courante (aucune sélection future) - Émet un événement `dateSelectionnee: DateOnly` à chaque changement **`ConsultationJourComponent`** (composant conteneur / layout principal) - Disposition en deux colonnes : zone principale (gauche/centre) + panneau latéral (droite) - Zone principale : `TrajetListComponent` des trajets du jour sélectionné, avec scroll vertical (overflow-y auto) - Panneau latéral toujours visible (sticky) : `PanneauTotauxComponent` en haut, `CarteTrajetsComponent` en dessous - Boutons "Jour précédent" / "Jour suivant" ; "Jour suivant" désactivé si le jour suivant > date courante - Sélecteur de Véhicule (par défaut : véhicule courant) ; filtre la zone des trajets, le Panneau_Totaux et la Carte sur le véhicule sélectionné - Coordonne le rafraîchissement de la liste, des totaux et de la carte (< 1 000 ms) à chaque changement de date **`PanneauTotauxComponent`** - Affiche trois totaux : total du jour sélectionné, 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 - Affiche également le Montant_Previsionnel minoré à 80% calculé sur le Total_Annuel_Partiel du véhicule sélectionné **`BaremeComponent`** - Sélecteur d'année - Tableau éditable des tranches : puissance fiscale, km min, km max, coefficient €/km, part fixe € - Ajout / modification / suppression de tranches - Validation du non-chevauchement des tranches pour une même puissance fiscale et année (message d'erreur si chevauchement) **`MontantsReglesComponent`** - Saisie du Montant_Regle par mois pour l'année et le véhicule sélectionnés - Affichage en regard : Montant_Previsionnel minoré 80% (indicatif) - Montants en euros à 2 décimales **`CarnetAdressesComponent`** - Liste des Adresses_Nommées (Dénomination + adresse + indication si coordonnées disponibles), triée par Dénomination - Formulaire de création / modification : champ `denomination` (1–100 car.) et champ `adresse` avec autocomplétion BAN (1–255 car.) - **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()` **`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 #### 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 ``` TrajetKmTracker.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 en kilomètres, arrondie à 2 décimales. Toujours > 0. [Required] public decimal Distance { get; set; } /// Commentaire optionnel (0–500 caractères). [MaxLength(500)] public string? Commentaire { 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 NOT NULL CHECK (Distance > 0), 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; } /// Véhicule de l'utilisateur avec date d'effet. Historique conservé. public class Vehicule { public int Id { get; set; } /// 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. /// Trois tranches par (année, puissance fiscale) : ≤5000 km, 5001–20000 km, >20000 km. /// 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 (incluse) ; valeur élevée pour la 3e tranche. [Required] public decimal KmMax { get; set; } /// Coefficient en euros par kilomètre. [Required] public decimal Coefficient { get; set; } /// Base fixe en euros ajoutée au montant (nulle pour les tranches 1 et 3). 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 Vehicules ( Id INTEGER PRIMARY KEY AUTOINCREMENT, 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, -- 0, 5000.01, 20000.01 KmMax REAL NOT NULL, -- 5000, 20000, valeur élevée Coefficient REAL NOT NULL, Base REAL NOT NULL DEFAULT 0, CHECK (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 [KmMin, KmMax] contient le total annuel, puis `Montant_Reel = total_km_annuel × Coefficient + Base`. 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 ); // 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 ); // 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 public record TrajetDto( int Id, DateOnly Date, string AdresseDepart, string AdresseArrivee, decimal Distance, // km, 2 décimales string? Commentaire, decimal Position, // rang dans la journée int VehiculeId, int PuissanceFiscale ); // 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, double? DepartLatitude, double? DepartLongitude, double? ArriveeLatitude, double? ArriveeLongitude, string? GeometrieJson // GeoJSON LineString ou null ); // 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 Plaque, int PuissanceFiscale, DateOnly DateEffet ); public record CreateVehiculeDto( 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, decimal Coefficient, decimal Base ); public record SaveTrancheBaremeDto( int Annee, int PuissanceFiscale, decimal KmMin, decimal KmMax, 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 ); ``` --- ## 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` | #### 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/jours-disponibles/{annee}/{mois}` | Jours du mois ayant des données | `200 OK` + `number[]` | | `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/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` | #### Codes d'erreur standardisés | Code HTTP | Cas | |-----------|-----| | `400 Bad Request` | Validation échouée (champ manquant, date invalide, paramètre hors plage) | | `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. ```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 (`trajets_MM_AAAA.pdf`) ``` ┌────────────────────────────────────────────────────────────────────────────┐ │ FEUILLE D'INDEMNITÉ KILOMÉTRIQUE MENSUELLE │ │ Cécile DOUCET │ ├────────────────────────────────────────────────────────────────────────────┤ │ Véhicule : GOLF (ES-036-YF) — 6 CV (Coef. : 0,447 / Base : 0) │ │ Période : JANVIER 2025 Total parcouru sur le mois : 931 Kms │ │ Coef. : 0,447 Total parcouru sur l'année : 931 Kms │ │ Base : 0 TOTAL IK du mois : 416,16 € │ │ Adresse de départ : 29 route des Cascades, 23400 Saint-Pardoux-Morterolles │ │ Réglé : 500,00 € │ ├────────────┬──────────────────────────┬──────────────────────────┬─────────┤ │ Date │ Motifs (commentaire) │ Adresse départ → arrivée │ Kms │ ├────────────┼──────────────────────────┼──────────────────────────┼─────────┤ │ 03/01/2025 │ Rdv chez Marie │ ... → Aureil │ 94 │ │ ... │ ... │ ... │ ... │ └────────────┴──────────────────────────┴──────────────────────────┴─────────┘ ``` > 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. #### PDF Annuel (`trajets_AAAA.pdf`) ``` ┌──────────────────────────────────────────────────────────────────────────────┐ │ SYNTHÈSE 2025 D'INDEMNITÉ KILOMÉTRIQUE │ │ Nom : Cécile DOUCET Année : 2025 │ │ Domicile : 29 route des Cascades, 23400 Saint-Pardoux-Morterolles │ ├──────────────────────────────────────────────────────────────────────────────┤ │ Barème applicable pour l'année (par puissance fiscale) : │ │ PF │ ≤5000 (coef/base) │ 5001–20000 (coef/base) │ >20000 (coef/base) │ │ 6 CV │ 0,665 / 0 │ 0,374 / 1457 │ 0,447 / 0 │ │ Véhicule : GOLF (ES-036-YF) Total Kms : 12 241 Barème applicable : D2 │ ├────────────┬───────────────┬───────────────────┬────────────────────────────┤ │ Mois │ Kms parcourus │ Frais payé (réglé)│ 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 € │ └──────────────────────────────────────────────────────────────────────────────┘ ``` > 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. > **Rapports distincts par véhicule** : si la période demandée (mois ou année) contient des Trajets associés à plusieurs Véhicules, le backend génère un fichier PDF distinct par Véhicule. Chaque PDF ne contient que les Trajets et montants du Véhicule concerné. ### En-tête obligatoire (Exigence 7.6) Tous les PDF incluent en en-tête : 1. Titre fixe : **"Rapport de trajets"** 2. Période couverte : `Mois AAAA` pour le mensuel, `AAAA` pour l'annuel 3. Total kilométrique de la période ### Nommage des fichiers - Mensuel : `trajets_MM_AAAA.pdf` (ex. `trajets_01_2025.pdf`) - Annuel : `trajets_AAAA.pdf` (ex. `trajets_2025.pdf`) Le header HTTP de réponse inclut : ``` Content-Disposition: attachment; filename="trajets_01_2025.pdf" Content-Type: application/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 le chemin du volume monté `/data/trajets.db`. - **`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 `8080` de l'hôte → nginx). ```mermaid graph LR U[Navigateur] -- "http://hôte:8080" --> 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[(Volume nommé
trajets-data)] end ``` > **Approche retenue** : reverse-proxy unique via nginx (frontend). Le backend n'est pas exposé directement à l'hôte ; tout le trafic transite par nginx, qui sert les fichiers statiques et relaie `/api` vers le backend sur le réseau interne Compose. ### Backend `Dockerfile` (multi-stage) ```dockerfile # ---- Étape build (SDK) ---- FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src COPY *.sln ./ COPY TrajetKmTracker.Api/*.csproj TrajetKmTracker.Api/ RUN dotnet restore TrajetKmTracker.Api/TrajetKmTracker.Api.csproj COPY . . RUN dotnet publish TrajetKmTracker.Api/TrajetKmTracker.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", "TrajetKmTracker.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/trajet-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} volumes: - trajets-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: - "8080:80" # hôte 8080 → nginx (frontend) depends_on: backend: condition: service_healthy restart: unless-stopped volumes: trajets-data: # persistance SQLite au-delà du cycle de vie des conteneurs ``` > **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. - L'**API BAN** ne nécessite aucune clé — aucune configuration de secret n'est requise pour le géocodage. - Le **volume nommé `trajets-data`** (monté sur `/data`) garantit que le fichier `trajets.db` persiste à travers les redémarrages, recréations et mises à jour des conteneurs. ### 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. --- ## 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 `trajets_MM_AAAA.pdf` (mensuel) ou `trajets_AAAA.pdf` (annuel), où MM et AAAA correspondent aux paramètres de la requête. **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: trajet-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: trajet-km-tracker, Property 2: Cohérence du total annuel [Property(MaxTest = 200)] public Property TotalAnnuelEgaleSommeMensuels() { ... } // Tag : Feature: trajet-km-tracker, Property 3: Précision de stockage des distances [Property(MaxTest = 200)] public Property DistanceArrondieDeuxDecimales() { ... } // Tag : Feature: trajet-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: trajet-km-tracker, Property 5: Invariant de suppression [Property(MaxTest = 200)] public Property TrajetSupprimePasInResume() { ... } // Tag : Feature: trajet-km-tracker, Property 6: Recalcul conditionnel de la distance [Property(MaxTest = 200)] public Property ModificationSansAdressePreserveDistance() { ... } // Tag : Feature: trajet-km-tracker, Property 7: Mois toujours présents dans le résumé annuel [Property(MaxTest = 200)] public Property ResumeAnnuelContient12Mois() { ... } // Tag : Feature: trajet-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: trajet-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: trajet-km-tracker, Property 10: Cohérence du cache après géocodage [Property(MaxTest = 200)] public Property AdresseGecoodeePresenteDansCache() { ... } // Tag : Feature: trajet-km-tracker, Property 11: Politique LRU [Property(MaxTest = 200)] public Property CacheNExcedePas1000Entrees() { ... } // Tag : Feature: trajet-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: trajet-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: trajet-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: trajet-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: trajet-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, 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}`, `/api/jours-disponibles/{annee}/{mois}` - 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 % |