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.
249 lines
9.5 KiB
C#
249 lines
9.5 KiB
C#
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 <= b2 ET b1 <= 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);
|
|
}
|