Refonte de l'interaction carte/liste suite au retour d'usage : - Suppression de la bande de navigation « Jour préc. / date / Jour suiv. » (boutons, code et styles associés retirés). - La journée affichée sur la carte suit le SURVOL des trajets : survoler un trajet bascule la carte sur sa journée (Subject debouncé ~80 ms) et met le tracé en évidence ; en fin de survol, la carte reste sur la dernière journée survolée. Au chargement d'un mois, la carte se cale sur la dernière journée ayant des trajets. - Bouton « Ouvrir toutes les journées » (avant « Ajouter un trajet » pour ne pas décaler ce dernier ; affiché seulement s'il reste des journées repliées). - Surbrillance du groupe de la journée affichée sur la carte (en-tête bleu accentué), pilotée par le nouvel Input jourAffiche de la liste. - Harmonisation : les mois « avec données » du sélecteur passent au même bleu que la journée mise en avant (le mois sélectionné reste en bleu plein). - Date par défaut à la création : date de la carte, sinon date du jour (inchangé). Specs : requirements.md (exigence 12, glossaire Sélecteur_Temporel) et design.md (ConsultationJour, TrajetList, SelecteurTemporel) mis à jour.
128 KiB
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.
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 |
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 conteneurbackend(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 renddata/trajets.dbdirectement 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 versIP_hôte:2202; le backend ne fait donc pas de redirection HTTPS lui-même
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.dbest 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./datade 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=5dè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 (date, nombre de trajets) ; par défaut tout est replié sauf le dernier jour du mois ayant des trajets.
- Ligne par trajet (triée par
Positioncroissante 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 (→
TrajetFormComponentpré-rempli en < 500 ms), supprimer (→ dialog de confirmation), insérer après (→TrajetFormComponenten mode insertion avecadresseDepart= 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
cdkDropListpar jour, listes non connectées entre elles (pas de déplacement inter-journées) → recalcul des positions du jour puisPATCH /api/trajets/reordonner - Rafraîchissement du résumé mensuel en < 1 000 ms après modification/suppression/insertion
CarteTrajetsComponent
- Intègre Leaflet.js via
leafletpackage 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
- Champs : nom (1–100 car.), prénom (1–100 car.), matricule (max 8 car., limite de saisie stricte), adresse de domicile (1–255 car.)
- Bouton "Enregistrer"
- Message d'avertissement si le géocodage de l'adresse a échoué
VehiculesComponent
- Liste de l'historique des véhicules (plaque, puissance fiscale, date d'effet), triée par date d'effet décroissante
- Formulaire d'ajout d'un véhicule : plaque, puissance fiscale, date d'effet (<= date du jour, sélecteur bloquant les dates futures)
- À l'enregistrement, si la réponse
VehiculeCreeDto.ReaffectationProposeeest true, ouvreReaffectationDialogComponent
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 signaldate(ISO) pilote la carte et la date par défaut d'ajout/insertion - Colonne gauche (fixe) :
SelecteurTemporelComponent, puisPanneauTotauxComponent, puisCarteTrajetsComponent(plus de bande de navigation par jour) - Colonne droite : bande fixe (sélecteur de Véhicule + bouton « Ajouter un trajet »), puis
TrajetListComponentdu 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
Subjectdebouncé ~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
- Affiche également le Montant_Previsionnel minoré à 80% calculé sur le Total_Annuel_Partiel du véhicule sélectionné
BaremeComponent
- Sélecteur d'année
- Tableau éditable des tranches : puissance fiscale, km min, km max (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 minoré 80% (indicatif)
- Montants en euros à 2 décimales
CarnetAdressesComponent
- Liste des Adresses_Nommées (Dénomination + adresse + indication si coordonnées disponibles), triée par Dénomination
- Formulaire de création / modification : champ
denomination(1–100 car.) et champadresseavec autocomplétion BAN (1–255 car.) - Autocomplétion BAN : appel à
https://api-adresse.data.gouv.fr/search/?q={saisie}&limit=5dè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()etVehiculeService.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
AuthServicegère l'inscription, la connexion, la déconnexion et le stockage du JWT danslocalStorage.AuthInterceptorajoute l'en-têteAuthorization: Bearer <jwt>à chaque appel HTTP protégé.authGuardvalide la session côté serveur viaGET /api/auth/moiavant 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 simple lien « Specs » vers
/specs(redirigé vers le premier document) ; le choix du document se fait dans le menu latéral de la page, pas dans la barre. - 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 parmarked(GFM : titres, listes, tableaux, code), puis assaini parDOMPurifyavant injection ([innerHTML]) — Exigence 19.7 - Diagrammes Mermaid : un renderer
markedtransforme les blocs```mermaiden<pre class="mermaid">; après injection du HTML (déclenchée viasetTimeoutpour 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
EstConfigureestfalse, 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
EstConfigureesttrue, 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.tssur les routes protégées (consultation, saisie, résumés, etc.) ; la route/onboardingreste accessible sans garde
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
Positionest unREAL(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çoitmax(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éeDenominationNormaliseeportant un index uniqueUX_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é siKmMaxest NULL) contient le total annuel, puisMontant_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
);
// --- 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 renvoient401 Unauthorizedsans 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é |
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-distancesne (re)sollicite ORS que pour les trajets sans géométrie routière (GeometrieJsonNULL), 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 :
- Titre fixe : "Rapport de trajets"
- Période couverte :
Mois AAAApour le mensuel,AAAApour l'annuel - 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 port8080en interne. Le fichier SQLite est stocké sous/data/trajets.db, monté depuis./datade 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 versindex.htmlpour le routage côté client) et agit comme reverse-proxy en relayant les requêtes/apivers le servicebackend. C'est le seul service exposé à l'hôte (port2202de 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
/apivers 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 versIP_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 (UseHttpsRedirectionretiré), 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(viaAddHealthChecks()/MapHealthChecks("/health")) utilisé par lehealthcheckCompose et la conditiondepends_on: service_healthy.
Configuration et secrets
- La clé ORS est fournie exclusivement via variable d'environnement (
ORS_API_KEY), typiquement à partir d'un fichier.envnon 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:/datagarantit que le fichiertrajets.dbpersiste à 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
Dockerfilebackend a pour contexte la racine du dépôt et copie.kiro/specs/schotziad-km-tracker/{requirements,design,tasks}.mddans./specs/de l'image. LeSpecDocumentServicelit 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.dbdepuis 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'indicateurEstConfigure(backend injoignable / erreur réseau) est géré côté client parProfilConfigureGuard, 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.
AdresseNommeeServicecalcule la forme normalisée (trim + minuscules + suppression des accents) et vérifie l'absence de collision avant persistance ; l'index uniqueUX_AdresseNommee_Denominationgarantit l'unicité au niveau du moteur SQLite en dernier recours. En cas de collision, uneDenominationDupliqueeExceptionest 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
CreateTrajetDtoavec 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 ORSTrajetService: création avec commentaire, création sans commentaire (null stocké), modification commentaire seul (pas de recalcul distance), validation commentaire > 500 car. → HTTP 400BANClient: résolution adresse réussie, adresse non trouvée (score < seuil), timeout BAN → avertissement discretDistanceCalculatorService: 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éeResumeService: total mois vide = 0, résumé annuel avec mois manquants = 0ResumeService/TotauxService: calcul TotalAnnuelPartiel (cumul jusqu'au mois inclus, exclusion des mois postérieurs), années disponibles (données + année courante), mois/jours disponiblesPdfService: génération mensuelle, génération annuelle, nommage des fichiers, timeoutAdresseCacheService: insertion nouvelle adresse, réutilisation adresse existante, éviction LRU à 1 001 entréesProfilService: sauvegarde avec géocodage réussi, sauvegarde avec géocodage échoué (warning sans erreur), mise à jourProfilService: 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 trajetTotauxService: 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, formulekm × coef + base, barème non configuré → montant zéroRemboursementService: 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égatifPdfService: 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 400AdresseNommeeService: 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 = falseAdresseNommeeService: 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/trajetsavec et sans commentaire - PUT
/api/trajets/{id}modification commentaire seul (distance inchangée) - POST
/api/trajetsavec coordonnées transmises (BAN) → calcul ORS → distance stockée - POST
/api/trajetssans coordonnées (saisie manuelle) → appel BAN backend → si OK : ORS ; si KO : distance null + 201 + warning - POST
/api/trajetsavec 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/profilavec 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/reaffectationaccepter=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/trajetssans VehiculeId → associé au véhicule courant - POST
/api/baremechevauchant → 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-nommeesavec coordonnées → 201 + entrée présente dans le Cache_Adresses - POST
/api/adresses-nommeesavec dénomination déjà utilisée (variante casse/accents/espaces) → 400 sans persistance - POST
/api/adresses-nommeesavec dénomination vide/> 100 car. ou adresse vide/> 255 car. → 400 - POST
/api/adresses-nommeesavec 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/trajetsavec 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é-rempliesResumeMensuelComponent: affichage 2 décimales, message erreur si API en échecResumeAnnuelComponent: 12 lignes affichées, total correct, max 999 999,99CarteTrajetsComponent: affichage tracés, fallback ligne droite, infobullesProfilComponent: sauvegarde, avertissement géocodage échoué, saisie nom/prénom/matricule (limite 8 car.), validationOnboardingComponent: bouton "Commencer" désactivé tant que nom/prénom/véhicule requis manquants, enregistrement réussi (profil + véhicule) → navigation vers la vue de consultationProfilConfigureGuard: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écroissanteReaffectationDialogComponent: affichage nombre trajets concernés, accepter/refuserTrajetListComponent: 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 insertionSelecteurTemporelComponent: années limitées (courante + données), surbrillance mois/jours avec données, désactivation dates futuresConsultationJourComponent: 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/cartePanneauTotauxComponent: affichage 3 totaux à 2 décimales, recalcul au changement de date, affichage montant prévisionnel minoré 80%BaremeComponent: saisie tranches, détection chevauchementMontantsReglesComponent: saisie montant réglé, rejet négatif, affichage prévisionnel indicatifCarnetAdressesComponent: 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 insertionTrajetService: 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 % |