- 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
782 lines
54 KiB
Markdown
782 lines
54 KiB
Markdown
# 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 »).
|