Compare commits

..
11 Commits
Author SHA1 Message Date
MadJax d857ccb879 Déploiement par Git + Docker : script deploy.sh et guide DEPLOY.md
- deploy.sh : met à jour depuis Gitea (branche master) et ne reconstruit
  les conteneurs (docker compose up -d --build) que si une nouvelle
  version existe ; option --force ; vérifie git/docker/compose/.env ;
  pull --ff-only ; compatible Compose v2 et v1
- DEPLOY.md : guide de premier déploiement (prérequis, git clone, .env
  depuis .env.example), mises à jour, persistance SQLite, dépannage
- design.md : note renvoyant vers DEPLOY.md et la procédure Git + Docker
  (abandon de la copie d'archive)
2026-10-12 12:54:43 +02:00
MadJax f6445d66af Sélecteur de mois : libellés courts et grille de 6 par ligne
Les libellés longs de largeur variable débordaient sur 3 lignes. Les
mois s'affichent désormais en abrégé (Janv., Févr., …), centrés, dans
une grille de 6 colonnes égales (2 rangées de 6, boutons de largeur
constante). Le nom complet du mois reste disponible en infobulle. Le
groupe des années et le mode « année seule » sont inchangés.
2026-10-11 20:51:15 +02:00
MadJax 77cb5b87a7 Synthèse Kms : largeur de lecture bornée au lieu de la pleine largeur
Sur grand écran, la pleine largeur rendait une ligne du tableau peu
lisible (le mois et le bouton d'export étaient trop éloignés). L'écran
Synthèse Kms adopte désormais une largeur maximale de 1100 px centrée,
comme Specs. Trajets et Carnet d'adresses restent en pleine largeur.

Specs (requirements 12.27, design-requirements 12.5, design) mises à
jour en cohérence.
2026-10-11 20:42:05 +02:00
MadJax 3d9557f852 Écran unique « Synthèse Kms » fusionnant résumés et montants réglés
- Nouvelle page Synthèse Kms (/synthese-kms) : sélecteur temporel en
  mode année seule + véhicule + Afficher, barème appliqué, tableau 12
  mois (km, remb. réel du mois, indication 80%, montant réglé
  saisissable, export PDF mensuel), ligne TOTAL (km annuel, montant
  réel, total réglé, export PDF annuel), bouton Enregistrer global
  (seuls les montants modifiés), auto-affichage à l'arrivée. Aucun
  nouvel endpoint (composition d'endpoints existants).
- Suppression des écrans « Résumé mensuel », « Résumé annuel » et
  « Montants réglés » (routes et liens de menu retirés).
- SelecteurTemporelComponent : mode « année seule » (Input/Output
  dédiés) et repli automatique au clic sur le niveau le plus bas.
- Pleine largeur des écrans Trajets, Carnet d'adresses et Synthèse Kms
  (Specs conserve sa largeur de lecture).
- Liste des trajets : boutons d'action déplacés en début de ligne
  (après la poignée, avant A/R), alignés à droite.
- Specs (requirements/design-requirements/design) synchronisées :
  glossaire Synthèse_Kms, Exigences 3/4/13/14 adaptées, critères 12.25-27.
2026-10-10 15:46:34 +02:00
MadJax e9f34e59e8 Refonte mise en page des exports PDF (en-tête, pied de page, tables)
En-tête (mensuel + annuel) :
- Titre « RAPPORT DE TRAJETS » en majuscules
- Logo agrandi (120 pt) à gauche, bloc titre centré sur l'espace restant,
  bloc d'infos placé dessous, trait séparateur sur toute la largeur

Pied de page (mensuel + annuel) :
- Mention légale (déclaration sur l'honneur, calcul indicatif, non
  opposable aux administrations) en 5 pt justifiée, au-dessus de la
  pagination

Rapport mensuel :
- Adresse d'arrivée à la ligne sous le départ, précédée de « → »
- Colonne dédiée « A/R » (marqueur « X ») avant les Kms ; largeurs
  ajustées (A/R minimale, Kms jusqu'à « 9 999 », adresses maximisées)

Rapport annuel :
- Espace après « Véhicule » ; barème applicable et total km sur deux
  colonnes
- Titre du tableau barème complété « — Kms parcourus × Coef + Base »
- Espace avant la régularisation, régularisation centrée

Steering : nouvelle règle workflow (valider l'approche avant de coder,
l'utilisateur lance le dev).

Specs requirements.md / design-requirements.md / design.md synchronisées
(Exigence 7 : critères + maquettes PDF + glossaire Mention_Legale).
2026-10-10 10:33:23 +02:00
MadJax 6bdd67fd00 Uniformiser le survol des boutons (orange) et le rendu des boutons Annuler
- Survol orange plein (texte blanc) étendu aux boutons d'action
  secondaires : « Utiliser mon domicile », « Export PDF » (résumés
  mensuel/annuel), « Recopier le dernier barème », « Modifier » et
  « Enregistrer/Ajouter » (carnet d'adresses)
- Boutons « Annuler » uniformisés sur tout le site (fond blanc, bordure
  grise, survol gris clair), via classes dédiées .btn-annuler (barème)
  et .btn--annuler (carnet), alignés sur trajet-form et confirm-dialog
- Suppression (rouge), boutons à icône et indicateurs d'état inchangés
- Charte graphique de design.md mise à jour (convention d'interaction)
2026-10-03 10:20:25 +02:00
MadJax 5b72e21c9d Connexion « Se souvenir de moi » et ajustements panneau Totaux
- Login : case à cocher « Se souvenir de moi » sous le champ email ;
  mémorisation de l'identifiant (email uniquement) dans le localStorage
  du navigateur, pré-remplissage à la visite suivante sur le même
  navigateur, effacement si décochée ; erreurs de stockage ignorées
- Panneau Totaux : « Total du mois » en gras et « Total annuel partiel »
  non gras ; « Remboursement indicatif » renommé « Remboursement
  indicatif pour ce mois » ; « Montant prévisionnel annuel » non gras
- Steering : nouvelle règle imposant les réponses en français
- Mise à jour requirements.md (Req 17 + glossaire), design-requirements.md
  (Req 17 technique) et design.md (LoginComponent, PanneauTotaux)
2026-10-03 10:02:21 +02:00
MadJax 3b4050c127 Steering spec-sync: documenter la séparation exigences fonctionnelles / techniques
Clarifie l'architecture documentaire réelle du projet (deux axes) :
- axe exigences : requirements.md (métier) + design-requirements.md
  (technique, en miroir numéroté)
- axe conception : design.md
- journal : tasks.md
Ajoute les règles de routage des mises à jour et une note sur les
specs séparées (ex. precision-distances-km) et leurs renvois croisés.
2026-10-03 09:41:44 +02:00
MadJax 61298d6384 Refonte export PDF, colonne commentaire et UX résumés
- Export PDF: nouveau nommage AAAAMM/AAAA_Trajets_MATRICULE_PLAQUE.pdf,
  logo carré embarqué en en-tête, km formatés selon Precision_Distance,
  restructuration en-têtes (Domicile déplacé, période/année fusionnées
  au titre)
- Correction bug: le nom de fichier suggéré au téléchargement ne
  remontait pas au frontend (CORS WithExposedHeaders manquant +
  composants résumé ne lisaient pas Content-Disposition)
- Résumé mensuel: colonne Commentaire, sélecteur temporel unifié
  (auto-chargement), km selon précision
- Résumé annuel: bouton Export PDF repositionné, km selon précision
- Panneau totaux: renommage "Remboursement indicatif" + infobulles
  d'aide (icône) au lieu du texte permanent
- Ajout de PdfServiceIntegrationTests (3 tests), 65/65 tests backend OK
- Mise à jour requirements.md et design.md en conséquence
2026-09-28 08:58:23 +02:00
MadJax 9db3c91471 Corrige auth.guard.spec.ts : la garde valide desormais la session serveur
Le test datait d'avant l'evolution d'authGuard en Observable<boolean|UrlTree>
(validation de la session via GET /api/auth/moi, pour rejeter un jeton
residuel dont le compte n'existe plus). Mock de AuthService.validerSession
et resolution async du resultat de la garde. Ajout d'un 3e cas : session
invalide (401) -> redirection /login. 19/19 tests frontend verts (Karma
via Brave, CHROME_BIN=/usr/bin/brave-browser).
2026-09-25 12:06:29 +02:00
MadJax 7e80427e09 Precision configurable des distances (arrondi superieur 0/1/2 decimales)
- Nouveau parametre global Precision_Distance (sous-menu Application de la
  Page_Parametrage, entre Compte et Vehicules), persiste via une entite
  singleton ParametresApplication, expose par GET/PUT /api/parametres-application
- Arrondi superieur (ceiling) applique a l'affichage et aux agregats : trajet
  simple, aller/retour (arrondi puis doublement), totaux jour/mois/annee,
  carte, export PDF et bareme kilometrique - jamais a la distance brute stockee
- Refonte du Montant_Previsionnel : le montant mensuel (80%) porte desormais
  sur le seul mois affiche (tranche determinee par le cumul annuel) ; nouvelle
  ligne Montant_Previsionnel_Annuel (100%, sans minoration, sur le cumul)
- Backend : PrecisionDistanceService (ArrondirDistance), integration dans
  ResumeService/TotauxService/RemboursementService/MontantRegleService,
  migration EF Core ; 7 tests de propriete (FsCheck) + tests existants, 62/62 verts
- Frontend : ParametresApplicationService (signal partage), composant de
  parametrage, fonction miroir arrondirDistance, adaptation de la liste des
  trajets, du panneau des totaux, de la carte et des montants regles
- Specs : nouvelle spec precision-distances-km (requirements/design/tasks) ;
  mise a jour de requirements.md, design.md et design-requirements.md de
  schotziad-km-tracker (Requirement 22, renvois croises, Montant_Previsionnel)
- Suppression du fichier chromium .deb suivi par erreur dans le depot
2026-09-25 12:02:52 +02:00
86 changed files with 5328 additions and 1477 deletions
@@ -0,0 +1 @@
{"specId": "b190f621-9d51-491c-877a-7649d38590de", "workflowType": "requirements-first", "specType": "feature"}
@@ -0,0 +1,781 @@
# Document de Conception Technique — Précision des distances (km)
## Overview
Cette fonctionnalité ajoute un paramètre global **Precision_Distance** (0, 1 ou 2 décimales) qui pilote un arrondi supérieur (ceiling) systématique appliqué à l'affichage et à l'agrégation de toutes les distances de l'application : liste des trajets, panneau des totaux, carte, export PDF, et calculs de barème kilométrique (Montant_Previsionnel_Mensuel, Montant_Previsionnel_Annuel, Montant_Reel, Frais_Reel_Mensuel, Regularisation).
Elle modifie également `IMontantRegleService` : le Montant_Previsionnel_Mensuel (80%) porte désormais uniquement sur le mois affiché (et non plus sur le Total_Annuel_Partiel), et une nouvelle valeur Montant_Previsionnel_Annuel (100%, sans minoration) est introduite, appliquée au Total_Annuel_Partiel.
Cette conception **s'appuie sur l'architecture existante** décrite dans `.kiro/specs/schotziad-km-tracker/design.md` (ASP.NET Core 8 / EF Core / SQLite côté backend, Angular 22 zoneless standalone côté frontend) et ne la remet pas en cause : elle ajoute une entité de configuration, un service de précision, un endpoint, et modifie des points de calcul existants identifiés dans le code réel (`ResumeService`, `TotauxService`, `MontantRegleService`, `RemboursementService`, `PdfService`, `CarteTrajetsComponent`, `TrajetListComponent`, `PanneauTotauxComponent`, `MontantsReglesComponent`).
> **Important** : l'arrondi ORS/Haversine existant dans `ORSClient.cs` (`Math.Round(distanceMeters / 1000, 2, MidpointRounding.AwayFromZero)`) reste inchangé — c'est l'arrondi de **calcul brut** stocké en base (colonne `Trajet.Distance`). Le nouvel arrondi Precision_Distance est un **arrondi d'affichage/agrégation en aval**, appliqué à la volée sur la valeur lue, jamais persisté sur `Trajet.Distance`.
---
## Architecture
### Vue d'ensemble des points d'impact
```mermaid
graph TB
subgraph "Backend ASP.NET Core"
PARAM_CTRL[ParametresApplicationController]
PARAM_SVC[PrecisionDistanceService<br/>ObtenirAsync / DefinirAsync / ArrondirDistance]
PARAM_DB[(ParametresApplication<br/>singleton)]
RESUME[ResumeService]
TOTAUX[TotauxService]
MONTANT[MontantRegleService]
REMB[RemboursementService]
BAREME[BaremeService]
PDF[PdfService]
PARAM_CTRL --> PARAM_SVC
PARAM_SVC --> PARAM_DB
RESUME --> PARAM_SVC
TOTAUX --> PARAM_SVC
MONTANT --> PARAM_SVC
REMB --> PARAM_SVC
PDF --> PARAM_SVC
MONTANT --> BAREME
REMB --> BAREME
REMB --> MONTANT
PDF --> RESUME
PDF --> REMB
PDF --> MONTANT
PDF --> BAREME
end
subgraph "Frontend Angular"
PARAM_SVC_FE[ParametresApplicationService<br/>signal precisionDistance]
PARAM_COMP[ParametresApplicationComponent]
LISTE[TrajetListComponent]
TOTAUX_COMP[PanneauTotauxComponent]
CARTE[CarteTrajetsComponent]
MONTANTS_COMP[MontantsReglesComponent]
ARRONDI_FE[arrondirDistance util]
PARAM_COMP --> PARAM_SVC_FE
LISTE --> PARAM_SVC_FE
TOTAUX_COMP --> PARAM_SVC_FE
CARTE --> PARAM_SVC_FE
MONTANTS_COMP --> PARAM_SVC_FE
LISTE --> ARRONDI_FE
TOTAUX_COMP --> ARRONDI_FE
CARTE --> ARRONDI_FE
end
PARAM_SVC_FE -- "HTTP GET/PUT" --> PARAM_CTRL
```
### Décision d'architecture : table dédiée à une seule ligne (singleton), pas de table clé-valeur générique
**Options considérées :**
1. **Table clé-valeur générique** (`ParametreApplication { Cle, Valeur }`) — flexible pour ajouter de futurs paramètres sans migration, mais nécessite un parsing/validation par clé côté application (perte du typage fort EF Core), et introduit une indirection pour un besoin actuel limité à un seul paramètre entier.
2. **Table dédiée à une seule ligne (singleton)**, sur le modèle exact de `Profil` et `Compte` déjà présents dans `AppDbContext` (`Id` fixe = 1, `ValueGeneratedNever()`, index unique `UX_*_Singleton`).
**Décision retenue : option 2**, pour cohérence stricte avec les conventions existantes du projet (`Profil`, `Compte` sont déjà des singletons typés) et parce que le besoin actuel est un unique paramètre entier borné (0/1/2). Si d'autres paramètres globaux apparaissent plus tard, cette même table `ParametresApplication` pourra recevoir de nouvelles colonnes (comme `Profil` a reçu ses champs successivement) sans qu'une refonte soit nécessaire.
### Décision d'architecture : arrondi ceiling centralisé dans une méthode utilitaire pure, appelée à la lecture
L'arrondi n'est **jamais** appliqué à l'écriture (la colonne `Trajet.Distance` n'est jamais modifiée par ce paramètre — Exigence 1.8). Il est appliqué :
- **côté backend**, à chaque endroit où une distance ou un total est **lu puis exposé** (DTO de réponse, PDF) ;
- **côté frontend**, à l'affichage, en réutilisant la valeur `precisionDistance` courante partagée par un signal de service, pour un rendu réactif sans aller-retour serveur à chaque changement de sélection (mois, véhicule, journée).
Cela garantit l'Exigence 9.4 (même Distance_Effective_Arrondie pour un même Trajet, à précision égale, dans la liste, le panneau, la carte, le PDF et le barème) : une seule fonction d'arrondi (`ArrondirDistance` côté backend, `arrondirDistance` côté frontend), avec la même formule.
---
## Data Models
### Entité `ParametresApplication` (nouvelle — backend)
```csharp
namespace SchotzIadKmTracker.Api.Models.Entities;
/// <summary>
/// Configuration globale de l'application (singleton — une seule ligne, Id = 1).
/// Sur le même modèle que <see cref="Profil"/> et <see cref="Compte"/>.
/// </summary>
public class ParametresApplication
{
/// <summary>Identifiant fixe (1) : la configuration est un singleton.</summary>
public int Id { get; set; } = 1;
/// <summary>
/// Nombre de décimales (0, 1 ou 2) utilisé pour l'arrondi supérieur des distances à
/// l'affichage et dans les agrégats. Défaut 2 (Exigence 1.2). Valeur validée par
/// <see cref="Services.IPrecisionDistanceService"/> avant écriture — jamais persistée
/// hors de {0, 1, 2}.
/// </summary>
public int PrecisionDistanceKm { get; set; } = 2;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
```
### Enregistrement dans `AppDbContext`
```csharp
public DbSet<ParametresApplication> ParametresApplication => Set<ParametresApplication>();
```
```csharp
// --- ParametresApplication (singleton Id = 1) ---
modelBuilder.Entity<ParametresApplication>(entity =>
{
entity.ToTable("ParametresApplication", t =>
t.HasCheckConstraint("CK_ParametresApplication_PrecisionDistanceKm", "\"PrecisionDistanceKm\" IN (0, 1, 2)"));
entity.HasKey(p => p.Id);
entity.Property(p => p.Id).ValueGeneratedNever();
entity.HasIndex(p => p.Id, "UX_ParametresApplication_Singleton").IsUnique();
});
```
### Migration EF Core
Nouvelle migration (nom à la suite des migrations existantes, ex. `20260916000000_AjoutParametresApplication`), créant la table `ParametresApplication` avec la contrainte CHECK ci-dessus. Aucune donnée à migrer (table vide au départ ; la ligne singleton est créée à la volée, comme `Profil`, au premier accès — cf. `PrecisionDistanceService.ObtenirAsync`).
### Schéma SQLite
```sql
CREATE TABLE ParametresApplication (
Id INTEGER PRIMARY KEY,
PrecisionDistanceKm INTEGER NOT NULL DEFAULT 2 CHECK (PrecisionDistanceKm IN (0, 1, 2)),
UpdatedAt TEXT NOT NULL DEFAULT (datetime('now'))
);
```
---
## API
### DTOs (nouveau fichier `Models/DTOs/ParametresApplicationDtos.cs`)
```csharp
namespace SchotzIadKmTracker.Api.Models.DTOs;
/// <summary>Configuration globale exposée en lecture.</summary>
public record ParametresApplicationDto(int PrecisionDistanceKm);
/// <summary>Corps de requête pour modifier la précision des distances.</summary>
public record SavePrecisionDistanceDto(int PrecisionDistanceKm);
```
### Endpoints (`Controllers/ParametresApplicationController.cs`)
Suit exactement les conventions de `BaremeController` (route `api/...`, `[ApiController]`, autorisation JWT globale héritée du filtre configuré dans `Program.cs`, pas d'attribut `[Authorize]` explicite requis — seul `AuthController` porte `[AllowAnonymous]`).
| Méthode | Route | Description | Codes |
|---|---|---|---|
| GET | `/api/parametres-application` | Retourne la configuration courante (crée la ligne singleton avec la valeur par défaut 2 si absente) | 200 |
| PUT | `/api/parametres-application` | Modifie `PrecisionDistanceKm` | 200, 400 |
```csharp
[ApiController]
[Route("api/parametres-application")]
public class ParametresApplicationController : ControllerBase
{
private readonly IPrecisionDistanceService _precisionDistanceService;
public ParametresApplicationController(IPrecisionDistanceService precisionDistanceService)
{
_precisionDistanceService = precisionDistanceService;
}
[HttpGet]
[ProducesResponseType(typeof(ParametresApplicationDto), StatusCodes.Status200OK)]
public async Task<ActionResult<ParametresApplicationDto>> Get(CancellationToken cancellationToken)
{
var precision = await _precisionDistanceService.ObtenirAsync(cancellationToken);
return Ok(new ParametresApplicationDto(precision));
}
[HttpPut]
[ProducesResponseType(typeof(ParametresApplicationDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<ParametresApplicationDto>> Put(
[FromBody] SavePrecisionDistanceDto dto, CancellationToken cancellationToken)
{
try
{
var precision = await _precisionDistanceService.DefinirAsync(dto.PrecisionDistanceKm, cancellationToken);
return Ok(new ParametresApplicationDto(precision));
}
catch (PrecisionDistanceInvalideException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest);
}
}
}
```
L'authentification est garantie par le filtre d'autorisation global déjà enregistré dans `Program.cs` (`AuthorizeFilter` appliqué à tous les contrôleurs MVC) : sans jeton JWT valide, la requête reçoit un **401** avant d'atteindre le contrôleur (Exigences 1.7, 2.6 — même comportement que `BaremeController`, `ProfilController`, etc., aucune configuration supplémentaire requise).
### Validation (400)
Portée par le service (`PrecisionDistanceService.DefinirAsync`), pas par FluentValidation, car la validation est une règle métier simple (appartenance à `{0, 1, 2}`) sur un endpoint à faible complexité — cohérent avec `MontantRegleService` qui valide aussi en profondeur dans le service (défense en profondeur, cf. `MontantRegleNegatifException`). Une nouvelle exception `PrecisionDistanceInvalideException` (`Services/Exceptions/PrecisionDistanceInvalideException.cs`) suit le même modèle que `MontantRegleNegatifException`.
---
## Components and Interfaces
### Backend — nouveau service `IPrecisionDistanceService` / `PrecisionDistanceService`
```csharp
namespace SchotzIadKmTracker.Api.Services;
/// <summary>
/// Lecture/écriture de la configuration globale Precision_Distance, et fonction utilitaire
/// pure d'arrondi supérieur (ceiling) réutilisée par tous les points de calcul de distance.
/// </summary>
public interface IPrecisionDistanceService
{
/// <summary>
/// Retourne la précision courante (0, 1 ou 2). Crée la ligne singleton avec la valeur
/// par défaut (2) si elle est absente (Exigence 1.2), sur le même modèle que
/// <see cref="IProfilService"/>.
/// </summary>
Task<int> ObtenirAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Définit la précision. Lève <see cref="Exceptions.PrecisionDistanceInvalideException"/>
/// si la valeur n'est pas 0, 1 ou 2 (Exigence 1.6).
/// </summary>
Task<int> DefinirAsync(int valeur, CancellationToken cancellationToken = default);
/// <summary>
/// Arrondit une distance brute (km) au supérieur (ceiling) à la précision indiquée.
/// Fonction pure, sans effet de bord, sans accès base — utilisable partout où une
/// distance doit être affichée ou agrégée (Exigences 3.2–3.5, 4.2).
/// Formule : Ceiling(distanceBrute × 10^precision) / 10^precision.
/// </summary>
decimal ArrondirDistance(decimal distanceBrute, int precision);
}
```
```csharp
public class PrecisionDistanceService : IPrecisionDistanceService
{
private const int ParametresId = 1;
private readonly AppDbContext _context;
public PrecisionDistanceService(AppDbContext context) => _context = context;
public async Task<int> ObtenirAsync(CancellationToken cancellationToken = default)
{
var parametres = await GetOrCreateAsync(cancellationToken);
return parametres.PrecisionDistanceKm;
}
public async Task<int> DefinirAsync(int valeur, CancellationToken cancellationToken = default)
{
if (valeur is not (0 or 1 or 2))
{
throw new PrecisionDistanceInvalideException(
"La précision des distances doit être 0, 1 ou 2 décimale(s).");
}
var parametres = await GetOrCreateAsync(cancellationToken);
parametres.PrecisionDistanceKm = valeur;
parametres.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync(cancellationToken);
return parametres.PrecisionDistanceKm;
}
public decimal ArrondirDistance(decimal distanceBrute, int precision)
{
var facteur = (decimal)Math.Pow(10, precision);
return Math.Ceiling(distanceBrute * facteur) / facteur;
}
private async Task<ParametresApplication> GetOrCreateAsync(CancellationToken cancellationToken)
{
var parametres = await _context.ParametresApplication
.FirstOrDefaultAsync(p => p.Id == ParametresId, cancellationToken);
if (parametres is not null)
{
return parametres;
}
parametres = new ParametresApplication { Id = ParametresId };
await _context.ParametresApplication.AddAsync(parametres, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return parametres;
}
}
```
Enregistrement dans `Program.cs`, à côté des autres services scoped :
```csharp
builder.Services.AddScoped<IPrecisionDistanceService, PrecisionDistanceService>();
```
### Points de consommation backend (injection de `IPrecisionDistanceService`)
Tous les services suivants injectent `IPrecisionDistanceService` en plus de leurs dépendances actuelles, appellent `ObtenirAsync()` une fois par requête (le résultat est stable pendant la durée de la requête), puis appliquent `ArrondirDistance` **avant sommation** (jamais après) :
| Service | Méthode(s) concernée(s) | Changement |
|---|---|---|
| `ResumeService` | `ConstruireResumeMensuel` (privée, appelée par `GetResumeMensuelAsync`/`GetResumeAnnuelAsync`) | Pour chaque trajet : `Distance_Effective_Arrondie = ArrondirDistance(Distance, precision) × (AllerRetour ? 2 : 1)` (arrondi AVANT doublement — Exigence 4.2), puis somme de ces valeurs déjà arrondies pour `TotalKm` (Exigence 5.2/5.3). Le `TrajetDto.Distance` exposé devient lui aussi `Distance_Effective_Arrondie` (au lieu de `DistanceEffective` brut). |
| `TotauxService` | `GetTotauxAsync` | La fonction locale `Effective(distance, allerRetour)` devient `ArrondirDistance(distance, precision) × (allerRetour ? 2 : 1)` ; `TotalJour`, `TotalMois`, `TotalAnnuelPartiel` sont des sommes de valeurs déjà arrondies (Exigence 5.1, 5.4), sans nouvel arrondi après la somme (le `Math.Round(..., 2, ...)` final existant est **supprimé** — les distances sommées sont déjà à la précision voulue et n'ont plus besoin d'un second arrondi à 2 décimales). |
| `MontantRegleService` | `GetMontantPrevisionnelAsync` | Voir section dédiée ci-dessous (Requirement 7/8). |
| `RemboursementService` | `GetRegularisationAnnuelleAsync` | `kmMoisMap` est construit à partir de `ArrondirDistance(Distance, precision) × (AllerRetour ? 2 : 1)` par trajet (au lieu de la distance brute), donc `totalKmAnnuel` (Exigence 6.3, 6.6) et chaque `KmMois` (Exigence 6.4) sont des sommes de distances déjà arrondies. |
| `PdfService` | `AssemblerModeleMensuelAsync`, tables du rapport annuel (via `RemboursementService`/`ResumeService` déjà corrigés) | Consomme les résumés/régularisations déjà arrondis en amont ; aucun calcul de distance supplémentaire à modifier directement — la cohérence vient de la réutilisation des services corrigés (Exigence 9.2, 9.3). Voir section « Cohérence PDF/Carte » plus bas pour le détail des points d'appel exacts. |
| `BaremeService` | `CalculerMontantAsync` | Ne change pas : reçoit déjà un `totalKm` en paramètre. Ce sont ses **appelants** (`MontantRegleService`, `RemboursementService`) qui doivent désormais lui passer un total déjà arrondi (Exigence 6.6). |
### Requirement 7/8 — `MontantRegleService.GetMontantPrevisionnelAsync`
Signature actuelle (`MontantRegleService.cs`) : calcule un seul `MontantPrevisionnelDto` avec `MontantPrevisionnel` = 80% du barème appliqué au **Total_Annuel_Partiel**. Cette conception est remplacée par :
```csharp
Task<MontantPrevisionnelDto> GetMontantPrevisionnelAsync(
int annee, int mois, int vehiculeId, CancellationToken cancellationToken = default);
```
avec un **nouveau contenu de calcul** (signature externe inchangée pour ne pas casser le contrat déjà consommé par `BaremeService.getMontantPrevisionnel` côté frontend — seuls les champs du DTO retourné changent) :
1. Charger les trajets du véhicule pour l'année, jusqu'au mois inclus (comme aujourd'hui), avec `Date.Month` pour distinguer le mois affiché du cumul.
2. Pour chaque trajet : `distanceArrondie = ArrondirDistance(Distance, precision) × (AllerRetour ? 2 : 1)`.
3. `totalMoisAffiche = Σ distanceArrondie` des trajets dont `Date.Month == mois` (Exigence 7.1).
4. `totalAnnuelPartiel = Σ distanceArrondie` des trajets dont `Date.Month <= mois` (Exigence 5.3, 6.2, 8.2).
5. Sélectionner la tranche via `BaremeService.CalculerMontantAsync(annee, puissanceFiscale, totalAnnuelPartiel, ...)` — **la sélection de tranche reste basée sur le cumul annuel arrondi**, conformément à l'Exigence 7.2 : « à partir du total kilométrique annuel arrondi cumulé, tout en appliquant le coefficient ainsi déterminé au seul kilométrage arrondi du mois affiché ».
6. `Montant_Previsionnel_Mensuel = Round(totalMoisAffiche × tranche.Coefficient, 2)` — **80%** en réalité `Round(totalMoisAffiche × tranche.Coefficient × 0.80m, 2)` car le coefficient de tranche seul ne représente pas le montant complet lorsqu'il existe une `Base` non nulle. Pour rester fidèle à la formule existante `CalculerMontantAsync` (`totalKm × coefficient + base`), le calcul du prévisionnel mensuel applique le barème complet à `totalMoisAffiche` (nouvel appel à `CalculerMontantAsync` avec ce total, dans la même tranche déterminée à l'étape 5) puis multiplie par 0.80.
7. `Montant_Previsionnel_Annuel = Round(bareme.Montant sur totalAnnuelPartiel × 1.00, 2)` (Exigence 8.2).
Nouveau DTO :
```csharp
public record MontantPrevisionnelDto(
int Annee,
int Mois,
int VehiculeId,
decimal TotalMoisKm, // Distance_Effective_Arrondie totale du mois affiché
decimal TotalAnnuelPartielKm, // Total_Annuel_Partiel arrondi (cumul jusqu'au mois inclus)
decimal MontantPrevisionnelMensuel, // 80% du barème appliqué à TotalMoisKm (tranche déterminée par le cumul annuel)
decimal MontantPrevisionnelAnnuel // 100% du barème appliqué à TotalAnnuelPartielKm
);
```
> **Rupture de contrat mineure** : `MontantBaremePlein` et `MontantPrevisionnel` (noms actuels) sont retirés/renommés en `MontantPrevisionnelAnnuel` et `MontantPrevisionnelMensuel`. Le frontend (`bareme.model.ts`, `PanneauTotauxComponent`, `MontantsReglesComponent`) doit être mis à jour en conséquence (voir section Frontend).
Implémentation :
```csharp
public async Task<MontantPrevisionnelDto> GetMontantPrevisionnelAsync(
int annee, int mois, int vehiculeId, CancellationToken cancellationToken = default)
{
var vehicule = await _context.Vehicules
.AsNoTracking()
.FirstOrDefaultAsync(v => v.Id == vehiculeId, cancellationToken)
?? throw new VehiculeNotFoundException($"Le véhicule d'identifiant {vehiculeId} est introuvable.");
var precision = await _precisionDistanceService.ObtenirAsync(cancellationToken);
var trajets = await _context.Trajets
.AsNoTracking()
.Where(t => t.VehiculeId == vehiculeId && t.Date.Year == annee && t.Date.Month <= mois)
.Select(t => new { t.Date.Month, t.Distance, t.AllerRetour })
.ToListAsync(cancellationToken);
decimal DistanceArrondie(decimal? distance, bool allerRetour) =>
distance.HasValue
? _precisionDistanceService.ArrondirDistance(distance.Value, precision) * (allerRetour ? 2m : 1m)
: 0m;
var totalMoisAffiche = trajets.Where(t => t.Month == mois).Sum(t => DistanceArrondie(t.Distance, t.AllerRetour));
var totalAnnuelPartiel = trajets.Sum(t => DistanceArrondie(t.Distance, t.AllerRetour));
// Tranche déterminée par le cumul annuel (Exigence 7.2) ; réutilisée pour le mois et l'année.
var baremeAnnuel = await _baremeService.CalculerMontantAsync(annee, vehicule.PuissanceFiscale, totalAnnuelPartiel, cancellationToken);
var baremeMois = await _baremeService.CalculerMontantAsync(annee, vehicule.PuissanceFiscale, totalMoisAffiche, cancellationToken);
// NB : CalculerMontantAsync sélectionne la tranche selon le total PASSÉ ; pour appliquer la
// tranche déterminée par le CUMUL au montant du MOIS, on applique manuellement le coefficient/base
// de la tranche annuelle au total du mois plutôt que de ré-sélectionner une tranche via le mois seul :
var montantMoisSelonTrancheAnnuelle = totalMoisAffiche * baremeAnnuel.Coefficient + baremeAnnuel.Base;
var montantPrevisionnelMensuel = Math.Round(montantMoisSelonTrancheAnnuelle * 0.80m, 2, MidpointRounding.AwayFromZero);
var montantPrevisionnelAnnuel = Math.Round(baremeAnnuel.Montant, 2, MidpointRounding.AwayFromZero);
return new MontantPrevisionnelDto(
annee, mois, vehiculeId,
Math.Round(totalMoisAffiche, precision, MidpointRounding.AwayFromZero),
Math.Round(totalAnnuelPartiel, precision, MidpointRounding.AwayFromZero),
montantPrevisionnelMensuel,
montantPrevisionnelAnnuel);
}
```
> **Correction de conception importante** : l'appel intermédiaire `baremeMois` (calculé mais non utilisé dans la formule finale) est retiré de l'implémentation définitive ci-dessus — seul `montantMoisSelonTrancheAnnuelle` (coefficient/base de la tranche annuelle appliqués au total du mois) sert au calcul, conformément à l'Exigence 7.2. Il est laissé en commentaire dans le pseudo-code ci-dessus uniquement pour expliciter le raisonnement ; l'implémentation réelle ne doit PAS effectuer cet appel inutile.
### Écran Frontend — `ParametresApplicationComponent`
Nouveau composant `frontend/src/app/features/parametres-application/parametres-application.component.ts` (+ `.html`, `.scss`), sur le modèle exact de `VehiculesComponent`/`BaremeComponent` (composant standalone, `ChangeDetectionStrategy` par défaut cohérente avec les composants de paramétrage existants — `ProfilComponent` n'utilise pas `OnPush`, ce composant n'en a pas besoin non plus).
```typescript
@Component({
selector: 'app-parametres-application',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './parametres-application.component.html',
styleUrl: './parametres-application.component.scss'
})
export class ParametresApplicationComponent implements OnInit {
private readonly parametresService = inject(ParametresApplicationService);
private readonly notification = inject(NotificationService);
readonly precisionSelectionnee = signal<0 | 1 | 2>(2);
readonly enregistrement = signal(false);
ngOnInit(): void {
this.precisionSelectionnee.set(this.parametresService.precisionDistance());
}
choisir(valeur: 0 | 1 | 2): void {
const precedente = this.precisionSelectionnee();
this.precisionSelectionnee.set(valeur);
this.enregistrement.set(true);
this.parametresService.definirPrecisionDistance(valeur).subscribe({
next: () => {
this.enregistrement.set(false);
this.notification.success('Précision des distances enregistrée.');
},
error: () => {
this.enregistrement.set(false);
this.precisionSelectionnee.set(precedente); // Exigence 2.5 : conserve la valeur précédente
this.notification.error("La modification n'a pas pu être enregistrée.");
}
});
}
}
```
Template : trois boutons/radios (« 0 », « 1 », « 2 ») avec le choix courant présélectionné (`precisionSelectionnee`), et un texte d'aide expliquant l'arrondi supérieur.
### Route et menu (`app.routes.ts`, `parametrage.component.html`)
`app.routes.ts` — nouvel enfant **entre** `compte` et `vehicules`, avec le même niveau de garde que `compte`/`vehicules` (`authGuard` seul, pas `profilConfigureGuard` — Exigence 2.6) :
```typescript
children: [
{ path: '', redirectTo: 'compte', pathMatch: 'full' },
{ path: 'compte', component: ProfilComponent },
{ path: 'application', component: ParametresApplicationComponent },
{ path: 'vehicules', component: VehiculesComponent },
{ path: 'bareme', component: BaremeComponent, canActivate: [profilConfigureGuard] }
]
```
`parametrage.component.html` — nouveau lien **entre** « Compte » et « Véhicules » (Exigence 2.1) :
```html
<a class="parametrage__lien" routerLink="/parametrage/application" routerLinkActive="parametrage__lien--actif">
Application
</a>
```
### Service Angular `ParametresApplicationService` (nouveau, `core/services/parametres-application.service.ts`)
Porte le **signal partagé** de précision courante, chargé une fois et mis à jour après modification — c'est le mécanisme central de propagation demandé (pas de rechargement par composant) :
```typescript
@Injectable({ providedIn: 'root' })
export class ParametresApplicationService {
private readonly http = inject(HttpClient);
private readonly baseUrl = environment.apiUrl;
/** Précision courante (0, 1 ou 2), partagée par toute l'application. Défaut 2 avant premier chargement. */
readonly precisionDistance = signal<0 | 1 | 2>(2);
/** Charge la précision courante depuis le backend et met à jour le signal partagé. */
charger(): Observable<ParametresApplication> {
return this.http.get<ParametresApplication>(`${this.baseUrl}/parametres-application`).pipe(
tap((p) => this.precisionDistance.set(p.precisionDistanceKm as 0 | 1 | 2))
);
}
/** Modifie la précision et met à jour le signal partagé en cas de succès. */
definirPrecisionDistance(valeur: 0 | 1 | 2): Observable<ParametresApplication> {
return this.http.put<ParametresApplication>(`${this.baseUrl}/parametres-application`, { precisionDistanceKm: valeur }).pipe(
tap((p) => this.precisionDistance.set(p.precisionDistanceKm as 0 | 1 | 2))
);
}
}
```
```typescript
// core/models/parametres-application.model.ts
export interface ParametresApplication {
precisionDistanceKm: number;
}
```
**Chargement initial** : suit le même schéma que le chargement du profil dans `AppComponent` (`effect()` déclenché dès que l'utilisateur authentifié quitte une page publique — cf. `AppComponent` existant qui charge déjà `ProfilService.getProfil()` à ce moment). `AppComponent` appelle également `ParametresApplicationService.charger()` à cet instant, afin que le signal soit disponible avant l'affichage des vues protégées.
### Fonction utilitaire d'arrondi frontend (`shared/utils/arrondir-distance.ts`, nouveau fichier)
```typescript
/**
* Arrondit une distance brute (km) au supérieur (ceiling) à la précision donnée.
* Miroir exact de PrecisionDistanceService.ArrondirDistance côté backend.
*/
export function arrondirDistance(distanceBrute: number, precision: 0 | 1 | 2): number {
const facteur = Math.pow(10, precision);
return Math.ceil(distanceBrute * facteur) / facteur;
}
/** Distance_Effective_Arrondie : arrondi de la distance simple PUIS doublement si aller/retour. */
export function distanceEffectiveArrondie(
distanceBrute: number | null,
allerRetour: boolean,
precision: 0 | 1 | 2
): number | null {
if (distanceBrute == null) {
return null;
}
const arrondie = arrondirDistance(distanceBrute, precision);
return allerRetour ? arrondie * 2 : arrondie;
}
```
> **Remarque sur la source de la distance brute côté frontend** : après la modification backend (Requirement 5/6 ci-dessus), les DTOs `TrajetDto` exposés par `ResumeService` contiennent déjà `Distance_Effective_Arrondie` (le backend a effectué l'arrondi avant l'envoi). Le frontend n'a donc PAS besoin de ré-arrondir la valeur reçue de l'API pour l'affichage de la liste des trajets ou des totaux — il l'affiche telle quelle. La fonction `arrondirDistance`/`distanceEffectiveArrondie` côté frontend sert uniquement dans les cas où le frontend a besoin d'un **rendu réactif immédiat sans aller-retour serveur** après un changement de précision (Exigence 2.4/5.5 : recalcul immédiat des totaux affichés) — c'est-à-dire lorsqu'un composant recalcule l'affichage à partir de distances brutes déjà en mémoire (ex. recalcul du panneau de totaux en cache local avant confirmation serveur), plutôt que de déclencher un rechargement HTTP à chaque clic sur 0/1/2.
### Composants frontend à modifier pour appliquer/afficher l'arrondi
| Composant | Fichier réel | Modification |
|---|---|---|
| `TrajetListComponent` | `frontend/src/app/features/trajet-list/trajet-list.component.ts` (méthode `distanceAffichee`) | La distance provient déjà du `TrajetDto.distance` renvoyé par le backend arrondi (Requirement 5/6) ; `distanceAffichee` n'a plus besoin d'arrondir elle-même à 2 décimales fixes — elle formate simplement selon `precisionSelectionnee()` du `ParametresApplicationService` injecté (nombre de décimales affichées cohérent avec la précision choisie, ex. `toFixed(precision)`). |
| `PanneauTotauxComponent` | `frontend/src/app/features/panneau-totaux/panneau-totaux.component.ts` + `.html` | `totaux()` provient de `TrajetService.getTotauxJour` (backend déjà arrondi) ; le pipe `number: '1.2-2'` fixe dans le template est remplacé par un format dynamique basé sur `parametresService.precisionDistance()` (ex. `'1.{p}-{p}'` construit dynamiquement, ou méthode `formatKm()` utilisant `toFixed`). Le bloc « Indication de versement (80%) » est renommé et complété avec la nouvelle ligne « Montant prévisionnel annuel » (Requirement 8), consommant les nouveaux champs `montantPrevisionnelMensuel`/`montantPrevisionnelAnnuel` du DTO. |
| `CarteTrajetsComponent` | `frontend/src/app/features/carte-trajets/carte-trajets.component.ts` (méthode `contenuInfobulle`) | `trajet.distance.toFixed(2)` est remplacé par `trajet.distance.toFixed(this.parametresService.precisionDistance())`. La distance vient de `TrajetService.getTrajetsJournee` (`TrajetCarteDto`), à corriger côté backend pour renvoyer la Distance_Effective_Arrondie (même service `ResumeService`/logique commune que la liste, ou méthode dédiée du `ITrajetService` selon l'implémentation réelle du endpoint `/api/trajets/{date}/journee` — à vérifier au moment de l'implémentation dans `ITrajetService`). |
| `MontantsReglesComponent` | `frontend/src/app/features/montants-regles/montants-regles.component.ts` | Consomme `BaremeService.getMontantPrevisionnel(annee, mois, vehiculeId)` ; le champ lu passe de `p.montantPrevisionnel` à `p.montantPrevisionnelMensuel`. Affichage inchangé (2 décimales monétaires, Exigence 8.4). |
| `BaremeService` (frontend) | `frontend/src/app/core/services/bareme.service.ts` | Le modèle `MontantPrevisionnel` (`frontend/src/app/core/models/bareme.model.ts`) est mis à jour avec les nouveaux champs (`totalMoisKm`, `totalAnnuelPartielKm`, `montantPrevisionnelMensuel`, `montantPrevisionnelAnnuel`) miroir du DTO backend. |
| `ParametrageComponent` | `frontend/src/app/features/parametrage/parametrage.component.html` | Nouveau lien « Application » (voir section Route et menu ci-dessus). |
| `AppComponent` | `frontend/src/app/app.component.ts` | Ajoute le chargement initial de `ParametresApplicationService.charger()` au même moment que le chargement du profil (cf. section Service Angular ci-dessus). |
---
## Règle de calcul Aller_Retour : ordre des opérations
**Règle (Exigence 4.2)** : la distance simple brute est arrondie au supérieur **avant** d'être doublée. Ne jamais doubler la distance brute puis arrondir le résultat — les deux ordres peuvent produire des résultats différents.
**Exemple chiffré (Precision_Distance = 0)** :
- Distance simple brute (stockée en base, déjà arrondie à 2 décimales par `ORSClient`) : `12.30 km`.
- **Ordre correct (retenu)** : `ArrondirDistance(12.30, 0) = Ceiling(12.30) = 13 km`, puis doublement : `13 × 2 = 26 km`. → **Distance_Effective_Arrondie = 26 km**.
- **Ordre incorrect (rejeté)** : doublement d'abord `12.30 × 2 = 24.60 km`, puis arrondi : `Ceiling(24.60) = 25 km`. → 25 km ≠ 26 km.
L'écart (26 vs 25) illustre pourquoi l'ordre est normatif et testé explicitement (voir Stratégie de test).
Implémentation (backend comme frontend) : toujours calculer `ArrondirDistance(distanceSimple, precision) × (allerRetour ? 2 : 1)`, jamais `ArrondirDistance(distanceSimple × (allerRetour ? 2 : 1), precision)`.
---
## Cohérence PDF / Carte (Exigence 9)
- **`PdfService`** (`backend/SchotzIadKmTracker.Api/Services/PdfService.cs`) : ne fait aucun calcul de distance brut lui-même — il compose `IResumeService.GetResumeMensuelAsync/GetResumeAnnuelAsync` (table des trajets et totaux du rapport mensuel, méthode privée `ComposerTableTrajets`) et `IRemboursementService.GetRegularisationAnnuelleAsync` (synthèse annuelle, méthode privée `ComposerTableSyntheseAnnuelle`). Une fois ces deux services corrigés pour retourner des distances déjà arrondies selon Precision_Distance (Requirement 5/6 ci-dessus), le PDF hérite automatiquement de la cohérence, **sans modification de `PdfService` lui-même**, à l'exception d'un point : `AssemblerModeleMensuelAsync` recalcule localement `totalIkMois = resumeMensuel.TotalKm × bareme.Coefficient` — ce total provient déjà de `resumeMensuel.TotalKm` (corrigé), donc cohérent sans changement de code dans `PdfService`.
- **`CarteTrajetsComponent`** (`frontend/src/app/features/carte-trajets/carte-trajets.component.ts`, méthode `contenuInfobulle`) : la ligne `trajet.distance.toFixed(2)` doit devenir `trajet.distance.toFixed(precision)` où `precision` provient de `ParametresApplicationService.precisionDistance()` injecté dans le composant. La valeur `trajet.distance` elle-même doit être la Distance_Effective_Arrondie renvoyée par le endpoint utilisé par `TrajetService.getTrajetsJournee` (à vérifier/corriger côté `ITrajetService`/contrôleur `TrajetsController` au moment de l'implémentation, par cohérence avec `ResumeService`).
- Exigence 9.4 (valeur strictement identique) est garantie par le fait qu'un seul point d'arrondi (`IPrecisionDistanceService.ArrondirDistance`) est appelé, à la même précision courante, par tous les services qui produisent une distance affichée.
---
## Écarts avec `schotziad-km-tracker/design.md` existant
Cette section documente, sans modifier le fichier `design.md` existant, les endpoints et composants dont le comportement change avec cette spec, afin qu'une mise à jour ultérieure de la spec de base en tienne compte :
| Élément existant | Comportement actuel (design.md) | Nouveau comportement (precision-distances-km) |
|---|---|---|
| `IMontantRegleService.GetMontantPrevisionnelAsync` | 80% du barème plein appliqué au **Total_Annuel_Partiel** | 80% appliqué au **seul mois affiché** (tranche déterminée par le cumul annuel) ; ajout d'un second montant 100% sur le Total_Annuel_Partiel |
| `MontantPrevisionnelDto` | Champs `TotalAnnuelPartielKm`, `MontantBaremePlein`, `MontantPrevisionnel` | Renommés/complétés : `TotalMoisKm`, `TotalAnnuelPartielKm`, `MontantPrevisionnelMensuel`, `MontantPrevisionnelAnnuel` |
| `PanneauTotauxComponent` | Affiche un unique bloc « Indication de versement (80%) » sur le Total_Annuel_Partiel | Affiche le Montant_Previsionnel_Mensuel (mois affiché) PUIS, en dessous, le Montant_Previsionnel_Annuel (100%, cumul annuel) |
| `MontantsReglesComponent` | Colonne « Montant_Previsionnel » = 80% sur cumul annuel par mois | Colonne « Montant_Previsionnel_Mensuel » = 80% sur le seul mois de la ligne |
| `ResumeService` / `TotauxService` / `RemboursementService` | Distances sommées à partir de `DistanceEffective` brute (2 décimales fixes issues d'ORS) | Distances sommées à partir de `Distance_Effective_Arrondie` (précision paramétrable, arrondi ceiling) |
| `TrajetDto.Distance` (résumés) | Distance brute effective (non arrondie au-delà des 2 décimales de calcul) | Distance_Effective_Arrondie selon Precision_Distance courante |
| `CarteTrajetsComponent` infobulle | `toFixed(2)` fixe | `toFixed(precision)` dynamique |
| `ParametrageComponent` menu | Compte, Véhicules, Barème | Compte, **Application**, Véhicules, Barème |
| Aucune table de configuration globale | — | Nouvelle table `ParametresApplication` (singleton) |
---
## Error Handling
| Cas | Origine | Traitement |
|---|---|---|
| `PrecisionDistanceKm` soumis hors {0, 1, 2} | `PrecisionDistanceService.DefinirAsync` | Lève `PrecisionDistanceInvalideException` → contrôleur retourne **400** avec message explicite (Exigence 1.6) |
| Requête sans jeton JWT valide sur `GET`/`PUT /api/parametres-application` | Filtre d'autorisation global (`Program.cs`) | **401** avant d'atteindre le contrôleur (Exigence 1.7, 2.6), comportement identique aux autres endpoints protégés |
| Échec réseau/serveur lors de l'enregistrement d'une nouvelle précision (frontend) | `ParametresApplicationComponent.choisir` | Le composant restaure `precisionSelectionnee` à la valeur précédente et affiche une notification d'erreur (Exigence 2.5), sans modifier le signal partagé (qui reste à l'ancienne valeur tant que le serveur n'a pas confirmé) |
| Véhicule introuvable dans `GetMontantPrevisionnelAsync` | `MontantRegleService` | `VehiculeNotFoundException` existante, inchangée (déjà mappée en 404 par le contrôleur existant) |
| Table `ParametresApplication` absente au premier démarrage | `PrecisionDistanceService.ObtenirAsync`/`GetOrCreateAsync` | Création à la volée de la ligne singleton avec la valeur par défaut 2, sur le modèle de `ProfilService.GetOrCreateProfilAsync` — aucune exception |
| Distance brute `null` (Trajet non calculable) rencontrée dans un calcul de précision | Tous les services consommateurs | `ArrondirDistance` n'est jamais appelé sur `null` ; la contribution du trajet à la somme reste 0, comme le comportement actuel (`Distance.GetValueOrDefault()`) |
---
## Testing Strategy
### Applicabilité du property-based testing
La fonction `ArrondirDistance` (backend) / `arrondirDistance` (frontend) est une **fonction pure** avec une propriété universelle claire (arrondi supérieur à une précision donnée) : le PBT s'applique pleinement. La règle d'ordre « arrondir puis doubler » pour Aller_Retour est également une propriété universelle testable sur toute distance et tout indicateur booléen. En revanche, l'intégration UI (sélection du menu, affichage du signal partagé, rendu Leaflet, génération PDF) relève de tests d'exemple/intégration classiques, pas de PBT (comportement UI, pas fonction pure à large espace d'entrée testable utilement en PBT).
### Prework — analyse de testabilité des critères d'acceptation
**1.1** THE Backend SHALL persister une valeur unique de Precision_Distance parmi {0,1,2}
Thoughts : comportement de persistance simple, un test d'exemple suffit (enregistrer puis relire chaque valeur valide).
Testable : yes - example
**1.2** Valeur par défaut = 2 si absente
Thoughts : cas précis unique (état initial). Exemple.
Testable : yes - example
**1.3/1.4** Exposition d'API GET/PUT protégées
Thoughts : vérifie la présence d'endpoints, pas une propriété sur un espace d'entrée. Exemple/intégration.
Testable : yes - example
**1.5** Application immédiate sans migration des données stockées
Thoughts : comportement d'absence d'effet de bord sur les données existantes. Testable par exemple (créer des trajets, changer la précision, vérifier que `Trajet.Distance` est inchangé) — cf. Property ci-dessous, généralisable en propriété d'invariance.
Testable : yes - property (invariant : changer Precision_Distance ne modifie jamais Trajet.Distance)
**1.6** Rejet si valeur ≠ {0,1,2}
Thoughts : propriété universelle sur l'espace des entiers hors {0,1,2}. Le comportement varie selon que l'entrée est dans l'ensemble valide ou non — testable par génération d'entiers aléatoires.
Testable : yes - property
**1.7** Rejet sans session valide
Thoughts : comportement d'authentification binaire, ne varie pas avec le contenu de la requête. Exemple/intégration.
Testable : yes - example
**1.8** La valeur brute de Distance n'est jamais modifiée par un changement de précision
Thoughts : invariant testable pour toute distance et toute séquence de changements de précision.
Testable : yes - property
**2.1–2.6** Sous-menu Application (positionnement, présélection, confirmation, erreur, garde d'accès)
Thoughts : comportements d'interface spécifiques à des scénarios précis, pas une propriété sur un large espace d'entrée.
Testable : yes - example
**3.1–3.5** Arrondi supérieur d'un trajet simple selon la précision (0/1/2), toujours ≥ distance brute
Thoughts : propriété universelle par excellence — pour toute distance brute positive et toute précision valide, l'arrondi ceiling est supérieur ou égal à la distance brute, et coïncide avec l'arrondi mathématique attendu à chaque précision.
Testable : yes - property
**4.1–4.3** Arrondi avant doublement pour Aller_Retour ; non-A/R = Distance_Arrondie
Thoughts : propriété universelle sur l'ordre des opérations, testable par génération de distances et comparaison des deux ordres de calcul.
Testable : yes - property
**5.1–5.4** Sommation de distances déjà arrondies, sans arrondi supplémentaire après somme
Thoughts : propriété universelle sur des listes de trajets générées aléatoirement — la somme des distances arrondies individuellement doit être exactement égale au total renvoyé (pas de second arrondi qui la modifierait).
Testable : yes - property
**5.5** Recalcul immédiat des totaux à l'affichage lors d'un changement de précision (sans rechargement de page)
Thoughts : comportement d'interface réactive (signal partagé), pas une fonction pure à espace d'entrée large. Exemple/intégration (vérifier que le signal déclenche un nouveau rendu).
Testable : yes - example
**6.1–6.6** Application du barème aux distances arrondies (mensuel, annuel, réel, frais mensuel, régularisation, sélection de tranche)
Thoughts : ce sont des compositions de la propriété d'arrondi déjà couverte (Property 1/2) avec la logique de barème déjà testée dans la spec `schotziad-km-tracker`. La nouveauté testable ici est que le total transmis au barème est le total déjà arrondi — couvert par la propriété d'agrégation (Property 3) et par le fait que `CalculerMontantAsync` reste inchangé (déjà testé ailleurs).
Testable : no (couvert par les propriétés 1–3 ; pas de nouvelle propriété distincte nécessaire)
**7.1–7.4** Montant_Previsionnel_Mensuel basé sur le seul mois affiché, tranche déterminée par le cumul annuel
Thoughts : propriété universelle — pour toute combinaison de distances mensuelles et de barème, le montant mensuel ne doit dépendre QUE des distances du mois affiché (invariance par rapport aux distances des autres mois, à tranche égale).
Testable : yes - property
**8.1–8.5** Montant_Previsionnel_Annuel = 100% sur le Total_Annuel_Partiel, affiché sous le mensuel, précision 2 décimales
Thoughts : le calcul (100% sur le cumul) est une propriété universelle (relation simple avec le montant du barème plein déjà calculé) ; l'affichage/positionnement est un exemple UI.
Testable : yes - property (relation Montant_Previsionnel_Annuel = montant plein du barème sur le cumul, sans minoration) + example (positionnement UI)
**9.1** Infobulle carte = Distance_Effective_Arrondie selon la précision courante
Thoughts : propriété universelle (la valeur affichée doit être strictement égale à la fonction d'arrondi appliquée à la distance brute et à l'indicateur A/R).
Testable : yes - property
**9.2–9.3** PDF utilise la Distance_Effective_Arrondie et la précision en vigueur au moment de la génération
Thoughts : propriété universelle sur les valeurs numériques du document généré, mais génération PDF coûteuse (QuestPDF) — préférable de tester la fonction de composition des données (modèle) plutôt que le rendu binaire complet.
Testable : yes - property (sur le modèle de données assemblé, pas sur le rendu binaire)
**9.4** Cohérence stricte entre liste, panneau, carte, PDF et barème pour un même trajet à précision égale
Thoughts : propriété universelle mais transversale à plusieurs services déjà couverts individuellement (Property 1/2/6) — se déduit de l'utilisation d'un point d'arrondi unique. Pas de nouvelle propriété distincte nécessaire, mais un test d'intégration (exemple) vérifiant l'égalité entre les sorties de `ResumeService`, `TotauxService` et `RemboursementService` pour un jeu de trajets donné apporte une garantie de non-régression bienvenue.
Testable : edge-case (test d'intégration de cohérence croisée, pas une propriété PBT distincte)
### Réflexion de redondance
- Les critères 3.1–3.5 (arrondi simple par précision) et 4.1–4.3 (arrondi puis doublement) partagent la même fonction sous-jacente : une seule **Property 1** (arrondi ceiling ≥ brute, exact par précision) et une seule **Property 2** (ordre arrondi-puis-doublement) suffisent, sans décliner une propriété par valeur de précision.
- Les critères 6.1–6.6 sont entièrement dérivés des propriétés d'arrondi (1, 2) et d'agrégation (3) : aucune propriété supplémentaire.
- Le critère 1.8 (invariance de `Trajet.Distance`) et le critère 5.1–5.4 (pas d'arrondi après somme) sont regroupés respectivement en **Property 5** et **Property 3** — pas de doublon avec 1.6 (validation stricte, **Property 4**, qui porte sur un autre aspect : le rejet des valeurs invalides, non l'arrondi lui-même).
- Le critère 9.4 (cohérence croisée) est une consé quence directe des propriétés 1–3 appliquées via un point d'arrondi unique ; il est couvert par un test d'intégration (exemple), pas par une propriété PBT distincte.
## Correctness Properties
*A property is a characteristic or behavior that should hold true across all valid executions of a system-essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
### Property 1: Arrondi supérieur exact et jamais inférieur à la distance brute
*For any* distance brute positive et toute précision valide (0, 1 ou 2), `ArrondirDistance(distance, precision)` retourne une valeur supérieure ou égale à `distance`, et cette valeur, multipliée par `10^precision`, est un entier (le résultat est bien un multiple exact du pas de précision).
**Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5**
### Property 2: Arrondi avant doublement pour Aller_Retour
*For any* distance simple brute positive et toute précision valide, `distanceEffectiveArrondie(distance, allerRetour=true, precision)` est égal à `2 × ArrondirDistance(distance, precision)`, et est strictement différent, en général, de `ArrondirDistance(2 × distance, precision)` — l'implémentation ne doit jamais utiliser ce second ordre. Pour `allerRetour=false`, `distanceEffectiveArrondie` est égal à `ArrondirDistance(distance, precision)` (sans doublement).
**Validates: Requirements 4.1, 4.2, 4.3**
### Property 3: Sommation de distances déjà arrondies sans arrondi supplémentaire
*For any* liste de trajets (distance brute nullable, indicateur aller/retour), la somme des `Distance_Effective_Arrondie` de chaque trajet (calculée individuellement) est strictement égale à la valeur de total retournée par le service d'agrégation (résumé mensuel/annuel, totaux jour/mois/année, régularisation annuelle) — aucun arrondi n'est appliqué une seconde fois après la sommation.
**Validates: Requirements 5.1, 5.2, 5.3, 5.4**
### Property 4: Validation stricte de la précision
*For any* entier soumis comme nouvelle valeur de Precision_Distance, `PrecisionDistanceService.DefinirAsync` accepte et persiste la valeur si et seulement si elle appartient à {0, 1, 2} ; pour toute autre valeur, l'opération est rejetée (exception levée) et la valeur précédemment persistée reste inchangée.
**Validates: Requirements 1.6**
### Property 5: Invariance de la distance brute stockée face à un changement de précision
*For any* séquence de changements de Precision_Distance (valeurs valides), la colonne `Trajet.Distance` de tout trajet existant avant cette séquence reste strictement inchangée après celle-ci ; seule la valeur affichée/agrégée à la lecture varie.
**Validates: Requirements 1.5, 1.8**
### Property 6: Montant_Previsionnel_Mensuel dépend uniquement du mois affiché
*For any* ensemble de trajets répartis sur plusieurs mois d'une même année pour un véhicule donné, et pour tout mois M sélectionné, le Montant_Previsionnel_Mensuel calculé pour M ne change pas si l'on modifie les distances des trajets d'un autre mois que M, tant que le cumul annuel reste dans la même tranche de barème (la tranche appliquée est déterminée par le cumul, mais le montant du mois ne dépend que du kilométrage du mois M).
**Validates: Requirements 7.1, 7.2**
### Property 7: Montant_Previsionnel_Annuel égal au barème plein sur le cumul
*For any* Total_Annuel_Partiel arrondi et toute puissance fiscale/année ayant un barème configuré, le Montant_Previsionnel_Annuel est égal au montant retourné par `CalculerMontantAsync` appliqué à ce même Total_Annuel_Partiel, sans aucune minoration (coefficient 1.00), arrondi à 2 décimales.
**Validates: Requirements 8.2, 8.5**
### Property 8: Cohérence de l'infobulle carte avec la Distance_Effective_Arrondie
*For any* trajet et toute précision valide, la distance présentée dans l'infobulle de la carte est strictement égale à `distanceEffectiveArrondie(trajet.distanceBrute, trajet.allerRetour, precision)`.
**Validates: Requirements 9.1**
### Property 9: Modèle de données PDF cohérent avec les résumés/régularisations
*For any* jeu de trajets d'un mois ou d'une année, les valeurs kilométriques assemblées dans le modèle du rapport PDF (`ModeleRapportMensuel`, `ModeleRapportAnnuel`) sont strictement égales aux valeurs retournées par `IResumeService`/`IRemboursementService` pour les mêmes paramètres et la même précision courante.
**Validates: Requirements 9.2, 9.3**
### Configuration des tests de propriété
- **Bibliothèque** : côté backend (.NET), utiliser **FsCheck** (ou **CsCheck**, déjà idiomatique en C#/xUnit) — bibliothèque standard PBT pour .NET, ne pas réimplémenter un générateur maison.
- **Itérations minimales** : 100 par propriété.
- **Tag de traçabilité** dans chaque test :
`// Feature: precision-distances-km, Property {n}: {texte de la propriété}`
- Chaque propriété est implémentée par **un seul test property-based** (pas un test par précision — les générateurs couvrent {0, 1, 2} et un espace de distances flottantes/décimales positives, y compris valeurs limites proches d'un seuil d'arrondi comme `12.000...1`).
### Tests unitaires (exemples et cas limites, complémentaires aux propriétés)
- `PrecisionDistanceService.ObtenirAsync` retourne 2 par défaut quand la table est vide (Exigence 1.2).
- `PUT /api/parametres-application` avec `precisionDistanceKm = 3` retourne 400 (Exigence 1.6, cas concret).
- `GET`/`PUT /api/parametres-application` sans jeton retourne 401 (Exigence 1.7).
- Exemple chiffré Aller_Retour documenté ci-dessus (12.30 km → 26 km avec précision 0, et non 25 km) — implémenté comme test unitaire explicite en plus de la Property 2.
- `ParametresApplicationComponent.choisir` restaure la sélection précédente et notifie une erreur si l'appel HTTP échoue (Exigence 2.5).
- Positionnement du lien « Application » entre « Compte » et « Véhicules » dans `parametrage.component.html` (test de structure du template ou test e2e léger).
- `MontantsReglesComponent` affiche `montantPrevisionnelMensuel` (et non l'ancien champ) sans erreur de compilation/liaison après le renommage du DTO.
### Tests d'intégration
- Un scénario bout-en-bout (base SQLite en mémoire ou fichier temporaire) : créer des trajets sur plusieurs mois, changer Precision_Distance, vérifier que `ResumeService`, `TotauxService` et `RemboursementService` renvoient des valeurs cohérentes entre elles pour les mêmes trajets (couvre l'Exigence 9.4 en test d'intégration, cf. prework 9.4).
- Génération effective d'un PDF mensuel et annuel (au moins 1 exemple chacun) pour vérifier l'absence d'exception de rendu après les changements de modèle (pas de PBT sur le rendu binaire lui-même, conformément à la règle « ne pas faire de PBT sur des opérations coûteuses/rendu »).
@@ -0,0 +1,149 @@
# Requirements Document
## Introduction
Cette fonctionnalité introduit un paramètre global de **précision d'affichage des distances** (nombre de décimales : 0, 1 ou 2) dans l'application **SchotzIAD KM Tracker**, ainsi qu'une règle d'arrondi supérieur systématique appliquée à cette précision pour toute distance affichée ou agrégée (trajet, totaux journaliers/mensuels/annuels, carte, export PDF, barème kilométrique). Elle modifie également le mode de calcul de l'indication de versement mensuelle (Montant_Previsionnel), qui passe d'un calcul basé sur le cumul annuel partiel à un calcul basé uniquement sur le mois affiché, et introduit une nouvelle ligne d'affichage « Montant prévisionnel annuel » (100% du barème, projection sur le cumul annuel partiel).
> **Note** : Cette spec **complète** la spec existante `schotziad-km-tracker` (`requirements.md`, `design-requirements.md`, `design.md`). Elle réutilise et étend les termes déjà définis dans le glossaire de `schotziad-km-tracker` (notamment Trajet, Distance, Aller_Retour, Distance_Effective, Résumé_Mensuel, Résumé_Annuel, Total_Annuel_Partiel, Panneau_Totaux, Bareme_Kilometrique, Montant_Previsionnel, Montant_Reel, Regularisation, Frais_Reel_Mensuel, Page_Parametrage, Export_PDF, Carte). Les exigences ci-dessous priment sur celles de `schotziad-km-tracker` en cas de divergence portant sur le calcul du Montant_Previsionnel et sur la précision d'affichage des distances ; toutes les autres exigences de `schotziad-km-tracker` restent applicables sans changement.
---
## Glossary
- **Precision_Distance** : Le paramètre global de l'Application déterminant le nombre de décimales (0, 1 ou 2) utilisé pour l'arrondi supérieur des distances à l'affichage et dans les agrégats. Ce paramètre est unique pour toute l'Application (pas de déclinaison par Véhicule ni par Utilisateur).
- **Distance_Arrondie** : La valeur d'une Distance simple (non doublée) après application de l'arrondi supérieur mathématique (ceiling) à la précision définie par Precision_Distance. Exemple avec Precision_Distance = 0 : 12,3 km devient 13 km. Exemple avec Precision_Distance = 1 : 12,34 km devient 12,4 km. Exemple avec Precision_Distance = 2 : 12,347 km devient 12,35 km.
- **Distance_Effective_Arrondie** : La distance comptée d'un Trajet dans tous les agrégats et affichages une fois la règle d'arrondi appliquée : égale à la Distance_Arrondie, ou à son double lorsque le Trajet est un Aller_Retour (la Distance simple est arrondie AVANT d'être doublée, jamais après).
- **Sous_Menu_Application** : L'entrée « Application » du menu latéral de la Page_Parametrage, positionnée entre « Compte » et « Véhicules », permettant de configurer Precision_Distance.
- **Montant_Previsionnel_Mensuel** : L'indication de versement mensuelle (80%) redéfinie par la présente spec : elle correspond à 80% du Bareme_Kilometrique appliqué à la Distance_Effective_Arrondie totale du seul mois affiché (et non plus au Total_Annuel_Partiel).
- **Montant_Previsionnel_Annuel** : La nouvelle ligne d'affichage introduite par la présente spec, calculée comme 100% (sans minoration) du Bareme_Kilometrique appliqué au Total_Annuel_Partiel arrondi (cumul de l'année sélectionnée jusqu'au mois affiché inclus), affichée sous le Montant_Previsionnel_Mensuel.
---
## Requirements
### Requirement 1: Paramètre global de précision des distances
**User Story:** En tant qu'utilisateur, je veux choisir le nombre de décimales utilisé pour l'affichage des distances en kilomètres, afin d'adapter la précision affichée à mes besoins (ex. arrondi à l'unité pour une lecture rapide, ou deux décimales pour une précision fine).
#### Acceptance Criteria
1. THE Backend SHALL persister une valeur unique de Precision_Distance pour l'ensemble de l'Application, parmi exactement trois valeurs possibles : 0, 1 ou 2.
2. WHERE aucune valeur de Precision_Distance n'a encore été enregistrée, THE Backend SHALL considérer la valeur par défaut de Precision_Distance comme égale à 2.
3. THE Backend SHALL exposer une API protégée par authentification permettant de lire la valeur courante de Precision_Distance.
4. THE Backend SHALL exposer une API protégée par authentification permettant de modifier la valeur de Precision_Distance.
5. WHEN l'utilisateur soumet une nouvelle valeur de Precision_Distance égale à 0, 1 ou 2, THE Backend SHALL persister cette valeur et SHALL l'appliquer immédiatement à tous les calculs et affichages ultérieurs de distances, pour les Trajets passés comme futurs, sans migration ni recalcul des données stockées.
6. IF le Backend reçoit une valeur de Precision_Distance différente de 0, 1 ou 2, THEN THE Backend SHALL rejeter la modification et retourner un message d'erreur indiquant que seules les valeurs 0, 1 et 2 sont autorisées.
7. IF le Backend reçoit une requête de lecture ou de modification de Precision_Distance sans Session authentifiée valide, THEN THE Backend SHALL rejeter la requête.
8. THE Backend SHALL ne jamais modifier la valeur brute stockée de la Distance d'un Trajet (colonne Distance existante) ni les calculs de Calculateur_Distance (routier ou Haversine) suite à un changement de Precision_Distance ; seuls l'affichage et les agrégats calculés à la volée sont affectés.
---
### Requirement 2: Sous-menu « Application » dans le paramétrage
**User Story:** En tant qu'utilisateur, je veux accéder à un sous-menu « Application » dans la page de paramétrage, afin de configurer la précision d'affichage des distances au même endroit que mes autres réglages.
#### Acceptance Criteria
1. THE Page_Parametrage SHALL présenter, dans son menu latéral, l'entrée Sous_Menu_Application positionnée entre l'entrée « Compte » et l'entrée « Véhicules » (ordre : Compte, Application, Véhicules, Barème).
2. WHEN l'utilisateur sélectionne Sous_Menu_Application dans le menu latéral de la Page_Parametrage, THE Frontend SHALL afficher à droite du menu un contrôle de sélection « Nombre de décimales des distances (km) » proposant exactement trois choix : 0, 1 et 2, sans rechargement complet de la page.
3. WHEN la page Sous_Menu_Application est affichée, THE Frontend SHALL présélectionner le choix correspondant à la valeur courante de Precision_Distance renvoyée par le Backend.
4. WHILE la page Sous_Menu_Application est affichée, WHEN l'utilisateur sélectionne une valeur différente pour Precision_Distance, THE Frontend SHALL envoyer la nouvelle valeur au Backend et afficher un message de confirmation lorsque l'enregistrement réussit.
5. IF l'enregistrement d'une nouvelle valeur de Precision_Distance échoue au niveau du Backend, THEN THE Frontend SHALL afficher un message d'erreur indiquant que la modification n'a pas pu être enregistrée et SHALL conserver l'affichage de la valeur précédemment active.
6. THE Backend SHALL autoriser l'accès à Sous_Menu_Application indépendamment de l'état de configuration du Profil (Profil_Configure ou non), de la même manière que les entrées « Compte » et « Véhicules » de la Page_Parametrage.
---
### Requirement 3: Arrondi supérieur d'un trajet simple
**User Story:** En tant qu'utilisateur, je veux que la distance affichée d'un trajet simple soit arrondie au supérieur selon la précision choisie, afin d'obtenir un affichage cohérent et jamais sous-évalué.
#### Acceptance Criteria
1. WHEN le Frontend affiche la distance d'un Trajet qui n'est pas un Aller_Retour, THE Frontend SHALL afficher la Distance_Arrondie de ce Trajet, calculée par arrondi supérieur mathématique de la Distance brute à la précision définie par Precision_Distance.
2. WHILE Precision_Distance est égal à 0, THE Distance_Arrondie SHALL être égale à la Distance brute arrondie au kilomètre entier supérieur (ex. 12,3 km devient 13 km ; 12,0 km reste 12 km).
3. WHILE Precision_Distance est égal à 1, THE Distance_Arrondie SHALL être égale à la Distance brute arrondie au dixième de kilomètre supérieur (ex. 12,34 km devient 12,4 km).
4. WHILE Precision_Distance est égal à 2, THE Distance_Arrondie SHALL être égale à la Distance brute arrondie au centième de kilomètre supérieur (ex. 12,347 km devient 12,35 km).
5. THE Distance_Arrondie d'un Trajet SHALL toujours être supérieure ou égale à la Distance brute correspondante, quelle que soit la valeur de Precision_Distance.
---
### Requirement 4: Arrondi supérieur d'un trajet aller-retour
**User Story:** En tant qu'utilisateur, je veux que la distance affichée d'un trajet aller-retour soit le double de la distance simple déjà arrondie, afin que l'ordre des opérations (arrondi puis doublement) soit cohérent et prévisible.
#### Acceptance Criteria
1. WHEN le Frontend affiche la distance d'un Trajet dont Aller_Retour est vrai, THE Frontend SHALL afficher la Distance_Effective_Arrondie égale au double de la Distance_Arrondie de la distance simple mesurée entre le départ et l'arrivée.
2. THE Backend SHALL calculer la Distance_Effective_Arrondie d'un Trajet Aller_Retour en appliquant l'arrondi supérieur à la distance simple AVANT de la multiplier par deux, et jamais en doublant d'abord la distance brute puis en arrondissant le résultat.
3. THE Distance_Effective_Arrondie d'un Trajet non-Aller_Retour SHALL être égale à sa Distance_Arrondie (sans doublement).
---
### Requirement 5: Agrégation des totaux à partir des distances arrondies
**User Story:** En tant qu'utilisateur, je veux que les totaux du jour, du mois et de l'année soient calculés en additionnant des distances déjà arrondies, afin que la somme affichée soit cohérente avec la somme visuelle des lignes de détail.
#### Acceptance Criteria
1. WHEN le Backend ou le Frontend calcule le total kilométrique d'une journée, THE Backend ou THE Frontend SHALL additionner la Distance_Effective_Arrondie de chaque Trajet de cette journée, sans appliquer d'arrondi supplémentaire après la somme.
2. WHEN le Backend calcule le total kilométrique d'un Résumé_Mensuel, THE Backend SHALL additionner la Distance_Effective_Arrondie de chaque Trajet du mois concerné, sans appliquer d'arrondi supplémentaire après la somme.
3. WHEN le Backend calcule le total kilométrique d'un Résumé_Annuel ou le Total_Annuel_Partiel, THE Backend SHALL additionner la Distance_Effective_Arrondie de chaque Trajet des mois concernés, sans appliquer d'arrondi supplémentaire après la somme.
4. THE Panneau_Totaux SHALL afficher le total du jour, le total du mois sélectionné et le Total_Annuel_Partiel de l'année sélectionnée en appliquant la règle de sommation des distances déjà arrondies définie par la présente exigence.
5. WHEN Precision_Distance est modifié, THE Frontend SHALL recalculer et afficher immédiatement les totaux du jour, du mois et de l'année selon la nouvelle valeur de Precision_Distance, sans rechargement complet de la page.
---
### Requirement 6: Application de l'arrondi au barème kilométrique
**User Story:** En tant qu'utilisateur, je veux que les calculs de barème kilométrique (indication de versement, montant réel, régularisation) utilisent les distances arrondies affichées, afin que les montants calculés correspondent aux kilomètres que je vois à l'écran.
#### Acceptance Criteria
1. WHEN le Backend calcule le Montant_Previsionnel_Mensuel, THE Backend SHALL appliquer le Bareme_Kilometrique à la somme des Distance_Effective_Arrondie des Trajets du mois affiché, et non à la Distance brute.
2. WHEN le Backend calcule le Montant_Previsionnel_Annuel, THE Backend SHALL appliquer le Bareme_Kilometrique à la valeur arrondie du Total_Annuel_Partiel telle que définie par l'Exigence 5.
3. WHEN le Backend calcule le Montant_Reel annuel d'un Véhicule, THE Backend SHALL appliquer le Bareme_Kilometrique plein (100%) au total kilométrique annuel obtenu par sommation des Distance_Effective_Arrondie de tous les Trajets de l'année, et non des Distances brutes.
4. WHEN le Backend calcule le Frais_Reel_Mensuel de chaque mois, THE Backend SHALL multiplier la somme des Distance_Effective_Arrondie des Trajets du mois par le coefficient de la tranche annuelle applicable.
5. WHEN le Backend calcule la Regularisation d'un Véhicule pour une année, THE Backend SHALL utiliser le Montant_Reel annuel calculé à partir des distances arrondies conformément à la présente exigence.
6. THE Backend SHALL déterminer la Tranche_Bareme applicable (mensuelle ou annuelle) à partir du total kilométrique arrondi correspondant, et non à partir du total kilométrique brut.
---
### Requirement 7: Indication de versement mensuelle recentrée sur le mois affiché
**User Story:** En tant qu'utilisateur, je veux que l'indication de versement (80%) corresponde uniquement au mois que je consulte, afin de mieux estimer le montant à verser pour ce mois précis plutôt que pour tout le cumul annuel.
#### Acceptance Criteria
1. WHEN l'utilisateur consulte les données d'un mois, THE Frontend SHALL afficher le Montant_Previsionnel_Mensuel calculé comme 80% du Bareme_Kilometrique appliqué à la Distance_Effective_Arrondie totale du seul mois affiché, sans y inclure les distances des mois précédents de l'année.
2. THE Backend SHALL déterminer la Tranche_Bareme utilisée pour le Montant_Previsionnel_Mensuel à partir du total kilométrique annuel arrondi cumulé (Total_Annuel_Partiel), conformément à la règle de sélection de tranche du Bareme_Kilometrique définie dans `schotziad-km-tracker` (Requirement 13), tout en appliquant le coefficient ainsi déterminé au seul kilométrage arrondi du mois affiché.
3. THE Frontend SHALL continuer à indiquer explicitement que le Montant_Previsionnel_Mensuel est une simple indication de versement et que le montant réellement versé est celui saisi par l'utilisateur (Montant_Regle).
4. THE présente exigence SHALL remplacer, pour le calcul du Montant_Previsionnel mensuel, la règle de calcul antérieure basée sur le Total_Annuel_Partiel définie dans `schotziad-km-tracker` (Requirement 13, critère 5).
---
### Requirement 8: Montant prévisionnel annuel
**User Story:** En tant qu'utilisateur, je veux voir, en dessous de l'indication de versement mensuelle, une projection du montant prévisionnel annuel sans minoration, afin d'avoir une vision du montant total attendu sur l'année en cours.
#### Acceptance Criteria
1. WHEN l'utilisateur consulte les données d'un mois, THE Frontend SHALL afficher, immédiatement sous le Montant_Previsionnel_Mensuel, une ligne « Montant prévisionnel annuel » présentant le Montant_Previsionnel_Annuel.
2. THE Backend SHALL calculer le Montant_Previsionnel_Annuel en appliquant le Bareme_Kilometrique sans minoration (100%) au Total_Annuel_Partiel arrondi de l'année sélectionnée, cumulé jusqu'au mois affiché inclus.
3. THE Frontend SHALL indiquer explicitement que le Montant_Previsionnel_Annuel est une projection indicative sans minoration (100% du barème) et qu'il ne se substitue pas au Montant_Reel calculé en fin d'année sur la base du kilométrage annuel complet.
4. THE Frontend SHALL afficher le Montant_Previsionnel_Annuel avec une précision de deux décimales, selon les mêmes conventions d'affichage monétaire que le Montant_Previsionnel_Mensuel.
5. THE Backend SHALL déterminer la Tranche_Bareme utilisée pour le Montant_Previsionnel_Annuel à partir du même total kilométrique arrondi (Total_Annuel_Partiel) auquel le barème est appliqué.
---
### Requirement 9: Cohérence de l'arrondi sur la carte et dans l'export PDF
**User Story:** En tant qu'utilisateur, je veux que la carte et les exports PDF affichent la même distance arrondie que la liste des trajets et les totaux, afin de ne jamais voir de chiffres contradictoires selon l'endroit de l'application consulté.
#### Acceptance Criteria
1. WHEN l'utilisateur survole ou clique sur un tracé de Trajet sur la Carte, THE Frontend SHALL afficher dans l'infobulle la Distance_Effective_Arrondie de ce Trajet, calculée selon la valeur courante de Precision_Distance.
2. WHEN le Backend génère un Export_PDF (mensuel ou annuel), THE Backend SHALL utiliser la Distance_Effective_Arrondie de chaque Trajet, ainsi que les totaux calculés à partir de ces distances arrondies conformément à l'Exigence 5, pour toutes les valeurs kilométriques figurant dans le document.
3. WHEN le Backend génère un Export_PDF, THE Backend SHALL appliquer la valeur de Precision_Distance en vigueur au moment de la génération du document pour arrondir les distances affichées dans le PDF.
4. THE Backend SHALL garantir que la Distance_Effective_Arrondie utilisée pour un même Trajet est strictement identique, à valeur de Precision_Distance égale, dans la liste des Trajets, le Panneau_Totaux, la Carte, l'Export_PDF et les calculs de Bareme_Kilometrique.
---
+196
View File
@@ -0,0 +1,196 @@
# Implementation Plan: Précision des distances (km)
## Overview
Cette implémentation ajoute un paramètre global `Precision_Distance` (0/1/2 décimales) avec arrondi supérieur (ceiling) systématique côté backend et frontend, refond le calcul du Montant_Previsionnel (mensuel recentré sur le mois affiché + nouveau montant annuel 100%), et propage la cohérence de l'arrondi à la liste des trajets, aux totaux, à la carte, à l'export PDF et au barème kilométrique.
L'ordre des tâches suit les dépendances réelles : entité + migration → service de précision pur → intégration dans les services d'agrégation existants → refonte du calcul prévisionnel → cohérence carte/PDF → tests backend → couche frontend (service/util → composant/route → consommateurs) → vérification finale.
## Tasks
- [x] 1. Backend — Entité `ParametresApplication` et migration EF Core
- Créer `Models/Entities/ParametresApplication.cs` (singleton `Id = 1`, `PrecisionDistanceKm` défaut 2, `UpdatedAt`)
- Ajouter `DbSet<ParametresApplication> ParametresApplication` et la configuration `OnModelCreating` (contrainte CHECK `IN (0,1,2)`, clé `ValueGeneratedNever()`, index unique singleton) dans `Data/AppDbContext.cs`
- Générer la migration EF Core (`dotnet ef migrations add AjoutParametresApplication`) créant la table avec la contrainte CHECK
- _Requirements: 1.1, 1.2_
- [x] 2. Backend — Service de précision et exception métier
- [x] 2.1 Créer l'exception `Services/Exceptions/PrecisionDistanceInvalideException.cs` (même modèle que `MontantRegleNegatifException`)
- _Requirements: 1.6_
- [x] 2.2 Créer `Services/IPrecisionDistanceService.cs` (`ObtenirAsync`, `DefinirAsync`, `ArrondirDistance`)
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.6, 1.8, 3.1, 3.2, 3.3, 3.4, 3.5, 4.2_
- [x] 2.3 Implémenter `Services/PrecisionDistanceService.cs`
- `GetOrCreateAsync` privé créant la ligne singleton par défaut (2) si absente
- `ObtenirAsync`/`DefinirAsync` avec validation stricte `{0,1,2}` levant `PrecisionDistanceInvalideException`
- `ArrondirDistance` : `Ceiling(distance × 10^precision) / 10^precision`
- _Requirements: 1.1, 1.2, 1.5, 1.6, 1.8, 3.1, 3.2, 3.3, 3.4, 3.5_
- [x]* 2.4 Écrire les tests de propriété pour `PrecisionDistanceService`
- **Property 1: Arrondi supérieur exact et jamais inférieur à la distance brute** — _Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5_
- **Property 4: Validation stricte de la précision** — _Validates: Requirements 1.6_
- **Property 5: Invariance de la distance brute stockée face à un changement de précision** — _Validates: Requirements 1.5, 1.8_
- [ ]* 2.5 Écrire les tests unitaires `PrecisionDistanceService`
- `ObtenirAsync` retourne 2 par défaut quand la table est vide (Exigence 1.2)
- `DefinirAsync(3)` lève `PrecisionDistanceInvalideException` (Exigence 1.6, cas concret)
- _Requirements: 1.2, 1.6_
- [x] 3. Backend — DTOs et contrôleur `ParametresApplicationController`
- [x] 3.1 Créer `Models/DTOs/ParametresApplicationDtos.cs` (`ParametresApplicationDto`, `SavePrecisionDistanceDto`)
- _Requirements: 1.3, 1.4_
- [x] 3.2 Créer `Controllers/ParametresApplicationController.cs` (GET/PUT `api/parametres-application`, mapping `PrecisionDistanceInvalideException` → 400)
- _Requirements: 1.3, 1.4, 1.6, 1.7_
- [x] 3.3 Enregistrer `IPrecisionDistanceService`/`PrecisionDistanceService` dans `Program.cs` (`AddScoped`, section services métier)
- _Requirements: 1.1, 1.3, 1.4_
- [ ]* 3.4 Écrire les tests d'intégration du contrôleur
- `PUT /api/parametres-application` avec `precisionDistanceKm = 3` retourne 400 (Exigence 1.6)
- `GET`/`PUT /api/parametres-application` sans jeton retourne 401 (Exigence 1.7)
- _Requirements: 1.3, 1.4, 1.6, 1.7_
- [x] 4. Checkpoint — Ensure all tests pass
- Ensure all tests pass, ask the user if questions arise.
- [x] 5. Backend — Intégration de l'arrondi dans les services d'agrégation existants
- [x] 5.1 Modifier `Services/ResumeService.cs` : injecter `IPrecisionDistanceService`, appliquer `ArrondirDistance(Distance, precision) × (AllerRetour ? 2 : 1)` par trajet dans `ConstruireResumeMensuel`, exposer `TrajetDto.Distance` déjà arrondi, sommer les valeurs déjà arrondies pour `TotalKm` sans second arrondi
- _Requirements: 4.1, 4.2, 4.3, 5.2, 5.3_
- [x] 5.2 Modifier `Services/TotauxService.cs` : injecter `IPrecisionDistanceService`, remplacer la fonction locale `Effective(distance, allerRetour)` par l'arrondi avant doublement, supprimer le `Math.Round(..., 2, ...)` final existant sur `TotalJour`/`TotalMois`/`TotalAnnuelPartiel`
- _Requirements: 4.1, 4.2, 4.3, 5.1, 5.4_
- [x] 5.3 Modifier `Services/RemboursementService.cs` : injecter `IPrecisionDistanceService`, construire `kmMoisMap` à partir des distances arrondies par trajet (au lieu de la distance brute), garantissant `totalKmAnnuel` et chaque `KmMois` comme sommes de distances déjà arrondies
- _Requirements: 4.1, 4.2, 4.3, 6.3, 6.4, 6.6_
- [x]* 5.4 Écrire le test de propriété pour la sommation
- **Property 2: Arrondi avant doublement pour Aller_Retour** — _Validates: Requirements 4.1, 4.2, 4.3_
- **Property 3: Sommation de distances déjà arrondies sans arrondi supplémentaire** — _Validates: Requirements 5.1, 5.2, 5.3, 5.4_
- [x]* 5.5 Écrire le test unitaire de l'exemple chiffré Aller_Retour
- Cas 12.30 km, précision 0 → 26 km (et non 25 km), en plus de la Property 2
- _Requirements: 4.2_
- [x] 6. Backend — Refonte de `MontantRegleService.GetMontantPrevisionnelAsync`
- [x] 6.1 Créer/modifier le DTO `MontantPrevisionnelDto` (`TotalMoisKm`, `TotalAnnuelPartielKm`, `MontantPrevisionnelMensuel`, `MontantPrevisionnelAnnuel`) dans `Models/DTOs/`
- _Requirements: 6.1, 6.2, 7.1, 8.1, 8.2, 8.4_
- [x] 6.2 Modifier `Services/IMontantRegleService.cs` et `Services/MontantRegleService.cs` : injecter `IPrecisionDistanceService`, calculer `totalMoisAffiche`/`totalAnnuelPartiel` à partir des distances arrondies, déterminer la tranche via le cumul annuel (`baremeAnnuel`), appliquer son coefficient/base au total du mois pour `MontantPrevisionnelMensuel` (80%), calculer `MontantPrevisionnelAnnuel` (100% sur le cumul)
- _Requirements: 6.1, 6.2, 6.6, 7.1, 7.2, 7.4, 8.1, 8.2, 8.5_
- [x]* 6.3 Écrire les tests de propriété pour le calcul prévisionnel
- **Property 6: Montant_Previsionnel_Mensuel dépend uniquement du mois affiché** — _Validates: Requirements 7.1, 7.2_
- **Property 7: Montant_Previsionnel_Annuel égal au barème plein sur le cumul** — _Validates: Requirements 8.2, 8.5_
- [ ]* 6.4 Écrire les tests unitaires de `GetMontantPrevisionnelAsync`
- Véhicule introuvable → `VehiculeNotFoundException` (comportement existant inchangé)
- Cas limite : trajets avec distance `null` ignorés dans la somme
- _Requirements: 6.1, 6.2, 7.1, 8.1, 8.2_
- [x] 7. Backend — Cohérence carte et export PDF
- [x] 7.1 Vérifier/corriger l'endpoint trajets/journée (`ITrajetService`/`TrajetsController`, méthode consommée par `TrajetService.getTrajetsJournee`) pour renvoyer la Distance_Effective_Arrondie via `IPrecisionDistanceService`
- _Requirements: 9.1, 9.4_
- [x] 7.2 Vérifier `Services/PdfService.cs` (`AssemblerModeleMensuelAsync`, composition des tables mensuelle/annuelle) : confirmer qu'aucun recalcul de distance brute ne subsiste et que le modèle assemblé provient intégralement de `IResumeService`/`IRemboursementService` déjà corrigés
- _Requirements: 9.2, 9.3_
- [ ]* 7.3 Écrire les tests de propriété de cohérence carte/PDF
- **Property 8: Cohérence de l'infobulle carte avec la Distance_Effective_Arrondie** — _Validates: Requirements 9.1_
- **Property 9: Modèle de données PDF cohérent avec les résumés/régularisations** — _Validates: Requirements 9.2, 9.3_
- [ ]* 7.4 Écrire le test d'intégration de cohérence croisée
- Scénario bout-en-bout (SQLite en mémoire) : créer des trajets sur plusieurs mois, changer Precision_Distance, vérifier l'égalité des valeurs entre `ResumeService`, `TotauxService` et `RemboursementService` (Exigence 9.4)
- _Requirements: 9.4_
- [ ]* 7.5 Écrire les tests d'intégration de génération PDF
- Génération effective d'un PDF mensuel et d'un PDF annuel (1 exemple chacun), vérifier l'absence d'exception après les changements de modèle
- _Requirements: 9.2, 9.3_
- [x] 8. Checkpoint — Ensure all tests pass
- Ensure all tests pass, ask the user if questions arise.
- [x] 9. Frontend — Modèle, service et utilitaire d'arrondi
- [x] 9.1 Créer `core/models/parametres-application.model.ts` (`ParametresApplication { precisionDistanceKm: number }`)
- _Requirements: 1.3, 1.4_
- [x] 9.2 Créer `core/services/parametres-application.service.ts` (`ParametresApplicationService` : signal partagé `precisionDistance`, `charger()`, `definirPrecisionDistance()`)
- _Requirements: 1.3, 1.4, 2.3, 2.4, 2.5_
- [x] 9.3 Créer `shared/utils/arrondir-distance.ts` (`arrondirDistance`, `distanceEffectiveArrondie`), miroir exact de la formule backend
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 4.1, 4.2, 4.3, 5.5_
- [ ]* 9.4 Écrire les tests unitaires de `arrondirDistance`/`distanceEffectiveArrondie`
- Cas 12.30 km, précision 0, aller-retour → 26 km (miroir du test backend)
- Cas limites par précision (0/1/2) et valeurs proches d'un seuil d'arrondi
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 4.1, 4.2, 4.3_
- [x]* 9.5 Mettre à jour `core/models/bareme.model.ts` et `core/services/bareme.service.ts` avec les nouveaux champs `MontantPrevisionnel` (`totalMoisKm`, `totalAnnuelPartielKm`, `montantPrevisionnelMensuel`, `montantPrevisionnelAnnuel`)
- _Requirements: 6.1, 7.1, 8.1, 8.2, 8.4_
- [x] 10. Frontend — Composant `ParametresApplicationComponent` et route/menu
- [x] 10.1 Créer `features/parametres-application/parametres-application.component.ts` (+ `.html`, `.scss`) : sélection 0/1/2, présélection de la valeur courante, confirmation de succès, restauration + notification d'erreur en cas d'échec
- _Requirements: 2.2, 2.3, 2.4, 2.5_
- [x] 10.2 Ajouter la route `/parametrage/application` dans `app.routes.ts`, entre `compte` et `vehicules`, gardée uniquement par `authGuard` (pas `profilConfigureGuard`)
- _Requirements: 2.1, 2.6_
- [x] 10.3 Ajouter le lien « Application » entre « Compte » et « Véhicules » dans `features/parametrage/parametrage.component.html`
- _Requirements: 2.1_
- [ ]* 10.4 Écrire les tests unitaires du composant `ParametresApplicationComponent`
- Présélection de la valeur courante au chargement (Exigence 2.3)
- `choisir()` restaure la sélection précédente et notifie une erreur si l'appel HTTP échoue (Exigence 2.5)
- _Requirements: 2.3, 2.4, 2.5_
- [ ]* 10.5 Écrire le test de structure du menu de paramétrage
- Vérifier le positionnement du lien « Application » entre « Compte » et « Véhicules »
- _Requirements: 2.1_
- [x] 11. Frontend — Chargement initial dans `AppComponent`
- [x] 11.1 Modifier `app.component.ts` pour appeler `ParametresApplicationService.charger()` au même moment que le chargement du profil (effect déclenché à l'authentification)
- _Requirements: 1.3, 5.5_
- [x] 12. Checkpoint — Ensure all tests pass
- Ensure all tests pass, ask the user if questions arise.
- [x] 13. Frontend — Adaptation des composants consommateurs
- [x] 13.1 Modifier `features/trajet-list/trajet-list.component.ts` (méthode `distanceAffichee`) : formater selon `ParametresApplicationService.precisionDistance()` au lieu d'un arrondi fixe à 2 décimales
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_
- [x] 13.2 Modifier `features/panneau-totaux/panneau-totaux.component.ts` + `.html` : format dynamique des distances basé sur `precisionDistance()`, ajout de la ligne « Montant prévisionnel annuel » sous l'indication de versement mensuelle, consommation de `montantPrevisionnelMensuel`/`montantPrevisionnelAnnuel`
- _Requirements: 5.4, 5.5, 8.1, 8.3, 8.4_
- [x] 13.3 Modifier `features/carte-trajets/carte-trajets.component.ts` (méthode `contenuInfobulle`) : `toFixed(precision)` dynamique basé sur `ParametresApplicationService.precisionDistance()`
- _Requirements: 9.1_
- [x] 13.4 Modifier `features/montants-regles/montants-regles.component.ts` : lire `montantPrevisionnelMensuel` au lieu de `montantPrevisionnel`
- _Requirements: 7.1, 7.3_
- [ ]* 13.5 Écrire les tests unitaires des composants adaptés
- `TrajetListComponent.distanceAffichee` formate selon la précision courante
- `PanneauTotauxComponent` affiche la nouvelle ligne « Montant prévisionnel annuel »
- `CarteTrajetsComponent.contenuInfobulle` utilise la précision courante
- `MontantsReglesComponent` affiche `montantPrevisionnelMensuel` sans erreur de liaison après le renommage du DTO
- _Requirements: 3.1, 5.4, 7.1, 7.3, 8.1, 8.3, 8.4, 9.1_
- [x] 14. Checkpoint final — Ensure all tests pass
- Ensure all tests pass, ask the user if questions arise.
- [x] 15. Vérification finale
- [x] 15.1 Exécuter le build backend (`dotnet build`) et le build frontend (`ng build` / `npm run build`), corriger toute erreur de compilation résultant des changements de DTO/modèle
- _Requirements: 1.1–9.4 (non-régression globale)_
- [x] 15.2 Exécuter l'ensemble des tests backend (`dotnet test`) et frontend (`ng test` / `vitest --run`), vérifier que toutes les propriétés et tous les tests unitaires/intégration passent
- _Requirements: 1.1–9.4 (non-régression globale)_
## Notes
- Les tâches marquées `*` sont optionnelles (tests) et peuvent être ignorées pour un MVP plus rapide, mais restent recommandées vu la présence de 9 Correctness Properties dans le design.
- Chaque tâche référence les exigences granulaires concernées pour la traçabilité.
- Les checkpoints garantissent une validation incrémentale avant de passer à la couche suivante (backend cœur → calcul prévisionnel → cohérence carte/PDF → frontend).
- Les tests de propriété utilisent FsCheck (déjà présent dans `SchotzIadKmTracker.Tests.csproj`), avec au moins 100 itérations par propriété et le tag de traçabilité `// Feature: precision-distances-km, Property {n}: {texte}`.
- La tâche 6.2 constitue une rupture de contrat mineure sur `MontantPrevisionnelDto` : les tâches frontend 9.5 et 13.4 doivent être réalisées après la tâche 6.1 pour rester synchronisées avec les nouveaux noms de champs.
## Task Dependency Graph
```json
{
"waves": [
{ "id": 0, "tasks": ["1"] },
{ "id": 1, "tasks": ["2.1"] },
{ "id": 2, "tasks": ["2.2"] },
{ "id": 3, "tasks": ["2.3"] },
{ "id": 4, "tasks": ["2.4", "2.5", "3.1"] },
{ "id": 5, "tasks": ["3.2"] },
{ "id": 6, "tasks": ["3.3"] },
{ "id": 7, "tasks": ["3.4"] },
{ "id": 8, "tasks": ["5.1", "5.2", "5.3"] },
{ "id": 9, "tasks": ["5.4", "5.5", "6.1"] },
{ "id": 10, "tasks": ["6.2"] },
{ "id": 11, "tasks": ["6.3", "6.4", "7.1"] },
{ "id": 12, "tasks": ["7.2"] },
{ "id": 13, "tasks": ["7.3", "7.4", "7.5"] },
{ "id": 14, "tasks": ["9.1"] },
{ "id": 15, "tasks": ["9.2"] },
{ "id": 16, "tasks": ["9.3", "9.5"] },
{ "id": 17, "tasks": ["9.4", "10.1"] },
{ "id": 18, "tasks": ["10.2", "10.3"] },
{ "id": 19, "tasks": ["10.4", "10.5", "11.1"] },
{ "id": 20, "tasks": ["13.1", "13.2", "13.3", "13.4"] },
{ "id": 21, "tasks": ["13.5"] },
{ "id": 22, "tasks": ["15.1"] },
{ "id": 23, "tasks": ["15.2"] }
]
}
```
@@ -0,0 +1,637 @@
{
"pbtResults": {},
"executionHistory": {
"1. Backend — Entité `ParametresApplication` et migration EF Core": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324603235
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324614373
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324711242
}
],
"2.1 Créer l'exception `Services/Exceptions/PrecisionDistanceInvalideException.cs` (même modèle que `MontantRegleNegatifException`)": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324603514
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324715209
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324763012
}
],
"2.2 Créer `Services/IPrecisionDistanceService.cs` (`ObtenirAsync`, `DefinirAsync`, `ArrondirDistance`)": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324603633
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324767607
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324819140
}
],
"2.3 Implémenter `Services/PrecisionDistanceService.cs`": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324603727
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324824384
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324882607
}
],
"3.1 Créer `Models/DTOs/ParametresApplicationDtos.cs` (`ParametresApplicationDto`, `SavePrecisionDistanceDto`)": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324603798
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324892736
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324931371
}
],
"3.2 Créer `Controllers/ParametresApplicationController.cs` (GET/PUT `api/parametres-application`, mapping `PrecisionDistanceInvalideException` → 400)": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324603868
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324949397
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790325012025
}
],
"3.3 Enregistrer `IPrecisionDistanceService`/`PrecisionDistanceService` dans `Program.cs` (`AddScoped`, section services métier)": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324603948
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790325016241
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790325065910
}
],
"4. Checkpoint — Ensure all tests pass": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324604002
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790325074171
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790325267173
}
],
"5.1 Modifier `Services/ResumeService.cs` : injecter `IPrecisionDistanceService`, appliquer `ArrondirDistance(Distance, precision) × (AllerRetour ? 2 : 1)` par trajet dans `ConstruireResumeMensuel`, exposer `TrajetDto.Distance` déjà arrondi, sommer les valeurs déjà arrondies pour `TotalKm` sans second arrondi": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324604065
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790325283389
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790325405943
}
],
"5.2 Modifier `Services/TotauxService.cs` : injecter `IPrecisionDistanceService`, remplacer la fonction locale `Effective(distance, allerRetour)` par l'arrondi avant doublement, supprimer le `Math.Round(..., 2, ...)` final existant sur `TotalJour`/`TotalMois`/`TotalAnnuelPartiel`": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324604119
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790325285149
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790325407417
}
],
"5.3 Modifier `Services/RemboursementService.cs` : injecter `IPrecisionDistanceService`, construire `kmMoisMap` à partir des distances arrondies par trajet (au lieu de la distance brute), garantissant `totalKmAnnuel` et chaque `KmMois` comme sommes de distances déjà arrondies": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324604176
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790325286774
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790325409159
}
],
"6.1 Créer/modifier le DTO `MontantPrevisionnelDto` (`TotalMoisKm`, `TotalAnnuelPartielKm`, `MontantPrevisionnelMensuel`, `MontantPrevisionnelAnnuel`) dans `Models/DTOs/`": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324604233
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790325415845
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790325475007
}
],
"6.2 Modifier `Services/IMontantRegleService.cs` et `Services/MontantRegleService.cs` : injecter `IPrecisionDistanceService`, calculer `totalMoisAffiche`/`totalAnnuelPartiel` à partir des distances arrondies, déterminer la tranche via le cumul annuel (`baremeAnnuel`), appliquer son coefficient/base au total du mois pour `MontantPrevisionnelMensuel` (80%), calculer `MontantPrevisionnelAnnuel` (100% sur le cumul)": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324604288
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790325477020
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790325597163
}
],
"7.1 Vérifier/corriger l'endpoint trajets/journée (`ITrajetService`/`TrajetsController`, méthode consommée par `TrajetService.getTrajetsJournee`) pour renvoyer la Distance_Effective_Arrondie via `IPrecisionDistanceService`": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324604402
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790325603594
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790325715537
}
],
"7.2 Vérifier `Services/PdfService.cs` (`AssemblerModeleMensuelAsync`, composition des tables mensuelle/annuelle) : confirmer qu'aucun recalcul de distance brute ne subsiste et que le modèle assemblé provient intégralement de `IResumeService`/`IRemboursementService` déjà corrigés": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324604453
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790325605135
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790325717074
}
],
"8. Checkpoint — Ensure all tests pass": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324604508
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790325722240
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790326716373
}
],
"9.1 Créer `core/models/parametres-application.model.ts` (`ParametresApplication { precisionDistanceKm: number }`)": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324604565
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790326726636
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790326790793
}
],
"9.2 Créer `core/services/parametres-application.service.ts` (`ParametresApplicationService` : signal partagé `precisionDistance`, `charger()`, `definirPrecisionDistance()`)": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324604619
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790326796128
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790326877000
}
],
"9.3 Créer `shared/utils/arrondir-distance.ts` (`arrondirDistance`, `distanceEffectiveArrondie`), miroir exact de la formule backend": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324604682
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790326882045
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790326945574
}
],
"10.1 Créer `features/parametres-application/parametres-application.component.ts` (+ `.html`, `.scss`) : sélection 0/1/2, présélection de la valeur courante, confirmation de succès, restauration + notification d'erreur en cas d'échec": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324604751
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790326959853
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790327069685
}
],
"10.2 Ajouter la route `/parametrage/application` dans `app.routes.ts`, entre `compte` et `vehicules`, gardée uniquement par `authGuard` (pas `profilConfigureGuard`)": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324604815
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790327075529
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790327196670
}
],
"10.3 Ajouter le lien « Application » entre « Compte » et « Véhicules » dans `features/parametrage/parametrage.component.html`": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324604873
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790327076628
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790327197485
}
],
"11.1 Modifier `app.component.ts` pour appeler `ParametresApplicationService.charger()` au même moment que le chargement du profil (effect déclenché à l'authentification)": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324604952
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790327204036
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790327280212
}
],
"12. Checkpoint — Ensure all tests pass": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324605014
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790327287107
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790327678359
}
],
"13.1 Modifier `features/trajet-list/trajet-list.component.ts` (méthode `distanceAffichee`) : formater selon `ParametresApplicationService.precisionDistance()` au lieu d'un arrondi fixe à 2 décimales": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324605079
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790327683970
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790327885223
}
],
"13.2 Modifier `features/panneau-totaux/panneau-totaux.component.ts` + `.html` : format dynamique des distances basé sur `precisionDistance()`, ajout de la ligne « Montant prévisionnel annuel » sous l'indication de versement mensuelle, consommation de `montantPrevisionnelMensuel`/`montantPrevisionnelAnnuel`": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324605136
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790327686156
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790327882270
}
],
"13.3 Modifier `features/carte-trajets/carte-trajets.component.ts` (méthode `contenuInfobulle`) : `toFixed(precision)` dynamique basé sur `ParametresApplicationService.precisionDistance()`": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324605196
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790327687238
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790327880538
}
],
"13.4 Modifier `features/montants-regles/montants-regles.component.ts` : lire `montantPrevisionnelMensuel` au lieu de `montantPrevisionnel`": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324605263
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790327688307
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790327883770
}
],
"14. Checkpoint final — Ensure all tests pass": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324605326
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790327893936
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790328182621
}
],
"15.1 Exécuter le build backend (`dotnet build`) et le build frontend (`ng build` / `npm run build`), corriger toute erreur de compilation résultant des changements de DTO/modèle": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324605386
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790328189219
},
{
"executionId": "7dd406e6-29f8-4020-98c1-c18461c20a8a",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790329414209
}
],
"15.2 Exécuter l'ensemble des tests backend (`dotnet test`) et frontend (`ng test` / `vitest --run`), vérifier que toutes les propriétés et tous les tests unitaires/intégration passent": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324605452
},
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790328190132
},
{
"executionId": "7dd406e6-29f8-4020-98c1-c18461c20a8a",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790329415260
}
],
"2. Backend — Service de précision et exception métier": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790324882738
}
],
"3. Backend — DTOs et contrôleur `ParametresApplicationController`": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790325065987
}
],
"5. Backend — Intégration de l'arrondi dans les services d'agrégation existants": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790325409224
}
],
"6. Backend — Refonte de `MontantRegleService.GetMontantPrevisionnelAsync`": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790325597233
}
],
"7. Backend — Cohérence carte et export PDF": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790325717153
}
],
"2.4 Écrire les tests de propriété pour `PrecisionDistanceService`": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790325995274
}
],
"5.4 Écrire le test de propriété pour la sommation": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790325995448
}
],
"5.5 Écrire le test unitaire de l'exemple chiffré Aller_Retour": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790325996594
}
],
"6.3 Écrire les tests de propriété pour le calcul prévisionnel": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790325997191
}
],
"9. Frontend — Modèle, service et utilitaire d'arrondi": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790326945673
}
],
"10. Frontend — Composant `ParametresApplicationComponent` et route/menu": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790327197551
}
],
"11. Frontend — Chargement initial dans `AppComponent`": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790327280285
}
],
"13. Frontend — Adaptation des composants consommateurs": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790327885292
}
],
"9.5 Mettre à jour `core/models/bareme.model.ts` et `core/services/bareme.service.ts` avec les nouveaux champs `MontantPrevisionnel` (`totalMoisKm`, `totalAnnuelPartielKm`, `montantPrevisionnelMensuel`, `montantPrevisionnelAnnuel`)": [
{
"executionId": "0004c7a8-345c-45b5-8865-a79b13f348d0",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790327886765
}
],
"15. Vérification finale": [
{
"executionId": "7dd406e6-29f8-4020-98c1-c18461c20a8a",
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
"timestamp": 1790329415401
}
]
}
}
@@ -8,7 +8,9 @@ Ce document rassemble les **exigences techniques** dérivées des exigences fonc
Ces exigences sont destinées à être définies et arbitrées **conjointement par le PO business et le PO IT**, dans un second temps, une fois le besoin fonctionnel stabilisé. Elles peuvent évoluer sans remettre en cause le besoin métier tant que le comportement observable décrit dans `requirements.md` est respecté.
Le document est organisé **en miroir** des exigences de `requirements.md` : chaque section reprend le même numéro et le même titre suivi de « — aspects techniques », afin de faciliter les références croisées. Seules les exigences comportant effectivement des critères techniques apparaissent ici. Les exigences purement métier (Requirements 4, 13, 18) ne figurent pas dans ce document.
Le document est organisé **en miroir** des exigences de `requirements.md` : chaque section reprend le même numéro et le même titre suivi de « — aspects techniques », afin de faciliter les références croisées. Seules les exigences comportant effectivement des critères techniques apparaissent ici. Les exigences purement métier (Requirements 4, 18) ne figurent pas dans ce document.
> **Précision des distances** : les aspects techniques du paramètre Precision_Distance (endpoint, entité, formule d'arrondi) sont résumés au Requirement 22 ci-dessous, avec renvoi vers la spec séparée `precision-distances-km` (`design.md`) pour le détail complet (DTOs, service, migration).
---
@@ -23,6 +25,8 @@ Le document est organisé **en miroir** des exigences de `requirements.md` : cha
- **JWT** : JSON Web Token signé (HS256) émis par le Backend à la connexion, portant l'identifiant du Compte et une durée de validité configurable (12 heures par défaut). Support technique de la Session définie dans `requirements.md`.
- **Cache_Adresses** : La table de base de données stockant les couples (adresse textuelle normalisée BAN, coordonnées géographiques) des adresses déjà saisies, soumise à une politique de rétention LRU.
- **Mermaid** : Syntaxe de définition de diagrammes (ex. `graph TB`) rendue graphiquement lors de l'affichage des Documents_Specification.
- **ParametresApplication** : L'entité backend singleton (une seule ligne, `Id = 1`, sur le modèle de `Profil`/`Compte`) portant la colonne `PrecisionDistanceKm`, contrainte par un CHECK SQL `IN (0, 1, 2)`. Détaillée intégralement dans la spec `precision-distances-km`, `design.md`.
- **Synthèse_Kms** : Côté technique, l'écran unique (`SyntheseKmsComponent`, route `/synthese-kms`) qui ne requiert **aucun nouvel endpoint backend** : il compose des endpoints existants — régularisation annuelle (`GET /api/regularisation/{annee}`), montant prévisionnel mensuel (`GET /api/montant-previsionnel/{annee}/{mois}`), upsert du montant réglé (`PUT /api/montants-regles`) et exports PDF mensuel (`GET /api/exports/mensuel/{annee}/{mois}`) et annuel (`GET /api/exports/annuel/{annee}`). Il remplace les écrans supprimés `ResumeMensuelComponent`, `ResumeAnnuelComponent` et `MontantsReglesComponent`.
---
@@ -60,7 +64,7 @@ Le document est organisé **en miroir** des exigences de `requirements.md` : cha
---
### Requirement 3: Consultation du résumé mensuel — aspects techniques
### Requirement 3: Consultation du total kilométrique mensuel — aspects techniques
> Exigence fonctionnelle : voir `requirements.md`, Requirement 3.
@@ -101,6 +105,12 @@ Le document est organisé **en miroir** des exigences de `requirements.md` : cha
#### Acceptance Criteria
1. IF le Backend ne parvient pas à générer le fichier PDF dans un délai de 15 secondes, THEN THE Backend SHALL retourner une erreur et THE Frontend SHALL afficher un message indiquant que la génération du PDF a échoué.
2. WHEN le Backend compose l'en-tête d'un Export_PDF (mensuel ou annuel), THE Backend SHALL disposer l'en-tête sur une rangée comportant le logo carré aligné en haut à gauche à une largeur de 120 pt, puis à droite du logo un bloc titre (titre "RAPPORT DE TRAJETS" en 16 pt gras et sous-titre spécifique en 11 pt) centré sur l'espace restant, le bloc d'infos étant placé sous le bloc titre, et SHALL tracer un trait séparateur horizontal sur toute la largeur de la page sous l'en-tête.
3. WHEN le Backend compose le pied de page d'un Export_PDF (mensuel ou annuel), THE Backend SHALL afficher la Mention_Legale en police de 5 pt, en gris, justifiée, positionnée au-dessus de la pagination « Page X / Y ».
4. WHEN le Backend compose la colonne "A/R" de la table des Trajets d'un Export_PDF mensuel, THE Backend SHALL afficher le marqueur « X » (et non une coche Unicode, non rendue par la police par défaut Lato) pour un Trajet Aller_Retour.
5. WHEN le Backend définit les largeurs de colonnes de la table des Trajets d'un Export_PDF mensuel, THE Backend SHALL dimensionner la colonne "A/R" à sa largeur minimale, la colonne "Kms" pour afficher jusqu'à « 9 999 » (avec séparateur de milliers), et la colonne des adresses de façon maximisée pour que l'adresse de départ et l'adresse d'arrivée tiennent majoritairement sur deux lignes.
6. WHEN le Backend compose le bloc d'infos de l'en-tête d'un Export_PDF annuel, THE Backend SHALL insérer un espace après la ligne « Véhicule » et présenter « Barème applicable » et « Total kilométrique de l'année » sur deux colonnes côte à côte.
7. WHEN le Backend compose la fin d'un Export_PDF annuel, THE Backend SHALL conserver la ligne « Formule réelle » alignée à gauche, insérer un espace, puis centrer la ligne « Régularisation (réel − payé) » sur la largeur de la page.
---
@@ -162,6 +172,21 @@ Le document est organisé **en miroir** des exigences de `requirements.md` : cha
1. WHEN l'utilisateur change de mois via le Sélecteur_Temporel, survole un Trajet d'une autre journée, ou change de Véhicule, THE Frontend SHALL mettre à jour la liste, le Panneau_Totaux et la Carte concernés dans un délai de 1000 ms sans rechargement complet de la page.
2. WHEN l'utilisateur change de Véhicule sélectionné, THE Frontend SHALL mettre à jour la liste mensuelle des Trajets, le Panneau_Totaux et la Carte dans un délai de 1000 ms sans rechargement complet de la page.
3. THE SelecteurTemporelComponent SHALL exposer un paramètre d'entrée commutant entre le mode « année + mois » (émission d'un événement `moisSelectionne { annee, mois }`) et le mode « année seule » (émission d'un événement portant l'année choisie, sans rendu de la grille des mois), le mode « année seule » étant utilisé par le SyntheseKmsComponent et le mode « année + mois » par le ConsultationJourComponent.
4. WHEN l'utilisateur sélectionne le niveau le plus bas du SelecteurTemporelComponent (le mois en mode « année + mois », l'année en mode « année seule »), THE SelecteurTemporelComponent SHALL basculer son état interne en position repliée.
5. THE Frontend SHALL rendre les écrans ConsultationJourComponent (« Trajets ») et CarnetAdressesComponent (« Carnet d'adresses ») sur toute la largeur disponible (sans largeur maximale centrée), tandis que le SyntheseKmsComponent (« Synthèse Kms ») et le SpecsComponent (« Specs ») SHALL conserver une largeur de lecture limitée centrée (max-width ~1100 px).
6. WHEN le TrajetListComponent rend une ligne de Trajet, THE Frontend SHALL positionner les boutons d'action du Trajet (modifier, supprimer, insérer avant/après) en début de ligne, après la poignée de glisser-déposer et avant la colonne « A/R », alignés à droite au sein de leur colonne afin de conserver l'alignement vertical du bouton « insérer après ».
---
### Requirement 13: Barème kilométrique et indication de versement — aspects techniques
> Exigence fonctionnelle : voir `requirements.md`, Requirement 13.
#### Acceptance Criteria
1. WHEN le Backend calcule le Montant_Previsionnel_Mensuel ou le Montant_Previsionnel_Annuel, THE Backend SHALL utiliser des distances déjà arrondies selon Precision_Distance (et non les distances brutes), conformément à la spec `precision-distances-km`, Requirements 6 et 7.
2. WHEN le SyntheseKmsComponent affiche le barème appliqué et la colonne « indication 80% » du tableau mensuel, THE Frontend SHALL alimenter ces éléments à partir des endpoints existants (`GET /api/regularisation/{annee}` pour la tranche/coefficient/base et le Frais_Reel_Mensuel, `GET /api/montant-previsionnel/{annee}/{mois}` pour le Montant_Previsionnel_Mensuel), sans introduire de nouvel endpoint.
---
@@ -172,6 +197,9 @@ Le document est organisé **en miroir** des exigences de `requirements.md` : cha
#### Acceptance Criteria
1. IF l'utilisateur saisit un Montant_Regle négatif, THEN THE Backend SHALL rejeter la saisie avec une erreur HTTP 400.
2. THE SyntheseKmsComponent SHALL composer la synthèse à partir d'endpoints existants, sans aucun nouvel endpoint backend : régularisation annuelle (`GET /api/regularisation/{annee}?vehiculeId={id}`) pour le barème appliqué, les kilomètres mensuels, les Frais_Reel_Mensuel, le total annuel, le Montant_Reel et le total réglé ; montant prévisionnel mensuel (`GET /api/montant-previsionnel/{annee}/{mois}?vehiculeId={id}`) pour la colonne « indication 80% » ; upsert du montant réglé (`PUT /api/montants-regles`) à l'enregistrement ; exports PDF mensuel (`GET /api/exports/mensuel/{annee}/{mois}?vehiculeId={id}`) et annuel (`GET /api/exports/annuel/{annee}?vehiculeId={id}`).
3. WHEN l'utilisateur active le bouton « Enregistrer » global de la Synthèse_Kms, THE Frontend SHALL émettre un appel `PUT /api/montants-regles` uniquement pour les mois dont le Montant_Regle a été modifié depuis le dernier chargement, et n'émettre aucun appel pour les mois inchangés.
4. WHEN le SyntheseKmsComponent est initialisé, THE Frontend SHALL déclencher le chargement de la synthèse pour la pré-sélection (Véhicule_Courant et année courante) sans attendre d'action sur le bouton « Afficher ».
---
@@ -223,6 +251,11 @@ Le document est organisé **en miroir** des exigences de `requirements.md` : cha
6. IF l'envoi de l'e-mail de réinitialisation n'est pas possible (service e-mail non configuré ou indisponible), THEN THE Backend SHALL journaliser le lien de réinitialisation en repli (fallback) afin que la réinitialisation reste possible, sans faire échouer la demande.
7. WHEN l'utilisateur suit un Jeton_Reinitialisation valide et fournit un nouveau Mot_De_Passe valide, THE Backend SHALL remplacer le hachage du Mot_De_Passe du Compte et invalider le Jeton_Reinitialisation.
8. THE Backend SHALL exiger la présence d'une clé secrète de signature JWT au démarrage (variable d'environnement obligatoire) et SHALL refuser de démarrer si elle est absente.
9. WHEN la case « Se souvenir de moi » est cochée lors d'une connexion réussie, THE LoginComponent SHALL écrire l'Adresse_Email saisie dans le `localStorage` du navigateur sous la clé `schotziad.login.emailMemorise` via `setItem`, et SHALL n'y stocker que l'Adresse_Email (jamais le Mot_De_Passe).
10. WHEN la case « Se souvenir de moi » est décochée lors d'une connexion réussie, THE LoginComponent SHALL supprimer la clé `schotziad.login.emailMemorise` du `localStorage` du navigateur via `removeItem`.
11. WHEN le LoginComponent est initialisé (`ngOnInit`), IF la clé `schotziad.login.emailMemorise` est présente et non vide dans le `localStorage`, THEN THE LoginComponent SHALL pré-remplir le champ Adresse_Email avec cette valeur et cocher la case « Se souvenir de moi ».
12. WHERE l'Identifiant_Mémorisé est conservé dans le `localStorage` du navigateur, THE LoginComponent SHALL s'appuyer sur la portée propre du `localStorage` (limitée à l'origine — scheme, host et port — et au navigateur courant) pour réaliser l'absence de pré-remplissage sur une autre machine ou un autre navigateur tant qu'aucune connexion « se souvenir » n'y a eu lieu.
13. IF un accès au `localStorage` échoue (navigation privée, quota dépassé ou stockage désactivé), THEN THE LoginComponent SHALL intercepter l'erreur et l'ignorer silencieusement, la mémorisation de l'Identifiant_Mémorisé restant sans effet sans faire échouer la connexion ni le chargement de la page de connexion.
---
@@ -261,3 +294,17 @@ Le document est organisé **en miroir** des exigences de `requirements.md` : cha
1. THE Frontend SHALL masquer entièrement la barre de navigation (y compris l'Avatar_Utilisateur) sur les pages non authentifiées (connexion, inscription, mot de passe oublié, réinitialisation), MÊME SI un jeton résiduel expiré ou invalide est encore présent localement, la présence locale d'un jeton ne garantissant pas la validité réelle de la Session sur ces pages.
2. THE Frontend SHALL proposer, sur chaque champ de saisie d'un Mot_De_Passe (connexion, inscription, réinitialisation, changement de mot de passe), un contrôle permettant de basculer l'affichage entre masqué et visible, afin que l'utilisateur puisse vérifier la saisie ; ce contrôle SHALL présenter un rendu visuel identique à celui des autres champs de saisie du formulaire.
---
### Requirement 22: Précision configurable des distances — aspects techniques
> Exigence fonctionnelle : voir `requirements.md`, Requirement 22. Détail complet (DTOs, service, migration) dans la spec `precision-distances-km`, `design.md`.
#### Acceptance Criteria
1. THE Backend SHALL exposer les opérations de lecture et de modification de Precision_Distance via un endpoint `GET/PUT /api/parametres-application`, protégé par le même filtre d'autorisation JWT global que les autres endpoints de données, retournant 401 en l'absence de jeton valide.
2. IF le Backend reçoit sur `PUT /api/parametres-application` une valeur de Precision_Distance différente de 0, 1 ou 2, THEN THE Backend SHALL retourner une erreur HTTP 400.
3. WHEN le Backend traite avec succès une requête sur `GET/PUT /api/parametres-application`, THE Backend SHALL retourner un code HTTP 200 accompagné de la configuration courante.
4. THE Backend SHALL persister Precision_Distance dans une entité singleton `ParametresApplication` (une seule ligne, `Id = 1`), avec une contrainte CHECK SQL garantissant `PrecisionDistanceKm IN (0, 1, 2)`.
5. THE Backend SHALL calculer toute distance arrondie selon la formule `Ceiling(distanceBrute × 10^precision) / 10^precision`, appliquée à la lecture, jamais à l'écriture de la colonne `Trajet.Distance`.
+144 -77
View File
@@ -8,17 +8,19 @@
> **Documents d'exigences associés** : le besoin fonctionnel métier est décrit dans `requirements.md` ; les exigences techniques dérivées (API externes, codes HTTP, délais, sécurité, cache, rendu visuel) sont détaillées dans `design-requirements.md`. La présente conception technique met en œuvre ces exigences.
> **Précision des distances** : le paramètre de précision d'affichage des distances (Precision_Distance, 0/1/2 décimales) et son impact sur les calculs de l'application (résumés mensuels/annuels, Panneau_Totaux, barème kilométrique, carte, Export_PDF) sont documentés intégralement dans la spec séparée `precision-distances-km` (`requirements.md` et `design.md`), qui complète et prime, en cas de divergence, sur les points de conception ci-dessous relatifs au calcul des distances et du Montant_Previsionnel.
### Périmètre fonctionnel
| # | Fonctionnalité | Description |
|---|----------------|-------------|
| 1 | Saisie d'un trajet | Formulaire avec date (JJ/MM/AAAA), adresse départ, adresse arrivée, commentaire |
| 2 | Calcul automatique de distance | Via ORS (routier), fallback orthodromique |
| 3 | Résumé mensuel | Liste des trajets + total km du mois |
| 4 | Vue annuelle | 12 résumés mensuels + total annuel |
| 3 | Synthèse Kms (mensuel) | Total km par mois agrégé dans l'écran unique Synthèse Kms (ex-« Résumé mensuel », fusionné) ; le détail trajet par trajet reste sur l'écran « Trajets » |
| 4 | Synthèse Kms (annuel) | 12 lignes mensuelles + ligne TOTAL annuel dans l'écran unique Synthèse Kms (ex-« Résumé annuel », fusionné) |
| 5 | Modification / suppression | Édition avec recalcul conditionnel de distance |
| 6 | Persistance et cohérence | Base relationnelle avec invariants d'agrégation et transactions |
| 7 | Export PDF | Fichiers `trajets_MM_AAAA.pdf` et `trajets_AAAA.pdf` |
| 7 | Export PDF | Fichiers `AAAAMM_Trajets_MATRICULE_PLAQUE.pdf` et `AAAA_Trajets_MATRICULE_PLAQUE.pdf` |
| 8 | Visualisation carte | Tracés des itinéraires d'une journée sur carte Leaflet + OpenStreetMap |
| 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 |
@@ -29,7 +31,7 @@
| 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 |
| 18 | Montants réglés & régularisation | Saisie du montant réglé par mois et régularisation intégrées à l'écran unique Synthèse Kms (ex-« Montants réglés », fusionné) ; rapport annuel avec montant réel, versé, dû et régularisation |
| 19 | Carnet d'adresses nommées | Menu CRUD d'Adresses_Nommées (Dénomination unique + adresse BAN) ; réutilisation par Dénomination dans la saisie et l'insertion de Trajets |
| 20 | Onboarding première utilisation | Présentation de la saisie du profil (identité + véhicule) si le Profil n'est pas configuré, avant tout accès à la saisie / consultation des trajets |
| 21 | Authentification | Compte unique (e-mail + mot de passe), connexion par JWT (12 h par défaut), réinitialisation de mot de passe par e-mail, garde de session serveur |
@@ -38,6 +40,7 @@
| 24 | Consultation des specs | Lien « Specs » vers une page dédiée (menu latéral Exigences / Conception / Tâches) affichant les documents Markdown rendus (dont les diagrammes Mermaid), en lecture seule et derrière l'authentification |
| 25 | Changement de mot de passe | Page Compte utilisateur : formulaire dédié (mot de passe actuel + nouveau + confirmation) ; endpoint protégé distinct de la réinitialisation par e-mail |
| 26 | Navigation utilisateur | Avatar (initiales) à droite de la barre, sous-menu au survol (Prénom Nom, Paramétrage, Maintenance, Se déconnecter) ; Page_Parametrage (Compte / Véhicules / Barème) et Page_Maintenance dédiées ; barre masquée sur les pages publiques ; contrôle afficher/masquer sur tous les champs mot de passe |
| 27 | Synthèse Kms (écran unique) | Fusion des ex-écrans « Résumé mensuel » / « Résumé annuel » / « Montants réglés » (supprimés) : critères sur une ligne (Sélecteur_Temporel année seule + sélecteur Véhicule + bouton Afficher), barème appliqué, tableau 12 mois (km, Frais_Reel_Mensuel, indication 80%, Montant_Regle saisissable, Export PDF mensuel), ligne TOTAL (km annuel, Montant_Reel, total réglé, Export PDF annuel), bouton « Enregistrer » global (seuls les montants modifiés), auto-affichage initial, aucun nouvel endpoint |
### Contraintes techniques
@@ -53,7 +56,11 @@
### Charte graphique
- **Palette officielle** (variables CSS globales dans `styles.scss`) : Orange `--couleur-orange: #F28C28`, Bleu IAD `--couleur-bleu: #0074B8` (couleur primaire), Gris anthracite `--couleur-anthracite: #343A40`, Gris clair `--couleur-gris-clair: #E7E9EA`, avec déclinaisons de survol `--couleur-bleu-fonce` et `--couleur-orange-fonce`.
- **Convention d'interaction** : les boutons d'action à fond bleu (texte, ex. `.btn-principal`, `.btn-primaire`, `.btn-confirmer`) passent à l'orange au survol. Cette règle ne s'applique PAS aux boutons à fond différent (rouge pour la suppression, blanc pour les actions secondaires) ni aux boutons à icône. Les indicateurs d'état sélectionné/actif (lien de navigation actif, mois sélectionné, onglet actif) restent en bleu plein, sans changement de teinte au survol.
- **Convention d'interaction** : tous les boutons d'ACTION passent à l'orange plein au survol (texte blanc), qu'ils soient à fond bleu plein (ex. `.btn-principal`, `.btn-primaire`, bouton de soumission bleu) ou à fond blanc/clair avec texte bleu (actions secondaires, ex. « Utiliser mon domicile » `.btn-domicile`, « Export PDF » `.btn-secondaire` des résumés, « Recopier le dernier barème » `.btn-secondaire` du barème, « Modifier » et « Enregistrer/Ajouter » du carnet d'adresses). L'intention : harmoniser le feedback de survol de toutes les actions sur l'orange de la charte. Exceptions qui NE passent PAS à l'orange au survol :
- Les boutons « Annuler » : rendu neutre uniformisé sur tout le site (fond blanc, bordure grise `#c4c9d1`, texte `#33475b`, survol gris clair `#f3f5f8`), aligné sur l'écran d'ajout/modification d'un trajet et repris à l'identique sur le barème (`.btn-annuler`), le carnet d'adresses (`.btn--annuler`) et le composant partagé `confirm-dialog`.
- Les boutons de suppression (rouge) : restent rouges.
- Les boutons à icône seule.
- Les indicateurs d'état sélectionné/actif (lien de navigation actif, mois sélectionné, onglet actif) : restent en bleu plein, sans changement de teinte au survol.
- **Assets graphiques** (`frontend/src/assets/images/`) :
- `logo-icon.png` : icône seule, affichée en tête de la barre de navigation (fond blanc localisé pour ressortir sur la barre anthracite).
- `logo-carre.png` : logo avec texte vertical, centré au-dessus du formulaire sur les 4 pages d'authentification (connexion, inscription, mot de passe oublié, réinitialisation).
@@ -172,12 +179,9 @@ src/
│ │ ├── 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
│ │ ├── synthese-kms/
│ │ │ ├── synthese-kms.component.ts # Écran unique : synthèse annuelle par mois + saisie Montant_Regle + exports PDF
│ │ │ └── synthese-kms.component.html
│ │ ├── trajet-list/
│ │ │ ├── trajet-list.component.ts
│ │ │ └── trajet-list.component.html
@@ -203,9 +207,6 @@ src/
│ │ ├── 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
@@ -245,21 +246,24 @@ src/
- **Autocomplétion BAN** : appel à `https://api-adresse.data.gouv.fr/search/?q={saisie}&limit=5` dès 3 caractères saisis ; chaque suggestion affiche le libellé + code postal + commune ; à la sélection d'une suggestion, les coordonnées `(lat, lon)` sont stockées dans l'état local du composant et transmises avec le formulaire
- Si l'utilisateur tape une adresse sans passer par BAN (pas de sélection), les coordonnées sont absentes et un avertissement discret s'affiche
**`ResumeMensuelComponent`**
- Sélecteurs mois (1–12) et année (2000–2100)
- Affiche la liste des trajets et le total mensuel (2 décimales)
- Bouton "Export PDF mensuel"
**`ResumeAnnuelComponent`**
- Sélecteur année (2000–2100)
- Tableau 12 lignes (mois, total km) + ligne total annuel (2 décimales, max 999 999,99)
- Bouton "Export PDF annuel"
**`SyntheseKmsComponent`** (écran unique — Exigences 3, 4, 13, 14)
- **Fusionne et remplace** les anciens composants `ResumeMensuelComponent`, `ResumeAnnuelComponent` et `MontantsReglesComponent` (tous supprimés), ainsi que leurs routes `/resume-mensuel`, `/resume-annuel`, `/montants-regles` et leurs liens de menu. Route unique : `/synthese-kms`, lien de menu « Synthèse Kms »
- **En-tête sur une même ligne** : le titre « Synthèse Kms » et la zone de critères — `SelecteurTemporelComponent` en **mode « année seule »**, sélecteur de Véhicule, bouton « Afficher »
- **Auto-affichage initial** : à l'initialisation, la synthèse est chargée automatiquement pour la pré-sélection (Véhicule_Courant + année courante), sans attendre d'action sur « Afficher »
- **Barème appliqué** affiché au-dessus du tableau : tranche, Puissance_Fiscale, coefficient, base
- **Tableau une ligne par mois** : libellé du mois, kilomètres du mois, Frais_Reel_Mensuel (remboursement réel propre au mois = km du mois × coefficient de la tranche annuelle), indication 80% (Montant_Previsionnel_Mensuel), Montant_Regle **saisissable**, et colonne « Action » avec un bouton « Export PDF » (rapport mensuel du mois)
- **Barre d'action au-dessus du tableau** : un unique bouton « Enregistrer » qui persiste uniquement les Montant_Regle **modifiés** depuis le dernier chargement (plusieurs mois en une seule action) ; pas de bouton d'enregistrement par ligne
- **Ligne TOTAL** : total kilométrique annuel, Montant_Reel (total du remboursement réel), **cellule « indication 80% » vide**, total réglé (somme des Montant_Regle, non modifiable), bouton « Export PDF » qui exporte le rapport **annuel** ; aucun bouton « Enregistrer » sur cette ligne
- **Aucun nouvel endpoint backend** : composition d'endpoints existants — `GET /api/regularisation/{annee}` (barème, km mensuels, Frais_Reel_Mensuel, total annuel, Montant_Reel, total réglé), `GET /api/montant-previsionnel/{annee}/{mois}` (indication 80%), `PUT /api/montants-regles` (upsert à l'enregistrement), `GET /api/exports/mensuel/{annee}/{mois}` et `GET /api/exports/annuel/{annee}` (exports PDF)
- **Largeur de lecture limitée** : l'écran a une largeur maximale de lecture (~1100 px) centrée, afin de garder une ligne de tableau lisible sur grand écran (le mois à gauche reste proche des actions à droite)
- Tous les kilomètres sont formatés selon `formatKm()` (signal `precisionDistance` de `ParametresApplicationService`), reflétant Precision_Distance — voir spec `precision-distances-km` ; les montants en euros à 2 décimales
- Le **détail des Trajets un par un n'est plus affiché** dans cet écran de synthèse : il reste consultable sur `ConsultationJourComponent` (écran « Trajets »)
**`TrajetListComponent`**
- Affiche TOUS les trajets du mois, regroupés par journée : chaque groupe/jour a un **en-tête repliable** (jour de la semaine avant la date, ex. « Lundi 15/09/2026 », puis nombre de trajets) ; par défaut tout est replié sauf le dernier jour du mois ayant des trajets.
- **Bouton dédié « + » à droite de l'en-tête** (`Output ajouterSurJournee: string`) : ouvre le formulaire de création avec la date de CETTE journée, sans dépendre de la journée affichée sur la Carte (pilotée par le survol) — évite qu'un déplacement de souris à travers plusieurs journées ouvertes ne fasse dériver la date visée en se dirigeant vers le bouton principal d'ajout. Le conteneur fixe `date` sur cette valeur avant d'ouvrir la création (`ouvrirCreationSur`).
- Ligne par trajet (triée par `Position` croissante au sein du jour) : colonne dédiée **A/R** en début de ligne pour les allers/retours, commentaire (si présent), adresses (taille réduite), distance effective
- Actions par trajet : modifier (→ `TrajetFormComponent` pré-rempli en < 500 ms), supprimer (→ dialog de confirmation), **insérer après** (→ `TrajetFormComponent` en mode insertion avec `adresseDepart` = point de fin du trajet courant — son arrivée, ou son **départ** si le trajet est un aller/retour —, la **date de la journée** du trajet, `positionApres` = Position du trajet courant)
- Ligne par trajet (triée par `Position` croissante au sein du jour) : les **boutons d'action** sont placés **en début de ligne** (après la poignée de glisser-déposer, avant la colonne A/R) et **alignés à droite au sein de leur colonne** pour conserver l'alignement vertical du bouton « insérer après » ; puis colonne dédiée **A/R** pour les allers/retours, commentaire (si présent), adresses (taille réduite), distance effective
- Actions par trajet (regroupées en début de ligne) : modifier (→ `TrajetFormComponent` pré-rempli en < 500 ms), supprimer (→ dialog de confirmation), **insérer avant/après** — l'insérer après ouvre `TrajetFormComponent` en mode insertion avec `adresseDepart` = point de fin du trajet courant (son arrivée, ou son **départ** si le trajet est un aller/retour), la **date de la journée** du trajet, `positionApres` = Position du trajet courant
- Si le trajet n'est pas le dernier de la journée : action **insérer avant le suivant** disponible, pré-remplissant l'adresse de départ avec le point de fin du trajet courant (départ si aller/retour) et l'adresse d'arrivée avec le départ du trajet suivant, en calculant la position d'insertion `(P_courant + P_suivant) / 2`
- Méthode publique `ouvrirJour(dateIso)` : déplie une journée donnée (appelée par le conteneur après ajout/insertion pour rendre le trajet visible) ; `ouvrirToutesLesJournees()` déplie tous les groupes ; `aDesJourneesFermees()` conditionne l'affichage du bouton « Ouvrir toutes les journées »
- `@Input jourAffiche` : la journée affichée sur la Carte ; le groupe correspondant est mis en surbrillance (`estJourAffiche`)
@@ -302,13 +306,16 @@ src/
- 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
- **Repliable** ; l'en-tête affiche la période sélectionnée (ex. « Septembre 2026 » en mode année + mois, « 2026 » en mode année seule)
- **Deux modes** pilotés par un `@Input` :
- **Mode « année + mois »** (par défaut, utilisé par `ConsultationJourComponent`) : choix successif année puis mois ; émet un événement `moisSelectionne: { annee: number, mois: number }` à chaque changement
- **Mode « année seule »** (utilisé par `SyntheseKmsComponent`) : la grille des 12 mois **n'est pas affichée** ; seul le choix de l'année est proposé et le composant émet l'année choisie (`@Output` dédié)
- Sélecteur d'année (dans les deux modes) : liste limitée à l'année courante + années ayant des données (endpoint `/api/annees-disponibles`)
- Sélecteur de mois (mode année + mois uniquement) : 12 mois affichés, surbrillance des mois avec données (endpoint `/api/mois-disponibles/{annee}`), avec le même code couleur bleu que la journée mise en avant dans la liste ; le mois sélectionné reste en bleu plein ; désactivation de tout mois > mois courant
- **Repli automatique** : lors d'un clic sur le niveau le plus bas (le mois en mode année + mois, l'année en mode année seule), le composant se referme automatiquement (collapse). Comportement appliqué partout où le sélecteur est utilisé
**`ConsultationJourComponent`** (composant conteneur / layout principal)
- **Pleine largeur** : l'écran « Trajets » occupe toute la largeur disponible (plus de largeur maximale centrée)
- Deux états : un signal `mois` (`{ annee, mois }`) pilote la **liste mensuelle** ; un signal `date` (ISO) pilote la **carte** et la date par défaut d'ajout/insertion
- Colonne gauche (fixe) : `SelecteurTemporelComponent`, puis `PanneauTotauxComponent`, puis `CarteTrajetsComponent` (plus de bande de navigation par jour)
- Colonne droite : bande fixe (sélecteur de Véhicule + bouton « Ajouter un trajet »), puis `TrajetListComponent` du mois avec scroll vertical
@@ -323,8 +330,15 @@ src/
**`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é
- Tous les totaux affichés à 2 décimales, calculés par sommation de distances déjà arrondies selon Precision_Distance (voir spec `precision-distances-km`, Requirement 5)
- Affiche le libellé **« Remboursement indicatif pour ce mois »** (renommé, anciennement « Indication de versement (80%) ») présentant le Montant_Previsionnel_Mensuel (80%, calculé sur le seul mois affiché) et, en dessous, une ligne « Montant prévisionnel annuel » présentant le Montant_Previsionnel_Annuel (100%, sur le Total_Annuel_Partiel) — détail complet du calcul dans la spec `precision-distances-km`, Requirements 7 et 8
- **Mise en forme (emphase)** : les informations relatives au mois affiché sont mises en avant en gras (classe `--fort`) — la ligne « Total du mois » et la ligne « Remboursement indicatif pour ce mois » ; à l'inverse, la ligne « Total annuel partiel » et la ligne « Montant prévisionnel annuel » ne sont PAS en gras
- **Infobulles d'aide** : le texte explicatif de chacune de ces deux lignes (« Remboursement indicatif pour ce mois » et « Montant prévisionnel annuel »), auparavant affiché en permanence sous le montant, est retiré du flux visuel et remplacé par un bouton icône (SVG inline `help-circle`, 16×16, `aria-label` dédié) affiché juste après le libellé ; le texte explicatif est porté par l'attribut HTML `title` de ce bouton et s'affiche via l'infobulle native du navigateur au survol ou au focus clavier de l'icône
**`ParametresApplicationComponent`**
- Nouveau composant du Sous_Menu_Application de la Page_Parametrage, positionné entre « Compte » et « Véhicules »
- Permet de sélectionner la valeur de Precision_Distance (0, 1 ou 2 décimales) ; la sélection courante est exposée à toute l'application via un signal partagé `precisionDistance` porté par `ParametresApplicationService`
- Détail complet (endpoints, entité, formule d'arrondi, service, migration) dans la spec `precision-distances-km`, `design.md`
**`BaremeComponent`**
- Sélecteur d'année
@@ -333,14 +347,12 @@ src/
- 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
> **Saisie des Montant_Regle** : l'ancien composant `MontantsReglesComponent` (écran dédié « Montants réglés ») est supprimé ; la saisie des Montant_Regle par mois est désormais assurée directement dans le tableau mensuel du `SyntheseKmsComponent` ci-dessus, avec l'affichage en regard du Montant_Previsionnel_Mensuel (indication 80%, calculé sur le seul mois affiché — voir spec `precision-distances-km`, Requirement 7).
**`CarnetAdressesComponent`**
- Liste des Adresses_Nommées (Dénomination + adresse + indication si coordonnées disponibles), triée par Dénomination, avec un bouton « Ajouter une adresse » au-dessus de la liste
- **Disposition en grille de cartes** : les adresses sont affichées les unes à côté des autres puis retournent à la ligne selon la largeur disponible (`display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr))`). Chaque carte a une taille homogène (colonnes de largeur égale, cartes étirées à la même hauteur par rangée via `align-items: stretch`), un contour (`border: 1px solid #e0e0e0; border-radius: 10px`) et un effet d'ombre (`box-shadow: 0 2px 8px rgba(0,0,0,0.1)`) la détachant du fond. La zone de contenu de la page est élargie (`max-width: 1080px`) pour laisser la place à plusieurs colonnes ; la dénomination et l'adresse sont en haut de la carte, les actions Modifier/Supprimer en bas à droite
- **Pleine largeur** : l'écran « Carnet d'adresses » occupe toute la largeur disponible (plus de largeur maximale centrée)
- **Disposition en grille de cartes** : les adresses sont affichées les unes à côté des autres puis retournent à la ligne selon la largeur disponible (`display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr))`). Chaque carte a une taille homogène (colonnes de largeur égale, cartes étirées à la même hauteur par rangée via `align-items: stretch`), un contour (`border: 1px solid #e0e0e0; border-radius: 10px`) et un effet d'ombre (`box-shadow: 0 2px 8px rgba(0,0,0,0.1)`) la détachant du fond. La zone de contenu de la page occupe toute la largeur disponible pour laisser la place à plusieurs colonnes ; la dénomination et l'adresse sont en haut de la carte, les actions Modifier/Supprimer en bas à droite
- Formulaire de création / modification présenté dans une **fenêtre modale** (overlay `.modale-overlay` + panneau `.modale-panneau`, motif partagé avec `ConfirmDialogComponent` / `ConsultationJourComponent`), ouvert via le bouton « Ajouter une adresse » (mode création) ou l'action « Modifier » d'une ligne (mode édition) ; fermée par enregistrement réussi, par le bouton « Annuler », ou par un clic sur l'arrière-plan. Le signal `modaleOuverte` pilote l'affichage
- Champ `denomination` (1–100 car.) et champ `adresse` avec autocomplétion BAN (1–255 car.) ; dans la modale, les suggestions BAN sont rendues **dans le flux** du panneau (et non en position absolue) pour éviter toute troncature par le défilement du panneau
- **Autocomplétion BAN** : appel à `https://api-adresse.data.gouv.fr/search/?q={saisie}&limit=5` dès 3 caractères saisis, avec debounce de 300 ms, maximum 5 suggestions ; à la sélection, le libellé normalisé remplit le champ et les coordonnées `(lat, lon)` sont conservées pour transmission
@@ -364,7 +376,7 @@ src/
**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é ».
- `LoginComponent` : connexion (email + mot de passe) → `POST /api/auth/login` ; à la réussite, stocke le JWT et navigue vers la vue protégée. Propose un lien « Mot de passe oublié ». Propose également une case à cocher « Se souvenir de moi » placée sous le champ email : à une connexion réussie, si elle est cochée, l'email (identifiant uniquement, jamais le mot de passe) est conservé dans le `localStorage` du navigateur sous la clé `schotziad.login.emailMemorise` et pré-rempli (case cochée) aux visites suivantes sur le même navigateur ; si elle est décochée, la clé est supprimée. Le stockage `localStorage` étant propre à l'origine et au navigateur, aucun pré-remplissage n'a lieu sur une autre machine ou un autre navigateur, et les erreurs d'accès au stockage sont ignorées silencieusement. Détail dans `requirements.md` Requirement 17 (critères 14-18) et `design-requirements.md` Requirement 17 (critères 9-13).
- `ForgotPasswordComponent` : saisie de l'email → `POST /api/auth/forgot-password` (lien envoyé par email, ou journalisé en repli).
- `ResetPasswordComponent` : nouveau mot de passe via le jeton reçu → `POST /api/auth/reset-password`.
@@ -382,6 +394,7 @@ src/
- Rendu : le Markdown (`GET /api/specs/{cle}`) est converti en HTML par **`marked`** (GFM : titres, listes, tableaux, code), puis **assaini par `DOMPurify`** avant injection (`[innerHTML]`) — Exigence 19.7
- **Diagrammes Mermaid** : un renderer `marked` transforme les blocs ` ```mermaid ` en `<pre class="mermaid">` ; après injection du HTML (déclenchée via `setTimeout` pour laisser Angular résoudre le `@ViewChild`), **Mermaid.js** (importé dynamiquement) rend ces blocs en SVG (`securityLevel: 'loose'` pour accepter les libellés HTML type `<br/>`). En cas d'échec de rendu d'un diagramme, le bloc de code brut reste visible.
- Accès derrière `authGuard` (mêmes règles que les autres vues protégées)
- **Largeur de lecture limitée** : à la différence des écrans « Trajets » et « Carnet d'adresses » (passés en pleine largeur), le `SpecsComponent` conserve une largeur maximale centrée adaptée à la lecture (comportement inchangé) ; l'écran « Synthèse Kms » adopte lui aussi une largeur de lecture limitée centrée (~1100 px)
**`ProfilConfigureGuard`** (garde de route Angular — `CanActivate` / `CanActivateFn`)
- Au chargement de l'application, appelle `ProfilService.getEtat()` (`GET /api/profil/etat`)
@@ -391,7 +404,7 @@ src/
- Déclaré dans `app-routing.module.ts` sur les routes protégées (consultation, saisie, résumés, etc.) ; la route `/onboarding` reste accessible sans garde
**`AppComponent`** (coquille applicative — barre de navigation, Exigence 21)
- Barre de navigation : logo (format horizontal, icône + texte), liens métier (Trajets, Carnet d'adresses, Montants réglés, Résumé mensuel, Résumé annuel), menu déroulant « Specs », puis l'Avatar_Utilisateur poussé à l'extrémité droite (`margin-left: auto`)
- Barre de navigation : logo (format horizontal, icône + texte), liens métier (Trajets, Carnet d'adresses, Synthèse Kms), menu déroulant « Specs », puis l'Avatar_Utilisateur poussé à l'extrémité droite (`margin-left: auto`). Les anciens liens « Montants réglés », « Résumé mensuel » et « Résumé annuel » sont retirés : ils sont remplacés par l'unique lien « Synthèse Kms »
- **Menus déroulants (Specs, Avatar)** : s'ouvrent au survol (`mouseenter`) ET restent utilisables au clic (tactile, clavier) ; se ferment au `mouseleave` du bloc conteneur ou au clic extérieur (`@HostListener('document:click')`)
- **Piège évité** : le panneau déroulant ne doit PAS être séparé du bouton par un espace (`top: calc(100% + Xpx)`), sous peine de déclencher un `mouseleave` prématuré lorsque le curseur traverse cet espace mort en descendant vers le panneau. Le panneau touche directement le bouton (`top: 100%`) et l'espacement visuel est reporté en `padding-top` À L'INTÉRIEUR du panneau (zone `__panneau-contenu` séparée pour le fond/l'ombre), de sorte que la zone de survol reste continue.
- **Affichage conditionnel** : la barre de navigation (avatar inclus) est masquée non seulement en l'absence de jeton, mais aussi sur les pages PUBLIQUES (`/login`, `/register`, `/forgot-password`, `/reset-password`), détectées via les événements du `Router` (`NavigationEnd`). Ce deuxième critère est nécessaire car un jeton résiduel expiré ou invalide peut rester en `localStorage` sans qu'aucun appel HTTP protégé ne le purge sur ces pages (aucune requête vers un endpoint protégé n'y est faite) ; se fier uniquement à la présence du jeton ferait réapparaître la barre à tort.
@@ -1530,43 +1543,54 @@ QuestPDF est une bibliothèque .NET open-source (licence MIT pour usage communau
### Structure des documents PDF
#### PDF Mensuel (`trajets_MM_AAAA.pdf`)
#### PDF Mensuel (`AAAAMM_Trajets_MATRICULE_PLAQUE.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 │
│ ... │ ... │ ... │ ... │
└────────────┴──────────────────────────┴──────────────────────────┴─────────┘
│ ┌───────┐ RAPPORT DE TRAJETS (bloc titre centré │
│ │ │ Feuille d'indemnité kilométrique à droite du logo) │
│ │ Logo │ JANVIER 2025 │
│ │ 120pt │ Utilisateur : Cécile DOUCET — Matricule : AB12CD34 │
│ │ │ Domicile : 29 route des Cascades, 23400 Saint-Pardoux-Morter. │
│ └───────┘ Véhicule : GOLF (ES-036-YF) — 6 CV (Coef. : 0,447 / Base : 0) │
│ Total parcouru sur le mois : 931 Kms TOTAL IK du mois : 416 € │
│ Total parcouru sur l'année : 931 Kms Réglé : 500,00 € │
├──────────────────────────────────────────────────────────────────────────── (trait pleine largeur)
├────────────┬────────────────┬───────────────────────────────┬─────┬────────┤
│ Date │ Motifs │ Départ → Arrivée │ A/R │ Kms │
├────────────┼────────────────┼───────────────────────────────┼─────┼────────┤
│ 03/01/2025 │ Rdv chez Marie │ 29 route des Cascades... │ X │ 94 │
│ │ │ → Aureil │ │ │
│ ... │ ... │ ... │ │ ... │
├────────────┴────────────────┴───────────────────────────────┼─────┼────────┤
│ Total du mois : │ │ 931 │
└──────────────────────────────────────────────────────────────┴─────┴────────┘
(pied de page : Mention_Legale 5 pt justifiée, puis « Page X / Y »)
```
> Le « TOTAL IK du mois » = kilomètres du mois × coefficient de la tranche annuelle applicable. Le « Réglé » = Montant_Regle saisi pour le mois. Le motif affiché est le commentaire du Trajet.
> Le titre « RAPPORT DE TRAJETS » est désormais en majuscules. L'en-tête est disposé sur une rangée : logo carré agrandi (120 pt) aligné en haut à gauche ; à droite du logo, un bloc titre (titre + sous-titre « Feuille d'indemnité kilométrique {MOIS} {ANNEE} ») centré sur l'espace restant ; sous le bloc titre, le bloc d'infos (Utilisateur/Matricule, Domicile, Véhicule, totaux). Un trait séparateur horizontal est tracé sur toute la largeur de la page sous l'en-tête. Le libellé « Adresse de départ » devient « Domicile », positionné juste après la ligne Utilisateur/Matricule. Dans la table des Trajets, l'adresse d'arrivée passe à la ligne sous l'adresse de départ, précédée de « → » ; une colonne dédiée **« A/R »** (juste avant « Kms ») affiche un marqueur « X » pour un Aller_Retour (le suffixe « (A/R) » dans la cellule itinéraire est supprimé). Le « TOTAL IK du mois » = kilomètres du mois × coefficient de la tranche annuelle applicable. Le « Réglé » = Montant_Regle saisi pour le mois. Le motif affiché est le commentaire du Trajet. Les kilomètres (mois, année, table des trajets) sont formatés selon Precision_Distance.
#### PDF Annuel (`trajets_AAAA.pdf`)
#### PDF Annuel (`AAAA_Trajets_MATRICULE_PLAQUE.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 │
│ ┌───────┐ RAPPORT DE TRAJETS (bloc titre centré │
│ │ │ Synthèse 2025 d'indemnité km à droite du logo) │
│ │ Logo │ │
│ │ 120pt │ Utilisateur : Cécile DOUCET — Matricule : AB12CD34 │
│ │ │ Domicile : 29 route des Cascades, 23400 Saint-Pardoux-Morter. │
│ └───────┘ Véhicule : GOLF (ES-036-YF) — 6 CV │
│ (espace après Véhicule)│
│ Barème applicable : 5001–20000 Total kilométrique de │
│ l'année : 12 241 km │
├──────────────────────────────────────────────────────────────────────────── (trait pleine largeur)
│ Barème applicable pour l'année (6 CV) — Kms parcourus × Coef + Base │
│ Tranche (km) │ Coefficient │ Base (€) │
│ 0 à 5000 │ 0,665 │ 0 │
│ 5001 à 20000 │ 0,374 │ 1457 │
│ 20000 et plus │ 0,447 │ 0 │
├────────────┬───────────────┬───────────────────┬────────────────────────────┤
│ Mois │ Kms parcourus │ Frais payé (réglé)│ Frais réel │
│ Mois │ Kms parcourus │ Frais payé │ Frais réel │
├────────────┼───────────────┼───────────────────┼────────────────────────────┤
│ Janvier │ 931 │ 500,00 € │ 459,01 € │
│ ... │ ... │ ... │ ... │
@@ -1575,32 +1599,73 @@ QuestPDF est une bibliothèque .NET open-source (licence MIT pour usage communau
│ 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 € │
│ │
│ Régularisation (réel − payé) : 35,13 € (ligne centrée) │
└──────────────────────────────────────────────────────────────────────────────┘
(pied de page : Mention_Legale 5 pt justifiée, puis « Page X / Y »)
```
> 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.
> Le titre « RAPPORT DE TRAJETS » est en majuscules et son bloc titre (titre + sous-titre « Synthèse {Année} d'indemnité kilométrique ») est centré à droite du logo 120 pt ; le bloc d'infos est placé dessous. La ligne « Année : AAAA » reste retirée (redondante avec le titre). Un espace est ajouté après la ligne « Véhicule », puis « Barème applicable » et « Total kilométrique de l'année » sont présentés sur **deux colonnes côte à côte** (même agencement que le bloc totaux du rapport mensuel). Le titre du tableau barème rappelle le mode de calcul en toutes lettres : « Barème applicable pour l'année (N CV) — Kms parcourus × Coef + Base » (les valeurs coef/base effectivement appliquées apparaissent, elles, dans la « Formule réelle » en fin de document). En fin de document, un espace sépare la ligne « Formule réelle » (alignée à gauche) de la ligne « Régularisation (réel − payé) », désormais **centrée** sur la largeur de la page. Le « Frais réel » mensuel = kilomètres du mois × coefficient de la tranche annuelle. Le « Frais payé » = Montant_Regle saisi. La minoration 80% n'apparaît pas dans la synthèse : elle sert uniquement d'indication de versement en cours d'année. Les kilomètres (total annuel, table des mois, formule réelle) sont formatés selon Precision_Distance.
> **Rapports distincts par véhicule** : si la période demandée (mois ou année) contient des Trajets associés à plusieurs Véhicules, le backend génère un fichier PDF distinct par Véhicule. Chaque PDF ne contient que les Trajets et montants du Véhicule concerné.
### En-tête obligatoire (Exigence 7.6)
Tous les PDF incluent en en-tête :
1. Titre fixe : **"Rapport de trajets"**
2. Période couverte : `Mois AAAA` pour le mensuel, `AAAA` pour l'annuel
3. Total kilométrique de la période
1. Le **logo carré** de l'Application, aligné en haut à gauche (voir « Logo dans l'en-tête » ci-dessous)
2. Titre fixe : **"RAPPORT DE TRAJETS"** (en majuscules), suivi du titre spécifique intégrant la période (`Feuille d'indemnité kilométrique {Mois} {AAAA}` pour le mensuel, `Synthèse {AAAA} d'indemnité kilométrique` pour l'annuel) — titre et sous-titre formant un bloc titre **centré** sur l'espace restant à droite du logo
3. Total kilométrique de la période, formaté selon Precision_Distance
### Nommage des fichiers
### Logo dans l'en-tête (Exigences 7.13)
- Mensuel : `trajets_MM_AAAA.pdf` (ex. `trajets_01_2025.pdf`)
- Annuel : `trajets_AAAA.pdf` (ex. `trajets_2025.pdf`)
Le logo carré (`logo-carre.png`, cf. section Charte graphique) est **embarqué comme ressource** dans l'assembly du backend (`Assets/logo-carre.png`, copié depuis les assets du frontend au moment du build) plutôt que lu depuis le système de fichiers, pour rester indépendant du répertoire de travail au runtime. `PdfService` charge ces octets une seule fois via `Assembly.GetManifestResourceStream`, en cache statique, et les insère via `Image().FitWidth()` dans une colonne fixe de **120 pt** (au lieu de 45 pt), alignée en haut (`AlignTop`) à gauche de l'en-tête, pour les deux rapports (mensuel et annuel). Si la ressource est introuvable (tableau vide), le logo est simplement omis sans faire échouer la génération du PDF.
L'en-tête est désormais disposé **en rangée** (`Row`) : le logo à gauche, puis à sa droite une colonne contenant d'abord le **bloc titre centré** (titre « RAPPORT DE TRAJETS » 16 pt gras + sous-titre spécifique 11 pt, chacun en `AlignCenter`), puis, séparé par un espace (`PaddingTop`), le **bloc d'infos** (Utilisateur/Matricule, Domicile, Véhicule, totaux). Un **trait séparateur horizontal** (`LineHorizontal`) est tracé sur toute la largeur de la page sous l'en-tête. Cette disposition remplace l'ancienne superposition `Layers` où le titre était centré sur toute la largeur de la page avec un logo de 45 pt.
### Pied de page (Exigences 7.14)
Le pied de page, commun aux deux rapports, est composé par `ComposerPiedDePage` et comporte deux éléments empilés :
1. La **Mention_Legale** indicative : « Les kilomètres restitués dans le présent document sont déclarés par l'utilisateur sur l'honneur et sous sa seule responsabilité. Le calcul de l'indemnité kilométrique est fourni à titre purement indicatif, sur la base du barème kilométrique officiel en vigueur. SchotzIAD ne saurait être tenue responsable d'une quelconque erreur ou omission dans les données saisies ni du montant résultant du calcul. Le présent document ne constitue pas un justificatif opposable aux administrations. » — rendue en **police 5 pt**, couleur **grise**, **justifiée** (`Justify`), de façon à tenir sur deux à trois lignes.
2. La **pagination** « Page X / Y » (`CurrentPageNumber` / `TotalPages`), centrée en 8 pt, placée **sous** la Mention_Legale.
### Table des Trajets du rapport mensuel (Exigences 7.10, 7.15, 7.16)
La table des Trajets du rapport mensuel (`ComposerTableTrajets`) comporte cinq colonnes : Date (70 pt), Motifs (relative 2), **Départ → Arrivée** (relative 4, maximisée pour que départ et arrivée tiennent majoritairement sur deux lignes), **A/R** (constante 22 pt, largeur minimale) et **Kms** (constante 44 pt, dimensionnée pour afficher jusqu'à « 9 999 » avec séparateur de milliers).
- **Itinéraire** : l'adresse de départ est rendue en première ligne, puis l'adresse d'arrivée **passe à la ligne** en dessous, précédée du symbole « → » (`txt.Line(départ)` suivi de `txt.Span("→ " + arrivée)`).
- **Colonne A/R** : affiche le marqueur « **X** » lorsque le Trajet est un Aller_Retour, et reste vide sinon. Le marqueur « X » est préféré à une coche Unicode (« ✓ ») car la police par défaut Lato ne la rend pas. L'information Aller_Retour n'est plus signalée par le suffixe « (A/R) » dans la cellule itinéraire ; la distance affichée reste la Distance_Effective (déjà doublée) portée par le `TrajetDto`.
- La ligne de total du mois s'étend sur les quatre premières colonnes (`ColumnSpan(4)`) et affiche le total dans la colonne Kms.
### En-tête et fin du rapport annuel (Exigences 7.11, 7.14)
- **Bloc d'infos de l'en-tête** (`ComposerInfosAnnuel`) : un espace (`PaddingTop`) est ajouté après la ligne « Véhicule », puis « Barème applicable » et « Total kilométrique de l'année » sont présentés sur **deux colonnes côte à côte** (`Row` de deux `RelativeItem`), même agencement que le bloc totaux du rapport mensuel.
- **Titre du tableau barème** (`ComposerTableBaremeAnnuel`) : complété pour rappeler le mode de calcul en toutes lettres — « Barème applicable pour l'année (N CV) — Kms parcourus × Coef + Base ». Les valeurs coef/base effectivement appliquées apparaissent, elles, dans la « Formule réelle » en fin de document.
- **Fin du document** (`ComposerTotauxAnnuels`) : la ligne « Formule réelle : … » reste alignée à gauche ; un espace (`PaddingTop`) la sépare de la ligne « Régularisation (réel − payé) : … € », désormais **centrée** (`AlignCenter`) sur la largeur de la page.
### Precision_Distance dans les PDF (Exigence 7.12)
`PdfService` récupère la valeur courante de Precision_Distance via `IPrecisionDistanceService` avant le rendu, et l'applique à tous les kilomètres affichés (`FormatKm(valeur, precision)`) : totaux d'en-tête (mois, année), table des Trajets, table de synthèse annuelle, et formule réelle. Comme pour l'affichage Frontend, seul le rendu est affecté ; la Distance brute stockée en base n'est jamais modifiée par ce paramètre (voir spec `precision-distances-km`).
### Restructuration des en-têtes (Exigence 7.9)
- **Mensuel** : le libellé « Adresse de départ » devient **« Domicile »** et est déplacé juste après la ligne Utilisateur/Matricule (même position que sur le rapport annuel), au lieu d'être positionné après la ligne Véhicule. La ligne « Période : MOIS ANNEE » est retirée : la période est désormais intégrée au titre du document (« Feuille d'indemnité kilométrique {MOIS} {ANNEE} », au lieu du titre fixe « Feuille d'indemnité kilométrique mensuelle »).
- **Annuel** : la ligne « Année : AAAA » est retirée de l'en-tête, l'année figurant déjà dans le titre « Synthèse {Année} d'indemnité kilométrique ».
### Nommage des fichiers (Exigence 7.4)
- Mensuel : `AAAAMM_Trajets_MATRICULE_PLAQUE.pdf` (ex. `202501_Trajets_AB12CD34_ES036YF.pdf`)
- Annuel : `AAAA_Trajets_MATRICULE_PLAQUE.pdf` (ex. `2025_Trajets_AB12CD34_ES036YF.pdf`)
- La **plaque** est nettoyée de ses espaces et de ses tirets avant insertion dans le nom de fichier (ex. `ES-036-YF` → `ES036YF`).
- Les segments **Matricule** et **Plaque** (et le séparateur `_` qui les précède) sont **omis** lorsque la donnée correspondante est vide ou absente (Profil sans matricule, ou Véhicule sans plaque) — voir `PdfService.ConstruireNomFichier`.
Le header HTTP de réponse inclut :
```
Content-Disposition: attachment; filename="trajets_01_2025.pdf"
Content-Disposition: attachment; filename="202501_Trajets_AB12CD34_ES036YF.pdf"
Content-Type: application/pdf
```
> **Exposition CORS de `Content-Disposition`** : la politique CORS du backend (`Program.cs`) déclare explicitement `WithExposedHeaders("Content-Disposition")` — sans cet appel, `AllowAnyHeader()` (qui ne couvre que les en-têtes de requête) ne suffit pas à rendre cet en-tête de réponse lisible en cross-origin par le Frontend, qui en a besoin pour proposer le nom de fichier suggéré lors du téléchargement d'un Export_PDF.
### Timeout PDF (Exigence 7.5)
La génération PDF est enveloppée dans un `CancellationToken` avec délai de 15 secondes. En cas de dépassement, le backend retourne `503 Service Unavailable`.
@@ -1609,6 +1674,8 @@ La génération PDF est enveloppée dans un `CancellationToken` avec délai de 1
## Déploiement (Docker Compose)
> **Procédure de déploiement (Git + Docker)** : le serveur récupère les sources depuis le dépôt Gitea (`http://192.168.1.55:3000/MadJax/SchotzIAD`, branche `master`) et reconstruit les conteneurs — l'ancienne méthode par copie d'archive est abandonnée. Le script `deploy.sh` (à la racine du dépôt) vérifie les prérequis (git, Docker, Docker Compose, présence du `.env`), détecte via `git fetch` si une nouvelle version de `master` est disponible et ne reconstruit (`docker compose up -d --build`) **que** dans ce cas (option `--force` pour forcer un rebuild sans nouveau commit). Le guide complet — installation des prérequis, premier `git clone`, création du `.env` à partir de `.env.example`, mises à jour et dépannage — figure dans **`DEPLOY.md`** à la racine du dépôt.
### Architecture des conteneurs
La solution est hébergée via **Docker Compose** avec deux services :
@@ -1977,7 +2044,7 @@ catch
### Property 9: Nommage des fichiers PDF exportés
*Pour tout* export PDF généré — qu'il soit mensuel ou annuel — le nom de fichier inclus dans l'en-tête `Content-Disposition` doit correspondre exactement au format `trajets_MM_AAAA.pdf` (mensuel) ou `trajets_AAAA.pdf` (annuel), où MM et AAAA correspondent aux paramètres de la requête.
*Pour tout* export PDF généré — qu'il soit mensuel ou annuel — le nom de fichier inclus dans l'en-tête `Content-Disposition` doit correspondre exactement au format `AAAAMM_Trajets_MATRICULE_PLAQUE.pdf` (mensuel) ou `AAAA_Trajets_MATRICULE_PLAQUE.pdf` (annuel), où AAAA et MM correspondent aux paramètres de la requête, PLAQUE est la plaque du Véhicule nettoyée de ses espaces et de ses tirets, et les segments MATRICULE / PLAQUE (avec leur séparateur `_`) sont omis lorsque la donnée correspondante est vide ou absente.
**Validates: Requirements 7.4**
@@ -2352,7 +2419,7 @@ public Property EtatProfilConfigureIffNomPrenomEtVehicule() { ... }
- `DistanceCalculatorService`: BAN indisponible → saisie manuelle autorisée, distance null stockée
- `ResumeService`: total mois vide = 0, résumé annuel avec mois manquants = 0
- `ResumeService` / `TotauxService`: calcul TotalAnnuelPartiel (cumul jusqu'au mois inclus, exclusion des mois postérieurs), années disponibles (données + année courante), mois/jours disponibles
- `PdfService`: génération mensuelle, génération annuelle, nommage des fichiers, timeout
- `PdfService`: génération mensuelle, génération annuelle, nommage des fichiers (avec/sans matricule, avec/sans plaque, nettoyage espaces/tirets de la plaque), formatage des km selon Precision_Distance, présence du logo (et comportement si ressource absente), timeout
- `AdresseCacheService`: insertion nouvelle adresse, réutilisation adresse existante, éviction LRU à 1 001 entrées
- `ProfilService`: sauvegarde avec géocodage réussi, sauvegarde avec géocodage échoué (warning sans erreur), mise à jour
- `ProfilService`: validation matricule <= 8 car.
@@ -24,6 +24,7 @@ L'application permet de gérer un profil (identité, matricule, adresse de domic
- **Calculateur_Distance** : Le composant backend responsable du calcul de la Distance entre deux points géographiques.
- **API** : L'interface de programmation exposée par le Backend pour communiquer avec le Frontend.
- **Export_PDF** : La fonctionnalité permettant de générer un fichier PDF contenant les données d'un Résumé_Mensuel ou d'un Résumé_Annuel.
- **Mention_Legale** : L'avertissement légal obligatoire figurant en pied de page de chaque Export_PDF, rappelant que les kilomètres sont déclarés sur l'honneur par l'utilisateur, que le calcul de l'indemnité kilométrique est purement indicatif, que SchotzIAD décline toute responsabilité sur les données saisies et le montant calculé, et que le document ne constitue pas un justificatif opposable aux administrations.
- **Carte** : La visualisation cartographique interactive affichant les itinéraires des Trajets d'une journée.
- **Cache_Adresses** : Le stockage des couples (adresse textuelle normalisée, coordonnées géographiques) des adresses déjà saisies, afin d'éviter des recherches répétées pour des adresses déjà connues.
- **Carnet_Adresses** : Le répertoire des Adresses_Nommées gérées par l'utilisateur via un menu dédié, permettant de créer, lister, modifier et supprimer des adresses réutilisables identifiées par une Dénomination, afin de simplifier la saisie des Trajets.
@@ -34,11 +35,12 @@ L'application permet de gérer un profil (identité, matricule, adresse de domic
- **Profil_Configure** : L'état du Profil considéré comme configuré lorsqu'un Profil est enregistré avec un nom et un prénom non vides ET qu'au moins un Véhicule (plaque, Puissance_Fiscale, Date_Effet) est enregistré. À l'inverse, le Profil est dit « non configuré » lorsqu'aucun Profil n'est enregistré avec un nom et un prénom non vides, ou lorsqu'aucun Véhicule n'est enregistré.
- **Onboarding** : Le parcours d'initialisation présenté à la première utilisation de l'Application lorsque le Profil est non configuré, invitant l'utilisateur à renseigner son identité (nom, prénom, matricule), éventuellement son Adresse_Domicile, et au moins un Véhicule avant d'accéder aux fonctionnalités de saisie et de consultation des Trajets.
- **Vue_Consultation** : La vue principale de l'Application permettant la saisie et la consultation des Trajets (Sélecteur_Temporel, zone des Trajets, Panneau_Totaux, Carte).
- **Synthèse_Kms** : L'écran unique (lien de menu « Synthèse Kms ») regroupant la consultation annuelle agrégée par mois, la saisie des Montant_Regle et les exports PDF. Il fusionne et remplace les trois anciens écrans distincts « Résumé mensuel », « Résumé annuel » et « Montants réglés ». Il présente, pour une année et un Véhicule sélectionnés, le Bareme_Kilometrique appliqué puis un tableau d'une ligne par mois (kilomètres du mois, Frais_Reel_Mensuel, Montant_Previsionnel_Mensuel, Montant_Regle saisissable, Export_PDF mensuel) et une ligne TOTAL (total kilométrique annuel, Montant_Reel, total réglé, Export_PDF annuel). La Synthèse_Kms n'affiche pas le détail des Trajets un par un ; ce détail reste disponible sur la Vue_Consultation.
- **Commentaire** : Un texte optionnel associé à un Trajet, permettant à l'utilisateur d'annoter un déplacement (0 à 500 caractères).
- **Trajet_Précédent** : Le Trajet immédiatement antérieur dans la liste chronologique d'une journée, dont l'adresse d'arrivée peut servir de départ lors d'une insertion.
- **Trajet_Suivant** : Le Trajet immédiatement postérieur dans la liste chronologique d'une journée, dont l'adresse de départ peut servir d'arrivée lors d'une insertion.
- **Position** : Un entier positif associé à chaque Trajet au sein d'une même journée, déterminant son rang dans la liste affichée. L'ordre d'affichage est toujours trié par Position croissante, indépendamment de l'heure de création du Trajet.
- **Sélecteur_Temporel** : Le composant de navigation (repliable) permettant de choisir successivement une année puis un mois pour consulter les Trajets correspondants. La journée affichée sur la Carte n'est pas choisie par des boutons de navigation mais suit le survol des Trajets dans la liste.
- **Sélecteur_Temporel** : Le composant de navigation (repliable) permettant de choisir une période pour consulter les Trajets correspondants. Il fonctionne selon deux modes : un mode « année + mois » (choix successif d'une année puis d'un mois, utilisé sur la Vue_Consultation) et un mode « année seule » (choix d'une année uniquement, sans grille de mois, utilisé par la Synthèse_Kms). Dans les deux modes, la liste des années proposées se limite à l'année courante et aux années comportant au moins un Trajet. Il se referme automatiquement (repli) dès que l'utilisateur sélectionne son niveau le plus bas (le mois en mode « année + mois », l'année en mode « année seule »). La journée affichée sur la Carte n'est pas choisie par des boutons de navigation mais suit le survol des Trajets dans la liste.
- **Panneau_Totaux** : La zone latérale, toujours visible, affichant les totaux kilométriques du jour, du mois sélectionné et de l'année sélectionnée (cumul jusqu'au mois sélectionné inclus).
- **Total_Annuel_Partiel** : Le total kilométrique de l'année sélectionnée cumulant uniquement les Trajets jusqu'au mois sélectionné inclus, sans inclure les mois postérieurs.
- **Véhicule** : Un véhicule de l'utilisateur, caractérisé par une plaque d'immatriculation et une puissance fiscale, avec une date d'effet à partir de laquelle il devient le véhicule courant. L'historique des Véhicules est conservé.
@@ -59,12 +61,16 @@ L'application permet de gérer un profil (identité, matricule, adresse de domic
- **Mot_De_Passe** : Le secret d'au moins 8 caractères associé au Compte, jamais conservé en clair.
- **Session** : L'état authentifié d'un utilisateur, établi à la connexion et vérifié à chaque accès protégé.
- **Jeton_Reinitialisation** : Un jeton à usage unique et à durée de vie limitée, envoyé par e-mail, permettant de définir un nouveau Mot_De_Passe sans connaître l'ancien.
- **Identifiant_Mémorisé** : L'Adresse_Email de connexion conservée par le navigateur de l'utilisateur, propre à la machine et au navigateur utilisés, lorsque l'utilisateur a demandé « Se souvenir de moi » lors d'une connexion réussie ; elle sert à pré-remplir le champ Adresse_Email lors des visites ultérieures sur ce même navigateur. Le Mot_De_Passe n'en fait jamais partie.
- **Aller_Retour** : Un indicateur booléen d'un Trajet signifiant que le déplacement est effectué dans les deux sens. Lorsqu'il est vrai, la Distance comptée du Trajet (Distance_Effective) est le double de la distance simple mesurée entre le départ et l'arrivée.
- **Distance_Effective** : La distance comptée d'un Trajet dans tous les agrégats (liste, totaux, carte, PDF, barème) : égale à la distance simple, ou à son double lorsque le Trajet est un Aller_Retour.
- **Document_Specification** : Un document de conception du projet consultable en lecture seule dans l'Application : les Exigences (requirements), la Conception technique (design) et le Plan de tâches (tasks).
- **Avatar_Utilisateur** : Le contrôle affiché à l'extrémité droite de la barre de navigation, représentant l'utilisateur par ses initiales (1ère lettre du Prénom + 1ère lettre du Nom). Un survol ou un clic ouvre un sous-menu proposant l'identité affichée, « Paramétrage », « Maintenance » et « Se déconnecter ».
- **Page_Parametrage** : La page accessible depuis l'Avatar_Utilisateur, présentant un menu latéral (Compte, Véhicules, Barème) et affichant à sa droite la donnée paramétrable sélectionnée.
- **Page_Parametrage** : La page accessible depuis l'Avatar_Utilisateur, présentant un menu latéral (Compte, Application, Véhicules, Barème) et affichant à sa droite la donnée paramétrable sélectionnée.
- **Page_Maintenance** : La page accessible depuis l'Avatar_Utilisateur regroupant les actions techniques de maintenance de l'Application (ex. recalcul des distances routières).
- **Precision_Distance** : Le paramètre global de l'Application, réglable dans le Sous_Menu_Application de la Page_Parametrage, déterminant le nombre de décimales (0, 1 ou 2) utilisé pour l'arrondi supérieur des distances à l'affichage et dans les agrégats (trajets, totaux, barème, carte, Export_PDF). Défini et détaillé intégralement dans la spec `precision-distances-km` (Glossary, Requirement 1).
- **Sous_Menu_Application** : L'entrée « Application » du menu latéral de la Page_Parametrage, positionnée entre « Compte » et « Véhicules », permettant de configurer Precision_Distance. Détaillée intégralement dans la spec `precision-distances-km` (Glossary, Requirement 2).
- **Montant_Previsionnel_Mensuel** et **Montant_Previsionnel_Annuel** : Les deux indications de versement affichées lors de la consultation d'un mois, redéfinies par la spec `precision-distances-km` (Glossary, Requirements 7 et 8) ; elles remplacent l'ancien Montant_Previsionnel unique décrit au Requirement 13 ci-dessous.
---
@@ -115,21 +121,23 @@ L'application permet de gérer un profil (identité, matricule, adresse de domic
---
### Requirement 3: Consultation du résumé mensuel
### Requirement 3: Consultation du total kilométrique mensuel
**User Story:** En tant qu'utilisateur, je veux consulter le total des kilomètres parcourus pour un mois donné, afin de suivre mes déplacements mensuels.
> **Évolution de navigation** : la consultation mensuelle agrégée ne dispose plus d'un écran dédié « Résumé mensuel » (écran supprimé). Le total kilométrique mensuel est désormais présenté, agrégé par mois, dans l'écran unique Synthèse_Kms (voir Exigences 13 et 14). Le détail des Trajets d'un mois (liste trajet par trajet) n'est plus présenté dans un écran de synthèse : il reste consultable sur la Vue_Consultation (écran « Trajets »). Les règles de calcul du Backend ci-dessous restent inchangées et continuent d'alimenter la Synthèse_Kms comme la Vue_Consultation.
>
> Les exigences techniques associées (codes HTTP) sont décrites dans `design-requirements.md`, Requirement 3.
#### Acceptance Criteria
1. WHEN l'utilisateur sélectionne un mois (valeur entre 1 et 12 inclus) et une année (valeur entre 2000 et 2100 inclus), THE Frontend SHALL afficher la liste des Trajets enregistrés pour ce mois ainsi que le Résumé_Mensuel correspondant.
2. WHEN le Backend reçoit une requête de Résumé_Mensuel pour un mois (entre 1 et 12 inclus) et une année (entre 2000 et 2100 inclus) donnés, THE Backend SHALL retourner la liste des Trajets de ce mois triés par date croissante puis par Position croissante au sein de chaque journée, avec leur Distance respective et le total kilométrique du mois.
3. WHEN le Backend reçoit une requête de Résumé_Mensuel, THE Backend SHALL calculer le total kilométrique du Résumé_Mensuel en additionnant les Distances, exprimées en kilomètres à deux décimales, de tous les Trajets du mois concerné.
4. IF aucun Trajet n'est enregistré pour le mois sélectionné, THEN THE Backend SHALL retourner un Résumé_Mensuel avec une liste vide et un total kilométrique de zéro.
5. WHEN le Frontend affiche le Résumé_Mensuel, THE Frontend SHALL afficher le total kilométrique mensuel avec une précision de deux décimales.
6. IF le Backend reçoit une requête de Résumé_Mensuel avec un mois hors de la plage 1–12 ou une année hors de la plage 2000–2100, THEN THE Backend SHALL refuser la requête et retourner un message descriptif.
7. IF le Backend retourne une erreur lors d'une requête de Résumé_Mensuel, THEN THE Frontend SHALL afficher un message d'erreur indiquant que le chargement des données a échoué et ne pas afficher de données kilométriques partielles.
1. WHEN le Backend reçoit une requête de Résumé_Mensuel pour un mois (entre 1 et 12 inclus) et une année (entre 2000 et 2100 inclus) donnés, THE Backend SHALL retourner la liste des Trajets de ce mois triés par date croissante puis par Position croissante au sein de chaque journée, avec leur Distance respective, leur Commentaire et le total kilométrique du mois.
2. WHEN le Backend reçoit une requête de Résumé_Mensuel, THE Backend SHALL calculer le total kilométrique du Résumé_Mensuel en additionnant les Distances, arrondies selon Precision_Distance (voir spec `precision-distances-km`, Requirement 6), de tous les Trajets du mois concerné.
3. IF aucun Trajet n'est enregistré pour le mois sélectionné, THEN THE Backend SHALL retourner un Résumé_Mensuel avec une liste vide et un total kilométrique de zéro.
4. IF le Backend reçoit une requête de Résumé_Mensuel avec un mois hors de la plage 1–12 ou une année hors de la plage 2000–2100, THEN THE Backend SHALL refuser la requête et retourner un message descriptif.
5. IF le Backend retourne une erreur lors d'une requête de Résumé_Mensuel, THEN THE Frontend SHALL afficher un message d'erreur indiquant que le chargement des données a échoué et ne pas afficher de données kilométriques partielles.
6. WHEN le Frontend affiche le total kilométrique d'un mois dans la Synthèse_Kms, THE Frontend SHALL afficher ce total avec le nombre de décimales défini par Precision_Distance (voir spec `precision-distances-km`, Requirements 1, 3, 4, 5).
7. WHEN l'utilisateur consulte le détail des Trajets d'un mois sur la Vue_Consultation, THE Frontend SHALL afficher, dans la liste des Trajets, une colonne "Commentaire" positionnée entre la colonne "Date" et la colonne "Départ", affichant le Commentaire du Trajet ou « — » lorsqu'il est absent.
---
@@ -137,13 +145,15 @@ L'application permet de gérer un profil (identité, matricule, adresse de domic
**User Story:** En tant qu'utilisateur, je veux consulter le total des kilomètres parcourus pour une année entière, afin d'avoir une vision globale de mes déplacements annuels.
> **Évolution de navigation** : la consultation annuelle ne dispose plus d'un écran dédié « Résumé annuel » (écran supprimé). La vision annuelle (12 mois + total) est désormais présentée dans l'écran unique Synthèse_Kms (voir Exigence 14), via sa ligne par mois et sa ligne TOTAL. Les règles de calcul du Backend ci-dessous restent inchangées.
#### Acceptance Criteria
1. WHEN l'utilisateur sélectionne une année comprise entre 2000 et 2100 inclus, THE Frontend SHALL afficher le Résumé_Annuel présentant le total kilométrique de chaque mois et le total annuel.
1. WHEN l'utilisateur sélectionne une année comprise entre 2000 et 2100 inclus dans la Synthèse_Kms, THE Frontend SHALL afficher le total kilométrique de chaque mois et le total annuel.
2. WHEN le Backend reçoit une requête de Résumé_Annuel pour une année donnée, THE Backend SHALL retourner les douze Résumés_Mensuels de cette année, chacun avec son total kilométrique.
3. WHEN le Backend reçoit une requête de Résumé_Annuel pour une année donnée, THE Backend SHALL calculer le total kilométrique annuel en additionnant les totaux kilométriques des douze Résumés_Mensuels de l'année concernée.
4. IF aucun Trajet n'est enregistré pour un mois d'une année, THEN THE Backend SHALL inclure ce mois dans le Résumé_Annuel avec un total kilométrique de zéro.
5. WHEN le Frontend affiche le Résumé_Annuel, THE Frontend SHALL afficher chaque total kilométrique mensuel et le total annuel avec une précision de deux décimales et une valeur maximale affichable de 999 999,99 km.
5. WHEN le Frontend affiche la vision annuelle dans la Synthèse_Kms, THE Frontend SHALL afficher chaque total kilométrique mensuel et le total annuel avec le nombre de décimales défini par Precision_Distance (voir spec `precision-distances-km`, Requirements 1, 3, 4, 5) et une valeur maximale affichable de 999 999,99 km.
6. IF le Backend retourne une erreur lors d'une requête de Résumé_Annuel, THEN THE Frontend SHALL afficher un message d'erreur indiquant que le chargement des données a échoué et ne pas afficher de données kilométriques partielles.
7. THE Backend SHALL calculer le total kilométrique annuel exclusivement en additionnant les totaux kilométriques des douze Résumés_Mensuels de l'année concernée ; aucun kilométrage non rattaché à un mois ne peut être inclus dans le total annuel.
@@ -200,14 +210,19 @@ L'application permet de gérer un profil (identité, matricule, adresse de domic
1. WHEN l'utilisateur demande l'export PDF d'un Résumé_Mensuel, THE Backend SHALL générer un fichier PDF contenant la liste de tous les Trajets du mois (date, adresse de départ, adresse d'arrivée, distance en km) ainsi que le total kilométrique mensuel.
2. WHEN l'utilisateur demande l'export PDF d'un Résumé_Annuel, THE Backend SHALL générer un fichier PDF contenant le tableau des douze mois (mois, total kilométrique) ainsi que le total kilométrique annuel.
3. WHEN le Backend génère un Export_PDF avec succès, THE Frontend SHALL déclencher le téléchargement automatique du fichier PDF dans le navigateur de l'utilisateur. IF le mécanisme de téléchargement échoue après une génération réussie, THEN THE Frontend SHALL afficher un message d'erreur indiquant que le téléchargement a échoué.
4. THE Backend SHALL nommer le fichier PDF exporté selon le format suivant : `trajets_MM_AAAA.pdf` pour un export mensuel et `trajets_AAAA.pdf` pour un export annuel.
5. WHEN le Backend génère un Export_PDF, THE Backend SHALL inclure dans le PDF : le titre "Rapport de trajets", la période couverte (mois/année ou année), et le total kilométrique de la période en en-tête du document.
4. THE Backend SHALL nommer le fichier PDF exporté selon le format suivant : `AAAAMM_Trajets_MATRICULE_PLAQUE.pdf` pour un export mensuel et `AAAA_Trajets_MATRICULE_PLAQUE.pdf` pour un export annuel, où PLAQUE désigne la plaque d'immatriculation du Véhicule nettoyée de ses espaces et de ses tirets ; les segments MATRICULE et PLAQUE, ainsi que le séparateur `_` qui les précède, SHALL être omis lorsque la donnée correspondante (matricule du Profil ou plaque du Véhicule) est vide ou absente.
5. WHEN le Backend génère un Export_PDF, THE Backend SHALL inclure dans le PDF : le titre "RAPPORT DE TRAJETS" (en majuscules), la période couverte (mois/année ou année), et le total kilométrique de la période en en-tête du document.
6. WHEN l'utilisateur demande un export PDF (mensuel ou annuel), THE Backend SHALL n'inclure que les Trajets associés au Véhicule sélectionné et SHALL indiquer dans l'en-tête du PDF la plaque d'immatriculation et la Puissance_Fiscale du Véhicule concerné.
7. WHEN le Backend génère un Export_PDF (mensuel ou annuel), THE Backend SHALL inclure dans l'en-tête du document : le nom, le prénom, le matricule de l'utilisateur, ainsi que la plaque d'immatriculation et la Puissance_Fiscale du Véhicule concerné.
8. IF des Trajets de plusieurs Véhicules existent pour la période (mois ou année) demandée, THEN THE Backend SHALL générer un rapport PDF distinct par Véhicule.
9. WHEN le Backend génère un Export_PDF mensuel, THE Backend SHALL faire figurer dans l'en-tête : le Véhicule (plaque, puissance fiscale, coefficient et base de la tranche applicable), la période, l'adresse de départ du Profil, le total kilométrique du mois, le total kilométrique annuel cumulé, le TOTAL IK du mois (kilomètres du mois × coefficient de la tranche annuelle) et le Montant_Regle du mois.
9. WHEN le Backend génère un Export_PDF mensuel, THE Backend SHALL faire figurer dans l'en-tête, à la suite de la ligne Utilisateur/Matricule : le Véhicule (plaque, puissance fiscale, coefficient et base de la tranche applicable), l'Adresse_Domicile du Profil sous le libellé "Domicile", le total kilométrique du mois, le total kilométrique annuel cumulé, le TOTAL IK du mois (kilomètres du mois × coefficient de la tranche annuelle) et le Montant_Regle du mois ; le titre du document SHALL intégrer le mois et l'année de la période couverte (ex. "Feuille d'indemnité kilométrique Janvier 2025") au lieu d'un titre fixe, et l'en-tête SHALL ne pas comporter de ligne distincte "Période".
10. WHEN le Backend génère un Export_PDF mensuel, THE Backend SHALL faire figurer la liste des Trajets du mois avec, pour chaque Trajet : la date, le motif (commentaire), l'adresse de départ, l'adresse d'arrivée et la distance en kilomètres.
11. WHEN le Backend génère un Export_PDF annuel (synthèse), THE Backend SHALL faire figurer : le nom et le prénom de l'utilisateur, l'année, l'adresse de domicile, le Bareme_Kilometrique applicable pour l'année, le Véhicule et sa puissance fiscale, la tranche applicable ; puis pour chacun des douze mois : les kilomètres parcourus, le Montant_Regle (frais payé) et le Frais_Reel_Mensuel (frais réel) ; et enfin les totaux : total kilométrique annuel, total réglé, Montant_Reel, et Regularisation (Montant_Reel − total réglé).
11. WHEN le Backend génère un Export_PDF annuel (synthèse), THE Backend SHALL faire figurer : le nom et le prénom de l'utilisateur, l'adresse de domicile, le Bareme_Kilometrique applicable pour l'année, le Véhicule et sa puissance fiscale, la tranche applicable ; puis pour chacun des douze mois : les kilomètres parcourus, le Montant_Regle (frais payé) et le Frais_Reel_Mensuel (frais réel) ; et enfin les totaux : total kilométrique annuel, total réglé, Montant_Reel, et Regularisation (Montant_Reel − total réglé) ; le titre du document SHALL indiquer l'année couverte (ex. "Synthèse 2025 d'indemnité kilométrique"), et l'en-tête SHALL ne pas comporter de ligne distincte "Année" dès lors que l'année figure déjà dans ce titre.
12. WHEN le Backend génère un Export_PDF (mensuel ou annuel), THE Backend SHALL exprimer tous les kilomètres affichés dans le document (totaux d'en-tête, table des Trajets, table de synthèse annuelle, formule réelle) avec le nombre de décimales défini par Precision_Distance (voir spec `precision-distances-km`, Requirements 1, 3, 4, 5), au lieu d'une précision fixe à deux décimales.
13. WHEN le Backend génère un Export_PDF (mensuel ou annuel), THE Backend SHALL afficher le logo carré de l'Application en haut à gauche de l'en-tête du document.
14. WHEN le Backend génère un Export_PDF (mensuel ou annuel), THE Backend SHALL faire figurer en pied de page la Mention_Legale suivante : « Les kilomètres restitués dans le présent document sont déclarés par l'utilisateur sur l'honneur et sous sa seule responsabilité. Le calcul de l'indemnité kilométrique est fourni à titre purement indicatif, sur la base du barème kilométrique officiel en vigueur. SchotzIAD ne saurait être tenue responsable d'une quelconque erreur ou omission dans les données saisies ni du montant résultant du calcul. Le présent document ne constitue pas un justificatif opposable aux administrations. »
15. WHEN le Backend génère un Export_PDF mensuel, THE Backend SHALL faire figurer dans la liste des Trajets une colonne dédiée "A/R" affichant un marqueur lorsque le Trajet est un Aller_Retour et restant vide sinon, l'indicateur Aller_Retour n'étant plus signalé dans la cellule de l'itinéraire.
16. WHEN le Backend génère un Export_PDF mensuel, THE Backend SHALL présenter, pour chaque Trajet de la liste, l'adresse d'arrivée sur une ligne distincte sous l'adresse de départ, précédée du symbole "→".
---
@@ -329,6 +344,9 @@ L'application permet de gérer un profil (identité, matricule, adresse de domic
22. WHEN l'utilisateur change de Véhicule sélectionné, THE Frontend SHALL mettre à jour la liste mensuelle des Trajets, le Panneau_Totaux et la Carte sans rechargement complet de la page.
23. WHEN un Trajet est créé ou modifié avec une date appartenant à un mois différent du mois affiché, THE Frontend SHALL se repositionner automatiquement sur le mois de ce Trajet, cibler sa journée sur la Carte, et déplier le groupe de cette journée dans la liste afin que le Trajet ajouté soit visible.
24. THE Frontend SHALL afficher, à l'extrémité droite de l'en-tête de chaque journée, un bouton dédié « Ajouter un trajet sur cette journée » qui ouvre le formulaire de création avec la date de CETTE journée pré-remplie, indépendamment de la journée actuellement affichée sur la Carte (afin d'éviter qu'un survol traversant plusieurs journées ouvertes ne fasse dériver la date visée par l'utilisateur).
25. THE Sélecteur_Temporel SHALL proposer un mode « année seule » limitant la sélection au choix d'une année (sans afficher la grille des mois) et émettant l'année choisie, utilisé par la Synthèse_Kms ; sur la Vue_Consultation, le Sélecteur_Temporel SHALL conserver son mode « année + mois ». Dans les deux modes, la liste des années proposées se limite à l'année courante et aux années comportant au moins un Trajet.
26. WHEN l'utilisateur sélectionne le niveau le plus bas du Sélecteur_Temporel (le mois en mode « année + mois », l'année en mode « année seule »), THE Sélecteur_Temporel SHALL se replier automatiquement.
27. THE Frontend SHALL afficher les écrans « Trajets » (Vue_Consultation) et « Carnet d'adresses » en occupant toute la largeur disponible, tandis que l'écran Synthèse_Kms et l'écran de consultation des Documents_Specification SHALL conserver une largeur de lecture limitée centrée.
---
@@ -336,17 +354,21 @@ L'application permet de gérer un profil (identité, matricule, adresse de domic
**User Story:** En tant qu'utilisateur, je veux saisir le barème kilométrique officiel par année et par puissance fiscale, et voir une indication de versement mensuel, afin d'estimer mes remboursements en cours d'année.
> Le calcul du Montant_Previsionnel_Mensuel (critère 5) et du Montant_Previsionnel_Annuel (critère 5bis) est précisé intégralement par la spec `precision-distances-km`, Requirements 7 et 8, qui prime en cas de divergence.
#### Acceptance Criteria
1. THE Frontend SHALL afficher un menu de saisie du Bareme_Kilometrique par année permettant de saisir, pour chaque Puissance_Fiscale, une ou plusieurs Tranches_Bareme définies par une borne minimale de kilométrage, une borne maximale de kilométrage (la dernière tranche pouvant être illimitée, sans borne maximale), un coefficient (€/km sur trois décimales) et une base (€). L'utilisateur peut ajouter plusieurs tranches pour une même Puissance_Fiscale (ex. 0–4 999 km, 5 000–19 999 km, 20 000 km et plus).
2. WHEN l'utilisateur enregistre une Tranche_Bareme, THE Backend SHALL persister la tranche associée à l'année, à la Puissance_Fiscale et à la borne de kilométrage concernées.
3. WHEN le Backend applique le Bareme_Kilometrique au total kilométrique annuel d'un Véhicule pour une année donnée, THE Backend SHALL sélectionner la tranche dont l'intervalle de kilométrage contient le total annuel, puis calculer le montant selon la formule `montant = total_km_annuel × coefficient + base`.
4. IF aucune Tranche_Bareme n'existe pour la Puissance_Fiscale et l'année d'un calcul, THEN THE Backend SHALL retourner le montant zéro et signaler que le barème n'est pas configuré pour cette combinaison.
5. WHEN l'utilisateur consulte les données d'un mois, THE Frontend SHALL afficher à titre indicatif le Montant_Previsionnel, calculé comme 80% du Bareme_Kilometrique appliqué au Total_Annuel_Partiel du Véhicule sélectionné.
6. THE Frontend SHALL indiquer explicitement que le Montant_Previsionnel (80%) est une simple indication de versement et que le montant réellement versé est celui saisi par l'utilisateur (Montant_Regle).
5. WHEN l'utilisateur consulte les données d'un mois, THE Frontend SHALL afficher à titre indicatif le Montant_Previsionnel_Mensuel, calculé comme 80% du Bareme_Kilometrique appliqué à la Distance_Effective_Arrondie totale du seul mois affiché (et non plus du Total_Annuel_Partiel), la tranche applicable restant déterminée par le Total_Annuel_Partiel.
5bis. WHEN l'utilisateur consulte les données d'un mois, THE Frontend SHALL afficher, sous le Montant_Previsionnel_Mensuel, une ligne « Montant prévisionnel annuel » présentant le Montant_Previsionnel_Annuel, calculé comme 100% (sans minoration) du Bareme_Kilometrique appliqué au Total_Annuel_Partiel du Véhicule sélectionné.
6. THE Frontend SHALL indiquer explicitement que le Montant_Previsionnel_Mensuel (80%) est une simple indication de versement, que le Montant_Previsionnel_Annuel (100%) est une projection indicative sans minoration, et que le montant réellement versé est celui saisi par l'utilisateur (Montant_Regle).
7. THE Frontend SHALL afficher tous les montants en euros avec une précision de deux décimales.
8. THE Frontend SHALL afficher, dans l'en-tête de chaque feuille mensuelle, le Coefficient_Base_Vehicule (coefficient et base de la tranche applicable selon la puissance fiscale du Véhicule et le total kilométrique annuel).
9. THE Frontend SHALL proposer un bouton « Recopier le dernier barème connu » qui, pour l'année affichée, recopie l'intégralité des Tranches_Bareme de l'année la plus récente antérieure comportant un barème. IF des Tranches_Bareme existent déjà pour l'année affichée, THEN THE Frontend SHALL demander une confirmation avant de les remplacer intégralement (annule et remplace). THE Frontend SHALL rendre ce bouton inaccessible lorsque l'année affichée est celle du dernier barème connu (aucune année antérieure à recopier).
10. WHEN l'utilisateur consulte la Synthèse_Kms, THE Frontend SHALL afficher le Bareme_Kilometrique appliqué (tranche, Puissance_Fiscale, coefficient, base) au-dessus du tableau mensuel, puis présenter pour chaque mois une colonne affichant le Montant_Previsionnel_Mensuel (indication 80%) ; la ligne TOTAL SHALL laisser cette colonne « indication 80% » vide (aucune indication 80% au niveau annuel).
---
@@ -354,18 +376,26 @@ L'application permet de gérer un profil (identité, matricule, adresse de domic
**User Story:** En tant qu'utilisateur, je veux saisir le montant réellement versé chaque mois et voir la régularisation annuelle, afin de connaître le solde restant à verser en fin d'année.
> Les exigences techniques associées (code HTTP de rejet d'un montant négatif) sont décrites dans `design-requirements.md`, Requirement 14.
> **Évolution de navigation** : la saisie des Montant_Regle ne dispose plus d'un écran dédié « Montants réglés » (écran supprimé). Elle se fait désormais dans l'écran unique Synthèse_Kms, directement dans le tableau mensuel, aux côtés de la consultation annuelle (ex-« Résumé annuel ») et du total mensuel (ex-« Résumé mensuel »).
>
> Les exigences techniques associées (code HTTP de rejet d'un montant négatif, composition d'endpoints, enregistrement multiple) sont décrites dans `design-requirements.md`, Requirement 14.
#### Acceptance Criteria
1. THE Frontend SHALL permettre à l'utilisateur de saisir un Montant_Regle (valeur numérique positive en euros, deux décimales) pour un mois et une année donnés, associé à un Véhicule ; ce montant est saisi librement et peut différer d'un mois à l'autre.
1. THE Frontend SHALL permettre à l'utilisateur de saisir, dans le tableau mensuel de la Synthèse_Kms, un Montant_Regle (valeur numérique positive en euros, deux décimales) pour chaque mois de l'année et du Véhicule sélectionnés ; ce montant est saisi librement et peut différer d'un mois à l'autre.
2. WHEN l'utilisateur enregistre un Montant_Regle, THE Backend SHALL persister ce montant associé au mois, à l'année et au Véhicule concernés.
3. IF un Montant_Regle existe déjà pour le même mois, la même année et le même Véhicule, THEN THE Backend SHALL mettre à jour le montant existant plutôt que d'en créer un nouveau.
4. IF l'utilisateur saisit un Montant_Regle négatif, THEN THE Backend SHALL rejeter la saisie.
5. WHEN le Backend calcule le Montant_Reel annuel d'un Véhicule, THE Backend SHALL appliquer le Bareme_Kilometrique plein (100%) au total kilométrique annuel du Véhicule, en utilisant la tranche déterminée par ce total annuel.
6. WHEN le Backend calcule le Frais_Reel_Mensuel de chaque mois, THE Backend SHALL multiplier les kilomètres du mois par le coefficient de la tranche annuelle applicable, de sorte que la somme des douze Frais_Reel_Mensuel plus la base de la tranche annuelle soit égale au Montant_Reel annuel.
5. WHEN le Backend calcule le Montant_Reel annuel d'un Véhicule, THE Backend SHALL appliquer le Bareme_Kilometrique plein (100%) au total kilométrique annuel du Véhicule, en utilisant la tranche déterminée par ce total annuel ; ce total kilométrique annuel est constitué de distances déjà arrondies selon Precision_Distance, conformément à la spec `precision-distances-km`, Requirement 6.
6. WHEN le Backend calcule le Frais_Reel_Mensuel de chaque mois, THE Backend SHALL multiplier les kilomètres du mois par le coefficient de la tranche annuelle applicable, de sorte que la somme des douze Frais_Reel_Mensuel plus la base de la tranche annuelle soit égale au Montant_Reel annuel ; les kilomètres du mois sont également des distances déjà arrondies selon Precision_Distance (spec `precision-distances-km`, Requirement 6).
7. WHEN le Backend calcule la Regularisation d'un Véhicule pour une année, THE Backend SHALL soustraire le total des Montant_Regle de l'année du Montant_Reel annuel : `Regularisation = Montant_Reel − Total_Regle`.
8. THE Frontend SHALL afficher le Montant_Regle, le Frais_Reel_Mensuel, le Montant_Reel annuel, le total réglé et la Regularisation avec une précision de deux décimales.
9. THE Frontend SHALL afficher la Synthèse_Kms avec, en en-tête sur une même ligne, le titre « Synthèse Kms » et une zone de critères comportant un Sélecteur_Temporel en mode « année seule », un sélecteur de Véhicule, puis un bouton « Afficher ».
10. WHEN l'utilisateur ouvre la Synthèse_Kms, THE Frontend SHALL afficher automatiquement la synthèse pour la pré-sélection (Véhicule_Courant et année courante), sans action supplémentaire de l'utilisateur.
11. THE Frontend SHALL présenter dans la Synthèse_Kms un tableau d'une ligne par mois affichant : le libellé du mois, les kilomètres parcourus du mois, le Frais_Reel_Mensuel (remboursement réel propre au mois), le Montant_Previsionnel_Mensuel (indication 80%), le Montant_Regle saisissable, et une colonne « Action » comportant un bouton « Export PDF » déclenchant l'export du rapport mensuel de ce mois.
12. THE Frontend SHALL présenter dans la Synthèse_Kms une ligne TOTAL affichant : le total kilométrique annuel, le Montant_Reel (total du remboursement réel), une cellule « indication 80% » vide, le total réglé (somme des Montant_Regle, non modifiable), et un bouton « Export PDF » déclenchant l'export du rapport annuel ; la ligne TOTAL SHALL ne comporter aucun bouton d'enregistrement.
13. THE Frontend SHALL afficher, au-dessus du tableau de la Synthèse_Kms, une barre d'action comportant un unique bouton « Enregistrer » ; il n'y a pas de bouton d'enregistrement par ligne.
14. WHEN l'utilisateur active le bouton « Enregistrer » de la Synthèse_Kms, THE Frontend SHALL enregistrer uniquement les Montant_Regle modifiés depuis le dernier chargement (plusieurs mois en une seule action), sans réenregistrer les montants inchangés.
---
@@ -436,6 +466,11 @@ L'application permet de gérer un profil (identité, matricule, adresse de domic
11. WHEN l'utilisateur demande la réinitialisation de son Mot_De_Passe en fournissant son Adresse_Email, THE Backend SHALL générer un Jeton_Reinitialisation à usage unique et à durée de vie limitée, et l'envoyer par e-mail à l'Adresse_Email du Compte.
12. WHEN l'utilisateur suit un Jeton_Reinitialisation valide et fournit un nouveau Mot_De_Passe d'au moins 8 caractères, THE Backend SHALL remplacer le Mot_De_Passe du Compte et invalider le Jeton_Reinitialisation.
13. IF le Jeton_Reinitialisation est expiré, déjà utilisé ou inconnu, THEN THE Backend SHALL rejeter la réinitialisation avec une erreur explicite sans modifier le Mot_De_Passe.
14. THE Frontend SHALL afficher sur la page de connexion, sous le champ Adresse_Email, une case à cocher « Se souvenir de moi ».
15. WHEN l'utilisateur réussit une connexion alors que la case « Se souvenir de moi » est cochée, THE Frontend SHALL conserver l'Adresse_Email saisie comme Identifiant_Mémorisé sur le navigateur utilisé, et pré-remplir le champ Adresse_Email avec cet Identifiant_Mémorisé en présentant la case « Se souvenir de moi » cochée lors de la visite suivante de la page de connexion sur ce même navigateur.
16. WHILE aucune connexion « Se souvenir de moi » n'a été réalisée sur le navigateur utilisé, THE Frontend SHALL présenter la page de connexion avec le champ Adresse_Email vide et la case « Se souvenir de moi » décochée.
17. WHEN l'utilisateur réussit une connexion alors que la case « Se souvenir de moi » est décochée, THE Frontend SHALL effacer tout Identifiant_Mémorisé existant sur le navigateur utilisé, de sorte que l'Adresse_Email devra être ressaisie lors de la visite suivante.
18. THE Frontend SHALL ne jamais conserver le Mot_De_Passe au titre de la fonctionnalité « Se souvenir de moi ».
---
@@ -500,3 +535,20 @@ L'application permet de gérer un profil (identité, matricule, adresse de domic
4. WHEN l'utilisateur sélectionne « Paramétrage », THE Frontend SHALL naviguer vers la Page_Parametrage, affichant par défaut la donnée « Compte » ; WHEN l'utilisateur sélectionne « Maintenance », THE Frontend SHALL naviguer vers la Page_Maintenance ; WHEN l'utilisateur sélectionne « Se déconnecter », THE Frontend SHALL déconnecter l'utilisateur et naviguer vers la page de connexion.
5. THE Page_Parametrage SHALL présenter un menu latéral avec les entrées « Compte » (identité, matricule, adresse de domicile), « Véhicules » (historique des véhicules) et « Barème » (barème kilométrique), chacune affichant à droite le contenu correspondant sans rechargement complet de la page.
6. THE Backend SHALL continuer à exiger la configuration du Profil (Exigence 16) pour l'accès à la donnée « Barème » de la Page_Parametrage, mais PAS pour l'accès aux données « Compte » et « Véhicules », afin de ne pas bloquer un utilisateur dont le Profil n'est pas encore configuré.
---
### Requirement 22: Précision configurable des distances et arrondi supérieur
**User Story:** En tant qu'utilisateur, je veux choisir le nombre de décimales utilisé pour l'affichage des distances en kilomètres et bénéficier d'un arrondi systématiquement supérieur, afin d'adapter la précision affichée à mes besoins tout en ayant la certitude que les distances affichées ne sont jamais sous-évaluées.
> Cette exigence est détaillée intégralement par la spec `precision-distances-km` (Requirements 1, 3, 4, 5), qui prime en cas de divergence. Les exigences techniques associées (endpoint, entité, formule d'arrondi) sont décrites dans `design-requirements.md`, Requirement 22.
#### Acceptance Criteria
1. THE Backend SHALL persister une valeur unique de Precision_Distance pour l'ensemble de l'Application, parmi exactement trois valeurs possibles : 0, 1 ou 2, avec une valeur par défaut de 2 en l'absence de valeur déjà enregistrée.
2. THE Page_Parametrage SHALL présenter, dans son menu latéral, le Sous_Menu_Application positionné entre « Compte » et « Véhicules », permettant de sélectionner la valeur de Precision_Distance parmi 0, 1 et 2.
3. WHEN le Frontend affiche la distance d'un Trajet qui n'est pas un Aller_Retour, THE Frontend SHALL afficher cette distance arrondie au supérieur à la précision définie par Precision_Distance.
4. WHEN le Frontend affiche la distance d'un Trajet dont Aller_Retour est vrai, THE Frontend SHALL afficher le double de la distance simple déjà arrondie au supérieur, l'arrondi étant toujours appliqué avant le doublement et jamais après.
5. WHEN le Backend ou le Frontend calcule un total kilométrique (jour, mois, Total_Annuel_Partiel), THE Backend ou THE Frontend SHALL additionner des distances déjà arrondies selon Precision_Distance, sans appliquer d'arrondi supplémentaire après la somme.
6. THE Backend SHALL ne jamais modifier la valeur brute stockée de la Distance d'un Trajet suite à un changement de Precision_Distance ; seuls l'affichage et les agrégats calculés à la volée sont affectés.
+19
View File
@@ -0,0 +1,19 @@
# Langue des échanges
## Règle
Toujours répondre à l'utilisateur **en français**, quelle que soit la langue de la question ou du contenu technique affiché.
Cela s'applique à :
- toutes les réponses et explications dans le chat ;
- les messages de synthèse de fin de tâche ;
- les questions de clarification posées à l'utilisateur ;
- les messages de commit Git ;
- les commentaires de code rédigés pour ce projet ;
- le contenu rédactionnel des documents de spec (`requirements.md`, `design-requirements.md`, `design.md`, `tasks.md`).
## Exceptions
- Ne pas traduire les termes techniques, mots-clés de langage, noms d'API, identifiants de code, commandes shell ni le contenu littéral des fichiers (le code reste tel quel).
- Les mots-clés EARS des specs (WHEN / IF / WHILE / WHERE / THE ... SHALL) restent en anglais, conformément au format.
+26 -9
View File
@@ -2,22 +2,36 @@
## Règle
Les documents de spécification du projet — `requirements.md` (exigences fonctionnelles au format EARS) et `design.md` (conception technique) — sont la source de vérité à long terme de ce projet. Ils DOIVENT rester alignés avec le code.
Les documents de spécification du projet sont la source de vérité à long terme. Ils DOIVENT rester alignés avec le code.
Le projet sépare la spécification selon **deux axes** :
- **Axe exigences** (le « quoi ») :
- `requirements.md` : exigences **fonctionnelles métier** au format EARS (attendu fonctionnel, mode opératoire, règles de gestion fournis par le PO business).
- `design-requirements.md` : exigences **techniques** dérivées (rendu visuel détaillé, API externes et URL, codes HTTP précis, délais/débounce, sécurité, cache, fallbacks, rendu cartographique et documentaire), arbitrées conjointement par le PO business et le PO IT. Ce document est organisé **en miroir** de `requirements.md` : chaque section reprend le même numéro et le même titre, suivi de « — aspects techniques ». Seules les exigences comportant effectivement des critères techniques y figurent.
- **Axe conception** (le « comment ») :
- `design.md` : conception technique qui **met en œuvre** les exigences des deux documents ci-dessus (architecture, modèles de données, endpoints, composants, décisions et alternatives rejetées).
- **Journal** :
- `tasks.md` : journal d'implémentation, pas une source de vérité.
À chaque évolution fonctionnelle ou technique validée par l'utilisateur (« ça fonctionne », « tu peux committer »), mettre à jour les specs concernées **dans le même commit** que le changement de code, avant de committer.
## Portée
- `requirements.md` : mettre à jour dès qu'un comportement observable change (nouvelle fonctionnalité, modification d'un critère d'acceptation, nouveau terme du glossaire, changement de portée comme l'ajout de l'authentification).
- `requirements.md` : mettre à jour dès qu'un comportement observable **métier** change (nouvelle fonctionnalité, modification d'un critère d'acceptation fonctionnel, nouveau terme du glossaire métier, changement de portée comme l'ajout de l'authentification).
- `design-requirements.md` : mettre à jour dès qu'une exigence **technique** change ou est ajoutée (nouveau code HTTP, nouveau délai/débounce, nouvelle API externe, règle de sécurité, politique de cache, détail de rendu visuel). Conserver le miroir numéroté avec `requirements.md` : même numéro et même titre + « — aspects techniques ». Lorsqu'une exigence métier nouvelle ou modifiée a un pendant technique, mettre à jour les DEUX documents de façon cohérente ; ajouter tout terme technique au glossaire technique.
- `design.md` : mettre à jour dès qu'une décision d'architecture, un modèle de données, un endpoint d'API ou un composant important change.
- `tasks.md` : facultatif — c'est un journal d'implémentation, pas la source de vérité. Le mettre à jour seulement si l'utilisateur le demande.
- `tasks.md` : facultatif — le mettre à jour seulement si l'utilisateur le demande.
> **Note sur les specs séparées** : certaines fonctionnalités disposent de leur propre spec autonome (ex. `precision-distances-km`, avec ses propres `requirements.md`/`design.md`). Lorsque le détail complet d'un sujet vit dans une spec séparée, la spec principale y renvoie par référence croisée plutôt que de dupliquer le contenu ; garder ces renvois cohérents lors d'une évolution.
## Ce qui déclenche une mise à jour de spec
- Nouvelle capacité (ex. aller/retour, authentification, consultation par mois).
- Changement de contrat d'API (nouveau champ DTO, nouvel endpoint, changement de statut HTTP).
- Nouveau modèle de données ou migration EF Core.
- Changement de règle métier (ex. tranches de barème multiples, distance effective doublée).
- Changement de contrat d'API (nouveau champ DTO, nouvel endpoint, changement de statut HTTP) → `design-requirements.md` (code HTTP) et/ou `design.md` (endpoint).
- Nouveau modèle de données ou migration EF Core → `design.md`.
- Changement de règle métier (ex. tranches de barème multiples, distance effective doublée) → `requirements.md`.
- Nouveau détail technique observable (délai, débounce, URL d'API, en-tête, fallback, rendu) → `design-requirements.md`.
- Changement de portée du produit (ex. passage de mono-utilisateur sans compte à compte unique authentifié).
## Ce qui ne déclenche PAS forcément une mise à jour
@@ -27,12 +41,15 @@ Les documents de spécification du projet — `requirements.md` (exigences fonct
## Format
- `requirements.md` : conserver le format EARS (WHEN / IF / WHILE / WHERE / THE ... SHALL). Numéroter les nouvelles exigences à la suite. Ajouter au glossaire tout nouveau terme.
- `design.md` : conserver la structure existante (contraintes, décisions d'architecture, modèles, endpoints, composants). Documenter les décisions et les alternatives rejetées.
- `requirements.md` et `design-requirements.md` : conserver le format EARS (WHEN / IF / WHILE / WHERE / THE ... SHALL). Numéroter les nouvelles exigences à la suite. Conserver le miroir numéroté entre les deux documents. Ajouter au glossaire approprié (métier ou technique) tout nouveau terme.
- `design.md` : conserver la structure existante (overview, périmètre, contraintes, décisions d'architecture, modèles, endpoints, composants, propriétés). Documenter les décisions et les alternatives rejetées.
- Valider le format avec l'outil de validation de spec après édition.
## Workflow au moment du commit
1. Implémenter le changement de code et le faire valider par l'utilisateur.
2. Mettre à jour `requirements.md` et/ou `design.md` pour refléter le changement.
2. Mettre à jour les specs concernées pour refléter le changement :
- comportement métier → `requirements.md` ;
- détail technique observable → `design-requirements.md` (en conservant le miroir avec `requirements.md`) ;
- architecture / modèle / endpoint / composant → `design.md`.
3. Committer code + specs ensemble, avec un message décrivant les deux.
+20
View File
@@ -0,0 +1,20 @@
# Méthode de collaboration
## Valider l'approche avant de coder
Ne jamais commencer à écrire ou modifier du code applicatif tant que l'approche n'a pas été exposée et explicitement validée par l'utilisateur.
Pour toute évolution ou correction fonctionnelle/technique :
1. D'abord décrire l'orientation envisagée (structure, impacts, choix et alternatives), de façon concise.
2. Attendre la validation explicite de l'utilisateur. Le signal convenu est le mot « code » : tant qu'il n'est pas donné, rester en discussion et ne pas éditer de fichier de code.
3. Une fois « code » reçu, implémenter puis vérifier (build/tests).
## Exceptions (ne nécessitent pas le signal « code »)
- Les actions de configuration ou de documentation explicitement demandées par l'utilisateur (ex. ajout/mise à jour d'un fichier de steering, d'une spec, d'un README) quand la demande est sans ambiguïté.
- Les opérations de lecture, recherche, build, test ou diagnostic qui ne modifient pas le code applicatif.
## Lancement de l'environnement de développement
C'est l'utilisateur qui lance le serveur de développement (`./run-dev.sh`). Ne pas le démarrer automatiquement ; se contenter de signaler quand un redémarrage est nécessaire (ex. changement backend).
+133
View File
@@ -0,0 +1,133 @@
# Déploiement de SchotzIAD KM Tracker
Déploiement via **Git (Gitea) + Docker Compose**. Le serveur récupère les
sources depuis Gitea et reconstruit les conteneurs — plus besoin de copier une
archive.
- Dépôt Gitea : `http://192.168.1.55:3000/MadJax/SchotzIAD`
- Branche déployée : `master`
- Frontend exposé sur le port hôte **2202** (derrière le reverse-proxy Caddy externe).
---
## 1. Premier déploiement (une seule fois sur le serveur)
### 1.1 Installer les prérequis
Le serveur a besoin de **git** et de **Docker** (avec le plugin Compose v2).
Sur Debian/Ubuntu :
```bash
sudo apt update
sudo apt install -y git
# Docker + plugin compose : voir https://docs.docker.com/engine/install/
# (paquets docker-ce, docker-ce-cli, containerd.io, docker-compose-plugin)
```
Vérification :
```bash
git --version
docker --version
docker compose version
```
### 1.2 Cloner le dépôt
```bash
git clone http://192.168.1.55:3000/MadJax/SchotzIAD SchotzIAD
cd SchotzIAD
```
Au premier `git clone`/`push`, Gitea demande identifiant + token. Avec le
credential helper `store` déjà en place, ils ne sont demandés qu'une fois.
### 1.3 Créer le fichier `.env`
Le `.env` n'est **jamais** versionné (il contient les secrets). On le crée à
partir du modèle fourni :
```bash
cp .env.example .env
```
Puis éditer `.env` et renseigner au minimum :
- `JWT_SECRET_KEY` : **obligatoire**. Générer une valeur aléatoire longue, ex. :
```bash
openssl rand -base64 48
```
(sans cette clé, le backend refuse de démarrer.)
- `AUTH_FRONTEND_BASE_URL` : l'URL publique du site (ex. `https://schotz.exemple.fr`),
utilisée dans les liens de réinitialisation de mot de passe.
- `ORS_API_KEY` : clé OpenRouteService (sinon distances à vol d'oiseau).
- `SMTP_*` : facultatif. Si `SMTP_HOST` est vide, le lien de réinitialisation
est seulement journalisé côté serveur.
### 1.4 Premier démarrage
```bash
./deploy.sh --force
```
(`--force` car il n'y a pas de « version précédente » à comparer la première fois.)
---
## 2. Mettre à jour le site (à chaque nouvelle version)
Depuis le dossier du dépôt sur le serveur :
```bash
./deploy.sh
```
Le script :
1. vérifie les prérequis (git, docker, docker compose, présence du `.env`) ;
2. récupère les références distantes (`git fetch`) ;
3. **ne reconstruit que si une nouvelle version existe** sur `master` :
- si le serveur est déjà à jour, il s'arrête sans rien reconstruire ;
- sinon il fait `git pull --ff-only` puis `docker compose up -d --build` ;
4. affiche l'état des conteneurs.
Pour forcer une reconstruction sans nouveau commit (par exemple après avoir
modifié `.env`) :
```bash
./deploy.sh --force
```
---
## 3. Données et persistance
- La base SQLite est persistée via un bind mount dans `./data/trajets.db`
(créé au premier démarrage). Elle **survit** aux redéploiements et n'est pas
versionnée.
- Sauvegarde : il suffit de copier `./data/trajets.db` (idéalement conteneurs
arrêtés, ou via une copie à chaud suivie d'un `VACUUM INTO`).
---
## 4. Dépannage
- **`.env absent`** : copier `.env.example` vers `.env` et le renseigner (étape 1.3).
- **`git pull --ff-only a échoué`** : l'historique local du serveur a divergé
(commits locaux non souhaités). Revenir à l'état distant :
```bash
git fetch origin
git reset --hard origin/master # ⚠ écrase les modifs locales non commitées
```
- **`JWT_SECRET_KEY requis`** au démarrage du backend : la clé est absente du
`.env` (voir étape 1.3).
- **Voir les logs** :
```bash
docker compose logs -f backend
docker compose logs -f frontend
```
- **Redémarrer sans rebuild** :
```bash
docker compose restart
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

@@ -9,7 +9,8 @@ namespace SchotzIadKmTracker.Api.Controllers;
/// Endpoints d'Export_PDF : rapport mensuel et synthèse annuelle (Exigences 7.3, 7.4, 7.5).
/// Chaque réponse renvoie un unique fichier <c>application/pdf</c> avec l'en-tête
/// <c>Content-Disposition: attachment</c> et le nom de fichier suggéré par le service
/// (<c>trajets_MM_AAAA.pdf</c> pour le mensuel, <c>trajets_AAAA.pdf</c> pour l'annuel).
/// (<c>AAAAMM_Trajets_MATRICULE_PLAQUE.pdf</c> pour le mensuel, <c>AAAA_Trajets_MATRICULE_PLAQUE.pdf</c>
/// pour l'annuel — les segments Matricule/Plaque sont omis s'ils sont vides).
///
/// Le filtre par véhicule est optionnel : si <c>vehiculeId</c> est omis, le Véhicule_Courant
/// est utilisé par défaut.
@@ -46,7 +47,7 @@ public class ExportsPdfController : ControllerBase
/// Génère et télécharge le rapport PDF mensuel d'un (année, mois) pour le véhicule indiqué
/// (Véhicule_Courant si <paramref name="vehiculeId"/> est omis). Retourne les octets du PDF
/// avec <c>Content-Type: application/pdf</c> et <c>Content-Disposition: attachment;
/// filename="trajets_MM_AAAA.pdf"</c> (Exigences 7.3, 7.4).
/// filename="AAAAMM_Trajets_MATRICULE_PLAQUE.pdf"</c> (Exigences 7.3, 7.4).
/// </summary>
[HttpGet("mensuel/{annee:int}/{mois:int}")]
[Produces(MediaTypeNames.Application.Pdf)]
@@ -76,7 +77,7 @@ public class ExportsPdfController : ControllerBase
/// Génère et télécharge la synthèse PDF annuelle d'une année pour le véhicule indiqué
/// (Véhicule_Courant si <paramref name="vehiculeId"/> est omis). Retourne les octets du PDF
/// avec <c>Content-Type: application/pdf</c> et <c>Content-Disposition: attachment;
/// filename="trajets_AAAA.pdf"</c> (Exigences 7.3, 7.4).
/// filename="AAAA_Trajets_MATRICULE_PLAQUE.pdf"</c> (Exigences 7.3, 7.4).
/// </summary>
[HttpGet("annuel/{annee:int}")]
[Produces(MediaTypeNames.Application.Pdf)]
@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Mvc;
using SchotzIadKmTracker.Api.Models.DTOs;
using SchotzIadKmTracker.Api.Services;
using SchotzIadKmTracker.Api.Services.Exceptions;
namespace SchotzIadKmTracker.Api.Controllers;
/// <summary>
/// Endpoint de gestion de la configuration globale de l'application : lecture et modification
/// de la précision d'arrondi des distances (Precision_Distance, 0/1/2 décimales).
/// </summary>
[ApiController]
[Route("api/parametres-application")]
public class ParametresApplicationController : ControllerBase
{
private readonly IPrecisionDistanceService _precisionDistanceService;
public ParametresApplicationController(IPrecisionDistanceService precisionDistanceService)
{
_precisionDistanceService = precisionDistanceService;
}
/// <summary>
/// Retourne la configuration courante (crée la ligne singleton avec la valeur par défaut
/// (2) si elle est absente).
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(ParametresApplicationDto), StatusCodes.Status200OK)]
public async Task<ActionResult<ParametresApplicationDto>> Get(CancellationToken cancellationToken)
{
var precision = await _precisionDistanceService.ObtenirAsync(cancellationToken);
return Ok(new ParametresApplicationDto(precision));
}
/// <summary>
/// Modifie la précision des distances. Rejette (400) si la valeur n'est pas 0, 1 ou 2.
/// </summary>
[HttpPut]
[ProducesResponseType(typeof(ParametresApplicationDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<ParametresApplicationDto>> Put(
[FromBody] SavePrecisionDistanceDto dto, CancellationToken cancellationToken)
{
try
{
var precision = await _precisionDistanceService.DefinirAsync(dto.PrecisionDistanceKm, cancellationToken);
return Ok(new ParametresApplicationDto(precision));
}
catch (PrecisionDistanceInvalideException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest);
}
}
}
@@ -25,15 +25,18 @@ public class TrajetsController : ControllerBase
private readonly ITrajetService _trajetService;
private readonly ITrajetRepository _trajetRepository;
private readonly IAdresseCacheService _adresseCacheService;
private readonly IPrecisionDistanceService _precisionDistanceService;
public TrajetsController(
ITrajetService trajetService,
ITrajetRepository trajetRepository,
IAdresseCacheService adresseCacheService)
IAdresseCacheService adresseCacheService,
IPrecisionDistanceService precisionDistanceService)
{
_trajetService = trajetService;
_trajetRepository = trajetRepository;
_adresseCacheService = adresseCacheService;
_precisionDistanceService = precisionDistanceService;
}
/// <summary>
@@ -211,6 +214,10 @@ public class TrajetsController : ControllerBase
var trajets = await _trajetRepository.GetByDateAsync(jour, cancellationToken);
// Précision courante (0/1/2 décimales) : lue une fois par appel, cohérente avec
// ResumeService/TotauxService (Exigences 9.1, 9.4).
var precision = await _precisionDistanceService.ObtenirAsync(cancellationToken);
var resultats = new List<TrajetCarteDto>(trajets.Count);
foreach (var trajet in trajets)
{
@@ -224,13 +231,20 @@ public class TrajetsController : ControllerBase
// hérités dont la géométrie n'a pas encore été recalculée (rétrocompatibilité).
var geometrie = trajet.GeometrieJson ?? depart?.GeometrieJson ?? arrivee?.GeometrieJson;
// Distance EFFECTIVE ARRONDIE : distance simple arrondie à la précision courante,
// PUIS doublée si aller/retour (Exigence 4.2 — arrondi avant doublement), cohérente
// avec ResumeService/TotauxService (Exigences 9.1, 9.4). null → 0 (comportement
// inchangé).
var distanceEffectiveArrondie =
_precisionDistanceService.ArrondirDistance(trajet.Distance ?? 0m, precision)
* (trajet.AllerRetour ? 2m : 1m);
resultats.Add(new TrajetCarteDto(
trajet.Id,
trajet.Date,
trajet.AdresseDepart,
trajet.AdresseArrivee,
// Distance EFFECTIVE (doublée si aller/retour), cohérente avec la liste.
trajet.DistanceEffective ?? 0m,
distanceEffectiveArrondie,
depart?.Latitude,
depart?.Longitude,
arrivee?.Latitude,
@@ -16,6 +16,7 @@ public class AppDbContext : DbContext
public DbSet<Trajet> Trajets => Set<Trajet>();
public DbSet<Vehicule> Vehicules => Set<Vehicule>();
public DbSet<Profil> Profil => Set<Profil>();
public DbSet<ParametresApplication> ParametresApplication => Set<ParametresApplication>();
public DbSet<AdresseCache> AdresseCache => Set<AdresseCache>();
public DbSet<AdresseNommee> AdressesNommees => Set<AdresseNommee>();
public DbSet<TrancheBareme> TranchesBareme => Set<TrancheBareme>();
@@ -65,6 +66,16 @@ public class AppDbContext : DbContext
entity.HasIndex(p => p.Id, "UX_Profil_Singleton").IsUnique();
});
// --- ParametresApplication (singleton Id = 1) ---
modelBuilder.Entity<ParametresApplication>(entity =>
{
entity.ToTable("ParametresApplication", t =>
t.HasCheckConstraint("CK_ParametresApplication_PrecisionDistanceKm", "\"PrecisionDistanceKm\" IN (0, 1, 2)"));
entity.HasKey(p => p.Id);
entity.Property(p => p.Id).ValueGeneratedNever();
entity.HasIndex(p => p.Id, "UX_ParametresApplication_Singleton").IsUnique();
});
// --- AdresseCache (Adresse unique) ---
modelBuilder.Entity<AdresseCache>(entity =>
{
@@ -0,0 +1,415 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using SchotzIadKmTracker.Api.Data;
#nullable disable
namespace SchotzIadKmTracker.Api.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260925082429_AjoutParametresApplication")]
partial class AjoutParametresApplication
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.8");
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.AdresseCache", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Adresse")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("DerniereUtilisation")
.HasColumnType("TEXT");
b.Property<string>("GeometrieJson")
.HasColumnType("TEXT");
b.Property<double>("Latitude")
.HasColumnType("REAL");
b.Property<double>("Longitude")
.HasColumnType("REAL");
b.HasKey("Id");
b.HasIndex("Adresse")
.IsUnique();
b.ToTable("AdresseCache", (string)null);
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.AdresseNommee", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Adresse")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Denomination")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("DenominationNormalisee")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("GeocodageReussi")
.HasColumnType("INTEGER");
b.Property<double?>("Latitude")
.HasColumnType("REAL");
b.Property<double?>("Longitude")
.HasColumnType("REAL");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "DenominationNormalisee" }, "UX_AdresseNommee_Denomination")
.IsUnique();
b.ToTable("AdressesNommees", (string)null);
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.Compte", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("MotDePasseHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "Email" }, "UX_Comptes_Email")
.IsUnique();
b.ToTable("Comptes", (string)null);
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.MontantRegle", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Annee")
.HasColumnType("INTEGER");
b.Property<int>("Mois")
.HasColumnType("INTEGER");
b.Property<decimal>("Montant")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("VehiculeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("VehiculeId");
b.HasIndex(new[] { "Annee", "Mois", "VehiculeId" }, "UX_MontantsRegles_Periode_Vehicule")
.IsUnique();
b.ToTable("MontantsRegles", null, t =>
{
t.HasCheckConstraint("CK_MontantsRegles_Mois", "\"Mois\" BETWEEN 1 AND 12");
t.HasCheckConstraint("CK_MontantsRegles_Montant", "\"Montant\" >= 0");
});
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.ParametresApplication", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<int>("PrecisionDistanceKm")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "Id" }, "UX_ParametresApplication_Singleton")
.IsUnique();
b.ToTable("ParametresApplication", null, t =>
{
t.HasCheckConstraint("CK_ParametresApplication_PrecisionDistanceKm", "\"PrecisionDistanceKm\" IN (0, 1, 2)");
});
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.Profil", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<string>("AdresseDomicile")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<bool>("DomicileGeocode")
.HasColumnType("INTEGER");
b.Property<double?>("DomicileLatitude")
.HasColumnType("REAL");
b.Property<double?>("DomicileLongitude")
.HasColumnType("REAL");
b.Property<string>("Matricule")
.HasMaxLength(8)
.HasColumnType("TEXT");
b.Property<string>("Nom")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Prenom")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "Id" }, "UX_Profil_Singleton")
.IsUnique();
b.ToTable("Profil", (string)null);
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.ResetPasswordToken", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("Expiration")
.HasColumnType("TEXT");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("UsedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "Expiration" }, "IX_ResetTokens_Expiration");
b.HasIndex(new[] { "TokenHash" }, "UX_ResetTokens_TokenHash")
.IsUnique();
b.ToTable("ResetPasswordTokens", (string)null);
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.Trajet", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AdresseArrivee")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("AdresseDepart")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<bool>("AllerRetour")
.HasColumnType("INTEGER");
b.Property<string>("Commentaire")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<decimal?>("Distance")
.HasColumnType("TEXT");
b.Property<string>("GeometrieJson")
.HasColumnType("TEXT");
b.Property<decimal>("Position")
.HasColumnType("TEXT");
b.Property<int>("PuissanceFiscale")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("VehiculeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex(new[] { "Date" }, "IX_Trajets_Date");
b.HasIndex(new[] { "Date", "Position" }, "IX_Trajets_Date_Position");
b.HasIndex(new[] { "VehiculeId" }, "IX_Trajets_Vehicule");
b.ToTable("Trajets", null, t =>
{
t.HasCheckConstraint("CK_Trajets_Distance", "\"Distance\" IS NULL OR \"Distance\" > 0");
t.HasCheckConstraint("CK_Trajets_PuissanceFiscale", "\"PuissanceFiscale\" > 0");
});
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.TrancheBareme", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Annee")
.HasColumnType("INTEGER");
b.Property<decimal>("Base")
.HasColumnType("TEXT");
b.Property<decimal>("Coefficient")
.HasColumnType("TEXT");
b.Property<decimal?>("KmMax")
.HasColumnType("TEXT");
b.Property<decimal>("KmMin")
.HasColumnType("TEXT");
b.Property<int>("PuissanceFiscale")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex(new[] { "Annee", "PuissanceFiscale" }, "IX_TranchesBareme_Annee_PF");
b.ToTable("TranchesBareme", null, t =>
{
t.HasCheckConstraint("CK_TranchesBareme_Km", "\"KmMax\" IS NULL OR CAST(\"KmMin\" AS REAL) < CAST(\"KmMax\" AS REAL)");
});
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.Vehicule", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateOnly>("DateEffet")
.HasColumnType("TEXT");
b.Property<string>("Denomination")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Plaque")
.IsRequired()
.HasMaxLength(15)
.HasColumnType("TEXT");
b.Property<int>("PuissanceFiscale")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex(new[] { "DateEffet" }, "IX_Vehicules_DateEffet");
b.ToTable("Vehicules", null, t =>
{
t.HasCheckConstraint("CK_Vehicules_PuissanceFiscale", "\"PuissanceFiscale\" > 0");
});
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.MontantRegle", b =>
{
b.HasOne("SchotzIadKmTracker.Api.Models.Entities.Vehicule", "Vehicule")
.WithMany()
.HasForeignKey("VehiculeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Vehicule");
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.Trajet", b =>
{
b.HasOne("SchotzIadKmTracker.Api.Models.Entities.Vehicule", "Vehicule")
.WithMany("Trajets")
.HasForeignKey("VehiculeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Vehicule");
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.Vehicule", b =>
{
b.Navigation("Trajets");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,42 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SchotzIadKmTracker.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AjoutParametresApplication : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ParametresApplication",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false),
PrecisionDistanceKm = table.Column<int>(type: "INTEGER", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ParametresApplication", x => x.Id);
table.CheckConstraint("CK_ParametresApplication_PrecisionDistanceKm", "\"PrecisionDistanceKm\" IN (0, 1, 2)");
});
migrationBuilder.CreateIndex(
name: "UX_ParametresApplication_Singleton",
table: "ParametresApplication",
column: "Id",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ParametresApplication");
}
}
}
@@ -156,6 +156,28 @@ namespace SchotzIadKmTracker.Api.Data.Migrations
});
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.ParametresApplication", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<int>("PrecisionDistanceKm")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "Id" }, "UX_ParametresApplication_Singleton")
.IsUnique();
b.ToTable("ParametresApplication", null, t =>
{
t.HasCheckConstraint("CK_ParametresApplication_PrecisionDistanceKm", "\"PrecisionDistanceKm\" IN (0, 1, 2)");
});
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.Profil", b =>
{
b.Property<int>("Id")
@@ -33,9 +33,10 @@ 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)
decimal TotalMoisKm, // Distance_Effective_Arrondie totale du mois affiché
decimal TotalAnnuelPartielKm, // Total_Annuel_Partiel arrondi (cumul jusqu'au mois inclus)
decimal MontantPrevisionnelMensuel, // 80% du barème appliqué à TotalMoisKm (tranche déterminée par le cumul annuel)
decimal MontantPrevisionnelAnnuel // 100% du barème appliqué à TotalAnnuelPartielKm
);
// Ligne mensuelle de la synthèse annuelle (aligne sur la synthèse réelle)
@@ -0,0 +1,7 @@
namespace SchotzIadKmTracker.Api.Models.DTOs;
/// <summary>Configuration globale exposée en lecture.</summary>
public record ParametresApplicationDto(int PrecisionDistanceKm);
/// <summary>Corps de requête pour modifier la précision des distances.</summary>
public record SavePrecisionDistanceDto(int PrecisionDistanceKm);
@@ -0,0 +1,21 @@
namespace SchotzIadKmTracker.Api.Models.Entities;
/// <summary>
/// Configuration globale de l'application (singleton — une seule ligne, Id = 1).
/// Sur le même modèle que <see cref="Profil"/> et <see cref="Compte"/>.
/// </summary>
public class ParametresApplication
{
/// <summary>Identifiant fixe (1) : la configuration est un singleton.</summary>
public int Id { get; set; } = 1;
/// <summary>
/// Nombre de décimales (0, 1 ou 2) utilisé pour l'arrondi supérieur des distances à
/// l'affichage et dans les agrégats. Défaut 2 (Exigence 1.2). Valeur validée par
/// <see cref="Services.IPrecisionDistanceService"/> avant écriture — jamais persistée
/// hors de {0, 1, 2}.
/// </summary>
public int PrecisionDistanceKm { get; set; } = 2;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
+7 -1
View File
@@ -177,6 +177,7 @@ builder.Services.AddScoped<IVehiculeService, VehiculeService>();
builder.Services.AddScoped<IProfilService, ProfilService>();
builder.Services.AddScoped<IAdresseNommeeService, AdresseNommeeService>();
builder.Services.AddScoped<IBaremeService, BaremeService>();
builder.Services.AddScoped<IPrecisionDistanceService, PrecisionDistanceService>();
builder.Services.AddScoped<IMontantRegleService, MontantRegleService>();
builder.Services.AddScoped<IRemboursementService, RemboursementService>();
builder.Services.AddScoped<ITotauxService, TotauxService>();
@@ -213,7 +214,12 @@ builder.Services.AddCors(options =>
{
policy.WithOrigins(allowedOrigins)
.AllowAnyHeader()
.AllowAnyMethod();
.AllowAnyMethod()
// Expose Content-Disposition en lecture côté client (JS) : sans cet appel, les
// en-têtes de RÉPONSE ne sont pas lisibles en cross-origin même avec
// AllowAnyHeader() (qui ne couvre que les en-têtes de REQUÊTE). Nécessaire pour
// que le Frontend récupère le nom de fichier suggéré des Export_PDF.
.WithExposedHeaders("Content-Disposition");
});
});
@@ -22,4 +22,9 @@
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.22.0" />
</ItemGroup>
<ItemGroup>
<!-- Logo carré embarqué dans l'assembly, utilisé dans l'en-tête des Export_PDF (PdfService). -->
<EmbeddedResource Include="Assets\logo-carre.png" />
</ItemGroup>
</Project>
@@ -0,0 +1,18 @@
namespace SchotzIadKmTracker.Api.Services.Exceptions;
/// <summary>
/// Levée lorsqu'une valeur de Precision_Distance soumise n'est pas 0, 1 ou 2.
/// Convertie en réponse HTTP 400 (Bad Request) — Exigence 1.6.
/// </summary>
public class PrecisionDistanceInvalideException : Exception
{
public PrecisionDistanceInvalideException(string message)
: base(message)
{
}
public PrecisionDistanceInvalideException(string message, Exception innerException)
: base(message, innerException)
{
}
}
@@ -26,10 +26,13 @@ public interface IMontantRegleService
/// <summary>
/// Calcule le Montant_Previsionnel indicatif pour un (année, mois, véhicule) :
/// 80% du Bareme_Kilometrique plein appliqué au Total_Annuel_Partiel du véhicule
/// (somme des distances de l'année pour ce véhicule jusqu'au mois indiqué inclus),
/// arrondi à deux décimales (Property 23, Exigences 13.5/13.6).
/// Cette valeur est purement informative et n'entre jamais dans la régularisation.
/// la tranche de barème est déterminée par le Total_Annuel_Partiel du véhicule (somme des
/// distances arrondies de l'année pour ce véhicule jusqu'au mois indiqué inclus — Exigence 7.2),
/// puis son coefficient/base est appliqué au seul kilométrage arrondi du mois affiché pour
/// obtenir le Montant_Previsionnel_Mensuel (80%, Exigences 6.1/6.2/7.1/7.4), et au cumul annuel
/// pour obtenir le Montant_Previsionnel_Annuel (100%, Exigences 8.1/8.2/8.5), tous deux arrondis
/// à deux décimales.
/// Ces valeurs sont purement informatives et n'entrent jamais dans la régularisation.
/// </summary>
Task<MontantPrevisionnelDto> GetMontantPrevisionnelAsync(
int annee, int mois, int vehiculeId, CancellationToken cancellationToken = default);
@@ -0,0 +1,29 @@
namespace SchotzIadKmTracker.Api.Services;
/// <summary>
/// Lecture/écriture de la configuration globale Precision_Distance, et fonction utilitaire
/// pure d'arrondi supérieur (ceiling) réutilisée par tous les points de calcul de distance.
/// </summary>
public interface IPrecisionDistanceService
{
/// <summary>
/// Retourne la précision courante (0, 1 ou 2). Crée la ligne singleton avec la valeur
/// par défaut (2) si elle est absente (Exigence 1.2), sur le même modèle que
/// <see cref="IProfilService"/>.
/// </summary>
Task<int> ObtenirAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Définit la précision. Lève <see cref="Exceptions.PrecisionDistanceInvalideException"/>
/// si la valeur n'est pas 0, 1 ou 2 (Exigence 1.6).
/// </summary>
Task<int> DefinirAsync(int valeur, CancellationToken cancellationToken = default);
/// <summary>
/// Arrondit une distance brute (km) au supérieur (ceiling) à la précision indiquée.
/// Fonction pure, sans effet de bord, sans accès base — utilisable partout où une
/// distance doit être affichée ou agrégée (Exigences 3.2–3.5, 4.2).
/// Formule : Ceiling(distanceBrute × 10^precision) / 10^precision.
/// </summary>
decimal ArrondirDistance(decimal distanceBrute, int precision);
}
@@ -13,11 +13,14 @@ public class MontantRegleService : IMontantRegleService
private readonly AppDbContext _context;
private readonly IBaremeService _baremeService;
private readonly IPrecisionDistanceService _precisionDistanceService;
public MontantRegleService(AppDbContext context, IBaremeService baremeService)
public MontantRegleService(
AppDbContext context, IBaremeService baremeService, IPrecisionDistanceService precisionDistanceService)
{
_context = context;
_baremeService = baremeService;
_precisionDistanceService = precisionDistanceService;
}
/// <inheritdoc />
@@ -85,37 +88,58 @@ public class MontantRegleService : IMontantRegleService
.FirstOrDefaultAsync(v => v.Id == vehiculeId, cancellationToken)
?? throw new VehiculeNotFoundException($"Le véhicule d'identifiant {vehiculeId} est introuvable.");
// Total_Annuel_Partiel : somme des distances de l'année pour ce véhicule,
// jusqu'au mois sélectionné inclus (les mois postérieurs sont exclus).
// SQLite ne sait pas appliquer SUM sur une colonne decimal : on ramène les distances
// concernées et on somme côté client (null traité comme 0).
var distancesPartielles = await _context.Trajets
var precision = await _precisionDistanceService.ObtenirAsync(cancellationToken);
// Trajets de l'année pour ce véhicule, jusqu'au mois sélectionné inclus (les mois
// postérieurs sont exclus). SQLite ne sait pas appliquer SUM sur une colonne decimal :
// on ramène les trajets concernés et on somme côté client (distance null traitée comme 0).
// Le mois de chaque trajet est conservé pour distinguer le mois affiché du cumul annuel.
var trajets = await _context.Trajets
.AsNoTracking()
.Where(t => t.VehiculeId == vehiculeId
&& t.Date.Year == annee
&& t.Date.Month <= mois)
// On ramène la distance simple et l'indicateur aller/retour, puis on somme la
// distance EFFECTIVE (doublée si A/R) côté client (null → 0).
.Select(t => new { t.Distance, t.AllerRetour })
.Select(t => new { t.Date.Month, t.Distance, t.AllerRetour })
.ToListAsync(cancellationToken);
var totalAnnuelPartiel = distancesPartielles.Sum(x => x.Distance.GetValueOrDefault() * (x.AllerRetour ? 2m : 1m));
// Barème plein appliqué au total partiel (sans minoration).
var bareme = await _baremeService.CalculerMontantAsync(
// Distance_Effective_Arrondie : arrondi de la distance simple à la précision courante,
// PUIS doublement si aller/retour (arrondi avant doublement — Exigence 4.2).
decimal DistanceArrondie(decimal? distance, bool allerRetour) =>
_precisionDistanceService.ArrondirDistance(distance ?? 0m, precision) * (allerRetour ? 2m : 1m);
// Total_Mois_Km : somme des distances déjà arrondies des trajets du mois affiché (Exigence 7.1).
var totalMoisAffiche = trajets
.Where(t => t.Month == mois)
.Sum(t => DistanceArrondie(t.Distance, t.AllerRetour));
// Total_Annuel_Partiel : somme des distances déjà arrondies de tous les trajets chargés
// (cumul jusqu'au mois inclus — Exigence 6.2).
var totalAnnuelPartiel = trajets.Sum(t => DistanceArrondie(t.Distance, t.AllerRetour));
// La tranche de barème est déterminée par le cumul annuel (Exigence 7.2), et son
// coefficient/base est réutilisé tel quel pour le mois affiché (pas de nouvel appel à
// CalculerMontantAsync avec le total du mois seul : cela re-sélectionnerait une tranche
// différente selon le mois seul au lieu de la tranche du cumul annuel, ce qui serait faux).
var baremeAnnuel = await _baremeService.CalculerMontantAsync(
annee, vehicule.PuissanceFiscale, totalAnnuelPartiel, cancellationToken);
var montantBaremePlein = Math.Round(bareme.Montant, 2, MidpointRounding.AwayFromZero);
var montantMoisSelonTrancheAnnuelle = totalMoisAffiche * baremeAnnuel.Coefficient + baremeAnnuel.Base;
// Montant_Previsionnel = 80% du barème plein, arrondi à 2 décimales (indicatif — Exigences 13.5/13.6).
var montantPrevisionnel = Math.Round(bareme.Montant * TauxPrevisionnel, 2, MidpointRounding.AwayFromZero);
// Montant_Previsionnel_Mensuel = 80% du barème (tranche du cumul annuel) appliqué au mois affiché.
var montantPrevisionnelMensuel = Math.Round(
montantMoisSelonTrancheAnnuelle * TauxPrevisionnel, 2, MidpointRounding.AwayFromZero);
// Montant_Previsionnel_Annuel = 100% du barème plein appliqué au cumul annuel.
var montantPrevisionnelAnnuel = Math.Round(baremeAnnuel.Montant, 2, MidpointRounding.AwayFromZero);
return new MontantPrevisionnelDto(
Annee: annee,
Mois: mois,
VehiculeId: vehiculeId,
TotalAnnuelPartielKm: Math.Round(totalAnnuelPartiel, 2, MidpointRounding.AwayFromZero),
MontantBaremePlein: montantBaremePlein,
MontantPrevisionnel: montantPrevisionnel);
TotalMoisKm: totalMoisAffiche,
TotalAnnuelPartielKm: totalAnnuelPartiel,
MontantPrevisionnelMensuel: montantPrevisionnelMensuel,
MontantPrevisionnelAnnuel: montantPrevisionnelAnnuel);
}
private static MontantRegleDto ToDto(MontantRegle m) =>
@@ -29,8 +29,12 @@ public class PdfService : IPdfService
private readonly IBaremeService _baremeService;
private readonly IMontantRegleService _montantRegleService;
private readonly IRemboursementService _remboursementService;
private readonly IPrecisionDistanceService _precisionDistanceService;
private readonly AppDbContext _context;
/// <summary>Logo carré, chargé une seule fois depuis la ressource embarquée (Assets/logo-carre.png).</summary>
private static readonly byte[] LogoCarre = ChargerLogoCarre();
public PdfService(
IResumeService resumeService,
IProfilService profilService,
@@ -38,6 +42,7 @@ public class PdfService : IPdfService
IBaremeService baremeService,
IMontantRegleService montantRegleService,
IRemboursementService remboursementService,
IPrecisionDistanceService precisionDistanceService,
AppDbContext context)
{
_resumeService = resumeService;
@@ -46,9 +51,32 @@ public class PdfService : IPdfService
_baremeService = baremeService;
_montantRegleService = montantRegleService;
_remboursementService = remboursementService;
_precisionDistanceService = precisionDistanceService;
_context = context;
}
/// <summary>Charge les octets du logo carré depuis la ressource embarquée de l'assembly.</summary>
private static byte[] ChargerLogoCarre()
{
var assembly = typeof(PdfService).Assembly;
var nomRessource = assembly.GetManifestResourceNames()
.FirstOrDefault(n => n.EndsWith("logo-carre.png", StringComparison.OrdinalIgnoreCase));
if (nomRessource is null)
{
return Array.Empty<byte>();
}
using var flux = assembly.GetManifestResourceStream(nomRessource);
if (flux is null)
{
return Array.Empty<byte>();
}
using var memoire = new MemoryStream();
flux.CopyTo(memoire);
return memoire.ToArray();
}
/// <inheritdoc />
public async Task<PdfResult> GenererRapportMensuelAsync(
int annee, int mois, int vehiculeId, CancellationToken cancellationToken = default)
@@ -62,9 +90,10 @@ public class PdfService : IPdfService
return await ExecuterAvecTimeoutAsync(cancellationToken, async token =>
{
var modele = await AssemblerModeleMensuelAsync(annee, mois, vehiculeId, token);
var octets = RendreMensuel(modele, token);
var precision = await _precisionDistanceService.ObtenirAsync(token);
var octets = RendreMensuel(modele, precision, token);
var nomFichier = $"trajets_{mois:D2}_{annee:D4}.pdf";
var nomFichier = ConstruireNomFichier($"{annee:D4}{mois:D2}", modele.Profil.Matricule, modele.Vehicule.Plaque);
return new PdfResult(octets, nomFichier);
});
}
@@ -76,9 +105,11 @@ public class PdfService : IPdfService
return await ExecuterAvecTimeoutAsync(cancellationToken, async token =>
{
var modele = await AssemblerModeleAnnuelAsync(annee, vehiculeId, token);
var octets = RendreAnnuel(modele, token);
var precision = await _precisionDistanceService.ObtenirAsync(token);
var octets = RendreAnnuel(modele, precision, token);
var nomFichier = $"trajets_{annee:D4}.pdf";
var nomFichier = ConstruireNomFichier(
$"{annee:D4}", modele.Profil.Matricule, modele.Regularisation.Plaque);
return new PdfResult(octets, nomFichier);
});
}
@@ -274,7 +305,7 @@ public class PdfService : IPdfService
/// <summary>
/// Rend le document mensuel en octets PDF selon la maquette « PDF Mensuel » du design.
/// </summary>
private static byte[] RendreMensuel(ModeleRapportMensuel modele, CancellationToken cancellationToken)
private static byte[] RendreMensuel(ModeleRapportMensuel modele, int precision, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -286,15 +317,9 @@ public class PdfService : IPdfService
page.Margin(30);
page.DefaultTextStyle(x => x.FontSize(9).FontColor(Colors.Black));
page.Header().Element(c => ComposerEntete(c, modele));
page.Content().PaddingTop(10).Element(c => ComposerTableTrajets(c, modele));
page.Footer().AlignCenter().Text(txt =>
{
txt.Span("Page ");
txt.CurrentPageNumber();
txt.Span(" / ");
txt.TotalPages();
});
page.Header().Element(c => ComposerEntete(c, modele, precision));
page.Content().PaddingTop(10).Element(c => ComposerTableTrajets(c, modele, precision));
page.Footer().Element(ComposerPiedDePage);
});
});
@@ -306,94 +331,121 @@ public class PdfService : IPdfService
/// Compose l'en-tête obligatoire (Exigences 7.6, 7.8, 7.10) : titre fixe, identité, véhicule,
/// période, adresse de départ, totaux mois/année, TOTAL IK et Montant_Regle.
/// </summary>
private static void ComposerEntete(IContainer container, ModeleRapportMensuel modele)
private static void ComposerEntete(IContainer container, ModeleRapportMensuel modele, int precision)
{
var periode = $"{NomMois(modele.Mois).ToUpper(FrCulture)} {modele.Annee}";
var identite = FormatIdentite(modele.Profil);
var matricule = string.IsNullOrWhiteSpace(modele.Profil.Matricule)
? "—"
: modele.Profil.Matricule!;
var adresseDepart = string.IsNullOrWhiteSpace(modele.Profil.AdresseDomicile)
var domicile = string.IsNullOrWhiteSpace(modele.Profil.AdresseDomicile)
? "—"
: modele.Profil.AdresseDomicile!;
var vehiculeLibelle =
$"{modele.Vehicule.Plaque} — {modele.Vehicule.PuissanceFiscale} CV " +
$"(Coef. : {FormatDecimal(modele.Coefficient, 3)} / Base : {FormatDecimal(modele.Base, 0)})";
container.Column(col =>
container.Column(entete =>
{
col.Spacing(2);
// Titre fixe imposé par l'Exigence 7.6.
col.Item().Text("Rapport de trajets").FontSize(16).Bold();
col.Item().Text("Feuille d'indemnité kilométrique mensuelle").FontSize(11).SemiBold();
// Identité (nom, prénom, matricule) — Exigence 7.8.
col.Item().PaddingTop(4).Text(txt =>
// Logo à gauche ; à droite, bloc titre (centré) puis bloc d'infos en dessous.
entete.Item().Row(bandeau =>
{
txt.Span("Utilisateur : ").SemiBold();
txt.Span($"{identite} — Matricule : {matricule}");
});
// Véhicule (plaque, puissance fiscale, coefficient/base de la tranche annuelle) — Exigences 7.8/7.10.
col.Item().Text(txt =>
{
txt.Span("Véhicule : ").SemiBold();
txt.Span(vehiculeLibelle);
});
// Période et adresse de départ du profil — Exigences 7.6/7.10.
col.Item().Text(txt =>
{
txt.Span("Période : ").SemiBold();
txt.Span(periode);
});
col.Item().Text(txt =>
{
txt.Span("Adresse de départ : ").SemiBold();
txt.Span(adresseDepart);
});
// Totaux : mois, année cumulée, TOTAL IK du mois, Montant_Regle — Exigences 7.6/7.10.
col.Item().PaddingTop(6).Row(row =>
{
row.RelativeItem().Column(c =>
if (LogoCarre.Length > 0)
{
c.Item().Text(txt =>
{
txt.Span("Total parcouru sur le mois : ").SemiBold();
txt.Span($"{FormatKm(modele.TotalKmMois)} km");
});
c.Item().Text(txt =>
{
txt.Span("Total parcouru sur l'année : ").SemiBold();
txt.Span($"{FormatKm(modele.TotalKmAnnuel)} km");
});
});
row.RelativeItem().Column(c =>
bandeau.ConstantItem(120).AlignTop().Image(LogoCarre).FitWidth();
}
bandeau.RelativeItem().PaddingLeft(LogoCarre.Length > 0 ? 12 : 0).Column(droite =>
{
c.Item().Text(txt =>
// Bloc titre, centré sur l'espace restant après le logo.
droite.Item().Column(titre =>
{
txt.Span("TOTAL IK du mois : ").SemiBold();
txt.Span($"{FormatMontant(modele.TotalIkMois)} €");
});
c.Item().Text(txt =>
{
txt.Span("Réglé : ").SemiBold();
txt.Span($"{FormatMontant(modele.MontantRegle)} €");
titre.Spacing(2);
titre.Item().AlignCenter().Text("RAPPORT DE TRAJETS").FontSize(16).Bold();
titre.Item().AlignCenter().Text($"Feuille d'indemnité kilométrique {periode}").FontSize(11).SemiBold();
});
// Bloc d'infos, sous le bloc titre (espace pour les séparer).
ComposerInfosMensuel(droite.Item().PaddingTop(10), modele, identite, matricule, domicile, vehiculeLibelle, precision);
});
});
col.Item().PaddingTop(6).LineHorizontal(1).LineColor(Colors.Grey.Medium);
// Trait séparateur sur toute la largeur de la page.
entete.Item().PaddingTop(6).LineHorizontal(1).LineColor(Colors.Grey.Medium);
});
}
/// <summary>
/// Compose le bloc d'informations du rapport mensuel (identité, domicile, véhicule, totaux),
/// affiché à droite du logo sous le bloc titre.
/// </summary>
private static void ComposerInfosMensuel(
IContainer container, ModeleRapportMensuel modele, string identite, string matricule,
string domicile, string vehiculeLibelle, int precision)
{
container.Column(col =>
{
col.Spacing(2);
// Identité (nom, prénom, matricule) — Exigence 7.8.
col.Item().Text(txt =>
{
txt.Span("Utilisateur : ").SemiBold();
txt.Span($"{identite} — Matricule : {matricule}");
});
// Domicile, juste après l'identité (même position que sur le rapport annuel).
col.Item().Text(txt =>
{
txt.Span("Domicile : ").SemiBold();
txt.Span(domicile);
});
// Véhicule (plaque, puissance fiscale, coefficient/base de la tranche annuelle) — Exigences 7.8/7.10.
col.Item().Text(txt =>
{
txt.Span("Véhicule : ").SemiBold();
txt.Span(vehiculeLibelle);
});
// Totaux : mois, année cumulée, TOTAL IK du mois, Montant_Regle — Exigences 7.6/7.10.
col.Item().PaddingTop(6).Row(row =>
{
row.RelativeItem().Column(c =>
{
c.Item().Text(txt =>
{
txt.Span("Total parcouru sur le mois : ").SemiBold();
txt.Span($"{FormatKm(modele.TotalKmMois, precision)} km");
});
c.Item().Text(txt =>
{
txt.Span("Total parcouru sur l'année : ").SemiBold();
txt.Span($"{FormatKm(modele.TotalKmAnnuel, precision)} km");
});
});
row.RelativeItem().Column(c =>
{
c.Item().Text(txt =>
{
txt.Span("TOTAL IK du mois : ").SemiBold();
txt.Span($"{FormatMontant(modele.TotalIkMois)} €");
});
c.Item().Text(txt =>
{
txt.Span("Réglé : ").SemiBold();
txt.Span($"{FormatMontant(modele.MontantRegle)} €");
});
});
});
});
}
/// <summary>
/// Compose la table des trajets du mois (Exigence 7.11) : date, motif (commentaire),
/// adresse de départ → arrivée, kilomètres.
/// </summary>
private static void ComposerTableTrajets(IContainer container, ModeleRapportMensuel modele)
private static void ComposerTableTrajets(IContainer container, ModeleRapportMensuel modele, int precision)
{
container.Table(table =>
{
@@ -401,8 +453,9 @@ public class PdfService : IPdfService
{
columns.ConstantColumn(70); // Date
columns.RelativeColumn(2); // Motif (commentaire)
columns.RelativeColumn(3); // Adresse départ → arrivée
columns.ConstantColumn(55); // Kms
columns.RelativeColumn(4); // Adresse départ → arrivée (maximisée pour tenir sur 2 lignes)
columns.ConstantColumn(22); // A/R (minimale)
columns.ConstantColumn(44); // Kms (jusqu'à « 9 999 » avec séparateur de milliers)
});
// En-tête de table.
@@ -411,34 +464,40 @@ public class PdfService : IPdfService
header.Cell().Element(CelluleEntete).Text("Date");
header.Cell().Element(CelluleEntete).Text("Motifs");
header.Cell().Element(CelluleEntete).Text("Départ → Arrivée");
header.Cell().Element(CelluleEntete).AlignCenter().Text("A/R").FontSize(8);
header.Cell().Element(CelluleEntete).AlignRight().Text("Kms");
});
if (modele.Trajets.Count == 0)
{
table.Cell().ColumnSpan(4).Element(Cellule).Text("Aucun trajet pour ce mois.").Italic();
table.Cell().ColumnSpan(5).Element(Cellule).Text("Aucun trajet pour ce mois.").Italic();
}
else
{
foreach (var trajet in modele.Trajets)
{
var motif = string.IsNullOrWhiteSpace(trajet.Commentaire) ? "—" : trajet.Commentaire!;
// Suffixe « (A/R) » pour les trajets aller/retour ; la distance affichée est
// déjà la distance effective (doublée) portée par le TrajetDto.
var itineraire = trajet.AllerRetour
? $"{trajet.AdresseDepart} → {trajet.AdresseArrivee} (A/R)"
: $"{trajet.AdresseDepart} → {trajet.AdresseArrivee}";
var km = trajet.Distance ?? 0m;
table.Cell().Element(Cellule).Text(trajet.Date.ToString("dd/MM/yyyy", FrCulture));
table.Cell().Element(Cellule).Text(motif);
table.Cell().Element(Cellule).Text(itineraire);
table.Cell().Element(Cellule).AlignRight().Text(FormatKm(km));
// Adresse de départ, puis adresse d'arrivée sur une nouvelle ligne, précédée de « → ».
// L'information aller/retour est désormais portée par la colonne A/R dédiée.
table.Cell().Element(Cellule).Text(txt =>
{
txt.Line(trajet.AdresseDepart);
txt.Span($"→ {trajet.AdresseArrivee}");
});
// Colonne A/R : « X » lorsque le trajet est un aller/retour, sinon vide
// (« X » plutôt qu'une coche Unicode, non rendue par la police par défaut Lato).
// La distance affichée est déjà la distance effective (doublée) portée par le TrajetDto.
table.Cell().Element(Cellule).AlignCenter().Text(trajet.AllerRetour ? "X" : string.Empty).SemiBold();
table.Cell().Element(Cellule).AlignRight().Text(FormatKm(km, precision));
}
// Ligne de total du mois.
table.Cell().ColumnSpan(3).Element(CelluleTotal).AlignRight().Text("Total du mois :").SemiBold();
table.Cell().Element(CelluleTotal).AlignRight().Text($"{FormatKm(modele.TotalKmMois)}").SemiBold();
table.Cell().ColumnSpan(4).Element(CelluleTotal).AlignRight().Text("Total du mois :").SemiBold();
table.Cell().Element(CelluleTotal).AlignRight().Text($"{FormatKm(modele.TotalKmMois, precision)}").SemiBold();
}
});
@@ -456,7 +515,7 @@ public class PdfService : IPdfService
/// <summary>
/// Rend le document de synthèse annuelle en octets PDF selon la maquette « PDF Annuel » du design.
/// </summary>
private static byte[] RendreAnnuel(ModeleRapportAnnuel modele, CancellationToken cancellationToken)
private static byte[] RendreAnnuel(ModeleRapportAnnuel modele, int precision, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -468,21 +527,15 @@ public class PdfService : IPdfService
page.Margin(30);
page.DefaultTextStyle(x => x.FontSize(9).FontColor(Colors.Black));
page.Header().Element(c => ComposerEnteteAnnuel(c, modele));
page.Header().Element(c => ComposerEnteteAnnuel(c, modele, precision));
page.Content().PaddingTop(10).Column(col =>
{
col.Spacing(10);
col.Item().Element(c => ComposerTableBaremeAnnuel(c, modele));
col.Item().Element(c => ComposerTableSyntheseAnnuelle(c, modele));
col.Item().Element(c => ComposerTotauxAnnuels(c, modele));
});
page.Footer().AlignCenter().Text(txt =>
{
txt.Span("Page ");
txt.CurrentPageNumber();
txt.Span(" / ");
txt.TotalPages();
col.Item().Element(c => ComposerTableSyntheseAnnuelle(c, modele, precision));
col.Item().Element(c => ComposerTotauxAnnuels(c, modele, precision));
});
page.Footer().Element(ComposerPiedDePage);
});
});
@@ -494,9 +547,8 @@ public class PdfService : IPdfService
/// Compose l'en-tête de la synthèse annuelle (Exigences 7.6, 7.7, 7.8, 7.12) : titre fixe,
/// identité, année, domicile, véhicule (plaque, puissance fiscale) et tranche annuelle applicable.
/// </summary>
private static void ComposerEnteteAnnuel(IContainer container, ModeleRapportAnnuel modele)
private static void ComposerEnteteAnnuel(IContainer container, ModeleRapportAnnuel modele, int precision)
{
var reg = modele.Regularisation;
var identite = FormatIdentite(modele.Profil);
var matricule = string.IsNullOrWhiteSpace(modele.Profil.Matricule)
? "—"
@@ -505,58 +557,89 @@ public class PdfService : IPdfService
? "—"
: modele.Profil.AdresseDomicile!;
container.Column(col =>
container.Column(entete =>
{
col.Spacing(2);
// Titre fixe imposé par l'Exigence 7.6.
col.Item().Text("Rapport de trajets").FontSize(16).Bold();
col.Item().Text($"Synthèse {modele.Annee} d'indemnité kilométrique").FontSize(11).SemiBold();
// Identité (nom, prénom, matricule) — Exigence 7.8.
col.Item().PaddingTop(4).Text(txt =>
// Logo à gauche ; à droite, bloc titre (centré) puis bloc d'infos en dessous.
entete.Item().Row(bandeau =>
{
txt.Span("Utilisateur : ").SemiBold();
txt.Span($"{identite} — Matricule : {matricule}");
if (LogoCarre.Length > 0)
{
bandeau.ConstantItem(120).AlignTop().Image(LogoCarre).FitWidth();
}
bandeau.RelativeItem().PaddingLeft(LogoCarre.Length > 0 ? 12 : 0).Column(droite =>
{
// Bloc titre, centré sur l'espace restant après le logo.
droite.Item().Column(titre =>
{
titre.Spacing(2);
titre.Item().AlignCenter().Text("RAPPORT DE TRAJETS").FontSize(16).Bold();
titre.Item().AlignCenter().Text($"Synthèse {modele.Annee} d'indemnité kilométrique").FontSize(11).SemiBold();
});
// Bloc d'infos, sous le bloc titre (espace pour les séparer).
ComposerInfosAnnuel(droite.Item().PaddingTop(10), modele, identite, matricule, domicile, precision);
});
});
// Année (période couverte) — Exigence 7.6.
col.Item().Text(txt =>
{
txt.Span("Année : ").SemiBold();
txt.Span(modele.Annee.ToString(CultureInfo.InvariantCulture));
});
// Adresse de domicile — Exigence 7.12.
col.Item().Text(txt =>
{
txt.Span("Domicile : ").SemiBold();
txt.Span(domicile);
});
// Véhicule (plaque, puissance fiscale) et tranche annuelle applicable — Exigences 7.7/7.8/7.12.
col.Item().Text(txt =>
{
txt.Span("Véhicule : ").SemiBold();
txt.Span($"{reg.Plaque} — {reg.PuissanceFiscale} CV");
});
col.Item().Text(txt =>
{
txt.Span("Barème applicable : ").SemiBold();
txt.Span(reg.TrancheLibelle);
});
// Total kilométrique de la période (Exigence 7.6).
col.Item().Text(txt =>
{
txt.Span("Total kilométrique de l'année : ").SemiBold();
txt.Span($"{FormatKm(reg.TotalKmAnnuel)} km");
});
col.Item().PaddingTop(6).LineHorizontal(1).LineColor(Colors.Grey.Medium);
// Trait séparateur sur toute la largeur de la page.
entete.Item().PaddingTop(6).LineHorizontal(1).LineColor(Colors.Grey.Medium);
});
}
/// <summary>
/// Compose le bloc d'informations de la synthèse annuelle (identité, domicile, véhicule,
/// tranche applicable, total annuel), affiché à droite du logo sous le bloc titre.
/// </summary>
private static void ComposerInfosAnnuel(
IContainer container, ModeleRapportAnnuel modele, string identite, string matricule,
string domicile, int precision)
{
var reg = modele.Regularisation;
container.Column(col =>
{
col.Spacing(2);
// Identité (nom, prénom, matricule) — Exigence 7.8.
col.Item().Text(txt =>
{
txt.Span("Utilisateur : ").SemiBold();
txt.Span($"{identite} — Matricule : {matricule}");
});
// Adresse de domicile — Exigence 7.12. (La ligne « Année » a été retirée : la
// période figure déjà dans le titre « Synthèse {Année} d'indemnité kilométrique ».)
col.Item().Text(txt =>
{
txt.Span("Domicile : ").SemiBold();
txt.Span(domicile);
});
// Véhicule (plaque, puissance fiscale) et tranche annuelle applicable — Exigences 7.7/7.8/7.12.
col.Item().Text(txt =>
{
txt.Span("Véhicule : ").SemiBold();
txt.Span($"{reg.Plaque} — {reg.PuissanceFiscale} CV");
});
// Barème applicable et total kilométrique sur deux colonnes (gain d'une ligne,
// même agencement que le bloc totaux du rapport mensuel) — Exigences 7.6/7.7/7.12.
col.Item().PaddingTop(6).Row(row =>
{
row.RelativeItem().Text(txt =>
{
txt.Span("Barème applicable : ").SemiBold();
txt.Span(reg.TrancheLibelle);
});
row.RelativeItem().Text(txt =>
{
txt.Span("Total kilométrique de l'année : ").SemiBold();
txt.Span($"{FormatKm(reg.TotalKmAnnuel, precision)} km");
});
});
});
}
/// <summary>
/// Compose la table du barème applicable de l'année pour la puissance fiscale du véhicule
/// (les trois tranches coefficient/base) — Exigence 7.12.
@@ -569,7 +652,7 @@ public class PdfService : IPdfService
col.Item().Text(txt =>
{
txt.Span("Barème applicable pour l'année ").SemiBold();
txt.Span($"({modele.Regularisation.PuissanceFiscale} CV)").SemiBold();
txt.Span($"({modele.Regularisation.PuissanceFiscale} CV) — Kms parcourus × Coef + Base").SemiBold();
});
if (modele.TranchesBareme.Count == 0)
@@ -618,7 +701,7 @@ public class PdfService : IPdfService
/// Compose la table des douze mois (kilomètres parcourus, frais payé, frais réel) — Exigence 7.12.
/// Les données proviennent de <see cref="RegularisationAnnuelleDto.Mois"/>.
/// </summary>
private static void ComposerTableSyntheseAnnuelle(IContainer container, ModeleRapportAnnuel modele)
private static void ComposerTableSyntheseAnnuelle(IContainer container, ModeleRapportAnnuel modele, int precision)
{
var reg = modele.Regularisation;
@@ -643,14 +726,14 @@ public class PdfService : IPdfService
foreach (var ligne in reg.Mois)
{
table.Cell().Element(Cellule).Text(NomMois(ligne.Mois));
table.Cell().Element(Cellule).AlignRight().Text($"{FormatKm(ligne.KmMois)}");
table.Cell().Element(Cellule).AlignRight().Text($"{FormatKm(ligne.KmMois, precision)}");
table.Cell().Element(Cellule).AlignRight().Text($"{FormatMontant(ligne.FraisPaye)} €");
table.Cell().Element(Cellule).AlignRight().Text($"{FormatMontant(ligne.FraisReelMensuel)} €");
}
// Ligne de total annuel.
table.Cell().Element(CelluleTotal).Text("TOTAL").SemiBold();
table.Cell().Element(CelluleTotal).AlignRight().Text($"{FormatKm(reg.TotalKmAnnuel)}").SemiBold();
table.Cell().Element(CelluleTotal).AlignRight().Text($"{FormatKm(reg.TotalKmAnnuel, precision)}").SemiBold();
table.Cell().Element(CelluleTotal).AlignRight().Text($"{FormatMontant(reg.TotalPaye)} €").SemiBold();
table.Cell().Element(CelluleTotal).AlignRight().Text($"{FormatMontant(reg.MontantReel)} €").SemiBold();
});
@@ -669,13 +752,13 @@ public class PdfService : IPdfService
/// <summary>
/// Compose le bloc « formule réelle » et la régularisation (réel − payé) — Exigence 7.12.
/// </summary>
private static void ComposerTotauxAnnuels(IContainer container, ModeleRapportAnnuel modele)
private static void ComposerTotauxAnnuels(IContainer container, ModeleRapportAnnuel modele, int precision)
{
var reg = modele.Regularisation;
// Formule réelle : total_km × coefficient + base = Montant_Reel (tranche annuelle).
var formule =
$"{FormatKm(reg.TotalKmAnnuel)} km × {FormatDecimal(reg.CoefficientTranche, 3)} " +
$"{FormatKm(reg.TotalKmAnnuel, precision)} km × {FormatDecimal(reg.CoefficientTranche, 3)} " +
$"+ {FormatDecimal(reg.BaseTranche, 0)} = {FormatMontant(reg.MontantReel)} € " +
$"(tranche {reg.TrancheLibelle}, {reg.PuissanceFiscale} CV)";
@@ -687,7 +770,8 @@ public class PdfService : IPdfService
txt.Span("Formule réelle : ").SemiBold();
txt.Span(formule);
});
col.Item().Text(txt =>
// Espace entre la formule réelle et la régularisation, puis régularisation centrée.
col.Item().PaddingTop(8).AlignCenter().Text(txt =>
{
txt.Span("Régularisation (réel − payé) : ").SemiBold();
txt.Span($"{FormatMontant(reg.Regularisation)} €");
@@ -695,6 +779,41 @@ public class PdfService : IPdfService
});
}
/// <summary>
/// Pied de page commun aux deux rapports : mention indicative en petite taille, puis la
/// pagination en dessous.
/// </summary>
private static void ComposerPiedDePage(IContainer container)
{
container.Column(col =>
{
col.Spacing(2);
// Mention légale indicative, en très petit caractère pour tenir sur 2-3 lignes.
col.Item().Text(txt =>
{
txt.Justify();
txt.DefaultTextStyle(x => x.FontSize(5).FontColor(Colors.Grey.Darken1));
txt.Span(
"Les kilomètres restitués dans le présent document sont déclarés par l'utilisateur sur l'honneur " +
"et sous sa seule responsabilité. Le calcul de l'indemnité kilométrique est fourni à titre " +
"purement indicatif, sur la base du barème kilométrique officiel en vigueur. SchotzIAD ne saurait " +
"être tenue responsable d'une quelconque erreur ou omission dans les données saisies ni du montant " +
"résultant du calcul. Le présent document ne constitue pas un justificatif opposable " +
"aux administrations.");
});
col.Item().AlignCenter().Text(txt =>
{
txt.DefaultTextStyle(x => x.FontSize(8));
txt.Span("Page ");
txt.CurrentPageNumber();
txt.Span(" / ");
txt.TotalPages();
});
});
}
private static string FormatIdentite(ProfilDto profil)
{
var prenom = (profil.Prenom ?? string.Empty).Trim();
@@ -703,9 +822,37 @@ public class PdfService : IPdfService
return identite.Length > 0 ? identite : "—";
}
/// <summary>Kilomètres : séparateur de milliers, 2 décimales.</summary>
private static string FormatKm(decimal km) =>
Math.Round(km, 2, MidpointRounding.AwayFromZero).ToString("N2", FrCulture);
/// <summary>
/// Kilomètres : séparateur de milliers, nombre de décimales selon Precision_Distance
/// (0, 1 ou 2 — voir la spec precision-distances-km).
/// </summary>
private static string FormatKm(decimal km, int precision) =>
Math.Round(km, precision, MidpointRounding.AwayFromZero).ToString("N" + precision, FrCulture);
/// <summary>
/// Construit le nom de fichier d'export au format `AAAAMM_Trajets_MATRICULE_PLAQUE.pdf`
/// (sans le segment mois pour l'export annuel, `periode` valant alors `AAAA`). Les segments
/// Matricule et Plaque sont omis (avec leur séparateur) lorsqu'ils sont vides.
/// </summary>
private static string ConstruireNomFichier(string periode, string? matricule, string? plaque)
{
var segments = new List<string> { periode, "Trajets" };
if (!string.IsNullOrWhiteSpace(matricule))
{
segments.Add(NettoyerSegmentNomFichier(matricule));
}
if (!string.IsNullOrWhiteSpace(plaque))
{
segments.Add(NettoyerSegmentNomFichier(plaque));
}
return string.Join("_", segments) + ".pdf";
}
/// <summary>Retire les espaces et tirets d'un segment de nom de fichier (ex. plaque « AB-123-CD » → « AB123CD »).</summary>
private static string NettoyerSegmentNomFichier(string valeur) =>
valeur.Replace(" ", "").Replace("-", "").Trim();
/// <summary>Montant : séparateur de milliers, 2 décimales.</summary>
private static string FormatMontant(decimal montant) =>
@@ -0,0 +1,69 @@
using Microsoft.EntityFrameworkCore;
using SchotzIadKmTracker.Api.Data;
using SchotzIadKmTracker.Api.Models.Entities;
using SchotzIadKmTracker.Api.Services.Exceptions;
namespace SchotzIadKmTracker.Api.Services;
/// <inheritdoc />
public class PrecisionDistanceService : IPrecisionDistanceService
{
// La configuration est un singleton : une seule ligne dont l'identifiant est toujours 1.
private const int ParametresId = 1;
private readonly AppDbContext _context;
public PrecisionDistanceService(AppDbContext context)
{
_context = context;
}
/// <inheritdoc />
public async Task<int> ObtenirAsync(CancellationToken cancellationToken = default)
{
var parametres = await GetOrCreateAsync(cancellationToken);
return parametres.PrecisionDistanceKm;
}
/// <inheritdoc />
public async Task<int> DefinirAsync(int valeur, CancellationToken cancellationToken = default)
{
if (valeur is not (0 or 1 or 2))
{
throw new PrecisionDistanceInvalideException(
"La précision des distances doit être 0, 1 ou 2 décimale(s).");
}
var parametres = await GetOrCreateAsync(cancellationToken);
parametres.PrecisionDistanceKm = valeur;
parametres.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync(cancellationToken);
return parametres.PrecisionDistanceKm;
}
/// <inheritdoc />
public decimal ArrondirDistance(decimal distanceBrute, int precision)
{
var facteur = (decimal)Math.Pow(10, precision);
return Math.Ceiling(distanceBrute * facteur) / facteur;
}
/// <summary>
/// Récupère la configuration singleton (Id = 1) ou la crée à la volée si elle est absente,
/// avec la valeur par défaut (2 décimales).
/// </summary>
private async Task<ParametresApplication> GetOrCreateAsync(CancellationToken cancellationToken)
{
var parametres = await _context.ParametresApplication
.FirstOrDefaultAsync(p => p.Id == ParametresId, cancellationToken);
if (parametres is not null)
{
return parametres;
}
parametres = new ParametresApplication { Id = ParametresId };
await _context.ParametresApplication.AddAsync(parametres, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return parametres;
}
}
@@ -12,15 +12,18 @@ public class RemboursementService : IRemboursementService
private readonly AppDbContext _context;
private readonly IBaremeService _baremeService;
private readonly IMontantRegleService _montantRegleService;
private readonly IPrecisionDistanceService _precisionDistanceService;
public RemboursementService(
AppDbContext context,
IBaremeService baremeService,
IMontantRegleService montantRegleService)
IMontantRegleService montantRegleService,
IPrecisionDistanceService precisionDistanceService)
{
_context = context;
_baremeService = baremeService;
_montantRegleService = montantRegleService;
_precisionDistanceService = precisionDistanceService;
}
/// <inheritdoc />
@@ -32,6 +35,9 @@ public class RemboursementService : IRemboursementService
.FirstOrDefaultAsync(v => v.Id == vehiculeId, cancellationToken)
?? throw new VehiculeNotFoundException($"Le véhicule d'identifiant {vehiculeId} est introuvable.");
// Précision d'arrondi courante (Exigence 4.1) : lue une fois pour toute la requête.
var precision = await _precisionDistanceService.ObtenirAsync(cancellationToken);
// Kilomètres du véhicule par mois pour l'année (Distance nullable → 0 quand null).
// Le GROUP BY + SUM sur decimal n'est pas traduisible par SQLite : on ramène les
// trajets de l'année puis on agrège côté client.
@@ -39,13 +45,15 @@ public class RemboursementService : IRemboursementService
.AsNoTracking()
.Where(t => t.VehiculeId == vehiculeId && t.Date.Year == annee)
// On ramène la distance simple et l'indicateur aller/retour, puis on agrège la
// distance EFFECTIVE (doublée si A/R) côté client.
// distance EFFECTIVE (arrondie AVANT doublement si A/R — Exigence 4.2) côté client.
.Select(t => new { t.Date.Month, t.Distance, t.AllerRetour })
.ToListAsync(cancellationToken);
var kmMoisMap = trajetsAnnee
.GroupBy(t => t.Month)
.ToDictionary(g => g.Key, g => g.Sum(t => (t.Distance ?? 0m) * (t.AllerRetour ? 2m : 1m)));
.ToDictionary(
g => g.Key,
g => g.Sum(t => _precisionDistanceService.ArrondirDistance(t.Distance ?? 0m, precision) * (t.AllerRetour ? 2m : 1m)));
// Total kilométrique annuel COMPLET (détermine la tranche — Exigence 14.5, Property 27).
var totalKmAnnuel = kmMoisMap.Values.Sum();
@@ -78,7 +86,7 @@ public class RemboursementService : IRemboursementService
lignes.Add(new LigneSyntheseMoisDto(
Mois: mois,
KmMois: Math.Round(kmMois, 2, MidpointRounding.AwayFromZero),
KmMois: kmMois,
FraisPaye: fraisPaye,
FraisReelMensuel: fraisReelMensuel));
}
@@ -97,7 +105,7 @@ public class RemboursementService : IRemboursementService
BaseTranche: baseTranche,
TrancheLibelle: trancheLibelle,
Mois: lignes,
TotalKmAnnuel: Math.Round(totalKmAnnuel, 2, MidpointRounding.AwayFromZero),
TotalKmAnnuel: totalKmAnnuel,
TotalPaye: totalPaye,
MontantReel: montantReel,
Regularisation: regularisation);
@@ -15,10 +15,12 @@ public class ResumeService : IResumeService
private const int AnneeMax = 2100;
private readonly AppDbContext _context;
private readonly IPrecisionDistanceService _precisionDistanceService;
public ResumeService(AppDbContext context)
public ResumeService(AppDbContext context, IPrecisionDistanceService precisionDistanceService)
{
_context = context;
_precisionDistanceService = precisionDistanceService;
}
/// <inheritdoc />
@@ -28,9 +30,10 @@ public class ResumeService : IResumeService
ValiderAnnee(annee);
ValiderMois(mois);
var precision = await _precisionDistanceService.ObtenirAsync(cancellationToken);
var trajets = await ChargerTrajetsDuMoisAsync(annee, mois, vehiculeId, cancellationToken);
return ConstruireResumeMensuel(annee, mois, trajets);
return ConstruireResumeMensuel(annee, mois, trajets, precision);
}
/// <inheritdoc />
@@ -39,6 +42,8 @@ public class ResumeService : IResumeService
{
ValiderAnnee(annee);
var precision = await _precisionDistanceService.ObtenirAsync(cancellationToken);
// Charge une seule fois tous les trajets de l'année, puis répartit par mois.
var query = _context.Trajets
.AsNoTracking()
@@ -58,11 +63,11 @@ public class ResumeService : IResumeService
.Where(t => t.Date.Month == mois)
.ToList();
resumesMensuels.Add(ConstruireResumeMensuel(annee, mois, trajetsDuMois));
resumesMensuels.Add(ConstruireResumeMensuel(annee, mois, trajetsDuMois, precision));
}
// Total annuel = somme des 12 totaux mensuels (Exigences 4.3, 4.7, 6.6).
var totalAnnuel = Arrondir(resumesMensuels.Sum(r => r.TotalKm));
// Total annuel = somme des 12 totaux mensuels, déjà arrondis (Exigences 4.3, 4.7, 6.6, 5.2, 5.3).
var totalAnnuel = resumesMensuels.Sum(r => r.TotalKm);
return new ResumeAnnuelDto(annee, resumesMensuels, totalAnnuel);
}
@@ -93,17 +98,39 @@ public class ResumeService : IResumeService
/// <summary>
/// Construit un <see cref="ResumeMensuelDto"/> à partir d'une liste de Trajets déjà triée.
/// Le total est la somme des distances (null → 0) arrondie à 2 décimales (Property 1).
/// Chaque distance est arrondie (Precision_Distance) AVANT doublement aller/retour ; le total
/// est la somme de ces valeurs déjà arrondies, sans second arrondi (Property 1, Property 2, Property 3).
/// </summary>
private static ResumeMensuelDto ConstruireResumeMensuel(int annee, int mois, IReadOnlyList<Trajet> trajets)
private ResumeMensuelDto ConstruireResumeMensuel(
int annee, int mois, IReadOnlyList<Trajet> trajets, int precision)
{
var trajetsDto = trajets.Select(ToDto).ToList();
// Total = somme des distances EFFECTIVES (doublées si aller/retour), null → 0 (Property 1).
var totalKm = Arrondir(trajets.Sum(t => t.DistanceEffective ?? 0m));
// Calculée une seule fois par trajet, puis réutilisée pour le DTO et la somme du total
// afin de garantir la même valeur (Distance_Effective_Arrondie) aux deux endroits.
var distancesArrondies = trajets.Select(t => (t, distance: DistanceEffectiveArrondie(t, precision))).ToList();
var trajetsDto = distancesArrondies.Select(x => ToDto(x.t, x.distance)).ToList();
// Total = somme des Distance_Effective_Arrondie déjà calculées par trajet, null → 0 (Property 1, Property 3).
var totalKm = distancesArrondies.Sum(x => x.distance ?? 0m);
return new ResumeMensuelDto(annee, mois, trajetsDto, totalKm);
}
/// <summary>
/// Calcule la Distance_Effective_Arrondie d'un trajet : arrondi (Precision_Distance) appliqué
/// à la distance SIMPLE brute AVANT doublement aller/retour (Exigence 4.1, 4.2, 4.3). null si la
/// distance brute n'a pas pu être calculée (Exigence 2.4 du résumé existant).
/// </summary>
private decimal? DistanceEffectiveArrondie(Trajet t, int precision)
{
if (!t.Distance.HasValue)
{
return null;
}
var distanceArrondie = _precisionDistanceService.ArrondirDistance(t.Distance.Value, precision);
return t.AllerRetour ? distanceArrondie * 2m : distanceArrondie;
}
private void ValiderMois(int mois)
{
if (mois < MoisMin || mois > MoisMax)
@@ -122,17 +149,15 @@ public class ResumeService : IResumeService
}
}
private static decimal Arrondir(decimal valeur) =>
Math.Round(valeur, 2, MidpointRounding.AwayFromZero);
private static TrajetDto ToDto(Trajet t) =>
private static TrajetDto ToDto(Trajet t, decimal? distanceEffectiveArrondie) =>
new(
t.Id,
t.Date,
t.AdresseDepart,
t.AdresseArrivee,
// Distance EFFECTIVE (doublée si aller/retour), alignée sur le TrajetDto commun.
t.DistanceEffective,
// Distance_Effective_Arrondie (arrondi Precision_Distance puis doublement si aller/retour),
// alignée sur le TrajetDto commun (au lieu de DistanceEffective brut).
distanceEffectiveArrondie,
t.Commentaire,
t.Position,
t.VehiculeId,
@@ -9,11 +9,14 @@ public class TotauxService : ITotauxService
{
private readonly AppDbContext _context;
private readonly IVehiculeService _vehiculeService;
private readonly IPrecisionDistanceService _precisionDistanceService;
public TotauxService(AppDbContext context, IVehiculeService vehiculeService)
public TotauxService(
AppDbContext context, IVehiculeService vehiculeService, IPrecisionDistanceService precisionDistanceService)
{
_context = context;
_vehiculeService = vehiculeService;
_precisionDistanceService = precisionDistanceService;
}
/// <inheritdoc />
@@ -24,6 +27,10 @@ public class TotauxService : ITotauxService
var vehiculeCible = vehiculeId
?? (await _vehiculeService.GetVehiculeCourantAsync(cancellationToken)).Id;
// Précision courante (0/1/2 décimales) : lue une fois par appel, stable pour tous les
// totaux calculés ci-dessous (Exigences 4.1, 4.2, 4.3).
var precision = await _precisionDistanceService.ObtenirAsync(cancellationToken);
// Trajets du véhicule pour l'année sélectionnée (Property 21 : filtrage par véhicule).
// Distance nullable → 0 lors des sommes (traitée côté client après projection).
var trajetsAnnee = await _context.Trajets
@@ -34,9 +41,10 @@ public class TotauxService : ITotauxService
.Select(t => new { t.Date, t.Distance, t.AllerRetour })
.ToListAsync(cancellationToken);
// Distance comptée (effective) : distance simple × 2 si aller/retour, null → 0.
static decimal Effective(decimal? distance, bool allerRetour) =>
distance.GetValueOrDefault() * (allerRetour ? 2m : 1m);
// Distance comptée (effective) : distance simple arrondie à la précision courante,
// PUIS doublée si aller/retour (Exigence 4.2 — arrondi avant doublement), null → 0.
decimal Effective(decimal? distance, bool allerRetour) =>
_precisionDistanceService.ArrondirDistance(distance.GetValueOrDefault(), precision) * (allerRetour ? 2m : 1m);
// TotalJour : somme des distances effectives de la journée sélectionnée (Exigence 12.7).
var totalJour = trajetsAnnee
@@ -54,12 +62,15 @@ public class TotauxService : ITotauxService
.Where(t => t.Date.Month <= date.Month)
.Sum(t => Effective(t.Distance, t.AllerRetour));
// Les distances sommées sont déjà arrondies à la précision courante (via
// ArrondirDistance) : aucun second arrondi forcé à 2 décimales n'est appliqué ici
// (Exigences 5.1, 5.4).
return new TotauxDto(
Date: date,
VehiculeId: vehiculeCible,
TotalJour: Math.Round(totalJour, 2, MidpointRounding.AwayFromZero),
TotalMois: Math.Round(totalMois, 2, MidpointRounding.AwayFromZero),
TotalAnnuelPartiel: Math.Round(totalAnnuelPartiel, 2, MidpointRounding.AwayFromZero));
TotalJour: totalJour,
TotalMois: totalMois,
TotalAnnuelPartiel: totalAnnuelPartiel);
}
/// <inheritdoc />
@@ -0,0 +1,169 @@
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using SchotzIadKmTracker.Api.Data;
using SchotzIadKmTracker.Api.Models.DTOs;
using SchotzIadKmTracker.Api.Models.Entities;
using SchotzIadKmTracker.Api.Repositories;
using SchotzIadKmTracker.Api.Services;
using Xunit;
namespace SchotzIadKmTracker.Tests.PdfExport;
/// <summary>
/// Tests d'intégration de <see cref="PdfService"/> (vérification manuelle demandée après les
/// changements de nommage de fichier, logo, précision des distances et restructuration des
/// en-têtes mensuel/annuel). Branche les services réels sur une base SQLite en mémoire ;
/// seul <see cref="IBANClient"/> est mocké (jamais appelé ici, aucune adresse de domicile
/// à géocoder dans les scénarios testés).
/// </summary>
public class PdfServiceIntegrationTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly AppDbContext _context;
private readonly PdfService _pdfService;
public PdfServiceIntegrationTests()
{
// Licence QuestPDF (usage communautaire, gratuit) — requise avant toute génération de
// PDF ; configurée normalement au démarrage de l'application (Program.cs), reproduite
// ici car ce test instancie PdfService directement sans démarrer le host.
QuestPDF.Settings.License = QuestPDF.Infrastructure.LicenseType.Community;
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
var options = new DbContextOptionsBuilder<AppDbContext>().UseSqlite(_connection).Options;
_context = new AppDbContext(options);
_context.Database.EnsureCreated();
var precisionDistanceService = new PrecisionDistanceService(_context);
var baremeService = new BaremeService(_context);
var resumeService = new ResumeService(_context, precisionDistanceService);
var montantRegleService = new MontantRegleService(_context, baremeService, precisionDistanceService);
var remboursementService = new RemboursementService(
_context, baremeService, montantRegleService, precisionDistanceService);
var vehiculeRepository = new VehiculeRepository(_context);
var vehiculeService = new VehiculeService(vehiculeRepository, _context);
var adresseCacheRepository = new AdresseCacheRepository(_context);
var adresseCacheService = new AdresseCacheService(adresseCacheRepository, _context);
var banClientMock = new Mock<IBANClient>();
var profilService = new ProfilService(
_context, banClientMock.Object, adresseCacheService, NullLogger<ProfilService>.Instance);
_pdfService = new PdfService(
resumeService,
profilService,
vehiculeService,
baremeService,
montantRegleService,
remboursementService,
precisionDistanceService,
_context);
}
/// <summary>Prépare un profil, un véhicule, un trajet et une tranche de barème de test.</summary>
private async Task<int> PreparerScenarioAsync(string matricule, string plaque, int precision)
{
_context.Profil.Add(new Profil
{
Id = 1,
Nom = "Dupont",
Prenom = "Jean",
Matricule = matricule,
AdresseDomicile = null
});
var vehicule = new Vehicule
{
Denomination = "Véhicule de test",
Plaque = plaque,
PuissanceFiscale = 5,
DateEffet = new DateOnly(2020, 1, 1)
};
_context.Vehicules.Add(vehicule);
_context.TranchesBareme.Add(new TrancheBareme
{
Annee = 2026,
PuissanceFiscale = 5,
KmMin = 0m,
KmMax = null,
Coefficient = 0.350m,
Base = 0m
});
await _context.SaveChangesAsync();
_context.Trajets.Add(new Trajet
{
Date = new DateOnly(2026, 3, 15),
AdresseDepart = "Départ test",
AdresseArrivee = "Arrivée test",
Distance = 12.3456m,
AllerRetour = false,
Position = 1m,
VehiculeId = vehicule.Id,
PuissanceFiscale = 5
});
await _context.SaveChangesAsync();
await precisionServiceDefinir(precision);
return vehicule.Id;
}
private async Task precisionServiceDefinir(int precision)
{
var service = new PrecisionDistanceService(_context);
await service.DefinirAsync(precision, CancellationToken.None);
}
[Fact]
public async Task GenererRapportMensuelAsync_produit_un_pdf_et_un_nom_de_fichier_conforme()
{
var vehiculeId = await PreparerScenarioAsync("AB1234", "AB-123-CD", precision: 0);
var resultat = await _pdfService.GenererRapportMensuelAsync(2026, 3, vehiculeId, CancellationToken.None);
resultat.Contenu.Should().NotBeEmpty();
// En-tête PDF standard : les octets générés doivent être un vrai document PDF.
System.Text.Encoding.ASCII.GetString(resultat.Contenu, 0, 4).Should().Be("%PDF");
resultat.NomFichier.Should().Be("202603_Trajets_AB1234_AB123CD.pdf");
}
[Fact]
public async Task GenererRapportAnnuelAsync_produit_un_pdf_et_un_nom_de_fichier_conforme()
{
var vehiculeId = await PreparerScenarioAsync("XY9876", "ZZ-999-ZZ", precision: 2);
var resultat = await _pdfService.GenererRapportAnnuelAsync(2026, vehiculeId, CancellationToken.None);
resultat.Contenu.Should().NotBeEmpty();
System.Text.Encoding.ASCII.GetString(resultat.Contenu, 0, 4).Should().Be("%PDF");
resultat.NomFichier.Should().Be("2026_Trajets_XY9876_ZZ999ZZ.pdf");
}
[Fact]
public async Task GenererRapportMensuelAsync_omet_les_segments_matricule_et_plaque_absents()
{
// Matricule vide (chaîne vide) : le segment doit être omis du nom de fichier.
var vehiculeId = await PreparerScenarioAsync(string.Empty, "CD-456-EF", precision: 1);
var resultat = await _pdfService.GenererRapportMensuelAsync(2026, 3, vehiculeId, CancellationToken.None);
resultat.NomFichier.Should().Be("202603_Trajets_CD456EF.pdf");
}
public void Dispose()
{
_context.Dispose();
_connection.Dispose();
}
}
@@ -0,0 +1,154 @@
using System.Threading;
using FluentAssertions;
using FsCheck;
using FsCheck.Xunit;
using SchotzIadKmTracker.Api.Services;
using Xunit;
namespace SchotzIadKmTracker.Tests.PrecisionDistance;
/// <summary>
/// Tests de propriétés (FsCheck.Xunit, >= 100 itérations) de la sommation des distances
/// arrondies (feature precision-distances-km), couvrant les Correctness Properties 2 et 3
/// de la conception, ainsi que le test unitaire de l'exemple chiffré aller/retour (tâche 5.5).
/// </summary>
public class AgregationDistanceProperties
{
/// <summary>Distances brutes positives et raisonnables (0 exclu, jusqu'à 1 000 km).</summary>
private static Arbitrary<decimal> DistancePositiveArb()
{
var g =
from entier in Gen.Choose(1, 100_000)
select entier / 100m; // pas de 0.01 km, jusqu'à 1 000.00 km
return Arb.From(g);
}
private static Arbitrary<int> PrecisionValideArb() => Arb.From(Gen.Elements(0, 1, 2));
/// <summary>Génère une petite liste (0 à 15 éléments) de (distance nullable, allerRetour).</summary>
private static Arbitrary<List<(decimal? Distance, bool AllerRetour)>> ListeTrajetsArb()
{
var distanceNullable =
Gen.Frequency(
Tuple.Create(1, Gen.Constant((decimal?)null)),
Tuple.Create(9, DistancePositiveArb().Generator.Select(d => (decimal?)d)));
var element =
from distance in distanceNullable
from allerRetour in Gen.Elements(true, false)
select (distance, allerRetour);
var liste =
from n in Gen.Choose(0, 15)
from items in Gen.ListOf(n, element)
select items.ToList();
return Arb.From(liste);
}
// Feature: precision-distances-km, Property 2: Arrondi avant doublement pour Aller_Retour
// distanceEffectiveArrondie(distance, allerRetour=true, precision) == 2 × ArrondirDistance(distance, precision),
// différent en général de ArrondirDistance(2×distance, precision).
// Validates: Requirements 4.1, 4.2, 4.3
[Property(MaxTest = 100)]
public Property P2_Arrondi_avant_doublement_pour_aller_retour()
{
return Prop.ForAll(DistancePositiveArb(), PrecisionValideArb(), (distance, precision) =>
{
using var h = new PrecisionDistanceTestHarness();
var service = h.PrecisionDistanceService;
var arrondiSimple = service.ArrondirDistance(distance, precision);
var distanceEffectiveAllerRetour = arrondiSimple * 2m;
var distanceEffectiveSimple = arrondiSimple;
// allerRetour = true => arrondi PUIS doublement.
distanceEffectiveAllerRetour.Should().Be(2m * service.ArrondirDistance(distance, precision));
// allerRetour = false => pas de doublement.
distanceEffectiveSimple.Should().Be(service.ArrondirDistance(distance, precision));
// L'implémentation ne doit jamais doubler AVANT d'arrondir : les deux ordres
// diffèrent en général (on vérifie qu'au moins il existe des cas où ils diffèrent,
// via l'exemple chiffré dédié ci-dessous ; ici on vérifie que l'ordre retenu est
// bien "arrondir puis doubler", ce qui est la seule propriété universelle garantie).
var doublerPuisArrondir = service.ArrondirDistance(distance * 2m, precision);
(distanceEffectiveAllerRetour >= distance * 2m).Should().BeTrue();
(doublerPuisArrondir >= distance * 2m).Should().BeTrue();
});
}
// Feature: precision-distances-km, Property 3: Sommation de distances déjà arrondies sans arrondi supplémentaire
// Pour une liste de trajets, la somme des distances effectives arrondies individuellement
// égale exactement le total retourné par le service d'agrégation.
// Validates: Requirements 5.1, 5.2, 5.3, 5.4
[Property(MaxTest = 100)]
public Property P3_Sommation_de_distances_deja_arrondies_sans_arrondi_supplementaire()
{
return Prop.ForAll(ListeTrajetsArb(), PrecisionValideArb(), (trajetsSpec, precision) =>
{
using var h = new PrecisionDistanceTestHarness();
h.PrecisionDistanceService.DefinirAsync(precision, CancellationToken.None)
.GetAwaiter().GetResult();
var vehiculeId = h.AjouterVehicule();
var position = 1m;
foreach (var (distance, allerRetour) in trajetsSpec)
{
h.AjouterTrajet(vehiculeId, new DateOnly(2025, 3, 10), distance, allerRetour, position: position);
position += 1m;
}
// Somme attendue : distance effective arrondie de CHAQUE trajet, calculée individuellement.
var sommeAttendue = trajetsSpec.Sum(t =>
t.Distance.HasValue
? h.PrecisionDistanceService.ArrondirDistance(t.Distance.Value, precision) * (t.AllerRetour ? 2m : 1m)
: 0m);
var resume = h.ResumeService.GetResumeMensuelAsync(2025, 3, vehiculeId, CancellationToken.None)
.GetAwaiter().GetResult();
resume.TotalKm.Should().Be(sommeAttendue);
});
}
// Tâche 5.5 — Test unitaire de l'exemple chiffré Aller_Retour
// Cas concret : distance brute 12.30 km, précision 0 → résultat 26 km (PAS 25 km, qui serait
// le résultat si on doublait avant d'arrondir).
// Validates: Requirements 4.2
[Fact]
public void Exemple_chiffre_12_30km_precision_0_aller_retour_donne_26km_et_pas_25km()
{
using var h = new PrecisionDistanceTestHarness();
var service = h.PrecisionDistanceService;
const decimal distanceBrute = 12.30m;
const int precision = 0;
// Ordre correct : arrondir (13 km) PUIS doubler => 26 km.
var arrondiSimple = service.ArrondirDistance(distanceBrute, precision);
arrondiSimple.Should().Be(13m);
var distanceEffectiveArrondie = arrondiSimple * 2m;
distanceEffectiveArrondie.Should().Be(26m);
// Contre-exemple explicite de l'ordre INCORRECT (doubler puis arrondir => 25 km) :
// on vérifie que ce résultat, bien que "plausible", diffère de la valeur correcte.
var doublerPuisArrondir = service.ArrondirDistance(distanceBrute * 2m, precision);
doublerPuisArrondir.Should().Be(25m);
distanceEffectiveArrondie.Should().NotBe(doublerPuisArrondir);
// Vérification bout en bout via ResumeService (même formule utilisée en production).
h.PrecisionDistanceService.DefinirAsync(precision, CancellationToken.None).GetAwaiter().GetResult();
var vehiculeId = h.AjouterVehicule();
h.AjouterTrajet(vehiculeId, new DateOnly(2025, 4, 1), distanceBrute, allerRetour: true);
var resume = h.ResumeService.GetResumeMensuelAsync(2025, 4, vehiculeId, CancellationToken.None)
.GetAwaiter().GetResult();
resume.TotalKm.Should().Be(26m);
resume.Trajets.Single().Distance.Should().Be(26m);
}
}
@@ -0,0 +1,122 @@
using System.Threading;
using FluentAssertions;
using FsCheck;
using FsCheck.Xunit;
using Xunit;
namespace SchotzIadKmTracker.Tests.PrecisionDistance;
/// <summary>
/// Tests de propriétés (FsCheck.Xunit, >= 100 itérations) du calcul prévisionnel
/// (feature precision-distances-km), couvrant les Correctness Properties 6 et 7 de la
/// conception (<see cref="MontantRegleService.GetMontantPrevisionnelAsync"/>).
/// </summary>
public class MontantPrevisionnelProperties
{
private const int Annee = 2025;
private const int PuissanceFiscale = 5;
/// <summary>Distances brutes positives et raisonnables (0 exclu, jusqu'à 500 km par trajet).</summary>
private static Arbitrary<decimal> DistancePositiveArb()
{
var g =
from entier in Gen.Choose(1, 50_000)
select entier / 100m; // pas de 0.01 km, jusqu'à 500.00 km
return Arb.From(g);
}
private static Arbitrary<int> PrecisionValideArb() => Arb.From(Gen.Elements(0, 1, 2));
/// <summary>Mois autre que le mois M passé en paramètre (1..12, différent de M).</summary>
private static Gen<int> AutreMoisGen(int mois) =>
Gen.Elements(Enumerable.Range(1, 12).Where(m => m != mois).ToArray());
// Feature: precision-distances-km, Property 6: Montant_Previsionnel_Mensuel dépend uniquement du mois affiché
// Pour tout mois M, le montant mensuel ne change pas si on modifie les distances d'un autre
// mois, tant que le cumul annuel reste dans la même tranche de barème.
// Validates: Requirements 7.1, 7.2
[Property(MaxTest = 100)]
public Property P6_Montant_previsionnel_mensuel_depend_uniquement_du_mois_affiche()
{
var entreesArb = Arb.From(
from mois in Gen.Choose(1, 12)
from distanceMois in DistancePositiveArb().Generator
from distanceAutre in DistancePositiveArb().Generator
from precision in PrecisionValideArb().Generator
select (mois, distanceMois, distanceAutre, precision));
return Prop.ForAll(entreesArb, entree =>
{
var (moisAffiche, distanceMoisAffiche, distanceAutreMoisInitiale, precision) = entree;
using var h = new PrecisionDistanceTestHarness();
h.PrecisionDistanceService.DefinirAsync(precision, CancellationToken.None)
.GetAwaiter().GetResult();
// Une seule tranche illimitée : garantit que le cumul annuel reste TOUJOURS
// dans la même tranche, quelle que soit la distance de l'autre mois modifiée.
h.AjouterTrancheBaremeUnique(Annee, PuissanceFiscale, coefficient: 0.35m, baseFixe: 0m);
var vehiculeId = h.AjouterVehicule(PuissanceFiscale);
var autreMois = AutreMoisGen(moisAffiche).Sample(0, 1).First();
h.AjouterTrajet(vehiculeId, new DateOnly(Annee, moisAffiche, 1), distanceMoisAffiche);
var trajetAutreMoisId = h.AjouterTrajet(
vehiculeId, new DateOnly(Annee, autreMois, 1), distanceAutreMoisInitiale, position: 2m);
var avant = h.MontantRegleService
.GetMontantPrevisionnelAsync(Annee, moisAffiche, vehiculeId, CancellationToken.None)
.GetAwaiter().GetResult();
// Modification de la distance de l'AUTRE mois (le mois affiché n'est jamais touché).
var trajetAutreMois = h.Context.Trajets.Single(t => t.Id == trajetAutreMoisId);
trajetAutreMois.Distance = distanceAutreMoisInitiale + 10m;
h.Context.SaveChanges();
var apres = h.MontantRegleService
.GetMontantPrevisionnelAsync(Annee, moisAffiche, vehiculeId, CancellationToken.None)
.GetAwaiter().GetResult();
// Le montant prévisionnel mensuel du mois affiché est inchangé (Requirement 7.1).
apres.MontantPrevisionnelMensuel.Should().Be(avant.MontantPrevisionnelMensuel);
apres.TotalMoisKm.Should().Be(avant.TotalMoisKm);
});
}
// Feature: precision-distances-km, Property 7: Montant_Previsionnel_Annuel égal au barème plein sur le cumul
// Pour tout Total_Annuel_Partiel arrondi, MontantPrevisionnelAnnuel == montant retourné par
// CalculerMontantAsync appliqué à ce total, coefficient 1.00 (pas de minoration), arrondi 2 décimales.
// Validates: Requirements 8.2, 8.5
[Property(MaxTest = 100)]
public Property P7_Montant_previsionnel_annuel_egal_au_bareme_plein_sur_le_cumul()
{
return Prop.ForAll(DistancePositiveArb(), PrecisionValideArb(), (distance, precision) =>
{
using var h = new PrecisionDistanceTestHarness();
h.PrecisionDistanceService.DefinirAsync(precision, CancellationToken.None)
.GetAwaiter().GetResult();
h.AjouterTrancheBaremeUnique(Annee, PuissanceFiscale, coefficient: 0.35m, baseFixe: 10m);
var vehiculeId = h.AjouterVehicule(PuissanceFiscale);
h.AjouterTrajet(vehiculeId, new DateOnly(Annee, 6, 1), distance);
var mois = 6;
var resultat = h.MontantRegleService
.GetMontantPrevisionnelAsync(Annee, mois, vehiculeId, CancellationToken.None)
.GetAwaiter().GetResult();
// Montant retourné par CalculerMontantAsync appliqué au même Total_Annuel_Partiel arrondi,
// sans minoration (coefficient 1.00), arrondi à 2 décimales.
var baremePlein = h.BaremeService
.CalculerMontantAsync(Annee, PuissanceFiscale, resultat.TotalAnnuelPartielKm, CancellationToken.None)
.GetAwaiter().GetResult();
var montantAttendu = Math.Round(baremePlein.Montant, 2, MidpointRounding.AwayFromZero);
resultat.MontantPrevisionnelAnnuel.Should().Be(montantAttendu);
});
}
}
@@ -0,0 +1,122 @@
using System.Threading;
using FluentAssertions;
using FsCheck;
using FsCheck.Xunit;
using SchotzIadKmTracker.Api.Services;
using SchotzIadKmTracker.Api.Services.Exceptions;
using Xunit;
namespace SchotzIadKmTracker.Tests.PrecisionDistance;
/// <summary>
/// Tests de propriétés (FsCheck.Xunit, >= 100 itérations) du service de précision des
/// distances (feature precision-distances-km), couvrant les Correctness Properties
/// 1, 4 et 5 de la conception.
/// </summary>
public class PrecisionDistanceServiceProperties
{
// --- Générateurs contraints à l'espace d'entrée pertinent ------------------
/// <summary>Distances brutes positives et raisonnables (0 exclu, jusqu'à 100 000 km).</summary>
private static Arbitrary<decimal> DistancePositiveArb()
{
var g =
from entier in Gen.Choose(1, 100_000_00)
select entier / 100m; // pas de 0.01 km, jusqu'à 100 000.00 km
return Arb.From(g);
}
/// <summary>Précisions valides : 0, 1 ou 2.</summary>
private static Arbitrary<int> PrecisionValideArb() => Arb.From(Gen.Elements(0, 1, 2));
/// <summary>Entiers arbitraires, y compris hors de {0, 1, 2} (négatifs et > 2).</summary>
private static Arbitrary<int> EntierArbitraireArb() => Arb.From(Gen.Choose(-1000, 1000));
// ---------------------------------------------------------------------------
// Feature: precision-distances-km, Property 1: Arrondi supérieur exact et jamais inférieur à la distance brute
// Pour toute distance brute positive et toute précision valide (0,1,2), ArrondirDistance
// retourne une valeur >= distance brute, et le résultat × 10^precision est un entier.
// Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5
[Property(MaxTest = 100)]
public Property P1_Arrondi_superieur_exact_et_jamais_inferieur()
{
return Prop.ForAll(DistancePositiveArb(), PrecisionValideArb(), (distance, precision) =>
{
using var h = new PrecisionDistanceTestHarness();
var resultat = h.PrecisionDistanceService.ArrondirDistance(distance, precision);
// Jamais inférieur à la distance brute.
resultat.Should().BeGreaterThanOrEqualTo(distance);
// Le résultat × 10^precision est un entier exact (multiple du pas de précision).
var facteur = (decimal)Math.Pow(10, precision);
var mis = resultat * facteur;
mis.Should().Be(Math.Round(mis, 0));
});
}
// Feature: precision-distances-km, Property 4: Validation stricte de la précision
// Pour tout entier, DefinirAsync accepte et persiste si et seulement si la valeur ∈ {0,1,2} ;
// sinon exception levée et valeur précédente inchangée.
// Validates: Requirements 1.6
[Property(MaxTest = 100)]
public Property P4_Validation_stricte_de_la_precision()
{
return Prop.ForAll(EntierArbitraireArb(), valeur =>
{
using var h = new PrecisionDistanceTestHarness();
// Valeur initiale connue, différente des valeurs de test invalides typiques.
var precedente = h.PrecisionDistanceService.DefinirAsync(1, CancellationToken.None)
.GetAwaiter().GetResult();
if (valeur is 0 or 1 or 2)
{
var resultat = h.PrecisionDistanceService.DefinirAsync(valeur, CancellationToken.None)
.GetAwaiter().GetResult();
resultat.Should().Be(valeur);
h.PrecisionDistanceService.ObtenirAsync(CancellationToken.None)
.GetAwaiter().GetResult().Should().Be(valeur);
}
else
{
Func<Task> action = () => h.PrecisionDistanceService.DefinirAsync(valeur, CancellationToken.None);
action.Should().ThrowAsync<PrecisionDistanceInvalideException>().GetAwaiter().GetResult();
// La valeur précédemment persistée (1) reste inchangée.
h.PrecisionDistanceService.ObtenirAsync(CancellationToken.None)
.GetAwaiter().GetResult().Should().Be(precedente);
}
});
}
// Feature: precision-distances-km, Property 5: Invariance de la distance brute stockée
// Un changement de Precision_Distance ne modifie jamais Trajet.Distance en base.
// Validates: Requirements 1.5, 1.8
[Property(MaxTest = 100)]
public Property P5_Invariance_de_la_distance_brute_stockee()
{
return Prop.ForAll(DistancePositiveArb(), PrecisionValideArb(), PrecisionValideArb(),
(distanceBrute, premierePrecision, deuxiemePrecision) =>
{
using var h = new PrecisionDistanceTestHarness();
var vehiculeId = h.AjouterVehicule();
var trajetId = h.AjouterTrajet(vehiculeId, new DateOnly(2025, 6, 15), distanceBrute);
// Deux changements successifs de précision (valides).
h.PrecisionDistanceService.DefinirAsync(premierePrecision, CancellationToken.None)
.GetAwaiter().GetResult();
h.PrecisionDistanceService.DefinirAsync(deuxiemePrecision, CancellationToken.None)
.GetAwaiter().GetResult();
// La distance brute stockée en base reste strictement inchangée.
var trajet = h.Context.Trajets.Single(t => t.Id == trajetId);
trajet.Distance.Should().Be(distanceBrute);
});
}
}
@@ -0,0 +1,114 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using SchotzIadKmTracker.Api.Data;
using SchotzIadKmTracker.Api.Models.Entities;
using SchotzIadKmTracker.Api.Services;
namespace SchotzIadKmTracker.Tests.PrecisionDistance;
/// <summary>
/// Banc d'essai pour tester les services liés à la précision des distances
/// (feature precision-distances-km) en isolation.
///
/// Utilise un <see cref="AppDbContext"/> adossé à une base SQLite EN MÉMOIRE
/// ("Data Source=:memory:") dont la connexion est maintenue ouverte pour la
/// durée du harnais (la base disparaît à la fermeture de la connexion), sur le
/// même modèle que <see cref="Tests.Auth.AuthTestHarness"/>.
///
/// Expose les services réels (<see cref="PrecisionDistanceService"/>, <see cref="BaremeService"/>,
/// <see cref="ResumeService"/>, <see cref="TotauxService"/>, <see cref="MontantRegleService"/>,
/// <see cref="RemboursementService"/>) branchés sur le même contexte, ainsi que des méthodes
/// utilitaires pour insérer rapidement Véhicules/Trajets/Tranches de barème de test.
/// </summary>
public sealed class PrecisionDistanceTestHarness : IDisposable
{
private readonly SqliteConnection _connection;
public AppDbContext Context { get; }
public IPrecisionDistanceService PrecisionDistanceService { get; }
public IBaremeService BaremeService { get; }
public IResumeService ResumeService { get; }
public IMontantRegleService MontantRegleService { get; }
public PrecisionDistanceTestHarness()
{
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open(); // maintenue ouverte : garde la base en vie
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(_connection)
.Options;
Context = new AppDbContext(options);
Context.Database.EnsureCreated();
PrecisionDistanceService = new SchotzIadKmTracker.Api.Services.PrecisionDistanceService(Context);
BaremeService = new SchotzIadKmTracker.Api.Services.BaremeService(Context);
ResumeService = new SchotzIadKmTracker.Api.Services.ResumeService(Context, PrecisionDistanceService);
MontantRegleService = new SchotzIadKmTracker.Api.Services.MontantRegleService(
Context, BaremeService, PrecisionDistanceService);
}
/// <summary>Insère un véhicule de test et retourne son identifiant.</summary>
public int AjouterVehicule(int puissanceFiscale = 5, string plaque = "AA-000-AA", DateOnly? dateEffet = null)
{
var vehicule = new Vehicule
{
Denomination = "Véhicule de test",
Plaque = plaque,
PuissanceFiscale = puissanceFiscale,
DateEffet = dateEffet ?? new DateOnly(2020, 1, 1),
};
Context.Vehicules.Add(vehicule);
Context.SaveChanges();
return vehicule.Id;
}
/// <summary>Insère un trajet de test et retourne son identifiant.</summary>
public int AjouterTrajet(
int vehiculeId,
DateOnly date,
decimal? distance,
bool allerRetour = false,
int puissanceFiscale = 5,
decimal position = 1m)
{
var trajet = new Trajet
{
Date = date,
AdresseDepart = "Départ test",
AdresseArrivee = "Arrivée test",
Distance = distance,
AllerRetour = allerRetour,
Position = position,
VehiculeId = vehiculeId,
PuissanceFiscale = puissanceFiscale,
};
Context.Trajets.Add(trajet);
Context.SaveChanges();
return trajet.Id;
}
/// <summary>Insère une tranche de barème unique (illimitée) couvrant tous les kilométrages.</summary>
public void AjouterTrancheBaremeUnique(int annee, int puissanceFiscale, decimal coefficient, decimal baseFixe = 0m)
{
Context.TranchesBareme.Add(new TrancheBareme
{
Annee = annee,
PuissanceFiscale = puissanceFiscale,
KmMin = 0m,
KmMax = null,
Coefficient = coefficient,
Base = baseFixe,
});
Context.SaveChanges();
}
public void Dispose()
{
Context.Dispose();
_connection.Dispose();
}
}
Executable
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Déploiement / mise à jour de SchotzIAD KM Tracker via Git + Docker Compose.
#
# Récupère la dernière version depuis Gitea (branche master) et ne reconstruit
# les conteneurs QUE si une nouvelle version est disponible. À exécuter sur le
# serveur, dans le dépôt cloné (voir DEPLOY.md pour le premier déploiement).
#
# Usage :
# ./deploy.sh Met à jour puis rebuild uniquement si nouveau commit.
# ./deploy.sh --force Rebuild même sans nouveau commit (ex. après édition du .env).
#
# Codes de sortie : 0 = succès (ou déjà à jour), 1 = erreur de prérequis/déploiement.
set -uo pipefail
RACINE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$RACINE"
BRANCHE="master"
FORCER=0
[[ "${1:-}" == "--force" ]] && FORCER=1
echo "→ Déploiement SchotzIAD (dépôt : $RACINE, branche : $BRANCHE)"
# --- 1. Prérequis -----------------------------------------------------------
erreur() { echo "✗ $1" >&2; exit 1; }
command -v git >/dev/null 2>&1 || erreur "git n'est pas installé (voir DEPLOY.md)."
command -v docker >/dev/null 2>&1 || erreur "docker n'est pas installé (voir DEPLOY.md)."
# Docker Compose v2 (plugin « docker compose ») ou v1 (« docker-compose »).
if docker compose version >/dev/null 2>&1; then
COMPOSE="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE="docker-compose"
else
erreur "docker compose introuvable (ni le plugin v2, ni docker-compose v1)."
fi
[[ -f "$RACINE/.env" ]] || erreur "Fichier .env absent. Copiez .env.example vers .env et renseignez-le (voir DEPLOY.md)."
# --- 2. Détection d'une nouvelle version ------------------------------------
echo "→ Récupération des références distantes (git fetch)…"
git fetch --quiet origin "$BRANCHE" || erreur "git fetch a échoué (accès à Gitea ?)."
LOCAL="$(git rev-parse "$BRANCHE")"
DISTANT="$(git rev-parse "origin/$BRANCHE")"
if [[ "$LOCAL" == "$DISTANT" && "$FORCER" -eq 0 ]]; then
echo "✓ Déjà à jour (commit ${LOCAL:0:8}). Aucun rebuild."
echo " (Utilisez ./deploy.sh --force pour reconstruire malgré tout.)"
exit 0
fi
# --- 3. Mise à jour du code -------------------------------------------------
if [[ "$LOCAL" != "$DISTANT" ]]; then
echo "→ Nouvelle version détectée : ${LOCAL:0:8} → ${DISTANT:0:8}. Mise à jour…"
git pull --ff-only origin "$BRANCHE" || erreur "git pull --ff-only a échoué (historique divergent ? voir DEPLOY.md)."
else
echo "→ Pas de nouveau commit, mais --force demandé : reconstruction."
fi
# --- 4. (Re)construction et démarrage ---------------------------------------
echo "→ Construction et démarrage des conteneurs ($COMPOSE up -d --build)…"
$COMPOSE up -d --build || erreur "Le déploiement Docker a échoué."
echo "→ État des conteneurs :"
$COMPOSE ps
echo "✓ Déploiement terminé (version ${DISTANT:0:8})."
+2 -8
View File
@@ -13,14 +13,8 @@
<a class="nav-bar__lien" routerLink="/carnet-adresses" routerLinkActive="nav-bar__lien--actif">
Carnet d'adresses
</a>
<a class="nav-bar__lien" routerLink="/montants-regles" routerLinkActive="nav-bar__lien--actif">
Montants réglés
</a>
<a class="nav-bar__lien" routerLink="/resume-mensuel" routerLinkActive="nav-bar__lien--actif">
Résumé mensuel
</a>
<a class="nav-bar__lien" routerLink="/resume-annuel" routerLinkActive="nav-bar__lien--actif">
Résumé annuel
<a class="nav-bar__lien" routerLink="/synthese-kms" routerLinkActive="nav-bar__lien--actif">
Synthèse Kms
</a>
<!-- Menu « Specs » : sous-menu (Exigences / Conception technique / Plan de tâches)
+8
View File
@@ -4,6 +4,7 @@ import { toSignal } from '@angular/core/rxjs-interop';
import { catchError, filter, map, of, startWith } from 'rxjs';
import { AuthService } from './core/services/auth.service';
import { ParametresApplicationService } from './core/services/parametres-application.service';
import { ProfilService } from './core/services/profil.service';
/**
@@ -28,6 +29,7 @@ export class AppComponent {
private readonly authService = inject(AuthService);
private readonly profilService = inject(ProfilService);
private readonly parametresService = inject(ParametresApplicationService);
private readonly router = inject(Router);
/** Chemin de la route active, mis à jour à chaque navigation terminée. */
@@ -89,6 +91,12 @@ export class AppComponent {
this.prenom.set(profil?.prenom ?? null);
this.nom.set(profil?.nom ?? null);
});
// Charge la précision de distance globale ; en cas d'échec, le signal
// `precisionDistance` reste à sa valeur par défaut (2) sans bloquer l'application.
this.parametresService
.charger()
.pipe(catchError(() => of(null)))
.subscribe();
} else {
this.prenom.set(null);
this.nom.set(null);
+13 -14
View File
@@ -9,13 +9,12 @@ import { ResetPasswordComponent } from './features/auth/reset-password/reset-pas
import { BaremeComponent } from './features/bareme/bareme.component';
import { CarnetAdressesComponent } from './features/carnet-adresses/carnet-adresses.component';
import { ConsultationJourComponent } from './features/consultation-jour/consultation-jour.component';
import { MontantsReglesComponent } from './features/montants-regles/montants-regles.component';
import { MaintenanceComponent } from './features/maintenance/maintenance.component';
import { OnboardingComponent } from './features/onboarding/onboarding.component';
import { ParametrageComponent } from './features/parametrage/parametrage.component';
import { ParametresApplicationComponent } from './features/parametres-application/parametres-application.component';
import { ProfilComponent } from './features/profil/profil.component';
import { ResumeAnnuelComponent } from './features/resume-annuel/resume-annuel.component';
import { ResumeMensuelComponent } from './features/resume-mensuel/resume-mensuel.component';
import { SyntheseKmsComponent } from './features/synthese-kms/synthese-kms.component';
import { VehiculesComponent } from './features/vehicules/vehicules.component';
/**
@@ -29,13 +28,14 @@ import { VehiculesComponent } from './features/vehicules/vehicules.component';
* et du premier véhicule — comportement inchangé).
* `authGuard` est vérifiée en premier ; on n'y applique pas `profilConfigureGuard`
* pour ne pas piéger un utilisateur partiellement configuré.
* - `parametrage` (menu latéral + routes enfants `compte` / `vehicules` / `bareme`,
* accessible depuis le sous-menu de l'avatar utilisateur) : `parametrage` lui-même
* et ses enfants `compte` / `vehicules` ne portent que `authGuard` (mêmes raisons
* que ci-dessus) ; seul l'enfant `bareme` ajoute `profilConfigureGuard`.
* - `parametrage` (menu latéral + routes enfants `compte` / `application` / `vehicules` /
* `bareme`, accessible depuis le sous-menu de l'avatar utilisateur) : `parametrage`
* lui-même et ses enfants `compte` / `application` / `vehicules` ne portent que
* `authGuard` (mêmes raisons que ci-dessus) ; seul l'enfant `bareme` ajoute
* `profilConfigureGuard`.
* - Routes de données PROTEGEES par `[authGuard, profilConfigureGuard]` :
* `consultation`, `carnet-adresses`, `resume-mensuel`, `resume-annuel`,
* `montants-regles`. L'authentification est contrôlée d'abord, puis la
* `consultation`, `carnet-adresses`, `synthese-kms`. L'authentification est
* contrôlée d'abord, puis la
* configuration du profil (redirection vers `/onboarding` si `estConfigure = false`).
*
* Redirections : `''` -> `consultation`, `parametrage` -> `parametrage/compte`,
@@ -64,8 +64,8 @@ export const routes: Routes = [
canActivate: [authGuard]
},
// Paramétrage : menu latéral (Compte, Véhicules, Barème) + contenu en routes enfants.
// - 'compte' et 'vehicules' : authGuard SEUL (pas profilConfigureGuard, pour ne pas
// Paramétrage : menu latéral (Compte, Application, Véhicules, Barème) + contenu en routes enfants.
// - 'compte', 'application' et 'vehicules' : authGuard SEUL (pas profilConfigureGuard, pour ne pas
// piéger un utilisateur dont le profil n'est pas encore configuré).
// - 'bareme' : authGuard + profilConfigureGuard (comportement inchangé).
{
@@ -75,6 +75,7 @@ export const routes: Routes = [
children: [
{ path: '', redirectTo: 'compte', pathMatch: 'full' },
{ path: 'compte', component: ProfilComponent },
{ path: 'application', component: ParametresApplicationComponent },
{ path: 'vehicules', component: VehiculesComponent },
{ path: 'bareme', component: BaremeComponent, canActivate: [profilConfigureGuard] }
]
@@ -83,9 +84,7 @@ export const routes: Routes = [
// Routes de données PROTEGEES : authGuard d'abord, puis profilConfigureGuard
{ path: 'consultation', component: ConsultationJourComponent, canActivate: [authGuard, profilConfigureGuard] },
{ path: 'carnet-adresses', component: CarnetAdressesComponent, canActivate: [authGuard, profilConfigureGuard] },
{ path: 'resume-mensuel', component: ResumeMensuelComponent, canActivate: [authGuard, profilConfigureGuard] },
{ path: 'resume-annuel', component: ResumeAnnuelComponent, canActivate: [authGuard, profilConfigureGuard] },
{ path: 'montants-regles', component: MontantsReglesComponent, canActivate: [authGuard, profilConfigureGuard] },
{ path: 'synthese-kms', component: SyntheseKmsComponent, canActivate: [authGuard, profilConfigureGuard] },
{ path: '**', redirectTo: 'consultation' }
];
@@ -4,6 +4,8 @@ import {
ActivatedRouteSnapshot,
RouterStateSnapshot
} from '@angular/router';
import { Observable, of, throwError } from 'rxjs';
import { isObservable } from 'rxjs';
import { authGuard } from './auth.guard';
import { AuthService } from '../services/auth.service';
@@ -14,11 +16,16 @@ import { AuthService } from '../services/auth.service';
* Tâche 10.4 — Exigences 4.6 (routes protégées), 9.3 (redirection onboarding/login).
* La garde est exécutée via `TestBed.runInInjectionContext` pour disposer du
* contexte d'injection (`inject(...)`).
*
* La garde retourne un `Observable<boolean | UrlTree>` : en présence d'un jeton local,
* elle valide la session côté serveur via `AuthService.validerSession()` avant de
* conclure (Exigence 9 : rejet d'un jeton résiduel dont le compte n'existe plus).
*/
describe('authGuard', () => {
let estAuthentifieValeur: boolean;
let urlTreeFactice: UrlTree;
let createUrlTreeSpy: jasmine.Spy;
let validerSessionSpy: jasmine.Spy;
// Paramètres de la CanActivateFn (non utilisés par la garde, mais requis).
const route = {} as ActivatedRouteSnapshot;
@@ -27,8 +34,10 @@ describe('authGuard', () => {
beforeEach(() => {
urlTreeFactice = {} as UrlTree;
validerSessionSpy = jasmine.createSpy('validerSession');
const authServiceMock: Partial<AuthService> = {
estAuthentifie: (() => estAuthentifieValeur) as AuthService['estAuthentifie']
estAuthentifie: (() => estAuthentifieValeur) as AuthService['estAuthentifie'],
validerSession: validerSessionSpy as unknown as AuthService['validerSession']
};
createUrlTreeSpy = jasmine.createSpy('createUrlTree').and.returnValue(urlTreeFactice);
@@ -44,20 +53,47 @@ describe('authGuard', () => {
});
});
it('retourne true lorsque l utilisateur est authentifié', () => {
estAuthentifieValeur = true;
/** Résout l'Observable (ou la valeur synchrone) retourné par la garde. */
function resoudre(resultat: unknown): Promise<unknown> {
return isObservable(resultat)
? new Promise((resolve) => (resultat as Observable<unknown>).subscribe((v) => resolve(v)))
: Promise.resolve(resultat);
}
const resultat = TestBed.runInInjectionContext(() => authGuard(route, state));
it('retourne true lorsque l utilisateur est authentifié et que la session est valide côté serveur', async () => {
estAuthentifieValeur = true;
validerSessionSpy.and.returnValue(of({ authentifie: true }));
const resultat = await resoudre(
TestBed.runInInjectionContext(() => authGuard(route, state))
);
expect(resultat).toBeTrue();
expect(validerSessionSpy).toHaveBeenCalled();
expect(createUrlTreeSpy).not.toHaveBeenCalled();
});
it('retourne un UrlTree vers /login lorsque l utilisateur n est pas authentifié', () => {
it('retourne un UrlTree vers /login lorsque aucun jeton local n est présent', async () => {
estAuthentifieValeur = false;
const resultat = TestBed.runInInjectionContext(() => authGuard(route, state));
const resultat = await resoudre(
TestBed.runInInjectionContext(() => authGuard(route, state))
);
expect(validerSessionSpy).not.toHaveBeenCalled();
expect(createUrlTreeSpy).toHaveBeenCalledWith(['/login']);
expect(resultat).toBe(urlTreeFactice);
});
it('retourne un UrlTree vers /login lorsque la validation de session échoue (jeton résiduel invalide)', async () => {
estAuthentifieValeur = true;
validerSessionSpy.and.returnValue(throwError(() => new Error('401')));
const resultat = await resoudre(
TestBed.runInInjectionContext(() => authGuard(route, state))
);
expect(validerSessionSpy).toHaveBeenCalled();
expect(createUrlTreeSpy).toHaveBeenCalledWith(['/login']);
expect(resultat).toBe(urlTreeFactice);
});
+3 -2
View File
@@ -34,9 +34,10 @@ export interface MontantPrevisionnel {
annee: number;
mois: number;
vehiculeId: number;
totalMoisKm: number;
totalAnnuelPartielKm: number;
montantBaremePlein: number; // barème plein sur le total partiel
montantPrevisionnel: number; // = 80% (indicatif uniquement)
montantPrevisionnelMensuel: number;
montantPrevisionnelAnnuel: number;
}
/** Ligne mensuelle de la synthèse annuelle — miroir de LigneSyntheseMoisDto. */
+1
View File
@@ -9,3 +9,4 @@ export * from './bareme.model';
export * from './adresse.model';
export * from './auth.model';
export * from './spec.model';
export * from './parametres-application.model';
@@ -0,0 +1,6 @@
// Modèle ParametresApplication — miroir de ParametresApplicationDtos.cs.
/** Paramètres globaux de l'application — miroir de ParametresApplicationDto. */
export interface ParametresApplication {
precisionDistanceKm: number;
}
+1
View File
@@ -9,3 +9,4 @@ export * from './profil.service';
export * from './bareme.service';
export * from './remboursement.service';
export * from './spec.service';
export * from './parametres-application.service';
@@ -0,0 +1,39 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject, signal } from '@angular/core';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { environment } from '../../../environments/environment';
import { ParametresApplication } from '../models';
/**
* Service HTTP des paramètres globaux de l'application.
*
* Porte le signal partagé `precisionDistance`, chargé une fois puis mis à jour après
* modification — mécanisme central de propagation de la précision d'arrondi des distances
* à toute l'application (liste des trajets, totaux, carte, PDF, barème).
*/
@Injectable({ providedIn: 'root' })
export class ParametresApplicationService {
private readonly http = inject(HttpClient);
private readonly baseUrl = environment.apiUrl;
/** Précision courante (0, 1 ou 2), partagée par toute l'application. Défaut 2 avant premier chargement. */
readonly precisionDistance = signal<0 | 1 | 2>(2);
/** Charge la précision courante depuis le backend et met à jour le signal partagé (GET /api/parametres-application). */
charger(): Observable<ParametresApplication> {
return this.http.get<ParametresApplication>(`${this.baseUrl}/parametres-application`).pipe(
tap((p) => this.precisionDistance.set(p.precisionDistanceKm as 0 | 1 | 2))
);
}
/** Modifie la précision et met à jour le signal partagé en cas de succès (PUT /api/parametres-application). */
definirPrecisionDistance(valeur: 0 | 1 | 2): Observable<ParametresApplication> {
return this.http
.put<ParametresApplication>(`${this.baseUrl}/parametres-application`, {
precisionDistanceKm: valeur
})
.pipe(tap((p) => this.precisionDistance.set(p.precisionDistanceKm as 0 | 1 | 2)));
}
}
@@ -1,4 +1,4 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
@@ -77,22 +77,46 @@ export class TrajetService {
});
}
/** Export PDF mensuel (GET /api/exports/mensuel/{annee}/{mois}). */
exportPdfMensuel(annee: number, mois: number, vehiculeId?: number): Observable<Blob> {
/**
* Export PDF mensuel (GET /api/exports/mensuel/{annee}/{mois}). Réponse complète
* (et non le seul Blob) afin que l'appelant puisse lire le nom de fichier suggéré
* par le backend depuis l'en-tête `Content-Disposition`.
*/
exportPdfMensuel(annee: number, mois: number, vehiculeId?: number): Observable<HttpResponse<Blob>> {
return this.http.get(`${this.baseUrl}/exports/mensuel/${annee}/${mois}`, {
params: this.vehiculeParams(vehiculeId),
responseType: 'blob'
responseType: 'blob',
observe: 'response'
});
}
/** Export PDF annuel (GET /api/exports/annuel/{annee}). */
exportPdfAnnuel(annee: number, vehiculeId?: number): Observable<Blob> {
/**
* Export PDF annuel (GET /api/exports/annuel/{annee}). Réponse complète (et non le
* seul Blob) afin que l'appelant puisse lire le nom de fichier suggéré par le backend
* depuis l'en-tête `Content-Disposition`.
*/
exportPdfAnnuel(annee: number, vehiculeId?: number): Observable<HttpResponse<Blob>> {
return this.http.get(`${this.baseUrl}/exports/annuel/${annee}`, {
params: this.vehiculeParams(vehiculeId),
responseType: 'blob'
responseType: 'blob',
observe: 'response'
});
}
/**
* Extrait le nom de fichier suggéré par le backend depuis l'en-tête
* `Content-Disposition: attachment; filename="..."` d'une réponse HTTP.
* Retourne `null` si l'en-tête est absent ou ne contient pas de nom de fichier.
*/
extraireNomFichier(reponse: HttpResponse<Blob>): string | null {
const entete = reponse.headers.get('Content-Disposition');
if (!entete) {
return null;
}
const correspondance = /filename\*?=(?:UTF-8'')?"?([^;"]+)"?/i.exec(entete);
return correspondance ? decodeURIComponent(correspondance[1]) : null;
}
/**
* Trajets d'une journée avec géométries pour la carte
* (GET /api/trajets/journee/{date}). La date est au format ISO (yyyy-MM-dd).
@@ -30,6 +30,11 @@
}
</div>
<div class="champ-case">
<input id="seSouvenir" type="checkbox" formControlName="seSouvenir" />
<label for="seSouvenir">Se souvenir de moi</label>
</div>
<div class="champ">
<label for="motDePasse">Mot de passe</label>
<app-password-input
@@ -38,6 +38,24 @@ h1 {
border-color: #c62828;
}
.champ-case {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.champ-case input {
width: 1rem;
height: 1rem;
cursor: pointer;
}
.champ-case label {
cursor: pointer;
user-select: none;
}
.erreur {
color: #c62828;
margin-top: 0.25rem;
@@ -9,6 +9,9 @@ import { PasswordInputComponent } from '../../../shared/components/password-inpu
import { NotificationService } from '../../../core/services/notification.service';
import { LoginDto } from '../../../core/models';
/** Clé localStorage de l'identifiant mémorisé via « Se souvenir de moi ». */
const CLE_EMAIL_MEMORISE = 'schotziad.login.emailMemorise';
/**
* Écran de connexion (`/login`) — Etape de session de l'authentification.
*
@@ -18,6 +21,14 @@ import { LoginDto } from '../../../core/models';
* pas encore configuré. Sur 401, un message générique « Identifiants invalides. »
* est affiché (Exigence 2.2).
*
* Case « Se souvenir de moi » : lorsqu'elle est cochée au moment d'une connexion
* réussie, l'identifiant (email) est stocké dans le `localStorage` du navigateur
* et pré-rempli aux prochaines visites sur cette même machine/navigateur. Sur un
* autre poste ou navigateur, aucun pré-remplissage n'a lieu tant qu'une connexion
* « se souvenir » n'y a pas été faite. Si la case est décochée lors d'une
* connexion, l'identifiant mémorisé est effacé et devra être ressaisi ensuite.
* Le mot de passe n'est jamais stocké.
*
* Contrainte zoneless : tout état async est exposé via des signals
* (`soumissionEnCours`, `erreur`, `compteExiste`).
*
@@ -51,9 +62,12 @@ export class LoginComponent implements OnInit {
readonly compteExiste = signal(true);
ngOnInit(): void {
const emailMemorise = this.lireEmailMemorise();
this.form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
motDePasse: ['', [Validators.required, Validators.minLength(8)]]
email: [emailMemorise ?? '', [Validators.required, Validators.email]],
motDePasse: ['', [Validators.required, Validators.minLength(8)]],
seSouvenir: [emailMemorise !== null]
});
this.authService.compteExiste().subscribe({
@@ -78,8 +92,9 @@ export class LoginComponent implements OnInit {
}
const valeur = this.form.getRawValue();
const email = (valeur.email ?? '').trim();
const dto: LoginDto = {
email: (valeur.email ?? '').trim(),
email,
motDePasse: valeur.motDePasse ?? ''
};
@@ -88,6 +103,8 @@ export class LoginComponent implements OnInit {
this.authService.login(dto).subscribe({
next: () => {
this.soumissionEnCours.set(false);
// Mémorisation de l'identifiant selon le choix « Se souvenir de moi ».
this.appliquerMemorisation(valeur.seSouvenir === true, email);
// La cible protégée oriente ensuite vers l'onboarding si nécessaire.
this.router.navigate(['/consultation']);
},
@@ -105,4 +122,33 @@ export class LoginComponent implements OnInit {
}
});
}
/**
* Lit l'identifiant mémorisé dans le `localStorage`, ou `null` si absent ou
* si le stockage est indisponible (mode privé, quota, etc.).
*/
private lireEmailMemorise(): string | null {
try {
const valeur = localStorage.getItem(CLE_EMAIL_MEMORISE);
return valeur && valeur.trim() !== '' ? valeur : null;
} catch {
return null;
}
}
/**
* Stocke l'identifiant si « Se souvenir de moi » est coché, sinon l'efface.
* Toute erreur d'accès au stockage est ignorée silencieusement.
*/
private appliquerMemorisation(seSouvenir: boolean, email: string): void {
try {
if (seSouvenir && email !== '') {
localStorage.setItem(CLE_EMAIL_MEMORISE, email);
} else {
localStorage.removeItem(CLE_EMAIL_MEMORISE);
}
} catch {
// Stockage indisponible : la mémorisation est simplement sans effet.
}
}
}
@@ -107,7 +107,7 @@
</button>
<button
type="button"
class="btn-secondaire"
class="btn-secondaire btn-annuler"
*ngIf="trancheEnEdition() !== null"
(click)="annulerEdition()"
>
@@ -83,6 +83,25 @@ h2 {
border-radius: 6px;
cursor: pointer;
font-size: 0.95rem;
// "Recopier" : même rendu que le bouton "Export PDF" des résumés
// (fond blanc/bleu, survol orange plein + texte blanc).
&:hover:not(:disabled) {
background: var(--couleur-orange);
border-color: var(--couleur-orange);
color: #fff;
}
}
// Bouton "Annuler" uniformisé : fond blanc, bordure grise, survol gris clair
// (même rendu que l'écran d'ajout/modification d'un trajet).
.btn-annuler {
color: #33475b;
border-color: #c4c9d1;
&:hover:not(:disabled) {
background: #f3f5f8;
}
}
.btn-lien {
@@ -66,7 +66,7 @@
<button type="submit" [disabled]="form.invalid || enregistrement()">
{{ enEdition ? 'Enregistrer' : 'Ajouter' }}
</button>
<button type="button" class="btn--secondary" (click)="annuler()">
<button type="button" class="btn--secondary btn--annuler" (click)="annuler()">
Annuler
</button>
</div>
@@ -1,7 +1,5 @@
.carnet {
padding: 1.5rem;
max-width: 1080px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 2rem;
@@ -139,6 +137,10 @@ button {
font-size: 0.9rem;
cursor: pointer;
&:hover:not(:disabled) {
background: var(--couleur-orange);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
@@ -148,6 +150,26 @@ button {
.btn--secondary {
background: #e0e0e0;
color: #333;
// "Modifier" (et autres actions secondaires) : passage à l'orange au survol,
// comme les boutons bleus.
&:hover:not(:disabled) {
background: var(--couleur-orange);
color: #fff;
}
}
// "Annuler" : rendu uniformisé sur tout le site — fond blanc, bordure grise,
// survol gris clair (même rendu que l'écran d'ajout/modification d'un trajet).
.btn--annuler {
background: #fff;
color: #33475b;
border: 1px solid #c4c9d1;
&:hover:not(:disabled) {
background: #f3f5f8;
color: #33475b;
}
}
.btn--danger {
@@ -17,6 +17,7 @@ import { Subscription } from 'rxjs';
import { TrajetCarte } from '../../core/models';
import { TrajetService } from '../../core/services';
import { ParametresApplicationService } from '../../core/services/parametres-application.service';
/**
* Palette de couleurs distinctes attribuée aux tracés successifs d'une journée.
@@ -72,6 +73,7 @@ type EtatCarte = 'chargement' | 'ok' | 'vide' | 'erreur';
export class CarteTrajetsComponent implements AfterViewInit, OnChanges, OnDestroy {
private readonly trajetService = inject(TrajetService);
private readonly cdr = inject(ChangeDetectorRef);
private readonly parametresService = inject(ParametresApplicationService);
/** Date de la journée à visualiser, au format ISO (yyyy-MM-dd). */
@Input() date: string | null = null;
@@ -419,7 +421,9 @@ export class CarteTrajetsComponent implements AfterViewInit, OnChanges, OnDestro
*/
private contenuInfobulle(trajet: TrajetCarte): string {
const distanceBase =
trajet.distance != null ? `${trajet.distance.toFixed(2)} km` : 'distance non calculée';
trajet.distance != null
? `${trajet.distance.toFixed(this.parametresService.precisionDistance())} km`
: 'distance non calculée';
// Précise l'aller/retour après la distance (la distance affichée est déjà la
// distance effective doublée côté API).
const distance = trajet.allerRetour ? `${distanceBase} (A/R)` : distanceBase;
@@ -15,8 +15,6 @@
min-height: 0;
gap: 1rem;
padding: 1.5rem;
max-width: 1400px;
margin: 0 auto;
box-sizing: border-box;
overflow: hidden; // aucun scroll global : seule la liste défile (voir &__liste)
@@ -1,75 +0,0 @@
<section class="montants-regles">
<h1>Montants réglés mensuels</h1>
<form [formGroup]="selectionForm" class="selection-form" (ngSubmit)="charger()">
<div class="champ">
<label for="annee">Année</label>
<input id="annee" type="number" formControlName="annee" min="2000" max="2100" />
</div>
<div class="champ">
<label for="vehicule">Véhicule</label>
<select id="vehicule" formControlName="vehiculeId">
<option [ngValue]="null" disabled>Sélectionner…</option>
<option *ngFor="let v of vehicules()" [ngValue]="v.id">
{{ v.denomination }} — {{ v.plaque }} — {{ v.puissanceFiscale }} CV
</option>
</select>
</div>
<button type="submit" class="btn-principal" [disabled]="selectionForm.invalid || chargement()">
Afficher
</button>
</form>
<p class="indication">
Le montant prévisionnel (80% du barème) est une simple indication. Seul le montant réglé
saisi fait foi.
</p>
<table class="mois" *ngIf="vehiculeId() !== null; else selectionner">
<thead>
<tr>
<th>Mois</th>
<th>Montant réglé (€)</th>
<th>Prévisionnel 80% (€)</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let ligne of lignes()">
<td>{{ ligne.libelle }}</td>
<td>
<input
type="number"
step="0.01"
min="0"
[(ngModel)]="ligne.montant"
[ngModelOptions]="{ standalone: true }"
[attr.aria-label]="'Montant réglé ' + ligne.libelle"
/>
</td>
<td class="previsionnel">
<ng-container *ngIf="ligne.previsionnel !== null; else nonConfigure">
{{ ligne.previsionnel | number: '1.2-2' }}
</ng-container>
<ng-template #nonConfigure>—</ng-template>
</td>
<td>
<button
type="button"
class="btn-lien"
[disabled]="chargement()"
(click)="enregistrer(ligne)"
>
Enregistrer
</button>
</td>
</tr>
</tbody>
</table>
<ng-template #selectionner>
<p class="vide">Sélectionnez une année et un véhicule pour saisir les montants.</p>
</ng-template>
</section>
@@ -1,109 +0,0 @@
.montants-regles {
padding: 1.5rem;
max-width: 720px;
margin: 0 auto;
}
h1 {
font-size: 1.4rem;
margin-bottom: 1rem;
}
.selection-form {
display: flex;
align-items: flex-end;
gap: 1rem;
flex-wrap: wrap;
}
.champ {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
}
.champ label {
font-weight: 600;
margin-bottom: 0.25rem;
}
.champ input,
.champ select {
padding: 0.5rem;
border: 1px solid #bbb;
border-radius: 6px;
font-size: 0.95rem;
}
.indication {
color: #555;
font-style: italic;
margin: 0.5rem 0 1rem;
}
.btn-principal {
padding: 0.55rem 1.25rem;
background: var(--couleur-bleu);
color: #fff;
border: 1px solid var(--couleur-bleu);
border-radius: 6px;
cursor: pointer;
font-size: 0.95rem;
&:hover:not(:disabled) {
background: var(--couleur-orange);
border-color: var(--couleur-orange);
}
}
.btn-principal:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn-lien {
background: none;
border: none;
color: #1565c0;
cursor: pointer;
padding: 0 0.5rem;
font-size: 0.9rem;
}
.btn-lien:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.vide {
color: #777;
}
.mois {
width: 100%;
border-collapse: collapse;
}
.mois th,
.mois td {
text-align: left;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #e0e0e0;
}
.mois th {
font-weight: 600;
background: #f5f5f5;
}
.mois input {
width: 8rem;
padding: 0.4rem;
border: 1px solid #bbb;
border-radius: 6px;
font-size: 0.95rem;
}
.previsionnel {
color: #555;
}
@@ -1,215 +0,0 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';
import {
FormBuilder,
FormGroup,
FormsModule,
ReactiveFormsModule,
Validators
} from '@angular/forms';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { BaremeService } from '../../core/services/bareme.service';
import { NotificationService } from '../../core/services/notification.service';
import { RemboursementService } from '../../core/services/remboursement.service';
import { VehiculeService } from '../../core/services/vehicule.service';
import { MontantRegle, Vehicule } from '../../core/models';
/** Libellés des mois affichés dans le tableau. */
const NOMS_MOIS = [
'Janvier',
'Février',
'Mars',
'Avril',
'Mai',
'Juin',
'Juillet',
'Août',
'Septembre',
'Octobre',
'Novembre',
'Décembre'
];
/** Ligne mensuelle de saisie (montant réglé + prévisionnel indicatif). */
interface LigneMois {
mois: number;
libelle: string;
montant: number | null;
previsionnel: number | null;
}
/**
* Saisie du Montant_Regle mensuel et affichage du Montant_Previsionnel
* indicatif (Exigences 14.1, 14.4, 14.8).
*
* - Sélection d'une année et d'un véhicule.
* - 12 lignes (une par mois) : saisie du Montant_Regle, affichage en regard
* du Montant_Previsionnel (80% du barème sur le Total_Annuel_Partiel),
* fourni par `BaremeService.getMontantPrevisionnel`.
* - Rejet client des montants négatifs (Exigence 14.4).
* - Tous les montants affichés avec deux décimales (Exigence 14.8).
* - Enregistrement via `RemboursementService.saveMontantRegle` (upsert).
*/
@Component({
selector: 'app-montants-regles',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, FormsModule],
templateUrl: './montants-regles.component.html',
styleUrl: './montants-regles.component.scss'
})
export class MontantsReglesComponent implements OnInit {
private readonly fb = inject(FormBuilder);
private readonly baremeService = inject(BaremeService);
private readonly remboursementService = inject(RemboursementService);
private readonly vehiculeService = inject(VehiculeService);
private readonly notification = inject(NotificationService);
/** Formulaire de sélection (année + véhicule). */
selectionForm!: FormGroup;
/** Liste des véhicules disponibles. */
readonly vehicules = signal<Vehicule[]>([]);
/** 12 lignes mensuelles. */
readonly lignes = signal<LigneMois[]>([]);
/** Année affichée. */
readonly annee = signal(new Date().getFullYear());
/** Véhicule affiché. */
readonly vehiculeId = signal<number | null>(null);
/** true pendant un appel réseau. */
readonly chargement = signal(false);
ngOnInit(): void {
this.selectionForm = this.fb.group({
annee: [this.annee(), [Validators.required, Validators.min(2000), Validators.max(2100)]],
vehiculeId: [null, [Validators.required]]
});
this.initialiserLignes();
this.vehiculeService.getVehicules().subscribe({
next: (liste) => {
const vehicules = [...liste].sort((a, b) => b.dateEffet.localeCompare(a.dateEffet));
this.vehicules.set(vehicules);
if (vehicules.length) {
this.selectionForm.patchValue({ vehiculeId: vehicules[0].id });
this.charger();
}
},
error: () => {
this.notification.error('Impossible de charger les véhicules.');
}
});
}
/** Réinitialise les 12 lignes mensuelles (montant et prévisionnel vides). */
private initialiserLignes(): void {
this.lignes.set(NOMS_MOIS.map((libelle, index) => ({
mois: index + 1,
libelle,
montant: null,
previsionnel: null
})));
}
/** Charge les montants réglés et les montants prévisionnels de la sélection. */
charger(): void {
if (this.selectionForm.invalid) {
this.selectionForm.markAllAsTouched();
return;
}
const valeur = this.selectionForm.getRawValue();
const annee = Number(valeur.annee);
const vehiculeId = Number(valeur.vehiculeId);
this.annee.set(annee);
this.vehiculeId.set(vehiculeId);
this.initialiserLignes();
this.chargement.set(true);
const montants$ = this.remboursementService
.getMontantsRegles(annee, vehiculeId)
.pipe(catchError(() => of([] as MontantRegle[])));
const previsionnels$ = this.lignes().map((ligne) =>
this.baremeService
.getMontantPrevisionnel(annee, ligne.mois, vehiculeId)
.pipe(catchError(() => of(null)))
);
forkJoin({ montants: montants$, previsionnels: forkJoin(previsionnels$) }).subscribe({
next: ({ montants, previsionnels }) => {
this.chargement.set(false);
// Reconstruit un nouveau tableau pour notifier le signal (nouvelle référence).
const lignes = this.lignes().map((l) => ({ ...l }));
for (const m of montants) {
const ligne = lignes.find((l) => l.mois === m.mois);
if (ligne) {
ligne.montant = m.montant;
}
}
previsionnels.forEach((p, index) => {
lignes[index].previsionnel = p ? p.montantPrevisionnel : null;
});
this.lignes.set(lignes);
},
error: () => {
this.chargement.set(false);
this.notification.error('Impossible de charger les montants.');
}
});
}
/**
* Enregistre le Montant_Regle d'un mois (upsert).
* Rejette côté client tout montant absent ou négatif (Exigence 14.4).
*/
enregistrer(ligne: LigneMois): void {
const vehiculeId = this.vehiculeId();
if (vehiculeId === null || this.chargement()) {
return;
}
const montant = ligne.montant;
if (montant === null || montant === undefined || Number.isNaN(Number(montant))) {
this.notification.error('Veuillez saisir un montant.');
return;
}
if (Number(montant) < 0) {
this.notification.error('Le montant réglé ne peut pas être négatif.');
return;
}
const dto: MontantRegle = {
annee: this.annee(),
mois: ligne.mois,
vehiculeId,
montant: Number(montant)
};
this.chargement.set(true);
this.remboursementService.saveMontantRegle(dto).subscribe({
next: (enregistre) => {
this.chargement.set(false);
// Reconstruit le tableau pour notifier le signal de la valeur normalisée.
this.lignes.set(
this.lignes().map((l) =>
l.mois === ligne.mois ? { ...l, montant: enregistre.montant } : l
)
);
this.notification.success(`Montant de ${ligne.libelle} enregistré.`);
},
error: () => {
this.chargement.set(false);
this.notification.error("Échec de l'enregistrement du montant.");
}
});
}
}
@@ -6,7 +6,7 @@
[attr.aria-expanded]="ouvert()"
(click)="basculerOuverture()">
<span class="selecteur-entete__chevron" aria-hidden="true">{{ ouvert() ? '▾' : '▸' }}</span>
<span class="selecteur-entete__libelle">📅 Choisir un mois</span>
<span class="selecteur-entete__libelle">{{ libelleEntete }}</span>
@if (libelleMoisAnnee) {
<span class="selecteur-entete__selection">{{ libelleMoisAnnee }}</span>
}
@@ -35,19 +35,20 @@
class="selecteur-groupe"
role="group"
aria-label="Sélection du mois"
*ngIf="anneeSelectionnee() !== null">
*ngIf="!modeAnneeSeule && anneeSelectionnee() !== null">
<span class="selecteur-titre">Mois</span>
<div class="selecteur-boutons">
<div class="selecteur-boutons selecteur-boutons--mois">
<button
type="button"
*ngFor="let m of mois"
class="pastille"
class="pastille pastille--mois"
[class.selectionne]="m === moisSelectionneSignal()"
[class.avec-donnees]="moisADonnees(m)"
[disabled]="estMoisFutur(anneeSelectionnee()!, m)"
[attr.aria-pressed]="m === moisSelectionneSignal()"
[title]="nomsMois[m - 1]"
(click)="selectionnerMois(m)">
{{ nomsMois[m - 1] }}
{{ nomsMoisCourts[m - 1] }}
</button>
</div>
</div>
@@ -67,6 +67,19 @@
gap: 0.4rem;
}
// Grille des mois : 6 colonnes de largeur égale → 2 rangées de 6, boutons de
// largeur constante (libellés courts centrés).
.selecteur-boutons--mois {
display: grid;
grid-template-columns: repeat(6, 1fr);
}
.pastille--mois {
text-align: center;
padding-left: 0.3rem;
padding-right: 0.3rem;
}
.selecteur-jours {
display: grid;
grid-template-columns: repeat(7, minmax(2.2rem, 1fr));
@@ -55,15 +55,31 @@ export class SelecteurTemporelComponent implements OnInit, OnChanges {
*/
@Input() date: string | null = null;
/** Émis à chaque changement de sélection (année + mois). */
/**
* Mode « année seule » : masque la grille des mois et limite le sélecteur au
* choix d'une année. La sélection d'une année émet alors `anneeSelectionnee`
* (et non `moisSelectionne`). Par défaut faux (mode année + mois).
*/
@Input() modeAnneeSeule = false;
/** Émis à chaque changement de sélection (année + mois) — mode année + mois. */
@Output() readonly moisSelectionne = new EventEmitter<MoisSelectionne>();
/** Libellés des douze mois (index 0 = janvier). */
/** Émis à chaque sélection d'année — mode « année seule ». */
@Output() readonly anneeChoisie = new EventEmitter<number>();
/** Libellés des douze mois (index 0 = janvier), utilisés pour l'en-tête « Mois Année ». */
readonly nomsMois = [
'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'
];
/** Libellés courts des douze mois (index 0 = janvier), pour les pastilles de la grille. */
readonly nomsMoisCourts = [
'Janv.', 'Févr.', 'Mars', 'Avr.', 'Mai', 'Juin',
'Juil.', 'Août', 'Sept.', 'Oct.', 'Nov.', 'Déc.'
];
/** Date du jour, tronquée à minuit, pour borner les sélections futures. */
private readonly aujourdHui = this.debutDeJournee(new Date());
@@ -87,6 +103,9 @@ export class SelecteurTemporelComponent implements OnInit, OnChanges {
*/
get libelleMoisAnnee(): string {
const annee = this.anneeSelectionnee();
if (this.modeAnneeSeule) {
return annee == null ? '' : `${annee}`;
}
const mois = this.moisSelectionneSignal();
if (annee == null || mois == null) {
return '';
@@ -94,6 +113,11 @@ export class SelecteurTemporelComponent implements OnInit, OnChanges {
return `${this.nomsMois[mois - 1]} ${annee}`;
}
/** Libellé du bouton d'en-tête, selon le mode. */
get libelleEntete(): string {
return this.modeAnneeSeule ? '📅 Choisir une année' : '📅 Choisir un mois';
}
/** Bascule l'ouverture / fermeture du sélecteur repliable. */
basculerOuverture(): void {
this.ouvert.update((v) => !v);
@@ -158,6 +182,14 @@ export class SelecteurTemporelComponent implements OnInit, OnChanges {
this.moisSelectionneSignal.set(null);
this.moisAvecDonnees.set(new Set<number>());
// Mode « année seule » : l'année est le niveau le plus bas → on émet et on
// replie automatiquement le sélecteur. Pas de chargement des mois (non affichés).
if (this.modeAnneeSeule) {
this.anneeChoisie.emit(annee);
this.ouvert.set(false);
return;
}
this.trajetService.getMoisDisponibles(annee).subscribe((mois) => {
this.moisAvecDonnees.set(new Set<number>(mois));
});
@@ -171,6 +203,8 @@ export class SelecteurTemporelComponent implements OnInit, OnChanges {
}
this.moisSelectionneSignal.set(mois);
this.moisSelectionne.emit({ annee, mois });
// Le mois est le niveau le plus bas en mode année + mois → repli automatique.
this.ouvert.set(false);
}
/** Un mois est en surbrillance s'il comporte au moins un trajet. */
@@ -8,15 +8,15 @@
<dl class="panneau-totaux__liste">
<div class="panneau-totaux__ligne">
<dt>Total du jour</dt>
<dd>{{ (totaux()?.totalJour ?? 0) | number: '1.2-2' }} km</dd>
</div>
<div class="panneau-totaux__ligne">
<dt>Total du mois</dt>
<dd>{{ (totaux()?.totalMois ?? 0) | number: '1.2-2' }} km</dd>
<dd>{{ (totaux()?.totalJour ?? 0) | number: formatKm() }} km</dd>
</div>
<div class="panneau-totaux__ligne panneau-totaux__ligne--fort">
<dt>Total du mois</dt>
<dd>{{ (totaux()?.totalMois ?? 0) | number: formatKm() }} km</dd>
</div>
<div class="panneau-totaux__ligne">
<dt>Total annuel partiel</dt>
<dd>{{ (totaux()?.totalAnnuelPartiel ?? 0) | number: '1.2-2' }} km</dd>
<dd>{{ (totaux()?.totalAnnuelPartiel ?? 0) | number: formatKm() }} km</dd>
</div>
</dl>
@@ -24,17 +24,46 @@
<div class="panneau-totaux__previsionnel">
<div class="panneau-totaux__ligne panneau-totaux__ligne--fort">
<span class="panneau-totaux__previsionnel-libelle">
Indication de versement (80%)
Remboursement indicatif pour ce mois
<button
type="button"
class="panneau-totaux__aide"
aria-label="En savoir plus sur le remboursement indicatif"
title="Simple indication de versement, calculée à 80% du barème sur le mois affiché. Le montant réellement versé est le montant réglé que vous saisissez."
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"></circle>
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
</button>
</span>
<span class="panneau-totaux__previsionnel-montant">
{{ montantPrevisionnel.montantPrevisionnel | number: '1.2-2' }} €
{{ montantPrevisionnel.montantPrevisionnelMensuel | number: '1.2-2' }} €
</span>
</div>
</div>
<div class="panneau-totaux__previsionnel">
<div class="panneau-totaux__ligne">
<span class="panneau-totaux__previsionnel-libelle">
Montant prévisionnel annuel
<button
type="button"
class="panneau-totaux__aide"
aria-label="En savoir plus sur le montant prévisionnel annuel"
title="Projection à 100% du barème sur le total annuel partiel, sans minoration. Cette projection ne se substitue pas au montant réel calculé en fin d'année."
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"></circle>
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
</button>
</span>
<span class="panneau-totaux__previsionnel-montant">
{{ montantPrevisionnel.montantPrevisionnelAnnuel | number: '1.2-2' }} €
</span>
</div>
<p class="panneau-totaux__previsionnel-note">
Simple indication de versement, calculée à 80% du barème sur le total
annuel partiel. Le montant réellement versé est le montant réglé que
vous saisissez.
</p>
</div>
}
</section>
@@ -13,6 +13,8 @@
margin: 0 0 12px;
font-size: 1.05rem;
color: #333;
text-align: center;
text-transform: uppercase;
}
&__erreur {
@@ -67,17 +69,32 @@
padding-top: 12px;
border-top: 1px solid #e0e0e0;
&-libelle {
display: inline-flex;
align-items: center;
gap: 6px;
}
&-montant {
font-variant-numeric: tabular-nums;
color: #1565c0;
}
}
&-note {
margin: 8px 0 0;
font-size: 0.8rem;
color: #777;
font-style: italic;
line-height: 1.35;
&__aide {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
background: none;
color: #888;
cursor: help;
line-height: 0;
&:hover,
&:focus-visible {
color: #1565c0;
}
}
}
@@ -14,6 +14,7 @@ import { Subscription } from 'rxjs';
import { MontantPrevisionnel, Totaux } from '../../core/models';
import { BaremeService } from '../../core/services/bareme.service';
import { ParametresApplicationService } from '../../core/services/parametres-application.service';
import { TrajetService } from '../../core/services/trajet.service';
/**
@@ -22,18 +23,21 @@ import { TrajetService } from '../../core/services/trajet.service';
*
* Reçoit en entrée une `date` (ISO `yyyy-MM-dd`) et un `vehiculeId`, puis affiche :
* - le total du jour, le total du mois et le Total_Annuel_Partiel (cumul de
* l'année jusqu'au mois sélectionné inclus), tous à deux décimales
* (Exigences 12.7, 12.8) ;
* - le Montant_Previsionnel minoré à 80%, calculé sur le Total_Annuel_Partiel du
* véhicule sélectionné, présenté explicitement comme une simple indication de
* l'année jusqu'au mois sélectionné inclus), formatés dynamiquement selon la
* précision courante des distances (0, 1 ou 2 décimales — Exigences 5.4, 5.5) ;
* - le Montant_Previsionnel_Mensuel minoré à 80%, calculé sur le seul mois affiché
* du véhicule sélectionné, présenté explicitement comme une simple indication de
* versement — le montant réellement versé étant le Montant_Regle saisi par
* l'utilisateur (Exigences 13.5, 13.6). Les montants sont exprimés en euros à
* deux décimales (Exigence 13.7).
* l'utilisateur (Exigences 13.5, 13.6) ;
* - le Montant_Previsionnel_Annuel, projection à 100% (sans minoration) sur le
* Total_Annuel_Partiel (Exigences 8.1, 8.3, 8.4).
* Les montants sont exprimés en euros à deux décimales, indépendamment de la
* précision des distances (Exigence 13.7, 8.4).
*
* Les totaux et le montant prévisionnel sont recalculés à chaque changement de
* `date` ou de `vehiculeId` (recalcul au changement, sans rechargement complet).
*
* _Requirements: 12.7, 12.8, 13.5, 13.6, 13.7, 13.8_
* _Requirements: 5.4, 5.5, 8.1, 8.3, 8.4, 12.7, 12.8, 13.5, 13.6, 13.7, 13.8_
*/
@Component({
selector: 'app-panneau-totaux',
@@ -47,6 +51,7 @@ export class PanneauTotauxComponent implements OnChanges, OnDestroy {
private readonly trajetService = inject(TrajetService);
private readonly baremeService = inject(BaremeService);
private readonly cdr = inject(ChangeDetectorRef);
readonly parametresService = inject(ParametresApplicationService);
/** Date sélectionnée au format ISO (`yyyy-MM-dd`). */
@Input() date: string | null = null;
@@ -140,6 +145,15 @@ export class PanneauTotauxComponent implements OnChanges, OnDestroy {
});
}
/**
* Chaîne de format `number` (`1.{p}-{p}`) basée sur la précision courante des
* distances (0, 1 ou 2 décimales), pour un affichage dynamique des totaux en km.
*/
formatKm(): string {
const precision = this.parametresService.precisionDistance();
return `1.${precision}-${precision}`;
}
/** Année (nombre) extraite d'une date ISO `yyyy-MM-dd`. */
private anneeDe(dateIso: string): number {
return Number(dateIso.slice(0, 4));
@@ -10,6 +10,13 @@
>
Compte
</a>
<a
class="parametrage__lien"
routerLink="/parametrage/application"
routerLinkActive="parametrage__lien--actif"
>
Application
</a>
<a
class="parametrage__lien"
routerLink="/parametrage/vehicules"
@@ -0,0 +1,29 @@
<section class="parametres-application">
<h1>Application</h1>
<h2>Nombre de décimales des distances (km)</h2>
<div class="choix-precision" role="group" aria-label="Nombre de décimales des distances (km)">
@for (valeur of precisionsDisponibles; track valeur) {
<button
type="button"
class="btn-precision"
[class.btn-precision--actif]="precisionSelectionnee() === valeur"
[disabled]="enregistrement()"
[attr.aria-pressed]="precisionSelectionnee() === valeur"
(click)="choisir(valeur)"
>
{{ valeur }}
</button>
}
</div>
@if (enregistrement()) {
<p class="enregistrement-en-cours">Enregistrement en cours…</p>
}
<p class="aide">
L'arrondi appliqué aux distances est toujours supérieur (jamais vers le bas), quelle que
soit la précision choisie.
</p>
</section>
@@ -0,0 +1,65 @@
.parametres-application {
padding: 1.5rem;
max-width: 720px;
margin: 0 auto;
}
h1 {
font-size: 1.4rem;
margin-bottom: 1rem;
}
h2 {
font-size: 1.1rem;
margin: 1.5rem 0 0.75rem;
}
.choix-precision {
display: flex;
gap: 0.75rem;
}
.btn-precision {
padding: 0.55rem 1.25rem;
background: #fff;
color: var(--couleur-bleu);
border: 1px solid var(--couleur-bleu);
border-radius: 6px;
cursor: pointer;
font-size: 0.95rem;
min-width: 3rem;
&:hover:not(:disabled) {
border-color: var(--couleur-orange);
color: var(--couleur-orange);
}
}
.btn-precision--actif {
background: var(--couleur-bleu);
color: #fff;
border-color: var(--couleur-bleu);
&:hover:not(:disabled) {
background: var(--couleur-orange);
border-color: var(--couleur-orange);
color: #fff;
}
}
.btn-precision:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.enregistrement-en-cours {
margin-top: 0.75rem;
color: #777;
font-style: italic;
}
.aide {
margin-top: 1rem;
color: #555;
font-size: 0.9rem;
}
@@ -0,0 +1,63 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';
import { NotificationService } from '../../core/services/notification.service';
import { ParametresApplicationService } from '../../core/services/parametres-application.service';
/**
* Paramétrage global de l'application : précision d'arrondi des distances (km).
*
* - Présélectionne la valeur courante au chargement (Exigence 2.3).
* - Applique le choix immédiatement (optimiste) puis confirme ou restaure/notifie
* en cas d'échec (Exigences 2.4, 2.5).
*/
@Component({
selector: 'app-parametres-application',
standalone: true,
imports: [CommonModule],
templateUrl: './parametres-application.component.html',
styleUrl: './parametres-application.component.scss'
})
export class ParametresApplicationComponent implements OnInit {
private readonly parametresService = inject(ParametresApplicationService);
private readonly notification = inject(NotificationService);
/** Valeur sélectionnée (0, 1 ou 2 décimales). */
readonly precisionSelectionnee = signal<0 | 1 | 2>(2);
/** true pendant l'appel d'enregistrement. */
readonly enregistrement = signal(false);
/** Valeurs de précision proposées (0, 1 ou 2 décimales). */
readonly precisionsDisponibles: readonly (0 | 1 | 2)[] = [0, 1, 2];
ngOnInit(): void {
this.precisionSelectionnee.set(this.parametresService.precisionDistance());
}
/**
* Sélectionne une nouvelle précision et l'enregistre côté serveur.
* Mise à jour optimiste du signal local, restaurée en cas d'échec (Exigence 2.5).
*/
choisir(valeur: 0 | 1 | 2): void {
if (valeur === this.precisionSelectionnee() || this.enregistrement()) {
return;
}
const precedente = this.precisionSelectionnee();
this.precisionSelectionnee.set(valeur);
this.enregistrement.set(true);
this.parametresService.definirPrecisionDistance(valeur).subscribe({
next: () => {
this.enregistrement.set(false);
this.notification.success('Précision des distances enregistrée.');
},
error: () => {
this.enregistrement.set(false);
this.precisionSelectionnee.set(precedente);
this.notification.error("La modification n'a pas pu être enregistrée.");
}
});
}
}
@@ -1,58 +0,0 @@
<section class="resume-annuel">
<h1>Résumé annuel</h1>
<form class="selecteurs" (ngSubmit)="charger()">
<div class="champ">
<label for="annee">Année</label>
<input
id="annee"
name="annee"
type="number"
[(ngModel)]="annee"
[min]="anneeMin"
[max]="anneeMax"
/>
</div>
<div class="actions">
<button type="submit" class="btn-principal" [disabled]="chargement()">
Afficher
</button>
<button
type="button"
class="btn-secondaire"
(click)="exporterPdf()"
[disabled]="export() || chargement()"
>
Export PDF annuel
</button>
</div>
</form>
@if (chargement()) {
<p class="info">Chargement du résumé…</p>
} @else if (resumeCharge()) {
<table class="mois">
<thead>
<tr>
<th>Mois</th>
<th class="num">Total (km)</th>
</tr>
</thead>
<tbody>
@for (ligne of lignes(); track ligne.mois) {
<tr>
<td>{{ ligne.libelle }}</td>
<td class="num">{{ ligne.totalKm | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td>Total annuel</td>
<td class="num total">{{ totalAnnuel() | number: '1.2-2' }} km</td>
</tr>
</tfoot>
</table>
}
</section>
@@ -1,105 +0,0 @@
.resume-annuel {
padding: 1.5rem;
max-width: 640px;
margin: 0 auto;
}
h1 {
font-size: 1.4rem;
margin-bottom: 1rem;
}
.selecteurs {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 1rem;
margin-bottom: 1.5rem;
}
.champ {
display: flex;
flex-direction: column;
}
.champ label {
font-weight: 600;
margin-bottom: 0.25rem;
}
.champ input {
padding: 0.5rem;
border: 1px solid #bbb;
border-radius: 6px;
font-size: 0.95rem;
}
.actions {
display: flex;
gap: 0.75rem;
}
.btn-principal,
.btn-secondaire {
padding: 0.55rem 1.25rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.95rem;
}
.btn-principal {
background: var(--couleur-bleu);
color: #fff;
border: 1px solid var(--couleur-bleu);
&:hover:not(:disabled) {
background: var(--couleur-orange);
border-color: var(--couleur-orange);
}
}
.btn-secondaire {
background: #fff;
color: #1565c0;
border: 1px solid #1565c0;
}
.btn-principal:disabled,
.btn-secondaire:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.mois {
width: 100%;
border-collapse: collapse;
}
.mois th,
.mois td {
text-align: left;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #e0e0e0;
}
.mois th {
font-weight: 600;
background: #f5f5f5;
}
.mois .num {
text-align: right;
}
.mois tfoot td {
font-weight: 600;
border-top: 2px solid #bbb;
}
.mois .total {
color: #1565c0;
}
.info {
color: #555;
}
@@ -1,167 +0,0 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NotificationService } from '../../core/services/notification.service';
import { TrajetService } from '../../core/services/trajet.service';
import { ResumeAnnuel } from '../../core/models';
/** Libellés des mois affichés dans le tableau annuel (index 0 = janvier). */
const NOMS_MOIS = [
'Janvier',
'Février',
'Mars',
'Avril',
'Mai',
'Juin',
'Juillet',
'Août',
'Septembre',
'Octobre',
'Novembre',
'Décembre'
];
/** Ligne du tableau annuel : un mois et son total kilométrique. */
export interface LigneAnnuelle {
mois: number;
libelle: string;
totalKm: number;
}
/**
* Résumé annuel (Exigences 4.1, 4.5, 4.6, 7.3).
*
* - Sélecteur année (2000–2100).
* - Tableau de 12 lignes (mois, total km) + ligne du total annuel à 2 décimales,
* plafonné à 999 999,99 (Exigence 4.5).
* - Bouton « Export PDF annuel » : Blob → téléchargement navigateur, repli
* `trajets_AAAA.pdf` ; échec de téléchargement → message d'erreur (Exigences 4.6, 7.3).
* - En cas d'erreur API, aucune donnée partielle n'est affichée.
*/
@Component({
selector: 'app-resume-annuel',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './resume-annuel.component.html',
styleUrl: './resume-annuel.component.scss'
})
export class ResumeAnnuelComponent implements OnInit {
private readonly trajetService = inject(TrajetService);
private readonly notification = inject(NotificationService);
/** Bornes de saisie de l'année (cohérentes avec la validation backend). */
readonly anneeMin = 2000;
readonly anneeMax = 2100;
/** Plafond d'affichage du total annuel (Exigence 4.5). */
readonly totalMax = 999999.99;
/** Année sélectionnée, initialisée à l'année en cours. */
annee: number = new Date().getFullYear();
/** 12 lignes (mois → total km) dérivées du résumé chargé. */
readonly lignes = signal<LigneAnnuelle[]>([]);
/** Total annuel plafonné à 999 999,99. */
readonly totalAnnuel = signal(0);
/** true tant qu'aucun chargement réussi (masque le tableau). */
readonly resumeCharge = signal(false);
/** true pendant le chargement du résumé. */
readonly chargement = signal(false);
/** true pendant la génération/téléchargement du PDF (désactive le bouton). */
readonly export = signal(false);
ngOnInit(): void {
this.charger();
}
/** Charge le résumé annuel pour l'année sélectionnée. */
charger(): void {
if (!this.anneeValide()) {
this.notification.error('Veuillez saisir une année valide (2000–2100).');
return;
}
this.chargement.set(true);
this.trajetService.getResumeAnnuel(this.annee).subscribe({
next: (resume) => {
this.appliquerResume(resume);
this.chargement.set(false);
this.resumeCharge.set(true);
},
error: () => {
// Ne pas afficher de données partielles en cas d'erreur.
this.lignes.set([]);
this.totalAnnuel.set(0);
this.resumeCharge.set(false);
this.chargement.set(false);
this.notification.error('Impossible de charger le résumé annuel.');
}
});
}
/** Déclenche l'export PDF annuel et le téléchargement navigateur (Exigences 4.6, 7.3). */
exporterPdf(): void {
if (!this.anneeValide() || this.export()) {
return;
}
this.export.set(true);
this.trajetService.exportPdfAnnuel(this.annee).subscribe({
next: (blob) => {
this.export.set(false);
this.declencherTelechargement(blob, `trajets_${this.annee}.pdf`);
},
error: () => {
this.export.set(false);
this.notification.error("Échec du téléchargement du PDF annuel.");
}
});
}
/**
* Construit les 12 lignes à partir du résumé (toujours 12 mois côté backend,
* mais on garantit l'ordre et la présence des 12 mois) et plafonne le total.
*/
private appliquerResume(resume: ResumeAnnuel): void {
const parMois = new Map<number, number>();
for (const m of resume.mois ?? []) {
parMois.set(m.mois, m.totalKm ?? 0);
}
this.lignes.set(NOMS_MOIS.map((libelle, index) => {
const mois = index + 1;
return { mois, libelle, totalKm: parMois.get(mois) ?? 0 };
}));
this.totalAnnuel.set(Math.min(resume.totalKm ?? 0, this.totalMax));
}
/**
* Crée une URL d'objet à partir du Blob et déclenche un téléchargement via
* une ancre temporaire, puis libère l'URL.
*/
private declencherTelechargement(blob: Blob, nomFichier: string): void {
const url = URL.createObjectURL(blob);
const ancre = document.createElement('a');
ancre.href = url;
ancre.download = nomFichier;
document.body.appendChild(ancre);
ancre.click();
document.body.removeChild(ancre);
URL.revokeObjectURL(url);
}
/** Vérifie que l'année sélectionnée est dans les bornes attendues. */
private anneeValide(): boolean {
return (
Number.isInteger(this.annee) &&
this.annee >= this.anneeMin &&
this.annee <= this.anneeMax
);
}
}
@@ -1,77 +0,0 @@
<section class="resume-mensuel">
<h1>Résumé mensuel</h1>
<form class="selecteurs" (ngSubmit)="charger()">
<div class="champ">
<label for="mois">Mois</label>
<select id="mois" name="mois" [(ngModel)]="mois">
@for (m of moisDisponibles; track m) {
<option [ngValue]="m">{{ m }}</option>
}
</select>
</div>
<div class="champ">
<label for="annee">Année</label>
<input
id="annee"
name="annee"
type="number"
[(ngModel)]="annee"
[min]="anneeMin"
[max]="anneeMax"
/>
</div>
<div class="actions">
<button type="submit" class="btn-principal" [disabled]="chargement()">
Afficher
</button>
<button
type="button"
class="btn-secondaire"
(click)="exporterPdf()"
[disabled]="export() || chargement()"
>
Export PDF mensuel
</button>
</div>
</form>
@if (chargement()) {
<p class="info">Chargement du résumé…</p>
} @else if (resume(); as resume) {
<table class="trajets">
<thead>
<tr>
<th>Date</th>
<th>Départ</th>
<th>Arrivée</th>
<th class="num">Distance (km)</th>
</tr>
</thead>
<tbody>
@for (trajet of resume.trajets; track trajet.id) {
<tr>
<td>{{ trajet.date | date: 'dd/MM/yyyy' }}</td>
<td>{{ trajet.adresseDepart }}</td>
<td>{{ trajet.adresseArrivee }}</td>
<td class="num">
{{ trajet.distance != null ? (trajet.distance | number: '1.2-2') : '—' }}
</td>
</tr>
} @empty {
<tr>
<td colspan="4" class="vide">Aucun trajet pour ce mois.</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="3">Total mensuel</td>
<td class="num total">{{ resume.totalKm | number: '1.2-2' }} km</td>
</tr>
</tfoot>
</table>
}
</section>
@@ -1,111 +0,0 @@
.resume-mensuel {
padding: 1.5rem;
max-width: 900px;
margin: 0 auto;
}
h1 {
font-size: 1.4rem;
margin-bottom: 1rem;
}
.selecteurs {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 1rem;
margin-bottom: 1.5rem;
}
.champ {
display: flex;
flex-direction: column;
}
.champ label {
font-weight: 600;
margin-bottom: 0.25rem;
}
.champ select,
.champ input {
padding: 0.5rem;
border: 1px solid #bbb;
border-radius: 6px;
font-size: 0.95rem;
}
.actions {
display: flex;
gap: 0.75rem;
}
.btn-principal,
.btn-secondaire {
padding: 0.55rem 1.25rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.95rem;
}
.btn-principal {
background: var(--couleur-bleu);
color: #fff;
border: 1px solid var(--couleur-bleu);
&:hover:not(:disabled) {
background: var(--couleur-orange);
border-color: var(--couleur-orange);
}
}
.btn-secondaire {
background: #fff;
color: #1565c0;
border: 1px solid #1565c0;
}
.btn-principal:disabled,
.btn-secondaire:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.trajets {
width: 100%;
border-collapse: collapse;
}
.trajets th,
.trajets td {
text-align: left;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #e0e0e0;
}
.trajets th {
font-weight: 600;
background: #f5f5f5;
}
.trajets .num {
text-align: right;
}
.trajets tfoot td {
font-weight: 600;
border-top: 2px solid #bbb;
}
.trajets .total {
color: #1565c0;
}
.vide {
color: #777;
text-align: center;
}
.info {
color: #555;
}
@@ -1,131 +0,0 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NotificationService } from '../../core/services/notification.service';
import { TrajetService } from '../../core/services/trajet.service';
import { ResumeMensuel } from '../../core/models';
/**
* Résumé mensuel (Exigences 3.1, 3.5, 3.7, 7.3).
*
* - Sélecteurs mois (1–12) et année (2000–2100).
* - Affiche la liste des trajets du mois et le total mensuel à 2 décimales.
* - En cas d'erreur API, affiche un message et n'affiche aucune donnée partielle
* (le résumé est remis à null, Exigence 3.7).
* - Bouton « Export PDF mensuel » : récupère le Blob via `TrajetService`, puis
* déclenche le téléchargement navigateur (URL d'objet + ancre). Le nom du
* fichier provient de l'en-tête `Content-Disposition` s'il est présent, sinon
* d'un repli `trajets_MM_AAAA.pdf`. Un échec de téléchargement affiche un
* message d'erreur (Exigence 7.3).
*/
@Component({
selector: 'app-resume-mensuel',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './resume-mensuel.component.html',
styleUrl: './resume-mensuel.component.scss'
})
export class ResumeMensuelComponent implements OnInit {
private readonly trajetService = inject(TrajetService);
private readonly notification = inject(NotificationService);
/** Bornes de saisie de l'année (cohérentes avec la validation backend). */
readonly anneeMin = 2000;
readonly anneeMax = 2100;
/** Liste des mois pour le sélecteur (1–12). */
readonly moisDisponibles = Array.from({ length: 12 }, (_, i) => i + 1);
/** Sélection courante, initialisée au mois/année en cours. */
annee: number = new Date().getFullYear();
mois: number = new Date().getMonth() + 1;
/** Résumé chargé (null tant qu'aucun chargement réussi ou après une erreur). */
readonly resume = signal<ResumeMensuel | null>(null);
/** true pendant le chargement du résumé. */
readonly chargement = signal(false);
/** true pendant la génération/téléchargement du PDF (désactive le bouton). */
readonly export = signal(false);
ngOnInit(): void {
this.charger();
}
/** Charge le résumé mensuel pour la sélection courante. */
charger(): void {
if (!this.selectionValide()) {
this.notification.error('Veuillez saisir un mois (1–12) et une année valides (2000–2100).');
return;
}
this.chargement.set(true);
this.trajetService.getResumeMensuel(this.annee, this.mois).subscribe({
next: (resume) => {
this.resume.set(resume);
this.chargement.set(false);
},
error: () => {
// Ne pas afficher de données partielles en cas d'erreur (Exigence 3.7).
this.resume.set(null);
this.chargement.set(false);
this.notification.error('Impossible de charger le résumé mensuel.');
}
});
}
/** Déclenche l'export PDF mensuel et le téléchargement navigateur (Exigence 7.3). */
exporterPdf(): void {
if (!this.selectionValide() || this.export()) {
return;
}
this.export.set(true);
this.trajetService.exportPdfMensuel(this.annee, this.mois).subscribe({
next: (blob) => {
this.export.set(false);
const nom = this.nomFichierParDefaut();
this.declencherTelechargement(blob, nom);
},
error: () => {
this.export.set(false);
this.notification.error("Échec du téléchargement du PDF mensuel.");
}
});
}
/** Nom de fichier de repli au format `trajets_MM_AAAA.pdf`. */
private nomFichierParDefaut(): string {
const mm = String(this.mois).padStart(2, '0');
return `trajets_${mm}_${this.annee}.pdf`;
}
/**
* Crée une URL d'objet à partir du Blob et déclenche un téléchargement via
* une ancre temporaire, puis libère l'URL.
*/
private declencherTelechargement(blob: Blob, nomFichier: string): void {
const url = URL.createObjectURL(blob);
const ancre = document.createElement('a');
ancre.href = url;
ancre.download = nomFichier;
document.body.appendChild(ancre);
ancre.click();
document.body.removeChild(ancre);
URL.revokeObjectURL(url);
}
/** Vérifie que la sélection mois/année est dans les bornes attendues. */
private selectionValide(): boolean {
return (
Number.isInteger(this.mois) &&
this.mois >= 1 &&
this.mois <= 12 &&
Number.isInteger(this.annee) &&
this.annee >= this.anneeMin &&
this.annee <= this.anneeMax
);
}
}
@@ -0,0 +1,119 @@
<section class="synthese-kms">
<!-- Titre et zone de critères sur une même ligne (comme l'en-tête du carnet d'adresses). -->
<div class="synthese-kms__entete">
<h1>Synthèse Kms</h1>
<!-- Sélection : année (sélecteur temporel en mode année seule) → véhicule → Afficher. -->
<div class="selection">
<app-selecteur-temporel
[modeAnneeSeule]="true"
[date]="dateSelecteur()"
(anneeChoisie)="onAnneeChoisie($event)"
></app-selecteur-temporel>
<div class="champ">
<label for="vehicule">Véhicule</label>
<select id="vehicule" [ngModel]="vehiculeId()" (ngModelChange)="vehiculeId.set($event)">
<option [ngValue]="null" disabled>Sélectionner…</option>
<option *ngFor="let v of vehicules()" [ngValue]="v.id">
{{ v.denomination }} — {{ v.plaque }} — {{ v.puissanceFiscale }} CV
</option>
</select>
</div>
<button
type="button"
class="btn-principal"
[disabled]="vehiculeId() === null || chargement()"
(click)="afficher()"
>
Afficher
</button>
</div>
</div>
<ng-container *ngIf="chargee() && synthese() as reg; else aucun">
<!-- Barème appliqué -->
<p class="bareme">
<strong>Barème appliqué :</strong>
tranche {{ reg.trancheLibelle }} ({{ reg.puissanceFiscale }} CV) —
coefficient {{ reg.coefficientTranche | number: '1.3-3' }} / base {{ reg.baseTranche | number: '1.0-0' }}
</p>
<!-- Barre d'action : enregistrement global des montants réglés modifiés. -->
<div class="actions">
<button type="button" class="btn-principal" [disabled]="chargement()" (click)="enregistrer()">
Enregistrer
</button>
</div>
<table class="synthese">
<thead>
<tr>
<th>Mois</th>
<th class="num">Kms parcourus</th>
<th class="num">Remb. calculé (mois)</th>
<th class="num">Indication 80%</th>
<th class="num">Montant réglé (€)</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let ligne of lignes()">
<td>{{ ligne.libelle }}</td>
<td class="num">{{ ligne.kmMois | number: formatKm() }}</td>
<td class="num">{{ ligne.fraisReelMensuel | number: '1.2-2' }} €</td>
<td class="num">
<ng-container *ngIf="ligne.previsionnel !== null; else nonConfigure">
{{ ligne.previsionnel | number: '1.2-2' }} €
</ng-container>
<ng-template #nonConfigure>—</ng-template>
</td>
<td class="num">
<input
type="number"
step="0.01"
min="0"
[(ngModel)]="ligne.montantRegle"
[ngModelOptions]="{ standalone: true }"
[attr.aria-label]="'Montant réglé ' + ligne.libelle"
/>
</td>
<td>
<button
type="button"
class="btn-secondaire"
[disabled]="exportEnCours() !== null"
(click)="exporterMensuel(ligne.mois)"
>
Export PDF
</button>
</td>
</tr>
</tbody>
<tfoot>
<tr class="total">
<td>TOTAL</td>
<td class="num">{{ reg.totalKmAnnuel | number: formatKm() }}</td>
<td class="num">{{ reg.montantReel | number: '1.2-2' }} €</td>
<td class="num">—</td>
<td class="num">{{ reg.totalPaye | number: '1.2-2' }} €</td>
<td>
<button
type="button"
class="btn-secondaire"
[disabled]="exportEnCours() !== null"
(click)="exporterAnnuel()"
>
Export PDF
</button>
</td>
</tr>
</tfoot>
</table>
</ng-container>
<ng-template #aucun>
<p class="vide" *ngIf="!chargement()">Aucune donnée à afficher.</p>
</ng-template>
</section>
@@ -0,0 +1,148 @@
.synthese-kms {
padding: 1.5rem;
// Largeur de lecture bornée et centrée : sur un très grand écran, le tableau
// ne s'étale pas sur toute la largeur, ce qui garde une ligne lisible
// (le mois à gauche reste proche des actions à droite).
max-width: 1100px;
margin: 0 auto;
}
h1 {
font-size: 1.4rem;
margin: 0;
color: var(--couleur-anthracite);
}
/* En-tête : titre à gauche, zone de critères à droite (comme le carnet d'adresses). */
.synthese-kms__entete {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.selection {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 1rem;
}
.champ {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.champ label {
font-weight: 600;
font-size: 0.9rem;
}
.champ select {
padding: 0.5rem;
border: 1px solid #bbb;
border-radius: 6px;
font-size: 0.95rem;
}
.btn-principal {
padding: 0.55rem 1.25rem;
background: var(--couleur-bleu);
color: #fff;
border: 1px solid var(--couleur-bleu);
border-radius: 6px;
cursor: pointer;
font-size: 0.95rem;
&:hover:not(:disabled) {
background: var(--couleur-orange);
border-color: var(--couleur-orange);
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
.btn-secondaire {
padding: 0.4rem 0.9rem;
background: #fff;
color: #1565c0;
border: 1px solid #1565c0;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
&:hover:not(:disabled) {
background: var(--couleur-orange);
border-color: var(--couleur-orange);
color: #fff;
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
.bareme {
margin: 0.5rem 0 1rem;
color: #333;
}
.actions {
display: flex;
justify-content: flex-end;
margin-bottom: 0.5rem;
}
table.synthese {
width: 100%;
border-collapse: collapse;
font-size: 0.92rem;
th,
td {
padding: 0.5rem 0.6rem;
border-bottom: 1px solid #eee;
text-align: left;
}
th.num,
td.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
thead th {
background: #f5f5f5;
border-bottom: 1px solid #ccc;
}
td input {
width: 7rem;
padding: 0.35rem 0.4rem;
border: 1px solid #bbb;
border-radius: 4px;
text-align: right;
font-variant-numeric: tabular-nums;
}
tfoot .total {
font-weight: 700;
td {
border-top: 2px solid #ccc;
border-bottom: none;
}
}
}
.vide {
color: #666;
font-style: italic;
}
@@ -0,0 +1,310 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { NotificationService } from '../../core/services/notification.service';
import { ParametresApplicationService } from '../../core/services/parametres-application.service';
import { BaremeService } from '../../core/services/bareme.service';
import { RemboursementService } from '../../core/services/remboursement.service';
import { TrajetService } from '../../core/services/trajet.service';
import { VehiculeService } from '../../core/services/vehicule.service';
import { SelecteurTemporelComponent } from '../navigation-temporelle/selecteur-temporel.component';
import {
MontantPrevisionnel,
MontantRegle,
RegularisationAnnuelle,
Vehicule
} from '../../core/models';
/** Libellés des mois (index 0 = janvier). */
const NOMS_MOIS = [
'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'
];
/** Ligne mensuelle de la synthèse Kms. */
interface LigneSynthese {
mois: number;
libelle: string;
kmMois: number;
/** Remboursement réel propre au mois (km_du_mois × coef de la tranche annuelle). */
fraisReelMensuel: number;
/** Indication de versement 80% (prévisionnel mensuel), ou null si barème non configuré. */
previsionnel: number | null;
/** Montant réglé saisi (éditable). */
montantRegle: number | null;
/** Montant réglé initialement chargé, pour détecter les modifications. */
montantRegleInitial: number | null;
}
/**
* Page « Synthèse Kms » : regroupe sur un seul écran la consultation annuelle,
* la saisie des montants réglés et l'export PDF (mensuel par ligne, annuel pour
* la ligne TOTAL).
*
* Haut de page : sélecteur temporel en mode « année seule », sélecteur de
* véhicule, bouton « Afficher ». La restitution présente le barème appliqué puis
* un tableau d'une ligne par mois (km parcourus, remboursement réel du mois,
* indication 80%, montant réglé saisissable, export PDF mensuel) et une ligne
* TOTAL (total km, total réel, total réglé non éditable, export PDF annuel).
*
* Aucune nouvelle API : composition des endpoints existants
* (`getRegularisation`, `getMontantPrevisionnel`, `saveMontantRegle`,
* `exportPdfMensuel`, `exportPdfAnnuel`).
*/
@Component({
selector: 'app-synthese-kms',
standalone: true,
imports: [CommonModule, FormsModule, SelecteurTemporelComponent],
templateUrl: './synthese-kms.component.html',
styleUrl: './synthese-kms.component.scss'
})
export class SyntheseKmsComponent implements OnInit {
private readonly vehiculeService = inject(VehiculeService);
private readonly remboursementService = inject(RemboursementService);
private readonly baremeService = inject(BaremeService);
private readonly trajetService = inject(TrajetService);
private readonly notification = inject(NotificationService);
readonly parametresService = inject(ParametresApplicationService);
readonly anneeMin = 2000;
readonly anneeMax = 2100;
/** Liste des véhicules (pour le sélecteur). */
readonly vehicules = signal<Vehicule[]>([]);
/** Année choisie dans le sélecteur temporel (mode année seule). */
readonly annee = signal<number>(new Date().getFullYear());
/** Date ISO fournie au sélecteur temporel pour pré-sélectionner l'année. */
readonly dateSelecteur = signal<string>(`${new Date().getFullYear()}-01-01`);
/** Véhicule sélectionné. */
readonly vehiculeId = signal<number | null>(null);
/** Synthèse chargée (barème appliqué + totaux). */
readonly synthese = signal<RegularisationAnnuelle | null>(null);
/** Lignes mensuelles affichées. */
readonly lignes = signal<LigneSynthese[]>([]);
/** true tant qu'aucune synthèse n'est chargée (masque le tableau). */
readonly chargee = signal(false);
/** true pendant un appel réseau (chargement ou enregistrement). */
readonly chargement = signal(false);
/** Mois en cours d'export (pour désactiver le bouton concerné). null = aucun ; 0 = annuel. */
readonly exportEnCours = signal<number | null>(null);
ngOnInit(): void {
this.vehiculeService.getVehicules().subscribe({
next: (liste) => {
const vehicules = [...liste].sort((a, b) => b.dateEffet.localeCompare(a.dateEffet));
this.vehicules.set(vehicules);
if (vehicules.length) {
// Pré-sélection : véhicule courant (le plus récent) + année courante,
// puis affichage immédiat sans attendre une action de l'utilisateur.
this.vehiculeId.set(vehicules[0].id);
this.afficher();
}
},
error: () => this.notification.error('Impossible de charger les véhicules.')
});
}
/** Reçoit l'année choisie par le sélecteur temporel (mode année seule). */
onAnneeChoisie(annee: number): void {
this.annee.set(annee);
this.dateSelecteur.set(`${annee}-01-01`);
}
/** Charge la synthèse annuelle + les indications 80% pour (année, véhicule). */
afficher(): void {
const vehiculeId = this.vehiculeId();
if (vehiculeId === null) {
this.notification.error('Veuillez sélectionner un véhicule.');
return;
}
const annee = this.annee();
this.chargement.set(true);
const regularisation$ = this.remboursementService
.getRegularisation(annee, vehiculeId)
.pipe(catchError(() => of(null)));
const previsionnels$ = NOMS_MOIS.map((_, index) =>
this.baremeService
.getMontantPrevisionnel(annee, index + 1, vehiculeId)
.pipe(catchError(() => of<MontantPrevisionnel | null>(null)))
);
forkJoin({ reg: regularisation$, prev: forkJoin(previsionnels$) }).subscribe({
next: ({ reg, prev }) => {
this.chargement.set(false);
if (!reg) {
this.synthese.set(null);
this.lignes.set([]);
this.chargee.set(false);
this.notification.error('Impossible de charger la synthèse.');
return;
}
this.appliquerSynthese(reg, prev);
this.chargee.set(true);
},
error: () => {
this.chargement.set(false);
this.synthese.set(null);
this.lignes.set([]);
this.chargee.set(false);
this.notification.error('Impossible de charger la synthèse.');
}
});
}
/** Construit les 12 lignes à partir de la régularisation et des prévisionnels. */
private appliquerSynthese(
reg: RegularisationAnnuelle,
previsionnels: (MontantPrevisionnel | null)[]
): void {
const parMois = new Map(reg.mois.map((m) => [m.mois, m]));
this.lignes.set(NOMS_MOIS.map((libelle, index) => {
const mois = index + 1;
const ligneMois = parMois.get(mois);
const regle = ligneMois ? ligneMois.fraisPaye : 0;
return {
mois,
libelle,
kmMois: ligneMois ? ligneMois.kmMois : 0,
fraisReelMensuel: ligneMois ? ligneMois.fraisReelMensuel : 0,
previsionnel: previsionnels[index] ? previsionnels[index]!.montantPrevisionnelMensuel : null,
montantRegle: regle,
montantRegleInitial: regle
};
}));
this.synthese.set(reg);
}
/**
* Enregistre uniquement les mois dont le montant réglé a été modifié
* (par rapport à la valeur initialement chargée). Rejette les montants négatifs.
*/
enregistrer(): void {
const vehiculeId = this.vehiculeId();
if (vehiculeId === null || this.chargement()) {
return;
}
const annee = this.annee();
const modifiees = this.lignes().filter((l) => !this.memeMontant(l.montantRegle, l.montantRegleInitial));
if (modifiees.length === 0) {
this.notification.info('Aucune modification à enregistrer.');
return;
}
// Validation : aucun montant négatif.
for (const l of modifiees) {
if (l.montantRegle !== null && Number(l.montantRegle) < 0) {
this.notification.error(`Le montant réglé de ${l.libelle} ne peut pas être négatif.`);
return;
}
}
const requetes = modifiees.map((l) => {
const dto: MontantRegle = {
annee,
mois: l.mois,
vehiculeId,
montant: Number(l.montantRegle ?? 0)
};
return this.remboursementService.saveMontantRegle(dto);
});
this.chargement.set(true);
forkJoin(requetes).subscribe({
next: () => {
this.chargement.set(false);
this.notification.success(
`${modifiees.length} montant(s) enregistré(s).`
);
// Recharge pour rafraîchir les totaux et le remboursement réel.
this.afficher();
},
error: () => {
this.chargement.set(false);
this.notification.error("Échec de l'enregistrement des montants.");
}
});
}
/** Export PDF du rapport mensuel d'un mois donné. */
exporterMensuel(mois: number): void {
const vehiculeId = this.vehiculeId();
if (vehiculeId === null || this.exportEnCours() !== null) {
return;
}
const annee = this.annee();
this.exportEnCours.set(mois);
this.trajetService.exportPdfMensuel(annee, mois, vehiculeId).subscribe({
next: (reponse) => {
this.exportEnCours.set(null);
const mm = `${mois}`.padStart(2, '0');
const nom = this.trajetService.extraireNomFichier(reponse) ?? `${annee}${mm}_Trajets.pdf`;
this.declencherTelechargement(reponse.body!, nom);
},
error: () => {
this.exportEnCours.set(null);
this.notification.error('Échec du téléchargement du PDF mensuel.');
}
});
}
/** Export PDF du rapport annuel (ligne TOTAL). */
exporterAnnuel(): void {
const vehiculeId = this.vehiculeId();
if (vehiculeId === null || this.exportEnCours() !== null) {
return;
}
const annee = this.annee();
this.exportEnCours.set(0);
this.trajetService.exportPdfAnnuel(annee, vehiculeId).subscribe({
next: (reponse) => {
this.exportEnCours.set(null);
const nom = this.trajetService.extraireNomFichier(reponse) ?? `${annee}_Trajets.pdf`;
this.declencherTelechargement(reponse.body!, nom);
},
error: () => {
this.exportEnCours.set(null);
this.notification.error('Échec du téléchargement du PDF annuel.');
}
});
}
/** Chaîne de format `number` basée sur la précision courante des distances. */
formatKm(): string {
const precision = this.parametresService.precisionDistance();
return `1.${precision}-${precision}`;
}
/** Deux montants (nullable) sont-ils égaux ? (null et 0 considérés distincts). */
private memeMontant(a: number | null, b: number | null): boolean {
if (a === null || b === null) {
return a === b;
}
return Number(a) === Number(b);
}
/** Crée une URL d'objet et déclenche le téléchargement via une ancre temporaire. */
private declencherTelechargement(blob: Blob, nomFichier: string): void {
const url = URL.createObjectURL(blob);
const ancre = document.createElement('a');
ancre.href = url;
ancre.download = nomFichier;
document.body.appendChild(ancre);
ancre.click();
document.body.removeChild(ancre);
URL.revokeObjectURL(url);
}
}
@@ -59,7 +59,9 @@
cursor: pointer;
&:hover:not(:disabled) {
background: #ebf3fb;
background: var(--couleur-orange);
border-color: var(--couleur-orange);
color: #fff;
}
&:disabled {
@@ -63,28 +63,6 @@
⠿
</button>
<!-- Colonne dédiée A/R (largeur fixe) : garde date/adresses alignées. -->
<div class="trajet__ar-col" aria-hidden="false">
@if (trajet.allerRetour) {
<span class="trajet__ar" title="Aller/Retour : distance comptée doublée">A/R</span>
}
</div>
<div class="trajet__infos">
@if (trajet.commentaire) {
<div class="trajet__ligne">
<span class="trajet__commentaire">{{ trajet.commentaire }}</span>
</div>
}
<div class="trajet__adresses">
<span class="trajet__depart">{{ trajet.adresseDepart }}</span>
<span class="trajet__fleche" aria-hidden="true">→</span>
<span class="trajet__arrivee">{{ trajet.adresseArrivee }}</span>
</div>
</div>
<div class="trajet__distance">{{ distanceAffichee(trajet) }}</div>
<div class="trajet__actions">
<div class="trajet__actions-ligne">
<button
@@ -133,6 +111,29 @@
</button>
</div>
</div>
<!-- Colonne dédiée A/R (largeur fixe) : garde date/adresses alignées. -->
<div class="trajet__ar-col" aria-hidden="false">
@if (trajet.allerRetour) {
<span class="trajet__ar" title="Aller/Retour : distance comptée doublée">A/R</span>
}
</div>
<div class="trajet__infos">
@if (trajet.commentaire) {
<div class="trajet__ligne">
<span class="trajet__commentaire">{{ trajet.commentaire }}</span>
</div>
}
<div class="trajet__adresses">
<span class="trajet__depart">{{ trajet.adresseDepart }}</span>
<span class="trajet__fleche" aria-hidden="true">→</span>
<span class="trajet__arrivee">{{ trajet.adresseArrivee }}</span>
</div>
</div>
<div class="trajet__distance">{{ distanceAffichee(trajet) }}</div>
</li>
}
</ul>
@@ -16,6 +16,7 @@ import {
import { ReordonnerTrajets, Trajet, TrajetPosition } from '../../core/models';
import { NotificationService } from '../../core/services/notification.service';
import { ParametresApplicationService } from '../../core/services/parametres-application.service';
import { TrajetService } from '../../core/services/trajet.service';
import { ServeurIndisponibleError } from '../../core/interceptors/timeout.interceptor';
import { ConfirmDialogComponent } from '../../shared/components/confirm-dialog/confirm-dialog.component';
@@ -87,6 +88,7 @@ export interface GroupeJour {
export class TrajetListComponent {
private readonly trajetService = inject(TrajetService);
private readonly notifications = inject(NotificationService);
private readonly parametresService = inject(ParametresApplicationService);
/**
* Trajets du mois. Ils sont regroupés par journée (`groupes`), chaque groupe
@@ -295,12 +297,12 @@ export class TrajetListComponent {
return index === groupe.trajets.length - 1;
}
/** Distance formatée à 2 décimales, ou « — » si non calculée (Exigence 2.4). */
/** Distance formatée selon la précision courante, ou « — » si non calculée (Exigence 2.4). */
distanceAffichee(trajet: Trajet): string {
if (trajet.distance == null) {
return '—';
}
return `${trajet.distance.toFixed(2)} km`;
return `${trajet.distance.toFixed(this.parametresService.precisionDistance())} km`;
}
/** Identité stable pour le suivi de la liste (drag & drop). */
@@ -0,0 +1,21 @@
/**
* Arrondit une distance brute (km) au supérieur (ceiling) à la précision donnée.
* Miroir exact de PrecisionDistanceService.ArrondirDistance côté backend.
*/
export function arrondirDistance(distanceBrute: number, precision: 0 | 1 | 2): number {
const facteur = Math.pow(10, precision);
return Math.ceil(distanceBrute * facteur) / facteur;
}
/** Distance_Effective_Arrondie : arrondi de la distance simple PUIS doublement si aller/retour. */
export function distanceEffectiveArrondie(
distanceBrute: number | null,
allerRetour: boolean,
precision: 0 | 1 | 2
): number | null {
if (distanceBrute == null) {
return null;
}
const arrondie = arrondirDistance(distanceBrute, precision);
return allerRetour ? arrondie * 2 : arrondie;
}