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
This commit is contained in:
2026-09-25 12:02:52 +02:00
parent 702f5b6e9c
commit 7e80427e09
48 changed files with 3478 additions and 91 deletions
@@ -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) =>
@@ -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 />