SchotzIAD KM Tracker : suivi des trajets kilométriques (Angular + .NET 8)

Application complète de saisie et de suivi des trajets kilométriques avec
calcul de distance routière (OpenRouteService), barème kilométrique, résumés
et régularisation annuelle.

Backend (ASP.NET Core .NET 8, EF Core, SQLite) :
- Trajets (CRUD, réordonnancement, insertion), calcul de distance ORS avec
  fallback Haversine, géométrie d'itinéraire stockée par trajet, recalcul en masse.
- Profil, véhicules (avec dénomination), carnet d'adresses nommées, cache d'adresses.
- Barème kilométrique : tranches multiples par puissance fiscale, borne max
  exclusive avec 'illimité' (KmMax nullable), coefficient à 3 décimales,
  recopie du dernier barème connu.
- Résumés mensuel/annuel, montants réglés, régularisation, export PDF.

Frontend (Angular 22, zoneless + signals) :
- Consultation par jour, navigation temporelle synchronisée, carte Leaflet,
  formulaire de trajet (adresses nommées, domicile, autocomplétion BAN).
- Détection de changement zoneless (indépendante de zone.js).

Hébergement : Docker Compose (nginx + backend), scripts run-dev.sh / run-docker.sh.
This commit is contained in:
2026-09-14 02:08:27 +02:00
commit e3f946f7d4
225 changed files with 38168 additions and 0 deletions
@@ -0,0 +1,248 @@
using Microsoft.EntityFrameworkCore;
using TrajetKmTracker.Api.Data;
using TrajetKmTracker.Api.Models.DTOs;
using TrajetKmTracker.Api.Models.Entities;
using TrajetKmTracker.Api.Services.Exceptions;
namespace TrajetKmTracker.Api.Services;
/// <inheritdoc />
public class BaremeService : IBaremeService
{
private readonly AppDbContext _context;
public BaremeService(AppDbContext context)
{
_context = context;
}
/// <inheritdoc />
public async Task<IReadOnlyList<TrancheBaremeDto>> GetBaremeAsync(
int annee, CancellationToken cancellationToken = default)
{
// KmMin est un decimal : SQLite ne peut pas l'utiliser dans un ORDER BY, on trie côté client.
var tranches = (await _context.TranchesBareme
.AsNoTracking()
.Where(t => t.Annee == annee)
.ToListAsync(cancellationToken))
.OrderBy(t => t.PuissanceFiscale)
.ThenBy(t => t.KmMin)
.ToList();
return tranches.Select(ToDto).ToList();
}
/// <inheritdoc />
public async Task<TrancheBaremeDto> AddTrancheAsync(
SaveTrancheBaremeDto dto, CancellationToken cancellationToken = default)
{
await EnsureNoOverlapAsync(dto.Annee, dto.PuissanceFiscale, dto.KmMin, dto.KmMax, excludeId: null, cancellationToken);
var tranche = new TrancheBareme
{
Annee = dto.Annee,
PuissanceFiscale = dto.PuissanceFiscale,
KmMin = dto.KmMin,
KmMax = dto.KmMax,
Coefficient = dto.Coefficient,
Base = dto.Base,
};
_context.TranchesBareme.Add(tranche);
await _context.SaveChangesAsync(cancellationToken);
return ToDto(tranche);
}
/// <inheritdoc />
public async Task<TrancheBaremeDto> UpdateTrancheAsync(
int id, SaveTrancheBaremeDto dto, CancellationToken cancellationToken = default)
{
var tranche = await _context.TranchesBareme
.FirstOrDefaultAsync(t => t.Id == id, cancellationToken)
?? throw new TrancheBaremeNotFoundException($"La tranche de barème d'identifiant {id} est introuvable.");
await EnsureNoOverlapAsync(dto.Annee, dto.PuissanceFiscale, dto.KmMin, dto.KmMax, excludeId: id, cancellationToken);
tranche.Annee = dto.Annee;
tranche.PuissanceFiscale = dto.PuissanceFiscale;
tranche.KmMin = dto.KmMin;
tranche.KmMax = dto.KmMax;
tranche.Coefficient = dto.Coefficient;
tranche.Base = dto.Base;
await _context.SaveChangesAsync(cancellationToken);
return ToDto(tranche);
}
/// <inheritdoc />
public async Task DeleteTrancheAsync(int id, CancellationToken cancellationToken = default)
{
var tranche = await _context.TranchesBareme
.FirstOrDefaultAsync(t => t.Id == id, cancellationToken)
?? throw new TrancheBaremeNotFoundException($"La tranche de barème d'identifiant {id} est introuvable.");
_context.TranchesBareme.Remove(tranche);
await _context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<BaremeCalculResult> CalculerMontantAsync(
int annee, int puissanceFiscale, decimal totalKm, CancellationToken cancellationToken = default)
{
var tranches = await _context.TranchesBareme
.AsNoTracking()
.Where(t => t.Annee == annee && t.PuissanceFiscale == puissanceFiscale)
.ToListAsync(cancellationToken);
// Aucune tranche configurée : montant zéro + signal (Exigence 13.4), sans erreur bloquante.
if (tranches.Count == 0)
{
return new BaremeCalculResult(
Montant: 0m,
BaremeConfigure: false,
Coefficient: 0m,
Base: 0m,
Avertissement: $"Aucun barème configuré pour la puissance fiscale {puissanceFiscale} CV et l'année {annee} ; le montant de remboursement est nul.");
}
// Sélection de la tranche dont [KmMin, KmMax[ contient le total annuel (Property 24/27).
// Borne max EXCLUSIVE ; KmMax == null = illimité. Les tranches ne se chevauchant pas,
// au plus une est sélectionnable.
var tranche = tranches.FirstOrDefault(t =>
totalKm >= t.KmMin && (t.KmMax == null || totalKm < t.KmMax.Value));
if (tranche is null)
{
// Des tranches existent mais aucune ne couvre ce total : traité comme non couvert (montant nul).
return new BaremeCalculResult(
Montant: 0m,
BaremeConfigure: false,
Coefficient: 0m,
Base: 0m,
Avertissement: $"Aucune tranche du barème ne couvre un total de {totalKm} km pour la puissance fiscale {puissanceFiscale} CV et l'année {annee} ; le montant de remboursement est nul.");
}
var montant = totalKm * tranche.Coefficient + tranche.Base;
return new BaremeCalculResult(
Montant: montant,
BaremeConfigure: true,
Coefficient: tranche.Coefficient,
Base: tranche.Base,
Avertissement: null);
}
/// <inheritdoc />
public async Task<int> CopierDernierBaremeAsync(int anneeCible, CancellationToken cancellationToken = default)
{
// Année source = année la plus récente STRICTEMENT antérieure à l'année cible qui a un barème.
var anneesAvecBareme = (await _context.TranchesBareme
.AsNoTracking()
.Select(t => t.Annee)
.Distinct()
.ToListAsync(cancellationToken))
.Where(a => a < anneeCible)
.OrderByDescending(a => a)
.ToList();
if (anneesAvecBareme.Count == 0)
{
return 0; // Aucun barème antérieur : rien à recopier.
}
var anneeSource = anneesAvecBareme.First();
var source = await _context.TranchesBareme
.AsNoTracking()
.Where(t => t.Annee == anneeSource)
.ToListAsync(cancellationToken);
await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
try
{
// Annule et remplace : on supprime d'abord les tranches existantes de l'année cible.
var existantes = await _context.TranchesBareme
.Where(t => t.Annee == anneeCible)
.ToListAsync(cancellationToken);
if (existantes.Count > 0)
{
_context.TranchesBareme.RemoveRange(existantes);
await _context.SaveChangesAsync(cancellationToken);
}
// Copie des tranches de l'année source vers l'année cible.
var copies = source.Select(t => new TrancheBareme
{
Annee = anneeCible,
PuissanceFiscale = t.PuissanceFiscale,
KmMin = t.KmMin,
KmMax = t.KmMax,
Coefficient = t.Coefficient,
Base = t.Base,
}).ToList();
_context.TranchesBareme.AddRange(copies);
await _context.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return copies.Count;
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
/// <summary>
/// Vérifie qu'aucune tranche existante pour la même (année, puissance fiscale) ne chevauche
/// l'intervalle [<paramref name="kmMin"/>, <paramref name="kmMax"/>]. La tranche d'identifiant
/// <paramref name="excludeId"/> (en cours de mise à jour) est exclue de la comparaison.
/// Deux intervalles [a1,a2] et [b1,b2] se chevauchent ssi a1 &lt;= b2 ET b1 &lt;= a2.
/// </summary>
private async Task EnsureNoOverlapAsync(
int annee, int puissanceFiscale, decimal kmMin, decimal? kmMax, int? excludeId, CancellationToken cancellationToken)
{
var existantes = await _context.TranchesBareme
.AsNoTracking()
.Where(t => t.Annee == annee
&& t.PuissanceFiscale == puissanceFiscale
&& (excludeId == null || t.Id != excludeId.Value))
.ToListAsync(cancellationToken);
// Convention [KmMin, KmMax[ (borne max EXCLUSIVE). KmMax == null = illimité (+infini).
// Deux intervalles [a1,a2[ et [b1,b2[ se chevauchent ssi a1 < b2 ET b1 < a2,
// en traitant une borne max nulle comme +infini.
var chevauche = existantes.Any(t =>
{
var aMaxInfini = kmMax == null;
var bMaxInfini = t.KmMax == null;
var condA = bMaxInfini || kmMin < t.KmMax!.Value; // a1 < b2
var condB = aMaxInfini || t.KmMin < kmMax!.Value; // b1 < a2
return condA && condB;
});
if (chevauche)
{
throw new TranchesBaremeChevauchantesException(
"Les tranches du barème pour cette puissance fiscale et cette année se chevauchent.");
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<int>> GetAnneesAvecBaremeAsync(CancellationToken cancellationToken = default)
{
var annees = await _context.TranchesBareme
.AsNoTracking()
.Select(t => t.Annee)
.Distinct()
.ToListAsync(cancellationToken);
annees.Sort();
return annees;
}
private static TrancheBaremeDto ToDto(TrancheBareme t) =>
new(t.Id, t.Annee, t.PuissanceFiscale, t.KmMin, t.KmMax, t.Coefficient, t.Base);
}