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:
@@ -0,0 +1,141 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrajetKmTracker.Api.Data;
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
using TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <inheritdoc />
|
||||
public class RemboursementService : IRemboursementService
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
private readonly IBaremeService _baremeService;
|
||||
private readonly IMontantRegleService _montantRegleService;
|
||||
|
||||
public RemboursementService(
|
||||
AppDbContext context,
|
||||
IBaremeService baremeService,
|
||||
IMontantRegleService montantRegleService)
|
||||
{
|
||||
_context = context;
|
||||
_baremeService = baremeService;
|
||||
_montantRegleService = montantRegleService;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RegularisationAnnuelleDto> GetRegularisationAnnuelleAsync(
|
||||
int annee, int vehiculeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var vehicule = await _context.Vehicules
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(v => v.Id == vehiculeId, cancellationToken)
|
||||
?? throw new VehiculeNotFoundException($"Le véhicule d'identifiant {vehiculeId} est introuvable.");
|
||||
|
||||
// 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.
|
||||
var trajetsAnnee = await _context.Trajets
|
||||
.AsNoTracking()
|
||||
.Where(t => t.VehiculeId == vehiculeId && t.Date.Year == annee)
|
||||
.Select(t => new { t.Date.Month, t.Distance })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var kmMoisMap = trajetsAnnee
|
||||
.GroupBy(t => t.Month)
|
||||
.ToDictionary(g => g.Key, g => g.Sum(t => t.Distance ?? 0m));
|
||||
|
||||
// Total kilométrique annuel COMPLET (détermine la tranche — Exigence 14.5, Property 27).
|
||||
var totalKmAnnuel = kmMoisMap.Values.Sum();
|
||||
|
||||
// Barème plein (100%) appliqué au total annuel : Montant_Reel = total_km × coef + base.
|
||||
var bareme = await _baremeService.CalculerMontantAsync(
|
||||
annee, vehicule.PuissanceFiscale, totalKmAnnuel, cancellationToken);
|
||||
|
||||
var coefficient = bareme.Coefficient;
|
||||
var baseTranche = bareme.Base;
|
||||
var montantReel = Math.Round(bareme.Montant, 2, MidpointRounding.AwayFromZero);
|
||||
|
||||
// Libellé de la tranche annuelle applicable (ex. « 5 001 à 20 000 km »).
|
||||
var trancheLibelle = await BuildTrancheLibelleAsync(
|
||||
annee, vehicule.PuissanceFiscale, totalKmAnnuel, bareme.BaremeConfigure, cancellationToken);
|
||||
|
||||
// Montant_Regle par mois pour ce véhicule/année.
|
||||
var montantsRegles = await _montantRegleService.GetMontantsReglesAsync(annee, vehiculeId, cancellationToken);
|
||||
var reglesMap = montantsRegles.ToDictionary(m => m.Mois, m => m.Montant);
|
||||
|
||||
// 12 lignes mensuelles :
|
||||
// FraisReelMensuel = km_du_mois × coefficient de la tranche annuelle (Exigence 14.6),
|
||||
// FraisPaye = Montant_Regle saisi du mois (0 si absent).
|
||||
var lignes = new List<LigneSyntheseMoisDto>(12);
|
||||
for (var mois = 1; mois <= 12; mois++)
|
||||
{
|
||||
var kmMois = kmMoisMap.TryGetValue(mois, out var km) ? km : 0m;
|
||||
var fraisPaye = reglesMap.TryGetValue(mois, out var paye) ? paye : 0m;
|
||||
var fraisReelMensuel = Math.Round(kmMois * coefficient, 2, MidpointRounding.AwayFromZero);
|
||||
|
||||
lignes.Add(new LigneSyntheseMoisDto(
|
||||
Mois: mois,
|
||||
KmMois: Math.Round(kmMois, 2, MidpointRounding.AwayFromZero),
|
||||
FraisPaye: fraisPaye,
|
||||
FraisReelMensuel: fraisReelMensuel));
|
||||
}
|
||||
|
||||
var totalPaye = Math.Round(montantsRegles.Sum(m => m.Montant), 2, MidpointRounding.AwayFromZero);
|
||||
|
||||
// Regularisation = Montant_Reel − total des Montant_Regle (Exigence 14.7).
|
||||
var regularisation = Math.Round(montantReel - totalPaye, 2, MidpointRounding.AwayFromZero);
|
||||
|
||||
return new RegularisationAnnuelleDto(
|
||||
Annee: annee,
|
||||
VehiculeId: vehiculeId,
|
||||
Plaque: vehicule.Plaque,
|
||||
PuissanceFiscale: vehicule.PuissanceFiscale,
|
||||
CoefficientTranche: coefficient,
|
||||
BaseTranche: baseTranche,
|
||||
TrancheLibelle: trancheLibelle,
|
||||
Mois: lignes,
|
||||
TotalKmAnnuel: Math.Round(totalKmAnnuel, 2, MidpointRounding.AwayFromZero),
|
||||
TotalPaye: totalPaye,
|
||||
MontantReel: montantReel,
|
||||
Regularisation: regularisation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Construit un libellé lisible de la tranche annuelle dont l'intervalle contient
|
||||
/// <paramref name="totalKmAnnuel"/>. Retourne un libellé « non configuré » si aucune tranche
|
||||
/// ne s'applique (barème absent ou total hors des intervalles définis).
|
||||
/// </summary>
|
||||
private async Task<string> BuildTrancheLibelleAsync(
|
||||
int annee, int puissanceFiscale, decimal totalKmAnnuel, bool baremeConfigure, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!baremeConfigure)
|
||||
{
|
||||
return "Barème non configuré";
|
||||
}
|
||||
|
||||
var tranches = await _context.TranchesBareme
|
||||
.AsNoTracking()
|
||||
.Where(t => t.Annee == annee && t.PuissanceFiscale == puissanceFiscale)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var tranche = tranches
|
||||
.FirstOrDefault(t => totalKmAnnuel >= t.KmMin && (t.KmMax == null || totalKmAnnuel < t.KmMax.Value));
|
||||
|
||||
if (tranche is null)
|
||||
{
|
||||
return "Barème non configuré";
|
||||
}
|
||||
|
||||
return tranche.KmMax.HasValue
|
||||
? $"{FormatKm(tranche.KmMin)} à {FormatKm(tranche.KmMax.Value)} km"
|
||||
: $"{FormatKm(tranche.KmMin)} km et plus";
|
||||
}
|
||||
|
||||
private static string FormatKm(decimal km)
|
||||
{
|
||||
// Espace comme séparateur de milliers, sans décimales (bornes de tranche entières).
|
||||
var fr = CultureInfo.GetCultureInfo("fr-FR");
|
||||
return Math.Round(km, 0, MidpointRounding.AwayFromZero).ToString("N0", fr);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user