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,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;
}
@@ -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>();
@@ -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 />
@@ -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();
}
}