Files
SchotzIAD/.kiro/specs/schotziad-km-tracker/design.md
T
MadJax 7e80427e09 Precision configurable des distances (arrondi superieur 0/1/2 decimales)
- Nouveau parametre global Precision_Distance (sous-menu Application de la
  Page_Parametrage, entre Compte et Vehicules), persiste via une entite
  singleton ParametresApplication, expose par GET/PUT /api/parametres-application
- Arrondi superieur (ceiling) applique a l'affichage et aux agregats : trajet
  simple, aller/retour (arrondi puis doublement), totaux jour/mois/annee,
  carte, export PDF et bareme kilometrique - jamais a la distance brute stockee
- Refonte du Montant_Previsionnel : le montant mensuel (80%) porte desormais
  sur le seul mois affiche (tranche determinee par le cumul annuel) ; nouvelle
  ligne Montant_Previsionnel_Annuel (100%, sans minoration, sur le cumul)
- Backend : PrecisionDistanceService (ArrondirDistance), integration dans
  ResumeService/TotauxService/RemboursementService/MontantRegleService,
  migration EF Core ; 7 tests de propriete (FsCheck) + tests existants, 62/62 verts
- Frontend : ParametresApplicationService (signal partage), composant de
  parametrage, fonction miroir arrondirDistance, adaptation de la liste des
  trajets, du panneau des totaux, de la carte et des montants regles
- Specs : nouvelle spec precision-distances-km (requirements/design/tasks) ;
  mise a jour de requirements.md, design.md et design-requirements.md de
  schotziad-km-tracker (Requirement 22, renvois croises, Montant_Previsionnel)
- Suppression du fichier chromium .deb suivi par erreur dans le depot
2026-09-25 12:02:52 +02:00

140 KiB
Raw Blame History

Document de Conception Technique — SchotzIAD KM Tracker


Overview

SchotzIAD KM Tracker est une application web mono-utilisateur, protégée par un compte unique authentifié (e-mail + mot de passe, session JWT), permettant de saisir, calculer et consulter l'historique kilométrique de déplacements professionnels ou personnels. L'application calcule automatiquement les distances routières via l'API OpenRouteService (ORS), agrège les données par mois et par année, et permet l'export en PDF des résumés.

Documents d'exigences associés : le besoin fonctionnel métier est décrit dans requirements.md ; les exigences techniques dérivées (API externes, codes HTTP, délais, sécurité, cache, rendu visuel) sont détaillées dans design-requirements.md. La présente conception technique met en œuvre ces exigences.

Précision des distances : le paramètre de précision d'affichage des distances (Precision_Distance, 0/1/2 décimales) et son impact sur les calculs de l'application (résumés mensuels/annuels, Panneau_Totaux, barème kilométrique, carte, Export_PDF) sont documentés intégralement dans la spec séparée precision-distances-km (requirements.md et design.md), qui complète et prime, en cas de divergence, sur les points de conception ci-dessous relatifs au calcul des distances et du Montant_Previsionnel.

Périmètre fonctionnel

# Fonctionnalité Description
1 Saisie d'un trajet Formulaire avec date (JJ/MM/AAAA), adresse départ, adresse arrivée, commentaire
2 Calcul automatique de distance Via ORS (routier), fallback orthodromique
3 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 (repliable) avec surbrillance des mois ayant des données, pas de mois futur
14 Mise en page consultation Liste mensuelle groupée par jour (repliable, scroll) + panneau totaux + carte du jour ciblé + navigation jour bornée au mois
15 Profil enrichi & véhicules Nom, prénom, matricule (8 car.), plaque, puissance fiscale ; historique des véhicules daté
16 Association véhicule par trajet Chaque trajet référence un véhicule + puissance fiscale ; totaux filtrés par véhicule
17 Barème kilométrique annuel Saisie par tranches (puissance fiscale, bornes km, coefficient, part fixe) ; montant prévisionnel minoré 80%
18 Montants réglés & régularisation Saisie du montant réglé par mois ; rapport annuel avec montant réel, versé, dû et régularisation
19 Carnet d'adresses nommées Menu CRUD d'Adresses_Nommées (Dénomination unique + adresse BAN) ; réutilisation par Dénomination dans la saisie et l'insertion de Trajets
20 Onboarding première utilisation Présentation de la saisie du profil (identité + véhicule) si le Profil n'est pas configuré, avant tout accès à la saisie / consultation des trajets
21 Authentification Compte unique (e-mail + mot de passe), connexion par JWT (12 h par défaut), réinitialisation de mot de passe par e-mail, garde de session serveur
22 Trajet aller/retour Case à cocher A/R doublant la distance comptée (Distance_Effective) sans ligne supplémentaire ; badge A/R en liste et mention « (A/R) » sur la carte
23 Barème multi-tranches & recopie Tranches paramétrables par puissance fiscale (dernière tranche illimitée), coefficient sur 3 décimales, bouton « Recopier le dernier barème connu »
24 Consultation des specs Lien « Specs » vers une page dédiée (menu latéral Exigences / Conception / Tâches) affichant les documents Markdown rendus (dont les diagrammes Mermaid), en lecture seule et derrière l'authentification
25 Changement de mot de passe Page Compte utilisateur : formulaire dédié (mot de passe actuel + nouveau + confirmation) ; endpoint protégé distinct de la réinitialisation par e-mail
26 Navigation utilisateur Avatar (initiales) à droite de la barre, sous-menu au survol (Prénom Nom, Paramétrage, Maintenance, Se déconnecter) ; Page_Parametrage (Compte / Véhicules / Barème) et Page_Maintenance dédiées ; barre masquée sur les pages publiques ; contrôle afficher/masquer sur tous les champs mot de passe

Contraintes techniques

  • Mono-utilisateur authentifié : un seul Compte (e-mail + mot de passe), session par jeton JWT signé HS256 (durée configurable, 12 h par défaut) ; tous les endpoints de données sont protégés
  • Frontend : Angular (TypeScript), compilé et servi en SPA
  • Backend : ASP.NET Core Web API (C#)
  • Base de données : SQLite (simplicité mono-utilisateur, zero-configuration, fichier unique portable) ; migrations EF Core appliquées automatiquement au démarrage
  • Service externe — adresses : API BAN (Base Adresses Nationale) — https://api-adresse.data.gouv.fr — autocomplétion et géocodage des adresses françaises, sans clé d'authentification
  • Service externe — itinéraires : OpenRouteService (ORS) — calcul de distance routière et géométrie d'itinéraire entre coordonnées géographiques connues
  • Génération PDF : côté backend, bibliothèque QuestPDF
  • Hébergement : l'application est packagée et hébergée via Docker Compose — un conteneur frontend (Angular servi par nginx en interne) publié sur le port hôte 2202, et un conteneur backend (ASP.NET Core API) non exposé à l'hôte. Le fichier SQLite est persisté via un bind mount (./data:/data) dans le conteneur backend, ce qui rend data/trajets.db directement accessible sur l'hôte (inspection et sauvegarde sans entrer dans le conteneur). La terminaison HTTPS est assurée par un reverse-proxy Caddy externe (sur une autre machine) qui relaie vers IP_hôte:2202 ; le backend ne fait donc pas de redirection HTTPS lui-même

Charte graphique

  • Palette officielle (variables CSS globales dans styles.scss) : Orange --couleur-orange: #F28C28, Bleu IAD --couleur-bleu: #0074B8 (couleur primaire), Gris anthracite --couleur-anthracite: #343A40, Gris clair --couleur-gris-clair: #E7E9EA, avec déclinaisons de survol --couleur-bleu-fonce et --couleur-orange-fonce.
  • Convention d'interaction : les boutons d'action à fond bleu (texte, ex. .btn-principal, .btn-primaire, .btn-confirmer) passent à l'orange au survol. Cette règle ne s'applique PAS aux boutons à fond différent (rouge pour la suppression, blanc pour les actions secondaires) ni aux boutons à icône. Les indicateurs d'état sélectionné/actif (lien de navigation actif, mois sélectionné, onglet actif) restent en bleu plein, sans changement de teinte au survol.
  • Assets graphiques (frontend/src/assets/images/) :
    • logo-icon.png : icône seule, affichée en tête de la barre de navigation (fond blanc localisé pour ressortir sur la barre anthracite).
    • logo-carre.png : logo avec texte vertical, centré au-dessus du formulaire sur les 4 pages d'authentification (connexion, inscription, mot de passe oublié, réinitialisation).
    • logo-horizontal.png : logo avec texte horizontal, réservé à une utilisation future sur les documents PDF (non encore intégré).
    • favicon.png : favicon référencé en priorité (<link rel="icon" type="image/png">) ; src/favicon.ico régénéré à partir de ce PNG (multi-résolutions 16/32/48) pour remplacer l'icône par défaut d'Angular CLI, avec un paramètre de cache-busting (?v=) à incrémenter en cas de remplacement futur du visuel.

Architecture

Architecture globale

graph TB
    subgraph "Navigateur (SPA)"
        FE[Angular App<br/>TypeScript]
    end

    subgraph "Backend (ASP.NET Core)"
        API[API REST<br/>Controllers]
        SVC[Services métier]
        ORS_SVC[ORSClient<br/>HttpClient]
        BAN_SVC[BANClient<br/>HttpClient]
        PDF_SVC[PdfService<br/>QuestPDF]
        DAL[Repository<br/>Entity Framework Core]
    end

    subgraph "Stockage"
        DB[(SQLite<br/>trajets.db)]
    end

    subgraph "Services externes"
        ORS[OpenRouteService<br/>API]
        BAN[BAN API<br/>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<br/>api-adresse.data.gouv.fr]

Flux de création d'un trajet

sequenceDiagram
    participant U as Utilisateur
    participant FE as Angular Frontend
    participant API as API Controller
    participant SVC as TrajetService
    participant ORS as ORSClient
    participant DB as SQLite

    U->>FE: Saisit formulaire + soumet
    FE->>FE: Validation locale (champs, date)
    FE->>API: POST /api/trajets
    API->>SVC: CreateTrajetAsync(dto)
    SVC->>ORS: GeocodeThenRouteAsync(depart, arrivee)
    ORS-->>SVC: distance (km) ou erreur
    alt ORS timeout ou erreur géocodage
        SVC-->>API: erreur descriptive
        API-->>FE: 400 / 503 + message
        FE-->>U: Message d'erreur
    else Succès
        SVC->>DB: INSERT Trajet (avec distance)
        DB-->>SVC: id créé
        SVC-->>API: TrajetCreatedDto
        API-->>FE: 201 Created + trajet
        FE-->>U: Message de succès, formulaire réinitialisé
    end

Décision d'architecture : SQLite

Justification : L'application est mono-utilisateur et n'a pas vocation à être multi-instances. SQLite élimine la complexité d'installation d'un serveur de base de données, offre des transactions ACID complètes via Entity Framework Core, et est parfaitement supporté par EF Core avec les migrations standard. Le fichier trajets.db est autoportant et facilement sauvegardable.

Emplacement en conteneur : dans le déploiement Docker Compose, le fichier de base de données est stocké sous /data/trajets.db, monté via un bind mount depuis ./data de l'hôte (voir la section « Déploiement (Docker Compose) » et « Accès à la base de données »). Le chemin est fourni au backend via la chaîne de connexion / variable d'environnement (ConnectionStrings__Default=Data Source=/data/trajets.db), ce qui garantit la persistance des données indépendamment du cycle de vie du conteneur.

Décision d'architecture : BAN pour le géocodage, ORS pour l'itinéraire

Justification : L'API BAN (Base Adresses Nationale) est le référentiel officiel des adresses françaises, maintenu par l'État. Elle est gratuite, sans clé d'authentification, et propose une API de recherche (/search) retournant directement les coordonnées géographiques avec une autocomplétion précise. En l'utilisant côté frontend dès la saisie, on garantit que les coordonnées sont connues avant même la soumission du formulaire, éliminant ainsi les erreurs de géocodage au moment de la création du trajet. ORS reste utilisé uniquement pour le calcul de l'itinéraire routier (distance + géométrie) entre deux coordonnées déjà résolues, ce qui réduit sa surface de défaillance à la seule étape de routage.


Components and Interfaces

Frontend Angular

Structure des modules

src/
├── app/
│   ├── core/
│   │   ├── services/
│   │   │   ├── trajet.service.ts        # HTTP + state management
│   │   │   ├── notification.service.ts  # Toast / snackbar
│   │   │   ├── adresse-cache.service.ts       # Autocomplétion adresses
│   │   │   ├── adresse-nommee.service.ts      # CRUD carnet d'adresses nommées
│   │   │   ├── profil.service.ts              # CRUD profil
│   │   │   ├── auth.service.ts                # Inscription, connexion, session, reset mot de passe
│   │   │   └── spec.service.ts                # Lecture des documents de spécification (specs)
│   │   ├── models/
│   │   │   ├── trajet.model.ts
│   │   │   ├── resume-mensuel.model.ts
│   │   │   └── resume-annuel.model.ts
│   │   └── interceptors/
│   │       ├── timeout.interceptor.ts   # Timeout 10s + erreurs réseau
│   │       └── auth.interceptor.ts      # Ajoute le jeton JWT (Authorization: Bearer)
│   ├── features/
│   │   ├── trajet-form/
│   │   │   ├── trajet-form.component.ts   # Saisie + édition
│   │   │   └── trajet-form.component.html
│   │   ├── 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 (repliable, sans jour)
│   │   │   └── selecteur-temporel.component.html
│   │   ├── consultation-jour/
│   │   │   ├── consultation-jour.component.ts     # Layout principal
│   │   │   └── consultation-jour.component.html
│   │   ├── panneau-totaux/
│   │   │   ├── panneau-totaux.component.ts        # Totaux jour/mois/année partiel
│   │   │   └── panneau-totaux.component.html
│   │   ├── bareme/
│   │   │   ├── bareme.component.ts                # Saisie du barème par année/tranches
│   │   │   └── bareme.component.html
│   │   ├── 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
│   │   ├── auth/
│   │   │   ├── login.component.ts                  # Connexion (email + mot de passe)
│   │   │   ├── register.component.ts               # Création de l'unique compte (étape 1 de l'initialisation)
│   │   │   ├── forgot-password.component.ts        # Demande de réinitialisation par email
│   │   │   └── reset-password.component.ts         # Définition d'un nouveau mot de passe via jeton
│   │   ├── specs/
│   │   │   ├── specs.component.ts                  # Rendu Markdown des documents de spécification (lazy-loadé)
│   │   │   └── specs.component.html
│   ├── core/
│   │   └── guards/
│   │       ├── auth.guard.ts                 # Valide la session serveur (GET /api/auth/moi) avant les vues protégées
│   │       └── profil-configure.guard.ts    # Redirige vers l'onboarding si le profil n'est pas configuré
│   ├── shared/
│   │   ├── components/
│   │   │   └── confirm-dialog/
│   │   └── validators/
│   │       └── date-jj-mm-aaaa.validator.ts
│   └── app-routing.module.ts

Composants principaux

TrajetFormComponent

  • Reactive Form avec les champs : date (string JJ/MM/AAAA), adresseDepart (1–255 car.), adresseArrivee (1–255 car.), commentaire (optionnel, 0–500 car.), allerRetour (case à cocher — distance comptée doublée)
  • Validations synchrones : présence, longueur, format date via DateJjMmAAAAValidator, longueur commentaire
  • Mode création / édition : pré-remplissage en édition, recalcul distance uniquement si adresse modifiée, modification commentaire sans recalcul
  • Mode insertion : accepte en entrée { adresseDepart?: string, adresseArrivee?: string } pour pré-remplir les champs depuis le Trajet_Précédent / Trajet_Suivant ; les champs restent modifiables
  • Boutons "Utiliser mon domicile" à côté de chaque champ adresse ; pour l'arrivée, si le Commentaire est vide, il est pré-rempli avec « Retour domicile » (jamais de remplacement d'un commentaire déjà saisi)
  • Date par défaut : en mode insertion, la date de la journée du trajet source (prefill.date) prime sur la date de la Carte (dateParDefaut)
  • Timeout de 10 secondes via TimeoutInterceptor
  • Autocomplétion BAN : appel à https://api-adresse.data.gouv.fr/search/?q={saisie}&limit=5 dès 3 caractères saisis ; chaque suggestion affiche le libellé + code postal + commune ; à la sélection d'une suggestion, les coordonnées (lat, lon) sont stockées dans l'état local du composant et transmises avec le formulaire
  • Si l'utilisateur tape une adresse sans passer par BAN (pas de sélection), les coordonnées sont absentes et un avertissement discret s'affiche

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

  • Affiche TOUS les trajets du mois, regroupés par journée : chaque groupe/jour a un en-tête repliable (jour de la semaine avant la date, ex. « Lundi 15/09/2026 », puis nombre de trajets) ; par défaut tout est replié sauf le dernier jour du mois ayant des trajets.
  • Bouton dédié « + » à droite de l'en-tête (Output ajouterSurJournee: string) : ouvre le formulaire de création avec la date de CETTE journée, sans dépendre de la journée affichée sur la Carte (pilotée par le survol) — évite qu'un déplacement de souris à travers plusieurs journées ouvertes ne fasse dériver la date visée en se dirigeant vers le bouton principal d'ajout. Le conteneur fixe date sur cette valeur avant d'ouvrir la création (ouvrirCreationSur).
  • Ligne par trajet (triée par Position croissante au sein du jour) : colonne dédiée A/R en début de ligne pour les allers/retours, commentaire (si présent), adresses (taille réduite), distance effective
  • Actions par trajet : modifier (→ TrajetFormComponent pré-rempli en < 500 ms), supprimer (→ dialog de confirmation), insérer après (→ TrajetFormComponent en mode insertion avec adresseDepart = point de fin du trajet courant — son arrivée, ou son départ si le trajet est un aller/retour —, la date de la journée du trajet, positionApres = Position du trajet courant)
  • Si le trajet n'est pas le dernier de la journée : action insérer avant le suivant disponible, pré-remplissant l'adresse de départ avec le point de fin du trajet courant (départ si aller/retour) et l'adresse d'arrivée avec le départ du trajet suivant, en calculant la position d'insertion (P_courant + P_suivant) / 2
  • Méthode publique ouvrirJour(dateIso) : déplie une journée donnée (appelée par le conteneur après ajout/insertion pour rendre le trajet visible) ; ouvrirToutesLesJournees() déplie tous les groupes ; aDesJourneesFermees() conditionne l'affichage du bouton « Ouvrir toutes les journées »
  • @Input jourAffiche : la journée affichée sur la Carte ; le groupe correspondant est mis en surbrillance (estJourAffiche)
  • Glisser-déposer (drag & drop) limité à chaque journée : un cdkDropList par jour, listes non connectées entre elles (pas de déplacement inter-journées) → recalcul des positions du jour puis PATCH /api/trajets/reordonner
  • Locale : registerLocaleData(localeFr) et LOCALE_ID: 'fr-FR' enregistrés dans app.config.ts, nécessaires pour que le pipe date (format EEEE, jour de la semaine) affiche les noms en français.
  • Rafraîchissement du résumé mensuel en < 1 000 ms après modification/suppression/insertion

CarteTrajetsComponent

  • Intègre Leaflet.js via leaflet package Angular
  • Sélecteur de date (JJ/MM/AAAA)
  • Affiche les tracés GeoJSON retournés par le backend, un tracé par trajet avec couleur distincte
  • Fallback ligne droite si géométrie indisponible (trait pointillé)
  • Infobulles au clic (popup) et au survol (tooltip) : commentaire en tête (si présent), adresse départ, adresse arrivée, distance suivie de « (A/R) » pour un aller/retour ; largeur bornée avec retour à la ligne pour ne pas déborder de la carte
  • Fond de carte : OpenStreetMap (tile layer https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png)

ProfilComponent (page « Compte utilisateur » — Exigences 10, 20)

  • Titre affiché : « Compte utilisateur » (aligné avec les intitulés « Véhicules » et « Barème » du menu de la Page_Parametrage)
  • Formulaire d'identité : nom (1–100 car.), prénom (1–100 car.), matricule (max 8 car., limite de saisie stricte), adresse de domicile (1–255 car.)
  • Bouton "Enregistrer" ; message d'avertissement si le géocodage de l'adresse a échoué
  • Formulaire de changement de mot de passe (distinct, Exigence 20) : mot de passe actuel + nouveau + confirmation (validateur de groupe d'égalité, même pattern que RegisterComponent) ; POST /api/auth/change-password via AuthService.changePassword ; une erreur 400 affiche « Le mot de passe actuel est incorrect » sur le formulaire
  • La section « Maintenance » (recalcul des distances) a été extraite vers MaintenanceComponent (route /maintenance, accessible depuis l'Avatar_Utilisateur)
  • Route : /parametrage/compte (au sein de la Page_Parametrage)

MaintenanceComponent (route /maintenance — Exigence 21)

  • Reprend l'action de recalcul des distances (POST /api/trajets/recalculer-distances) extraite de l'ancienne page Profil
  • Accessible depuis le sous-menu de l'Avatar_Utilisateur, authGuard seul (pas de dépendance au profil configuré)

ParametrageComponent (route /parametrage — Exigence 21)

  • Layout menu latéral + contenu, sur le même modèle que SpecsComponent : colonne gauche (« Compte », « Véhicules », « Barème »), colonne droite = <router-outlet> avec les routes enfants parametrage/compte, parametrage/vehicules, parametrage/bareme
  • Réutilise ProfilComponent, VehiculesComponent, BaremeComponent sans duplication ; seul le point d'entrée change
  • Gardes : compte et vehicules sur authGuard seul ; bareme ajoute profilConfigureGuard (Exigence 21.6)

VehiculesComponent (route /parametrage/vehicules)

  • Liste de l'historique des véhicules (plaque, puissance fiscale, date d'effet), triée par date d'effet décroissante
  • Formulaire d'ajout d'un véhicule : plaque, puissance fiscale, date d'effet (<= date du jour, sélecteur bloquant les dates futures)
  • À l'enregistrement, si la réponse VehiculeCreeDto.ReaffectationProposee est true, ouvre ReaffectationDialogComponent

ReaffectationDialogComponent

  • Affiche le nombre de trajets concernés (date >= date d'effet, puissance fiscale différente)
  • Deux actions : "Réaffecter au nouveau véhicule" (POST reaffectation accepter=true) / "Conserver l'existant" (accepter=false)

SelecteurTemporelComponent

  • Repliable ; l'en-tête affiche le mois et l'année sélectionnés (ex. « Septembre 2026 »)
  • 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}), avec le même code couleur bleu que la journée mise en avant dans la liste ; le mois sélectionné reste en bleu plein
  • Pas de sélecteur de jour (la consultation se fait par mois) ; désactivation de tout mois > mois courant
  • Émet un événement moisSelectionne: { annee: number, mois: number } à chaque changement

ConsultationJourComponent (composant conteneur / layout principal)

  • Deux états : un signal mois ({ annee, mois }) pilote la liste mensuelle ; un signal date (ISO) pilote la carte et la date par défaut d'ajout/insertion
  • Colonne gauche (fixe) : SelecteurTemporelComponent, puis PanneauTotauxComponent, puis CarteTrajetsComponent (plus de bande de navigation par jour)
  • Colonne droite : bande fixe (sélecteur de Véhicule + bouton « Ajouter un trajet »), puis TrajetListComponent du mois avec scroll vertical
  • La journée affichée sur la Carte est pilotée par le survol des Trajets de la liste : survoler un Trajet bascule la Carte sur sa journée (via un Subject debouncé ~80 ms pour éviter les rechargements en rafale) et met le tracé en évidence ; en fin de survol, la Carte reste sur la dernière journée survolée (seule la surbrillance du tracé est retirée)
  • Au chargement d'un mois, la Carte se positionne par défaut sur la dernière journée du mois comportant des Trajets
  • Bouton « Ouvrir toutes les journées » (dans la barre liste, avant « Ajouter un trajet » pour ne pas décaler ce dernier ; affiché seulement s'il reste des journées repliées) → TrajetListComponent.ouvrirToutesLesJournees()
  • Sélecteur de Véhicule (par défaut : véhicule courant) ; filtre la liste, le Panneau_Totaux et la Carte sur le véhicule sélectionné
  • Transmet la date affichée à la liste ([jourAffiche]) : le groupe de cette journée est mis en surbrillance (en-tête bleu accentué) pour la repérer
  • Coordonne le rafraîchissement (< 1 000 ms) ; un mois vide affiche des totaux à 0 et un unique message « Aucun trajet pour ce mois », sans alerte d'erreur
  • Après création/modification d'un trajet (onFormEnregistre(trajet)), si la date du trajet est dans un autre mois, se repositionne sur ce mois, cale la Carte sur la journée du trajet, puis déplie ce jour dans la liste (TrajetListComponent.ouvrirJour, différé au tick suivant pour passer après le recalcul de repliage du setter)

PanneauTotauxComponent

  • Affiche trois totaux : total du jour ciblé par la navigation, total du mois sélectionné, Total_Annuel_Partiel (cumul de l'année jusqu'au mois sélectionné inclus)
  • Reçoit en entrée la date sélectionnée et recalcule les totaux à chaque changement
  • Tous les totaux affichés à 2 décimales, calculés par sommation de distances déjà arrondies selon Precision_Distance (voir spec precision-distances-km, Requirement 5)
  • Affiche également le Montant_Previsionnel_Mensuel (80%, désormais calculé sur le seul mois affiché) et, en dessous, une nouvelle ligne « Montant prévisionnel annuel » présentant le Montant_Previsionnel_Annuel (100%, sur le Total_Annuel_Partiel) — détail complet du calcul dans la spec precision-distances-km, Requirements 7 et 8

ParametresApplicationComponent

  • Nouveau composant du Sous_Menu_Application de la Page_Parametrage, positionné entre « Compte » et « Véhicules »
  • Permet de sélectionner la valeur de Precision_Distance (0, 1 ou 2 décimales) ; la sélection courante est exposée à toute l'application via un signal partagé precisionDistance porté par ParametresApplicationService
  • Détail complet (endpoints, entité, formule d'arrondi, service, migration) dans la spec precision-distances-km, design.md

BaremeComponent

  • Sélecteur d'année
  • Tableau éditable des tranches : puissance fiscale, km min, km max (vide = illimité), coefficient €/km (3 décimales, saisi à côté de la base), base €
  • Plusieurs tranches possibles pour une même puissance fiscale ; ajout / modification / suppression
  • Bouton « Recopier le dernier barème connu » (recopie l'année antérieure la plus récente, confirmation si l'année cible contient déjà des tranches, inaccessible sur l'année du dernier barème)
  • Validation du non-chevauchement des tranches pour une même puissance fiscale et année

MontantsReglesComponent

  • Saisie du Montant_Regle par mois pour l'année et le véhicule sélectionnés
  • Affichage en regard : Montant_Previsionnel_Mensuel minoré 80% (indicatif), désormais calculé sur le seul mois affiché (et non plus sur le cumul annuel) — voir spec precision-distances-km, Requirement 7
  • 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, avec un bouton « Ajouter une adresse » au-dessus de la liste
  • Disposition en grille de cartes : les adresses sont affichées les unes à côté des autres puis retournent à la ligne selon la largeur disponible (display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr))). Chaque carte a une taille homogène (colonnes de largeur égale, cartes étirées à la même hauteur par rangée via align-items: stretch), un contour (border: 1px solid #e0e0e0; border-radius: 10px) et un effet d'ombre (box-shadow: 0 2px 8px rgba(0,0,0,0.1)) la détachant du fond. La zone de contenu de la page est élargie (max-width: 1080px) pour laisser la place à plusieurs colonnes ; la dénomination et l'adresse sont en haut de la carte, les actions Modifier/Supprimer en bas à droite
  • Formulaire de création / modification présenté dans une fenêtre modale (overlay .modale-overlay + panneau .modale-panneau, motif partagé avec ConfirmDialogComponent / ConsultationJourComponent), ouvert via le bouton « Ajouter une adresse » (mode création) ou l'action « Modifier » d'une ligne (mode édition) ; fermée par enregistrement réussi, par le bouton « Annuler », ou par un clic sur l'arrière-plan. Le signal modaleOuverte pilote l'affichage
  • Champ denomination (1–100 car.) et champ adresse avec autocomplétion BAN (1–255 car.) ; dans la modale, les suggestions BAN sont rendues dans le flux du panneau (et non en position absolue) pour éviter toute troncature par le défilement du panneau
  • Autocomplétion BAN : appel à https://api-adresse.data.gouv.fr/search/?q={saisie}&limit=5 dès 3 caractères saisis, avec debounce de 300 ms, maximum 5 suggestions ; à la sélection, le libellé normalisé remplit le champ et les coordonnées (lat, lon) sont conservées pour transmission
  • Actions par ligne : modifier (→ formulaire pré-rempli), supprimer (→ dialog de confirmation)
  • À chaque succès de création, modification ou suppression, affiche un message de confirmation via NotificationService
  • Avertissement discret si le géocodage BAN d'une adresse a échoué (coordonnées absentes)

TrajetFormComponent — sélecteur de Carnet d'adresses

  • Pour le champ adresse de départ et le champ adresse d'arrivée, en mode création comme en mode insertion, le composant propose un choix entre : sélectionner une Adresse_Nommee pré-enregistrée du Carnet_Adresses (par sa Dénomination) via un menu déroulant alimenté par AdresseNommeeService.getAdressesNommees(), ou saisir une nouvelle adresse manuellement avec l'autocomplétion BAN
  • À la sélection d'une Adresse_Nommee, le champ concerné est rempli avec le texte d'adresse de l'Adresse_Nommee et ses coordonnées (lat, lon) sont associées pour transmission au backend (y compris lorsque le champ était pré-rempli depuis le Trajet_Précédent / Trajet_Suivant en mode insertion, auquel cas le contenu pré-rempli est remplacé)

OnboardingComponent (première utilisation)

  • Présenté à l'utilisateur lorsque le Profil n'est pas encore configuré (voir ProfilConfigureGuard)
  • Formulaire de mise en route en une étape regroupant :
    • Identité : nom (1–100 car.), prénom (1–100 car.), matricule (max 8 car.)
    • Adresse de domicile : optionnelle (1–255 car.), avec autocomplétion BAN
    • Premier véhicule : plaque, puissance fiscale, date d'effet (<= date du jour) — au moins un véhicule requis pour finaliser
  • Bouton "Commencer" désactivé tant que les champs requis (nom, prénom, un véhicule valide) ne sont pas renseignés
  • À l'enregistrement réussi (PUT /api/profil + POST /api/vehicules), navigue vers la vue principale de consultation (ConsultationJourComponent)
  • Réutilise ProfilService.saveProfil() et VehiculeService.createVehicule()

Composants d'authentification (LoginComponent, RegisterComponent, ForgotPasswordComponent, ResetPasswordComponent)

  • RegisterComponent : création de l'unique compte (email + mot de passe ≥ 8 car.), étape 1 de l'initialisation ; à la première ouverture sans compte, l'application y redirige directement. Après création, redirige vers la connexion.
  • LoginComponent : connexion (email + mot de passe) → POST /api/auth/login ; à la réussite, stocke le JWT et navigue vers la vue protégée. Propose un lien « Mot de passe oublié ».
  • ForgotPasswordComponent : saisie de l'email → POST /api/auth/forgot-password (lien envoyé par email, ou journalisé en repli).
  • ResetPasswordComponent : nouveau mot de passe via le jeton reçu → POST /api/auth/reset-password.

AuthService / AuthInterceptor / authGuard

  • AuthService gère l'inscription, la connexion, la déconnexion et le stockage du JWT dans localStorage.
  • AuthInterceptor ajoute l'en-tête Authorization: Bearer <jwt> à chaque appel HTTP protégé.
  • authGuard valide la session côté serveur via GET /api/auth/moi avant d'afficher les vues protégées : un jeton résiduel dont le compte n'existe plus est ainsi rejeté.

Décision — stockage du JWT dans localStorage : retenu pour sa simplicité, malgré le risque XSS, acceptable pour une application personnelle mono-utilisateur. Le cookie HttpOnly a été rejeté pour éviter la complexité de protection CSRF.

SpecsComponent (documents de spécification — Exigence 19)

  • La barre de navigation principale expose un menu déroulant « Specs » (survol ou clic, même mécanisme que l'Avatar_Utilisateur) listant directement les 3 documents ; le choix détaillé (disponibilité, etc.) reste géré par le menu latéral de la page elle-même.
  • Route paramétrée /specs/:cle ; lazy-loadée (loadComponent) pour sortir la librairie Markdown du bundle initial
  • Colonne gauche : menu listant les documents (GET /api/specs), le document indisponible étant signalé ; colonne droite : contenu rendu
  • Rendu : le Markdown (GET /api/specs/{cle}) est converti en HTML par marked (GFM : titres, listes, tableaux, code), puis assaini par DOMPurify avant injection ([innerHTML]) — Exigence 19.7
  • Diagrammes Mermaid : un renderer marked transforme les blocs ```mermaid en <pre class="mermaid"> ; après injection du HTML (déclenchée via setTimeout pour laisser Angular résoudre le @ViewChild), Mermaid.js (importé dynamiquement) rend ces blocs en SVG (securityLevel: 'loose' pour accepter les libellés HTML type <br/>). En cas d'échec de rendu d'un diagramme, le bloc de code brut reste visible.
  • Accès derrière authGuard (mêmes règles que les autres vues protégées)

ProfilConfigureGuard (garde de route Angular — CanActivate / CanActivateFn)

  • Au chargement de l'application, appelle ProfilService.getEtat() (GET /api/profil/etat)
  • Si EstConfigure est false, redirige vers la route d'onboarding (/onboarding) et bloque l'accès à la saisie / consultation des trajets tant que le profil n'est pas configuré
  • Si EstConfigure est true, laisse passer la navigation vers la vue principale
  • Gestion d'erreur : en cas d'échec de récupération de l'indicateur (backend injoignable / erreur réseau), le guard affiche un message d'erreur de connexion avec une action "Réessayer" plutôt que de rediriger arbitrairement, laissant l'utilisateur relancer la vérification
  • Déclaré dans app-routing.module.ts sur les routes protégées (consultation, saisie, résumés, etc.) ; la route /onboarding reste accessible sans garde

AppComponent (coquille applicative — barre de navigation, Exigence 21)

  • Barre de navigation : logo (format horizontal, icône + texte), liens métier (Trajets, Carnet d'adresses, Montants réglés, Résumé mensuel, Résumé annuel), menu déroulant « Specs », puis l'Avatar_Utilisateur poussé à l'extrémité droite (margin-left: auto)
  • Menus déroulants (Specs, Avatar) : s'ouvrent au survol (mouseenter) ET restent utilisables au clic (tactile, clavier) ; se ferment au mouseleave du bloc conteneur ou au clic extérieur (@HostListener('document:click'))
    • Piège évité : le panneau déroulant ne doit PAS être séparé du bouton par un espace (top: calc(100% + Xpx)), sous peine de déclencher un mouseleave prématuré lorsque le curseur traverse cet espace mort en descendant vers le panneau. Le panneau touche directement le bouton (top: 100%) et l'espacement visuel est reporté en padding-top À L'INTÉRIEUR du panneau (zone __panneau-contenu séparée pour le fond/l'ombre), de sorte que la zone de survol reste continue.
  • Affichage conditionnel : la barre de navigation (avatar inclus) est masquée non seulement en l'absence de jeton, mais aussi sur les pages PUBLIQUES (/login, /register, /forgot-password, /reset-password), détectées via les événements du Router (NavigationEnd). Ce deuxième critère est nécessaire car un jeton résiduel expiré ou invalide peut rester en localStorage sans qu'aucun appel HTTP protégé ne le purge sur ces pages (aucune requête vers un endpoint protégé n'y est faite) ; se fier uniquement à la présence du jeton ferait réapparaître la barre à tort.
  • Avatar : initiales calculées à partir du Profil (ProfilService.getProfil(), chargé via un effect() déclenché dès que l'utilisateur authentifié quitte une page publique) ; sous-menu = identité (Prénom Nom), « Paramétrage » (/parametrage), « Maintenance » (/maintenance), « Se déconnecter » (AuthService.logout())

PasswordInputComponent (shared/components/password-input/ — Exigence 21.8)

  • Champ de mot de passe réutilisable avec bouton « œil » (SVG inline, pas d'icône externe) pour basculer l'affichage entre masqué et visible
  • Implémente ControlValueAccessor : s'utilise comme un <input> classique avec formControlName (remplace directement <input type="password"> dans les 4 pages d'authentification et la page Compte utilisateur, 8 occurrences au total)
  • Style aligné sur les champs texte classiques (padding: 0.5rem, border: 1px solid #bbb, border-radius: 6px, font-size: 0.95rem, variante rouge si aria-invalid) : porté par le composant lui-même (pas par la page hôte, qui ne peut pas styler l'intérieur d'un composant enfant à travers l'encapsulation de vue Angular) afin que le rendu soit identique à celui d'un champ texte normal quelle que soit la page

Services Angular

TrajetService

interface TrajetService {
  createTrajet(dto: CreateTrajetDto): Observable<TrajetDto>;
  updateTrajet(id: number, dto: UpdateTrajetDto): Observable<TrajetDto>;
  deleteTrajet(id: number): Observable<void>;
  getResumeMensuel(annee: number, mois: number): Observable<ResumeMensuelDto>;
  getResumeAnnuel(annee: number): Observable<ResumeAnnuelDto>;
  exportPdfMensuel(annee: number, mois: number): Observable<Blob>;
  exportPdfAnnuel(annee: number): Observable<Blob>;
  getTrajetsJournee(date: string): Observable<TrajetCarteDto[]>;
  getAnneesDisponibles(): Observable<number[]>;
  getMoisDisponibles(annee: number): Observable<number[]>;
  getJoursDisponibles(annee: number, mois: number): Observable<number[]>;
  getTotauxJour(date: string, vehiculeId: number): Observable<TotauxDto>;
}

interface VehiculeService {
  getVehicules(): Observable<VehiculeDto[]>;
  getVehiculeCourant(): Observable<VehiculeDto>;
  createVehicule(dto: CreateVehiculeDto): Observable<VehiculeCreeDto>;
  reaffecter(dto: ReaffectationDto): Observable<void>;
}

interface BANService {
  searchAdresses(query: string): Observable<BANFeatureDto[]>;
}

interface AdresseCacheService {
  searchLocal(query: string): Observable<AdresseCacheDto[]>;  // recherche dans le cache local
  saveAdresse(adresse: AdresseCacheDto): Observable<void>;    // mise en cache après sélection BAN
}

interface AdresseNommeeService {
  getAdressesNommees(): Observable<AdresseNommeeDto[]>;                       // liste du carnet
  create(dto: SaveAdresseNommeeDto): Observable<AdresseNommeeDto>;           // création
  update(id: number, dto: SaveAdresseNommeeDto): Observable<AdresseNommeeDto>; // modification
  delete(id: number): Observable<void>;                                      // suppression
}

interface ProfilService {
  getProfil(): Observable<ProfilDto>;
  saveProfil(dto: SaveProfilDto): Observable<ProfilDto>;
  getEtat(): Observable<EtatProfilDto>;   // état de configuration (onboarding)
}

interface BaremeService {
  getBareme(annee: number): Observable<TrancheBaremeDto[]>;
  saveTranche(dto: SaveTrancheBaremeDto): Observable<TrancheBaremeDto>;
  updateTranche(id: number, dto: SaveTrancheBaremeDto): Observable<TrancheBaremeDto>;
  deleteTranche(id: number): Observable<void>;
  getMontantPrevisionnel(annee: number, mois: number, vehiculeId: number): Observable<MontantPrevisionnelDto>;
}

interface RemboursementService {
  getMontantsRegles(annee: number, vehiculeId: number): Observable<MontantRegleDto[]>;
  saveMontantRegle(dto: MontantRegleDto): Observable<MontantRegleDto>;
  getRegularisation(annee: number, vehiculeId: number): Observable<RegularisationAnnuelleDto>;
}

Modèle BAN

// 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é

// shared/validators/date-jj-mm-aaaa.validator.ts
export function dateJjMmAAAAValidator(): ValidatorFn {
  // Vérifie le format et l'existence de la date (mois 1-12, jours cohérents avec le mois/année)
}

Backend ASP.NET Core

Structure du projet

SchotzIadKmTracker.Api/
├── Controllers/
│   ├── TrajetsController.cs
│   ├── ResumesMensuelsController.cs
│   ├── ResumesAnnuelsController.cs
│   ├── ExportsPdfController.cs
│   └── AdressesNommeesController.cs         # CRUD carnet d'adresses
├── Services/
│   ├── ITrajetService.cs / TrajetService.cs
│   ├── IDistanceCalculator.cs / DistanceCalculatorService.cs
│   ├── IORSClient.cs / ORSClient.cs         # calcul itinéraire uniquement
│   ├── IBANClient.cs / BANClient.cs         # géocodage adresses françaises
│   ├── IAdresseNommeeService.cs / AdresseNommeeService.cs  # CRUD + normalisation + cache/BAN
│   └── IPdfService.cs / PdfService.cs
├── Repositories/
│   ├── ITrajetRepository.cs / TrajetRepository.cs
│   └── IResumeRepository.cs / ResumeRepository.cs
├── Models/
│   ├── Entities/
│   │   ├── Trajet.cs
│   │   └── AdresseNommee.cs
│   └── DTOs/
│       ├── CreateTrajetDto.cs
│       ├── UpdateTrajetDto.cs
│       ├── TrajetDto.cs
│       ├── ResumeMensuelDto.cs
│       ├── ResumeAnnuelDto.cs
│       ├── AdresseNommeeDto.cs
│       └── SaveAdresseNommeeDto.cs
├── Validators/
│   └── SaveAdresseNommeeDtoValidator.cs     # FluentValidation (dénomination, adresse)
├── Data/
│   ├── AppDbContext.cs
│   └── Migrations/
├── Configuration/
│   ├── ORSOptions.cs
│   └── BANOptions.cs
└── Program.cs

Data Models

Entité Trajet (base de données)

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;

    /// <summary>
    /// Distance SIMPLE en kilomètres (aller), arrondie à 2 décimales, strictement positive
    /// lorsqu'elle est connue. <c>null</c> lorsque la distance n'a pas pu être calculée (aucune
    /// coordonnée résoluble) : le Trajet est alors persisté avec une Distance nulle et un
    /// avertissement (Exigence 2.4). Une modification ultérieure peut la recalculer (Exigence 5.10).
    /// </summary>
    public decimal? Distance { get; set; }

    /// <summary>
    /// Trajet aller/retour (Exigence 18). Lorsqu'il vaut <c>true</c>, la distance COMPTÉE est le
    /// double de la distance simple, sans créer de ligne supplémentaire. Défaut <c>false</c>.
    /// </summary>
    public bool AllerRetour { get; set; }

    /// <summary>
    /// Distance COMPTÉE (effective), doublée si <see cref="AllerRetour"/>. Propriété calculée
    /// NON MAPPÉE (non persistée), dérivée de <see cref="Distance"/> et <see cref="AllerRetour"/> ;
    /// <c>null</c> si la distance simple est inconnue. C'est cette valeur qui alimente tous les
    /// agrégats (totaux, résumés, barème, PDF) et l'affichage.
    /// </summary>
    [NotMapped]
    public decimal? DistanceEffective => Distance.HasValue ? Distance.Value * (AllerRetour ? 2m : 1m) : null;

    /// <summary>Commentaire optionnel (0–500 caractères).</summary>
    [MaxLength(500)]
    public string? Commentaire { get; set; }

    /// <summary>
    /// Géométrie GeoJSON (LineString) de l'itinéraire routier départ→arrivée renvoyée par ORS,
    /// pour le tracé exact sur la carte. <c>null</c> si l'itinéraire routier n'a pu être obtenu
    /// (fallback ligne droite). Portée par le Trajet (dépend de la paire départ→arrivée) et non
    /// par le Cache_Adresses.
    /// </summary>
    public string? GeometrieJson { get; set; }

    /// <summary>
    /// 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.
    /// </summary>
    [Required]
    public decimal Position { get; set; }

    /// <summary>Référence au véhicule associé au trajet (véhicule courant à la date de création par défaut).</summary>
    [Required]
    public int VehiculeId { get; set; }
    public Vehicule? Vehicule { get; set; }

    /// <summary>Puissance fiscale (CV) du véhicule au moment de l'association ; stockée pour l'historique.</summary>
    [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)

CREATE TABLE Trajets (
    Id              INTEGER PRIMARY KEY AUTOINCREMENT,
    Date            TEXT    NOT NULL,
    AdresseDepart   TEXT    NOT NULL,
    AdresseArrivee  TEXT    NOT NULL,
    Distance        REAL    NULL,            -- distance SIMPLE ; NULL si non calculable
    AllerRetour     INTEGER NOT NULL DEFAULT 0,  -- booléen ; distance comptée doublée si 1
    GeometrieJson   TEXT,                     -- GeoJSON LineString de l'itinéraire, ou NULL
    Commentaire     TEXT,
    Position        REAL    NOT NULL DEFAULT 0,
    VehiculeId      INTEGER NOT NULL REFERENCES Vehicules(Id),
    PuissanceFiscale INTEGER NOT NULL,
    CreatedAt       TEXT    NOT NULL DEFAULT (datetime('now')),
    UpdatedAt       TEXT    NOT NULL DEFAULT (datetime('now'))
);

CREATE INDEX IX_Trajets_Date ON Trajets (Date);
CREATE INDEX IX_Trajets_Date_Position ON Trajets (Date, Position);
CREATE INDEX IX_Trajets_Vehicule ON Trajets (VehiculeId);  -- filtrage par véhicule

Stratégie d'insertion sans réordonnancement : la Position est un REAL (décimal) permettant d'insérer entre deux positions existantes P₁ et P₂ en assignant (P₁ + P₂) / 2. Un nouveau trajet ajouté en fin de journée reçoit max(Position) + 1.0. Lorsque la précision devient trop faible (différence < 0.001), le backend renumérote les positions de la journée concernée par pas de 1.0 en transaction.

Entité AdresseCache (base de données)

/// <summary>Cache des adresses géocodées via ORS.</summary>
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

    /// <summary>Géométrie GeoJSON de l'itinéraire (null si indisponible).</summary>
    public string? GeometrieJson { get; set; }

    public DateTime DerniereUtilisation { get; set; } = DateTime.UtcNow;
}

Entité AdresseNommee (base de données)

/// <summary>
/// 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.
/// </summary>
public class AdresseNommee
{
    public int Id { get; set; }

    /// <summary>Libellé libre et unique identifiant l'adresse (1 à 100 caractères).</summary>
    [Required, MaxLength(100)]
    public string Denomination { get; set; } = string.Empty;

    /// <summary>Texte d'adresse résolu via l'autocomplétion BAN (1 à 255 caractères).</summary>
    [Required, MaxLength(255)]
    public string Adresse { get; set; } = string.Empty;

    /// <summary>Latitude (précision 6 décimales) ; null si le géocodage a échoué.</summary>
    public double? Latitude { get; set; }

    /// <summary>Longitude (précision 6 décimales) ; null si le géocodage a échoué.</summary>
    public double? Longitude { get; set; }

    /// <summary>Vrai si les coordonnées ont été obtenues (cache ou BAN) ; faux sinon.</summary>
    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)

/// <summary>Profil utilisateur (singleton — une seule ligne).</summary>
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; }

    /// <summary>Matricule (8 caractères maximum).</summary>
    [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;
}

/// <summary>
/// Compte unique de l'application mono-utilisateur (singleton, comme le Profil).
/// Protège l'accès aux données ; authentification par email + mot de passe (Exigence 17).
/// </summary>
public class Compte
{
    /// <summary>Identifiant du compte. Valeur fixe (1) : le compte est un singleton.</summary>
    public int Id { get; set; } = 1;

    /// <summary>Adresse email, unique, servant d'identifiant de connexion (1–255 caractères).</summary>
    [Required, MaxLength(255)]
    public string Email { get; set; } = string.Empty;

    /// <summary>Hachage salé du mot de passe (PBKDF2 via PasswordHasher). Jamais exposé ni journalisé.</summary>
    [Required]
    public string MotDePasseHash { get; set; } = string.Empty;

    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;

    /// <summary>Mise à jour notamment lors d'une réinitialisation du mot de passe.</summary>
    public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

/// <summary>
/// Jeton de réinitialisation de mot de passe à usage unique et à durée de vie limitée (Exigence 17.11–17.14).
/// Seul le hachage du jeton est persisté ; le jeton en clair n'existe que dans l'email envoyé.
/// </summary>
public class ResetPasswordToken
{
    public int Id { get; set; }

    /// <summary>Hachage SHA-256 du jeton (le jeton en clair n'est jamais persisté).</summary>
    [Required]
    public string TokenHash { get; set; } = string.Empty;

    /// <summary>Date d'expiration (UTC), par défaut création + 60 min (configurable).</summary>
    public DateTime Expiration { get; set; }

    /// <summary>Date d'utilisation (UTC) ; null tant qu'inutilisé (garantit l'usage unique).</summary>
    public DateTime? UsedAt { get; set; }

    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

/// <summary>Véhicule de l'utilisateur avec date d'effet. Historique conservé.</summary>
public class Vehicule
{
    public int Id { get; set; }

    /// <summary>Dénomination libre du véhicule (ex. « Marque, Modèle »), obligatoire (1–100 caractères).</summary>
    [Required, MaxLength(100)]
    public string Denomination { get; set; } = string.Empty;

    /// <summary>Plaque d'immatriculation (1–15 caractères).</summary>
    [Required, MaxLength(15)]
    public string Plaque { get; set; } = string.Empty;

    /// <summary>Puissance fiscale en chevaux fiscaux (entier positif).</summary>
    [Required]
    public int PuissanceFiscale { get; set; }

    /// <summary>Date à partir de laquelle ce véhicule devient le véhicule courant (<= date du jour).</summary>
    [Required]
    public DateOnly DateEffet { get; set; }

    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;

    public ICollection<Trajet> Trajets { get; set; } = new List<Trajet>();
}

/// <summary>
/// Tranche du barème kilométrique URSSAF pour une année et une puissance fiscale. Une puissance
/// fiscale peut comporter PLUSIEURS tranches paramétrables (ex. 0–4999, 5000–19999, 20000+),
/// la dernière pouvant être illimitée. Les bornes sont exclusives en haut : [KmMin, KmMax[.
/// </summary>
public class TrancheBareme
{
    public int Id { get; set; }

    [Required]
    public int Annee { get; set; }

    [Required]
    public int PuissanceFiscale { get; set; }

    /// <summary>Borne minimale de kilométrage annuel (incluse).</summary>
    [Required]
    public decimal KmMin { get; set; }

    /// <summary>
    /// Borne maximale de kilométrage annuel, EXCLUSIVE : la tranche couvre [KmMin, KmMax[.
    /// <c>null</c> signifie « illimité » (dernière tranche, sans borne supérieure).
    /// </summary>
    public decimal? KmMax { get; set; }

    /// <summary>Coefficient en euros par kilomètre (3 décimales).</summary>
    [Required]
    public decimal Coefficient { get; set; }

    /// <summary>Base fixe en euros ajoutée au montant (souvent nulle). Formule : km × Coefficient + Base.</summary>
    public decimal Base { get; set; } = 0m;
}

/// <summary>Montant réglé (remboursement reçu) pour un mois, une année et un véhicule.</summary>
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; }

    /// <summary>Montant réglé en euros (>= 0), deux décimales.</summary>
    [Required]
    public decimal Montant { get; set; }

    public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

Schéma SQLite additionnel

CREATE TABLE AdresseCache (
    Id                  INTEGER PRIMARY KEY AUTOINCREMENT,
    Adresse             TEXT    NOT NULL UNIQUE,
    Latitude            REAL    NOT NULL,
    Longitude           REAL    NOT NULL,
    GeometrieJson       TEXT,
    DerniereUtilisation TEXT    NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE AdressesNommees (
    Id                    INTEGER PRIMARY KEY AUTOINCREMENT,
    Denomination          TEXT    NOT NULL,               -- 100 caractères maximum, casse/accents d'origine
    DenominationNormalisee TEXT   NOT NULL,               -- trim + minuscules + sans accents (clé d'unicité)
    Adresse               TEXT    NOT NULL,               -- 255 caractères maximum
    Latitude              REAL,                            -- null si géocodage échoué
    Longitude             REAL,                            -- null si géocodage échoué
    GeocodageReussi       INTEGER NOT NULL DEFAULT 0,
    CreatedAt             TEXT    NOT NULL DEFAULT (datetime('now')),
    UpdatedAt             TEXT    NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX UX_AdresseNommee_Denomination ON AdressesNommees (DenominationNormalisee);

CREATE TABLE Profil (
    Id                  INTEGER PRIMARY KEY DEFAULT 1,
    Nom                 TEXT,
    Prenom              TEXT,
    Matricule           TEXT,          -- 8 caractères maximum
    AdresseDomicile     TEXT,
    DomicileLatitude    REAL,
    DomicileLongitude   REAL,
    DomicileGeocode     INTEGER NOT NULL DEFAULT 0,
    UpdatedAt           TEXT    NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX UX_Profil_Singleton ON Profil (Id);

CREATE TABLE Comptes (
    Id             INTEGER PRIMARY KEY DEFAULT 1,      -- singleton (un seul compte)
    Email          TEXT    NOT NULL UNIQUE,            -- identifiant de connexion, 1–255 car.
    MotDePasseHash TEXT    NOT NULL,                    -- hachage salé (PBKDF2), jamais en clair
    CreatedAt      TEXT    NOT NULL DEFAULT (datetime('now')),
    UpdatedAt      TEXT    NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX UX_Compte_Singleton ON Comptes (Id);

CREATE TABLE ResetPasswordTokens (
    Id          INTEGER PRIMARY KEY AUTOINCREMENT,
    TokenHash   TEXT    NOT NULL,                       -- SHA-256 du jeton (jeton en clair jamais persisté)
    Expiration  TEXT    NOT NULL,                        -- UTC ; défaut création + 60 min
    UsedAt      TEXT,                                    -- NULL tant qu'inutilisé (usage unique)
    CreatedAt   TEXT    NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE Vehicules (
    Id               INTEGER PRIMARY KEY AUTOINCREMENT,
    Denomination     TEXT    NOT NULL,   -- libellé libre (ex. « Marque, Modèle »), 1–100 car.
    Plaque           TEXT    NOT NULL,
    PuissanceFiscale INTEGER NOT NULL CHECK (PuissanceFiscale > 0),
    DateEffet        TEXT    NOT NULL,   -- YYYY-MM-DD, <= date du jour
    CreatedAt        TEXT    NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IX_Vehicules_DateEffet ON Vehicules (DateEffet);

Détermination du Véhicule_Courant : SELECT * FROM Vehicules WHERE DateEffet <= date('now') ORDER BY DateEffet DESC, Id DESC LIMIT 1. À la création d'un Trajet, on résout le véhicule dont la DateEffet est la plus récente <= date du Trajet, et on copie sa PuissanceFiscale sur le Trajet (dénormalisation pour figer l'historique même si le véhicule est modifié plus tard).

CREATE TABLE TranchesBareme (
    Id               INTEGER PRIMARY KEY AUTOINCREMENT,
    Annee            INTEGER NOT NULL,
    PuissanceFiscale INTEGER NOT NULL,
    KmMin            REAL    NOT NULL,   -- borne minimale incluse (ex. 0, 5000, 20000)
    KmMax            REAL    NULL,       -- borne maximale EXCLUSIVE ; NULL = illimité (dernière tranche)
    Coefficient      REAL    NOT NULL,   -- €/km, 3 décimales
    Base             REAL    NOT NULL DEFAULT 0,
    CHECK (KmMax IS NULL OR KmMin < KmMax)
);
-- Plusieurs tranches par (Annee, PuissanceFiscale) : bornes exclusives [KmMin, KmMax[.
CREATE INDEX IX_TranchesBareme_Annee_PF ON TranchesBareme (Annee, PuissanceFiscale);

CREATE TABLE MontantsRegles (
    Id          INTEGER PRIMARY KEY AUTOINCREMENT,
    Annee       INTEGER NOT NULL,
    Mois        INTEGER NOT NULL CHECK (Mois BETWEEN 1 AND 12),
    VehiculeId  INTEGER NOT NULL REFERENCES Vehicules(Id),
    Montant     REAL    NOT NULL CHECK (Montant >= 0),
    UpdatedAt   TEXT    NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX UX_MontantsRegles_Periode_Vehicule ON MontantsRegles (Annee, Mois, VehiculeId);

Calcul du barème (URSSAF) : pour un total kilométrique annuel, une année et une puissance fiscale donnés, on sélectionne la tranche dont l'intervalle [KmMin, KmMax[ (KmMax exclusive, ou illimité si KmMax est NULL) contient le total annuel, puis Montant_Reel = total_km_annuel × Coefficient + Base. Le nombre de tranches par puissance fiscale est libre (paramétrable). La tranche est déterminée par le total annuel et non par les kilomètres d'un mois isolé. Le frais réel mensuel d'un mois = km_du_mois × Coefficient de la tranche annuelle ; la somme des douze frais réels mensuels + la Base de la tranche = Montant_Reel. Le Montant_Previsionnel (80% du barème sur le total partiel) est purement indicatif et n'entre pas dans le calcul de la régularisation.

DTOs

// Création — les coordonnées sont transmises par le Frontend (issues de la sélection BAN)
public record CreateTrajetDto(
    string Date,               // JJ/MM/AAAA en entrée
    string AdresseDepart,
    double? DepartLatitude,    // null si adresse saisie manuellement sans BAN
    double? DepartLongitude,
    string AdresseArrivee,
    double? ArriveeLatitude,   // null si adresse saisie manuellement sans BAN
    double? ArriveeLongitude,
    string? Commentaire,       // optionnel, max 500 car.
    int? VehiculeId,           // optionnel ; null → véhicule courant à la date du trajet
    bool AllerRetour = false   // true → distance comptée doublée (aller/retour)
);

// Mise à jour
public record UpdateTrajetDto(
    string Date,
    string AdresseDepart,
    double? DepartLatitude,
    double? DepartLongitude,
    string AdresseArrivee,
    double? ArriveeLatitude,
    double? ArriveeLongitude,
    string? Commentaire,
    int? VehiculeId,           // optionnel ; null → véhicule courant à la date du trajet
    bool AllerRetour = false   // true → distance comptée doublée (aller/retour)
);

// Résultat de recherche BAN (proxied via backend si nécessaire)
public record BANAdresseDto(
    string Label,
    double Latitude,
    double Longitude,
    string Postcode,
    string City
);

// Réponse trajet — Distance = distance EFFECTIVE (doublée si aller/retour), null si non calculée
public record TrajetDto(
    int Id,
    DateOnly Date,
    string AdresseDepart,
    string AdresseArrivee,
    decimal? Distance,      // distance COMPTÉE (effective), km 2 décimales ; null si non calculée
    string? Commentaire,
    decimal Position,       // rang dans la journée
    int VehiculeId,
    int PuissanceFiscale,
    bool AllerRetour        // information aller/retour conservée par trajet
);

// Réordonnancement manuel des trajets d'une journée
public record ReordonnerTrajetsDto(
    IReadOnlyList<TrajetPositionDto> Trajets
);

public record TrajetPositionDto(
    int Id,
    decimal Position
);

// Résumé mensuel
public record ResumeMensuelDto(
    int Annee,
    int Mois,
    IReadOnlyList<TrajetDto> Trajets,
    decimal TotalKm        // somme distances, 2 décimales
);

// Résumé annuel
public record ResumeAnnuelDto(
    int Annee,
    IReadOnlyList<ResumeMensuelDto> Mois,  // toujours 12 éléments
    decimal TotalKm
);

DTOs — nouvelles fonctionnalités

// Trajet enrichi pour la carte
public record TrajetCarteDto(
    int Id,
    DateOnly Date,
    string AdresseDepart,
    string AdresseArrivee,
    decimal Distance,           // distance EFFECTIVE (doublée si aller/retour)
    double? DepartLatitude,
    double? DepartLongitude,
    double? ArriveeLatitude,
    double? ArriveeLongitude,
    string? GeometrieJson,      // GeoJSON LineString ou null
    string? Commentaire = null, // affiché en tête de l'infobulle carte
    bool AllerRetour = false    // mention « (A/R) » après la distance dans l'infobulle
);

// Suggestion autocomplétion
public record AdresseCacheDto(
    string Adresse,
    double Latitude,
    double Longitude
);

// Carnet d'adresses — Adresse_Nommee retournée par le backend
public record AdresseNommeeDto(
    int Id,
    string Denomination,
    string Adresse,
    double? Latitude,        // null si le géocodage a échoué
    double? Longitude        // null si le géocodage a échoué
);

// Carnet d'adresses — création / mise à jour d'une Adresse_Nommee
public record SaveAdresseNommeeDto(
    string Denomination,     // 1 à 100 car., unique après normalisation
    string Adresse,          // 1 à 255 car.
    double? Latitude,        // fournies par le frontend si sélection BAN
    double? Longitude
);

// Profil
public record ProfilDto(
    string? Nom,
    string? Prenom,
    string? Matricule,
    string? AdresseDomicile,
    bool DomicileGeocode
);

public record SaveProfilDto(
    string? Nom,
    string? Prenom,
    string? Matricule,       // max 8 car.
    string AdresseDomicile
);

// État de configuration du profil (onboarding première utilisation)
public record EtatProfilDto(
    bool EstConfigure        // true ssi Profil.Nom et Profil.Prenom non vides ET au moins un Vehicule existe
);

// Véhicules
public record VehiculeDto(
    int Id,
    string Denomination,     // libellé libre (ex. « Marque, Modèle »)
    string Plaque,
    int PuissanceFiscale,
    DateOnly DateEffet
);

public record CreateVehiculeDto(
    string Denomination,     // 1 à 100 car.
    string Plaque,
    int PuissanceFiscale,
    string DateEffet         // JJ/MM/AAAA, <= date du jour
);

// Réponse à la création d'un véhicule : indique s'il faut proposer une réaffectation
public record VehiculeCreeDto(
    VehiculeDto Vehicule,
    bool ReaffectationProposee,       // true si des trajets >= DateEffet ont une autre puissance fiscale
    int NombreTrajetsConcernes
);

// Requête de réaffectation
public record ReaffectationDto(
    int VehiculeId,          // nouveau véhicule
    bool Accepter            // true = réaffecter les trajets >= DateEffet ; false = ne rien changer
);

// Totaux affichés dans le Panneau_Totaux
public record TotauxDto(
    DateOnly Date,
    int VehiculeId,              // véhicule sur lequel portent les totaux
    decimal TotalJour,
    decimal TotalMois,
    decimal TotalAnnuelPartiel
);

// Barème
public record TrancheBaremeDto(
    int Id,
    int Annee,
    int PuissanceFiscale,
    decimal KmMin,
    decimal? KmMax,          // borne exclusive ; null = illimité (dernière tranche)
    decimal Coefficient,     // €/km, 3 décimales
    decimal Base
);

public record SaveTrancheBaremeDto(
    int Annee,
    int PuissanceFiscale,
    decimal KmMin,
    decimal? KmMax,          // borne exclusive ; null = illimité
    decimal Coefficient,
    decimal Base
);

// Montant réglé
public record MontantRegleDto(
    int Annee,
    int Mois,
    int VehiculeId,
    decimal Montant
);

// Indication de versement mensuel (informatif) affichée à la saisie
public record MontantPrevisionnelDto(
    int Annee,
    int Mois,
    int VehiculeId,
    decimal TotalAnnuelPartielKm,
    decimal MontantBaremePlein,      // barème plein sur le total partiel
    decimal MontantPrevisionnel      // = 80% (indicatif uniquement)
);

// Ligne mensuelle de la synthèse annuelle (aligne sur la synthèse réelle)
public record LigneSyntheseMoisDto(
    int Mois,
    decimal KmMois,                  // kilomètres parcourus dans le mois
    decimal FraisPaye,               // Montant_Regle du mois (saisi)
    decimal FraisReelMensuel         // km_du_mois × coefficient de la tranche annuelle
);

// Synthèse annuelle de régularisation pour un véhicule (page 1 du rapport)
public record RegularisationAnnuelleDto(
    int Annee,
    int VehiculeId,
    string Plaque,
    int PuissanceFiscale,
    decimal CoefficientTranche,      // coefficient de la tranche annuelle applicable
    decimal BaseTranche,             // base de la tranche annuelle applicable
    string TrancheLibelle,           // ex. "5 001 à 20 000 km — D2"
    IReadOnlyList<LigneSyntheseMoisDto> Mois,   // 12 lignes
    decimal TotalKmAnnuel,
    decimal TotalPaye,               // somme des Montant_Regle
    decimal MontantReel,             // barème plein : total_km × coef + base
    decimal Regularisation           // MontantReel − TotalPaye
);

// --- Authentification (Exigence 17) ---

// Inscription : création de l'unique compte
public record RegisterDto(
    string Email,            // identifiant de connexion, 1–255 car.
    string MotDePasse        // >= 8 caractères
);

// Connexion
public record LoginDto(
    string Email,
    string MotDePasse
);

// Réponse de connexion : jeton JWT + expiration
public record LoginResultDto(
    string Token,            // JWT signé HS256
    DateTime Expiration      // date d'expiration (UTC)
);

// Demande de réinitialisation de mot de passe
public record ForgotPasswordDto(
    string Email
);

// Réinitialisation effective à partir du jeton reçu par email
public record ResetPasswordDto(
    string Token,            // jeton en clair reçu par email
    string MotDePasse        // nouveau mot de passe, >= 8 caractères
);

// Changement de mot de passe par un utilisateur authentifié (Exigence 20). À la
// différence de ResetPasswordDto (jeton reçu par email), exige l'ancien mot de passe.
public record ChangePasswordDto(
    string MotDePasseActuel,
    string NouveauMotDePasse  // >= 8 caractères
);

// --- Documents de spécification (Exigence 19) ---

// Métadonnées d'un document (élément du sous-menu)
public record SpecDocumentDto(
    string Cle,              // whitelist : "requirements" | "design" | "tasks"
    string Titre,            // libellé lisible (ex. « Exigences »)
    bool Disponible          // fichier présent et lisible
);

// Contenu Markdown d'un document
public record SpecContenuDto(
    string Cle,
    string Titre,
    string Markdown          // contenu brut, rendu en HTML côté frontend
);

Endpoints API REST

Base URL : /api

Trajets

Méthode Route Description Corps Réponse succès
POST /api/trajets Créer un trajet CreateTrajetDto 201 Created + TrajetDto
GET /api/trajets/{id} Récupérer un trajet — 200 OK + TrajetDto
PUT /api/trajets/{id} Modifier un trajet UpdateTrajetDto 200 OK + TrajetDto
DELETE /api/trajets/{id} Supprimer un trajet — 204 No Content
PATCH /api/trajets/reordonner Mettre à jour l'ordre des trajets d'une journée ReordonnerTrajetsDto 204 No Content
POST /api/trajets/recalculer-distances Recalculer via ORS les trajets sans géométrie routière (limite les appels ORS aux trajets en mode dégradé) — 200 OK + nombre recalculé

Authentification (Exigence 17)

Ces endpoints sont anonymes ([AllowAnonymous]). Tous les autres endpoints exigent un JWT valide et renvoient 401 Unauthorized sans jeton (ou si le compte associé n'existe plus).

Méthode Route Description Corps Réponse succès
GET /api/auth/compte-existe Indique si l'unique compte existe déjà (oriente inscription vs connexion) — 200 OK + { existe: bool }
POST /api/auth/register Créer l'unique compte RegisterDto 200 OK (compte créé ; 400/409 si déjà existant)
POST /api/auth/login Se connecter LoginDto 200 OK + LoginResultDto ; 401 si identifiants invalides
GET /api/auth/moi Valider la session courante (jeton valide + compte existant) — endpoint protégé — 200 OK ; 401 sinon
POST /api/auth/forgot-password Demander un lien de réinitialisation (envoi email, sinon lien journalisé) ForgotPasswordDto 200 OK
POST /api/auth/reset-password Réinitialiser le mot de passe via le jeton reçu ResetPasswordDto 200 OK ; 400 si jeton invalide/expiré/déjà utilisé
POST /api/auth/change-password Changer le mot de passe d'un utilisateur authentifié (exige l'ancien mot de passe) — endpoint protégé ChangePasswordDto 200 OK ; 400 si mot de passe actuel incorrect ; 401 sans jeton

Placement de change-password : cet endpoint est déclaré dans SessionController (route api/auth, SANS [AllowAnonymous] au niveau classe) et non dans AuthController (qui porte [AllowAnonymous] au niveau classe). En ASP.NET Core, [AllowAnonymous] posé sur une classe s'applique à TOUTES ses actions et ne peut pas être contredit par un [Authorize] posé sur une seule action : un premier essai avec [Authorize] sur l'action dans AuthController a laissé l'endpoint accessible sans jeton (vérifié par test manuel), d'où le déplacement vers SessionController, protégé par l'autorisation globale de l'application.

Résumés

Méthode Route Description Réponse succès
GET /api/resumes/mensuel/{annee}/{mois}?vehiculeId={id} Résumé mensuel (filtre véhicule, courant si omis) 200 OK + ResumeMensuelDto
GET /api/resumes/annuel/{annee}?vehiculeId={id} Résumé annuel (filtre véhicule, courant si omis) 200 OK + ResumeAnnuelDto

Exports PDF

Méthode Route Description Réponse succès
GET /api/exports/mensuel/{annee}/{mois}?vehiculeId={id} PDF mensuel (filtre véhicule, courant si omis) 200 OK + application/pdf
GET /api/exports/annuel/{annee}?vehiculeId={id} PDF annuel (filtre véhicule, courant si omis) 200 OK + application/pdf

Carte

Méthode Route Description Réponse succès
GET /api/trajets/journee/{date} Trajets d'une journée avec géométries 200 OK + TrajetCarteDto[]

Adresses cache

Méthode Route Description Réponse succès
GET /api/adresses/search?q={query} Recherche autocomplétion (min 3 car.) 200 OK + AdresseCacheDto[]

Carnet d'adresses

Méthode Route Description Corps Réponse succès
GET /api/adresses-nommees Lister les Adresses_Nommées (triées par Dénomination) — 200 OK + AdresseNommeeDto[]
POST /api/adresses-nommees Créer une Adresse_Nommee SaveAdresseNommeeDto 201 Created + AdresseNommeeDto
PUT /api/adresses-nommees/{id} Modifier une Adresse_Nommee (re-géocodage si l'adresse change et n'est pas en cache) SaveAdresseNommeeDto 200 OK + AdresseNommeeDto
DELETE /api/adresses-nommees/{id} Supprimer une Adresse_Nommee (sans impact sur les Trajets existants) — 204 No Content

Profil

Méthode Route Description Réponse succès
GET /api/profil Récupérer le profil 200 OK + ProfilDto
PUT /api/profil Sauvegarder le profil 200 OK + ProfilDto
GET /api/profil/etat État de configuration du profil (onboarding) : EstConfigure = true ssi le Profil a un Nom + Prénom non vides ET au moins un Véhicule existe 200 OK + EtatProfilDto

Véhicules

Méthode Route Description Corps Réponse succès
GET /api/vehicules Historique des véhicules — 200 OK + VehiculeDto[]
GET /api/vehicules/courant Véhicule courant — 200 OK + VehiculeDto
POST /api/vehicules Enregistrer un véhicule CreateVehiculeDto 201 Created + VehiculeCreeDto
POST /api/vehicules/reaffectation Accepter/refuser la réaffectation ReaffectationDto 204 No Content

Navigation temporelle

Méthode Route Description Réponse succès
GET /api/annees-disponibles Années ayant des données + année courante 200 OK + number[]
GET /api/mois-disponibles/{annee} Mois de l'année ayant des données 200 OK + number[] (1–12)
GET /api/totaux/{date}?vehiculeId={id} Totaux jour/mois/année partiel du véhicule (courant si omis) 200 OK + TotauxDto

Barème et remboursements

Méthode Route Description Corps Réponse succès
GET /api/bareme/{annee} Barème d'une année (toutes tranches) — 200 OK + TrancheBaremeDto[]
POST /api/bareme Ajouter une tranche SaveTrancheBaremeDto 201 Created + TrancheBaremeDto
PUT /api/bareme/{id} Modifier une tranche SaveTrancheBaremeDto 200 OK + TrancheBaremeDto
DELETE /api/bareme/{id} Supprimer une tranche — 204 No Content
GET /api/bareme/annees-disponibles Années ayant un barème défini — 200 OK + number[]
POST /api/bareme/copier/{anneeCible} Recopier le dernier barème antérieur vers l'année cible (annule et remplace) — 200 OK + nombre de tranches copiées
GET /api/montant-previsionnel/{annee}/{mois}?vehiculeId={id} Montant prévisionnel minoré 80% — 200 OK + MontantPrevisionnelDto
GET /api/montants-regles/{annee}?vehiculeId={id} Montants réglés de l'année — 200 OK + MontantRegleDto[]
PUT /api/montants-regles Enregistrer/mettre à jour un montant réglé MontantRegleDto 200 OK + MontantRegleDto
GET /api/regularisation/{annee}?vehiculeId={id} Synthèse annuelle de régularisation : RegularisationAnnuelleDto avec les 12 LigneSyntheseMoisDto, le coefficient et la base de la tranche annuelle, le total réel (barème plein) et la régularisation (réel − payé) — 200 OK + RegularisationAnnuelleDto

Documents de spécification (Exigence 19)

Endpoints protégés (authentification requise). L'accès est restreint à une whitelist de clés (requirements, design, tasks) : aucun chemin de fichier n'est construit à partir d'une entrée libre (pas de path traversal).

Méthode Route Description Réponse succès
GET /api/specs Lister les documents et leur disponibilité (sous-menu) 200 OK + SpecDocumentDto[]
GET /api/specs/{cle} Contenu Markdown d'un document (clé whitelistée) 200 OK + SpecContenuDto ; 404 si clé inconnue / fichier absent

Codes d'erreur standardisés

Code HTTP Cas
400 Bad Request Validation échouée (champ manquant, date invalide, paramètre hors plage)
401 Unauthorized Jeton JWT absent, invalide, expiré, ou compte associé inexistant
404 Not Found Trajet inexistant
503 Service Unavailable ORS indisponible ou timeout
500 Internal Server Error Erreur inattendue

Toutes les erreurs retournent un objet ProblemDetails (RFC 7807) :

{
  "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

sequenceDiagram
    participant U as Utilisateur
    participant FE as Angular Frontend
    participant BAN as API BAN
    participant API as Backend API
    participant ORS as ORS API
    participant DB as SQLite

    U->>FE: Saisit 3+ caractères dans champ adresse
    FE->>BAN: GET /search/?q={saisie}&limit=5
    BAN-->>FE: Liste de suggestions (label + coords)
    FE-->>U: Affiche suggestions dropdown
    U->>FE: Sélectionne une suggestion
    FE->>FE: Stocke label + lat/lon en état local

    U->>FE: Soumet formulaire (adresses + coords)
    FE->>API: POST /api/trajets (avec coordonnées)
    
    alt Coordonnées départ ET arrivée présentes
        API->>ORS: POST /v2/directions/driving-car
        ORS-->>API: distance (mètres) + géométrie GeoJSON
        API->>DB: INSERT Trajet (distance routière)
    else Coordonnées partiellement absentes
        API->>BAN: GET /search/?q={adresse_manquante}
        alt BAN géocode avec succès
            API->>ORS: POST /v2/directions/driving-car
            API->>DB: INSERT Trajet (distance calculée)
        else BAN échoue aussi
            API->>DB: INSERT Trajet (distance = null, avertissement)
        end
    end
    API-->>FE: 201 Created + TrajetDto

Appel ORS (itinéraire uniquement)

ORS est appelé uniquement avec des coordonnées déjà résolues. Plus aucun appel de géocodage ORS.

Géométrie portée par le Trajet : la géométrie GeoJSON de l'itinéraire dépend de la paire départ→arrivée ; elle est donc stockée sur le Trajet (GeometrieJson) et non dans le Cache_Adresses (indexé par adresse seule). Le cache sert de repli pour les trajets hérités sans géométrie.

Recalcul ciblé des distances : l'endpoint POST /api/trajets/recalculer-distances ne (re)sollicite ORS que pour les trajets sans géométrie routière (GeometrieJson NULL), afin de corriger les distances calculées en mode dégradé (ex. clé ORS invalide à la saisie) sans re-solliciter inutilement ORS pour les trajets déjà tracés.

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é)

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

// appsettings.json
{
  "BAN": {
    "BaseUrl": "https://api-adresse.data.gouv.fr",
    "TimeoutSeconds": 5
  },
  "ORS": {
    "BaseUrl": "https://api.openrouteservice.org",
    "ApiKey": "<YOUR_ORS_API_KEY>",
    "TimeoutSeconds": 10
  }
}
// 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 /data/trajets.db, monté depuis ./data de l'hôte via un bind mount (accès direct au fichier).
  • frontend : application Angular compilée et servie par nginx. Nginx sert la SPA (fallback vers index.html pour le routage côté client) et agit comme reverse-proxy en relayant les requêtes /api vers le service backend. C'est le seul service exposé à l'hôte (port 2202 de l'hôte → nginx). La terminaison HTTPS est assurée par un reverse-proxy Caddy externe.
graph LR
    U[Navigateur] -- "Caddy HTTPS externe → http://hôte:2202" --> NGINX
    subgraph "Réseau Docker Compose"
        NGINX[frontend<br/>nginx + SPA Angular]
        API[backend<br/>ASP.NET Core :8080]
        NGINX -- "proxy /api → backend:8080" --> API
        API -- "/data/trajets.db" --> VOL[(Bind mount<br/>./data sur l'hôte)]
    end

Approche retenue : à l'intérieur de Compose, nginx (conteneur frontend) sert les fichiers statiques Angular et relaie /api vers le backend sur le réseau interne ; le backend n'est pas exposé directement à l'hôte. La terminaison HTTPS est déléguée à un reverse-proxy Caddy externe (sur une autre machine) qui expose l'application sur le net via une URL HTTPS et relaie vers IP_hôte:2202. Le port hôte 2202 est donc à restreindre par pare-feu à l'IP du proxy Caddy. Le backend ne réalise pas de redirection HTTPS (UseHttpsRedirection retiré), la terminaison TLS étant assurée en amont.

Backend Dockerfile (multi-stage)

# ---- Étape build (SDK) ----
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY *.sln ./
COPY SchotzIadKmTracker.Api/*.csproj SchotzIadKmTracker.Api/
RUN dotnet restore SchotzIadKmTracker.Api/SchotzIadKmTracker.Api.csproj
COPY . .
RUN dotnet publish SchotzIadKmTracker.Api/SchotzIadKmTracker.Api.csproj -c Release -o /app/publish

# ---- Étape runtime (ASP.NET) ----
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .

# Répertoire du volume pour la base SQLite persistée
RUN mkdir -p /data
VOLUME ["/data"]

# La base SQLite est stockée sur le volume monté ; le chemin est fourni via la chaîne de connexion
ENV ConnectionStrings__Default="Data Source=/data/trajets.db"
ENV ASPNETCORE_URLS="http://+:8080"

EXPOSE 8080
ENTRYPOINT ["dotnet", "SchotzIadKmTracker.Api.dll"]

Frontend Dockerfile (multi-stage)

# ---- Étape build (Node) ----
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build -- --configuration production

# ---- Étape serve (nginx) ----
FROM nginx:1.27-alpine AS serve
# Configuration nginx (SPA + reverse-proxy /api)
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Artefacts Angular compilés
COPY --from=build /app/dist/schotziad-km-tracker /usr/share/nginx/html
EXPOSE 80

Configuration nginx (nginx.conf)

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

services:
  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    environment:
      - ASPNETCORE_URLS=http://+:8080
      - ConnectionStrings__Default=Data Source=/data/trajets.db
      # La clé ORS est fournie par l'environnement / .env — jamais intégrée à l'image
      - ORS__ApiKey=${ORS_API_KEY}
      # Clé de signature JWT : OBLIGATOIRE (le backend refuse de démarrer si absente)
      - JWT__SecretKey=${JWT_SECRET_KEY:?JWT_SECRET_KEY requis}
      # URL publique du frontend (liens de réinitialisation de mot de passe), ex. https://mon-domaine
      - Auth__FrontendBaseUrl=${AUTH_FRONTEND_BASE_URL}
    volumes:
      # Bind mount : base accessible sur l'hôte dans ./data/trajets.db
      - ./data:/data
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 20s

  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
    ports:
      - "2202:80"          # hôte 2202 → nginx (frontend) ; 8080 évité (conflit hôte fréquent)
    depends_on:
      backend:
        condition: service_healthy
    restart: unless-stopped

Endpoint de santé : le backend expose GET /health (via AddHealthChecks() / MapHealthChecks("/health")) utilisé par le healthcheck Compose et la condition depends_on: service_healthy.

Configuration et secrets

  • La clé ORS est fournie exclusivement via variable d'environnement (ORS_API_KEY), typiquement à partir d'un fichier .env non commité (ajouté au .gitignore) et lu automatiquement par Docker Compose. Elle n'est jamais intégrée dans une image Docker.
  • La clé secrète JWT (JWT_SECRET_KEY) est obligatoire : le backend refuse de démarrer si elle est absente. Elle est fournie via .env, jamais commitée.
  • L'URL publique du frontend (AUTH_FRONTEND_BASE_URL, ex. https://mon-domaine) sert à construire les liens de réinitialisation de mot de passe envoyés par e-mail — et non le port interne.
  • La configuration e-mail (SMTP via MailKit) est optionnelle : si elle est absente, le lien de réinitialisation est journalisé en repli (fallback) au lieu d'être envoyé.
  • L'API BAN ne nécessite aucune clé — aucune configuration de secret n'est requise pour le géocodage.
  • Le bind mount ./data:/data garantit que le fichier trajets.db persiste à travers les redémarrages, recréations et mises à jour des conteneurs, tout en le rendant directement accessible sur l'hôte.

Migrations au démarrage

Les migrations EF Core sont appliquées automatiquement au démarrage du backend (await dbContext.Database.MigrateAsync() dans Program.cs avant app.Run()). Ainsi, au premier lancement, la base /data/trajets.db est créée sur le volume monté ; aux lancements suivants, elle est mise à jour avec les migrations en attente sans intervention manuelle.

Documents de spécification embarqués : le Dockerfile backend a pour contexte la racine du dépôt et copie .kiro/specs/schotziad-km-tracker/{requirements,design,tasks}.md dans ./specs/ de l'image. Le SpecDocumentService lit en priorité ce dossier embarqué (ContentRootPath/specs), avec repli sur .kiro/specs/... en développement. L'application est ainsi autonome, sans montage du dépôt à l'exécution.

Accès à la base de données

La base SQLite est exposée à l'hôte via un bind mount (./data:/data dans docker-compose.yml) : le fichier data/trajets.db est directement lisible et sauvegardable sans entrer dans le conteneur, à côté du docker-compose.yml.

Inspection / lecture : ouvrir data/trajets.db avec un outil SQLite (DB Browser for SQLite, sqlite3, extension VS Code…). La lecture pendant que l'application tourne est sûre.

Sauvegarde à froid (recommandée) : SQLite tolère mal l'écriture concurrente. Pour un backup cohérent, arrêter le backend le temps de la copie, ou utiliser la commande de sauvegarde en ligne intégrée :

# Option 1 — snapshot à froid (cohérence garantie)
docker compose stop backend
cp data/trajets.db "backup-$(date +%F).db"
docker compose start backend

# Option 2 — sauvegarde en ligne SQLite (sans arrêt), si sqlite3 est installé sur l'hôte
sqlite3 data/trajets.db ".backup 'backup-$(date +%F).db'"

Concurrence : éviter d'écrire dans data/trajets.db depuis l'extérieur pendant que l'application tourne (risque de corruption). La lecture reste sûre.

Migration depuis l'ancien volume nommé : les déploiements antérieurs utilisaient un volume nommé Docker (trajets-data). Pour récupérer ces données dans le nouveau bind mount ./data avant de basculer :

# Extraire la base de l'ancien volume vers ./data (backend encore basé sur l'ancienne image)
mkdir -p data
docker compose cp backend:/data/trajets.db ./data/trajets.db   # si le conteneur tourne encore
# — ou, si le service est déjà recréé, copier depuis le volume nommé :
docker run --rm -v trajets-data:/from -v "$PWD/data":/to alpine \
  sh -c "cp -a /from/. /to/"
# Puis recréer avec le bind mount :
docker compose up -d --build

Après migration et vérification, l'ancien volume nommé peut être supprimé : docker volume rm trajets-data.

Migrations notables : VehiculeDenomination (dénomination du véhicule), TrajetGeometrieJson (géométrie d'itinéraire portée par le trajet), TrancheBaremeKmMaxNullable / bornes exclusives / coefficient numérique (barème multi-tranches à dernière tranche illimitée), AjoutAuthentification (comptes + jetons de réinitialisation), TrajetAllerRetour (indicateur aller/retour).

Error Handling

Stratégie globale

Un middleware GlobalExceptionHandlerMiddleware (ou IExceptionHandler .NET 8) intercepte toutes les exceptions non gérées et retourne un ProblemDetails standardisé.

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 :

public class CreateTrajetDtoValidator : AbstractValidator<CreateTrajetDto>
{
    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<SaveAdresseNommeeDto>
{
    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 :

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.

// Tag : Feature: schotziad-km-tracker, Property 1: Cohérence du total mensuel
[Property(MaxTest = 200)]
public Property TotalMensuelEgaleSommeDistances()
{
    return Prop.ForAll(
        Arb.From(GenTrajetList()),
        trajets => {
            var total = _resumeService.CalculerTotalMensuel(trajets);
            var somme = trajets.Sum(t => t.Distance);
            return Math.Abs(total - somme) <= 0.01m;
        });
}

// Tag : Feature: schotziad-km-tracker, Property 2: Cohérence du total annuel
[Property(MaxTest = 200)]
public Property TotalAnnuelEgaleSommeMensuels() { ... }

// Tag : Feature: schotziad-km-tracker, Property 3: Précision de stockage des distances
[Property(MaxTest = 200)]
public Property DistanceArrondieDeuxDecimales() { ... }

// Tag : Feature: schotziad-km-tracker, Property 4: Rejet systématique des données d'entrée invalides
[Property(MaxTest = 500)]
public Property EntreeInvalideRetourne400SansPersistance() { ... }
// Génère des CreateTrajetDto avec dates invalides (mauvais format, date inexistante)
// ET/OU adresses vides/null/> 255 caractères → vérifie HTTP 400 + aucune entrée créée en base

// Tag : Feature: schotziad-km-tracker, Property 5: Invariant de suppression
[Property(MaxTest = 200)]
public Property TrajetSupprimePasInResume() { ... }

// Tag : Feature: schotziad-km-tracker, Property 6: Recalcul conditionnel de la distance
[Property(MaxTest = 200)]
public Property ModificationSansAdressePreserveDistance() { ... }

// Tag : Feature: schotziad-km-tracker, Property 7: Mois toujours présents dans le résumé annuel
[Property(MaxTest = 200)]
public Property ResumeAnnuelContient12Mois() { ... }

// Tag : Feature: schotziad-km-tracker, Property 8: Rejet des paramètres de résumé hors plage
[Property(MaxTest = 300)]
public Property ParamsHorsPlageRetourne400() { ... }
// Génère des mois hors [1,12] et des années hors [2000,2100]

// Tag : Feature: schotziad-km-tracker, Property 9: Nommage des fichiers PDF exportés
[Property(MaxTest = 200)]
public Property NomFichierPdfConforme() { ... }
// Génère des couples (mois 1-12, année 2000-2100) et des années valides,
// vérifie que Content-Disposition correspond au format attendu

// Tag : Feature: schotziad-km-tracker, Property 10: Cohérence du cache après géocodage
[Property(MaxTest = 200)]
public Property AdresseGecoodeePresenteDansCache() { ... }

// Tag : Feature: schotziad-km-tracker, Property 11: Politique LRU
[Property(MaxTest = 200)]
public Property CacheNExcedePas1000Entrees() { ... }

// Tag : Feature: schotziad-km-tracker, Property 12: Cohérence profil domicile
[Property(MaxTest = 200)]
public Property ProfilDomicileCoherent() { ... }

// Property 13: Persistance optionnelle du commentaire
[Property(MaxTest = 300)]
public Property CommentaireStockeEtRetourneIntact() { ... }
// Génère des commentaires de longueur 0 à 500 et vérifie round-trip,
// génère aussi des commentaires > 500 et vérifie HTTP 400

// Property 14: Cohérence de l'ordre des trajets d'une journée
[Property(MaxTest = 200)]
public Property InsertionPositionStrictementEntreDeuxExistants()
{
    // Génère une liste de trajets d'une journée avec positions P1 < P2,
    // insère un trajet entre les deux et vérifie P1 < Position_inséré < P2
}

// Property 15: Unicité et cohérence des positions
[Property(MaxTest = 200)]
public Property PositionsUniquesParJournee()
{
    // Vérifie qu'après N insertions, aucune position n'est dupliquée dans une journée
}

// Property 16: Priorité BAN sur ORS
[Property(MaxTest = 200)]
public Property BANAppeleeAvantORSQuandCoordManquantes() { ... }
// Vérifie que ORS n'est jamais appelé avec des coordonnées null

// Property 17: Total annuel partiel borné au mois
[Property(MaxTest = 200)]
public Property TotalAnnuelPartielBorneAuMois() { ... }
// Vérifie TotalAnnuelPartiel = somme des trajets mois <= M, et >= TotalMois

// Property 18: Aucune date future sélectionnable
[Property(MaxTest = 200)]
public Property AucuneDateFutureProposee() { ... }

// Property 19: Association véhicule courant par défaut
[Property(MaxTest = 200)]
public Property TrajetAssocieVehiculeCourant() { ... }

// Property 20: Réaffectation bornée à la date d'effet
[Property(MaxTest = 200)]
public Property ReaffectationBorneeDateEffet() { ... }

// Property 21: Totaux filtrés par véhicule
[Property(MaxTest = 200)]
public Property TotauxFiltresParVehicule() { ... }

// Property 22: Conservation historique véhicules
[Property(MaxTest = 200)]
public Property HistoriqueVehiculesConserve() { ... }

// Property 23: Indication 80% informative
[Property(MaxTest = 200)]
public Property Previsionnel80IndicatifSansImpactRegularisation() { ... }

// Property 24: Sélection de tranche
[Property(MaxTest = 300)]
public Property TrancheSelectionneeContientKm() { ... }

// Property 25: Régularisation barème plein
[Property(MaxTest = 200)]
public Property RegularisationBaremePlein() { ... }
// Vérifie Montant_Reel = km_annuel × coef + base, Regularisation = reel − total réglé,
// et somme des frais réels mensuels + base = Montant_Reel (tolérance 0,01)

// Property 26: Unicité montant réglé par période/véhicule
[Property(MaxTest = 200)]
public Property MontantRegleUniqueParPeriode() { ... }

// Property 27: Sélection de tranche par le total annuel
[Property(MaxTest = 300)]
public Property TrancheSelectionneeParTotalAnnuel() { ... }

// Tag : Feature: schotziad-km-tracker, Property 28: Unicité normalisée de la dénomination
[Property(MaxTest = 300)]
public Property DenominationUniqueApresNormalisation() { ... }
// Génère des dénominations variant en casse/accents/espaces de bord ;
// vérifie qu'une seconde dénomination normalisée identique est rejetée (HTTP 400, aucune persistance)

// Tag : Feature: schotziad-km-tracker, Property 29: Préservation des trajets à la suppression
[Property(MaxTest = 200)]
public Property SuppressionAdresseNommeePreserveTrajets() { ... }
// Génère des trajets + des adresses nommées, supprime une adresse nommée,
// vérifie que les textes d'adresse et coordonnées des trajets sont inchangés

// Tag : Feature: schotziad-km-tracker, Property 30: Réutilisation du cache et cohérence du géocodage
[Property(MaxTest = 200)]
public Property AdresseNommeeReutiliseCacheOuGeocode() { ... }
// Adresse en cache (après normalisation) → coords réutilisées, BAN non appelé (mock BAN vérifié) ;
// adresse valide absente du cache → couple présent en cache après opération ;
// BAN indisponible/timeout → coordonnées null + GeocodageReussi = false

// Tag : Feature: schotziad-km-tracker, Property 31: Détermination de l'état de configuration du profil
[Property(MaxTest = 300)]
public Property EtatProfilConfigureIffNomPrenomEtVehicule() { ... }
// Génère des états variés (Profil absent/présent, Nom/Prénom vides ou remplis, 0..N véhicules) ;
// vérifie EstConfigure == (Nom non vide && Prénom non vide && au moins un véhicule)

Tests unitaires spécifiques (xUnit)

  • TrajetService: création, modification avec/sans recalcul, suppression, transactions, rollback sur erreur ORS
  • TrajetService: création avec commentaire, création sans commentaire (null stocké), modification commentaire seul (pas de recalcul distance), validation commentaire > 500 car. → HTTP 400
  • BANClient: résolution adresse réussie, adresse non trouvée (score < seuil), timeout BAN → avertissement discret
  • DistanceCalculatorService: coordonnées connues → appel ORS ; coordonnées inconnues → appel BAN d'abord, puis ORS si BAN OK ; ORS timeout → fallback Haversine (plus d'erreur bloquante)
  • DistanceCalculatorService: BAN indisponible → saisie manuelle autorisée, distance null stockée
  • ResumeService: total mois vide = 0, résumé annuel avec mois manquants = 0
  • ResumeService / TotauxService: calcul TotalAnnuelPartiel (cumul jusqu'au mois inclus, exclusion des mois postérieurs), années disponibles (données + année courante), mois/jours disponibles
  • PdfService: génération mensuelle, génération annuelle, nommage des fichiers, timeout
  • AdresseCacheService: insertion nouvelle adresse, réutilisation adresse existante, éviction LRU à 1 001 entrées
  • ProfilService: sauvegarde avec géocodage réussi, sauvegarde avec géocodage échoué (warning sans erreur), mise à jour
  • ProfilService: validation matricule <= 8 car.
  • ProfilService.getEtat(): EstConfigure = true (Nom + Prénom non vides ET au moins un véhicule), EstConfigure = false (Profil absent, Nom vide, Prénom vide, ou aucun véhicule)
  • VehiculeService: enregistrement véhicule, détermination véhicule courant (DateEffet max <= aujourd'hui), rejet DateEffet future, conservation historique, détection trajets à réaffecter (date >= DateEffet & puissance différente)
  • VehiculeService: réaffectation acceptée (trajets >= DateEffet mis à jour), réaffectation refusée (aucun trajet modifié)
  • TrajetService: association véhicule courant par défaut à la création, PuissanceFiscale copiée sur le trajet
  • TotauxService: totaux filtrés par véhicule (exclusion des trajets d'autres véhicules)
  • Validators FluentValidation: tous les cas de validation (date, adresses)
  • BaremeService: saisie des 3 tranches (≤5000, 5001–20000, >20000) par puissance fiscale, sélection de tranche selon total annuel, formule km × coef + base, barème non configuré → montant zéro
  • RemboursementService: Montant_Reel = barème plein sur total annuel (tranche selon total annuel), Frais_Reel_Mensuel = km_mois × coef tranche annuelle, somme des frais réels + base = Montant_Reel, Montant_Previsionnel = 80% indicatif (jamais dans la régularisation), Regularisation = Montant_Reel − total réglé
  • MontantRegleService: upsert par (année, mois, véhicule), rejet montant négatif
  • PdfService: en-tête identité (nom, prénom, matricule, plaque, puissance fiscale), PDF distinct par véhicule si plusieurs véhicules sur la période, montants prévisionnel/réglé (mensuel), régularisation (annuel)
  • AdresseNommeeService: création avec coordonnées (mise en cache si absente), création dénomination dupliquée après normalisation (casse/accents/espaces) → HTTP 400, dénomination vide/> 100 car. → HTTP 400, adresse vide/> 255 car. → HTTP 400
  • AdresseNommeeService: adresse correspondant au cache (après normalisation) → réutilisation coords sans appel BAN (mock BAN), modification du texte d'adresse absente du cache → re-géocodage BAN, BAN indisponible/timeout 5 s → persistance sans coordonnées + GeocodageReussi = false
  • AdresseNommeeService: suppression d'une Adresse_Nommee → aucun Trajet modifié (texte d'adresse et coordonnées inchangés)
  • SaveAdresseNommeeDtoValidator: cas de validation dénomination (vide, > 100) et adresse (vide, > 255)

Tests d'intégration (WebApplicationFactory + SQLite en mémoire)

  • Flux complet POST /api/trajets → vérification en base
  • POST /api/trajets avec et sans commentaire
  • PUT /api/trajets/{id} modification commentaire seul (distance inchangée)
  • POST /api/trajets avec coordonnées transmises (BAN) → calcul ORS → distance stockée
  • POST /api/trajets sans coordonnées (saisie manuelle) → appel BAN backend → si OK : ORS ; si KO : distance null + 201 + warning
  • POST /api/trajets avec coordonnées + ORS timeout → fallback Haversine → 201 avec distance orthodromique
  • Insertion entre deux trajets : vérification position (Position_inséré strictement entre P₁ et P₂)
  • Insertion entre P=1.0 et P=2.0 : vérification que Position_inséré = 1.5
  • Insertion répétée jusqu'au déclenchement de la renumérotation (précision < 0.001) : vérification que l'ordre relatif est préservé
  • PATCH /api/trajets/reordonner : vérification du nouvel ordre retourné, préservation des positions non modifiées
  • GET résumé mensuel après insertions (vérification tri par date puis Position croissante)
  • GET résumé annuel, y compris mois sans trajet
  • PUT modification avec recalcul (ORS mocké)
  • DELETE + vérification disparition du résumé
  • GET exports PDF (vérification Content-Type, Content-Disposition, taille > 0)
  • GET /api/trajets/journee/{date} avec géométries
  • GET /api/adresses/search?q=...
  • PUT /api/profil avec et sans géocodage réussi
  • GET /api/profil/etat → EstConfigure = true (profil complet + véhicule) ; EstConfigure = false (profil incomplet ou sans véhicule)
  • GET /api/annees-disponibles, /api/mois-disponibles/{annee}
  • GET /api/totaux/{date} : vérification TotalAnnuelPartiel borné au mois sélectionné
  • POST /api/vehicules → détermination réaffectation proposée si trajets >= DateEffet avec autre puissance
  • POST /api/vehicules/reaffectation accepter=true → trajets >= DateEffet réaffectés ; accepter=false → inchangés
  • GET /api/vehicules → historique complet conservé
  • GET /api/totaux/{date}?vehiculeId= → totaux filtrés par véhicule
  • POST /api/trajets sans VehiculeId → associé au véhicule courant
  • POST /api/bareme chevauchant → 400
  • GET /api/montant-previsionnel/{annee}/{mois}?vehiculeId= → 80% du barème sur total partiel
  • PUT /api/montants-regles (upsert) puis re-PUT même période → mise à jour sans doublon
  • GET /api/regularisation/{annee}?vehiculeId= → dû − versé cohérent
  • GET export PDF annuel avec 2 véhicules → 2 fichiers distincts
  • POST /api/adresses-nommees avec coordonnées → 201 + entrée présente dans le Cache_Adresses
  • POST /api/adresses-nommees avec dénomination déjà utilisée (variante casse/accents/espaces) → 400 sans persistance
  • POST /api/adresses-nommees avec dénomination vide/> 100 car. ou adresse vide/> 255 car. → 400
  • POST /api/adresses-nommees avec adresse déjà en cache → réutilisation coords (BAN mocké non appelé)
  • PUT /api/adresses-nommees/{id} modifiant le texte d'adresse hors cache → re-géocodage BAN (mocké) ; BAN timeout → 200/warning + coordonnées null
  • GET /api/adresses-nommees → liste triée par Dénomination
  • DELETE /api/adresses-nommees/{id} → 204 puis vérification que les Trajets existants conservent adresse et coordonnées
  • POST /api/trajets avec adresse issue d'une Adresse_Nommee sélectionnée (coordonnées associées) → distance calculée normalement

Tests frontend Angular (Jest + Testing Library)

  • TrajetFormComponent : validation synchrone (champs vides, format date, longueur), soumission succès/erreur, mode édition pré-rempli, autocomplétion BAN à 3 caractères, sélection suggestion → coordonnées stockées, saisie manuelle sans BAN → avertissement affiché, BAN indisponible → mode dégradé, bouton "Utiliser mon domicile" actif/désactivé
  • TrajetFormComponent : saisie commentaire optionnel, validation longueur 500 car., mode insertion avec adresses pré-remplies depuis Trajet_Précédent/Trajet_Suivant, modification des adresses pré-remplies
  • ResumeMensuelComponent : affichage 2 décimales, message erreur si API en échec
  • ResumeAnnuelComponent : 12 lignes affichées, total correct, max 999 999,99
  • CarteTrajetsComponent : affichage tracés, fallback ligne droite, infobulles
  • ProfilComponent : sauvegarde, avertissement géocodage échoué, saisie nom/prénom/matricule (limite 8 car.), validation
  • OnboardingComponent : bouton "Commencer" désactivé tant que nom/prénom/véhicule requis manquants, enregistrement réussi (profil + véhicule) → navigation vers la vue de consultation
  • ProfilConfigureGuard : EstConfigure = false → redirection vers /onboarding (accès trajets/consultation bloqué) ; EstConfigure = true → navigation autorisée ; échec de récupération de l'état → message d'erreur de connexion + action "Réessayer" (pas de redirection arbitraire)
  • VehiculesComponent : ajout véhicule, date effet future bloquée, historique trié par date effet décroissante
  • ReaffectationDialogComponent : affichage nombre trajets concernés, accepter/refuser
  • TrajetListComponent : affichage commentaire (icône + tooltip), bouton "insérer après", bouton "insérer avant le suivant", drag & drop réordonne la liste et appelle PATCH, liste triée par Position croissante après insertion
  • SelecteurTemporelComponent : années limitées (courante + données), surbrillance mois/jours avec données, désactivation dates futures
  • ConsultationJourComponent : layout deux colonnes, scroll zone trajets, navigation jour±1, bouton "jour suivant" désactivé si futur, rafraîchissement < 1 000 ms, sélecteur véhicule filtre trajets/totaux/carte
  • PanneauTotauxComponent : affichage 3 totaux à 2 décimales, recalcul au changement de date, affichage montant prévisionnel minoré 80%
  • BaremeComponent : saisie tranches, détection chevauchement
  • MontantsReglesComponent : saisie montant réglé, rejet négatif, affichage prévisionnel indicatif
  • CarnetAdressesComponent : liste triée par dénomination, création/modification (autocomplétion BAN à 3 car., debounce 300 ms, max 5 suggestions), confirmation à la suppression, message de confirmation sur succès CRUD, avertissement si géocodage échoué
  • TrajetFormComponent : sélecteur Adresse_Nommee (par Dénomination) pour départ et arrivée, en mode création et insertion ; sélection → champ rempli + coordonnées associées ; remplacement du contenu pré-rempli en mode insertion
  • TrajetService : mocking HttpClient, timeout interceptor
  • Tests E2E (Playwright, optionnel) : flux création → consultation → export PDF

Couverture cible

Périmètre Cible
Backend services (unitaire + PBT) ≥ 90 %
Controllers / intégration ≥ 80 %
Frontend components ≥ 75 %