Files
SchotzIAD/backend/TrajetKmTracker.Api/Services/TrajetService.cs
T
MadJax e3f946f7d4 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.
2026-09-14 02:08:27 +02:00

447 lines
18 KiB
C#

using System.Globalization;
using System.Text;
using Microsoft.EntityFrameworkCore;
using TrajetKmTracker.Api.Data;
using TrajetKmTracker.Api.Models.DTOs;
using TrajetKmTracker.Api.Models.Entities;
using TrajetKmTracker.Api.Repositories;
using TrajetKmTracker.Api.Services.Exceptions;
namespace TrajetKmTracker.Api.Services;
/// <inheritdoc />
public class TrajetService : ITrajetService
{
private readonly AppDbContext _context;
private readonly ITrajetRepository _repository;
private readonly IVehiculeService _vehiculeService;
private readonly IDistanceCalculator _distanceCalculator;
private readonly IAdresseCacheService _adresseCacheService;
public TrajetService(
AppDbContext context,
ITrajetRepository repository,
IVehiculeService vehiculeService,
IDistanceCalculator distanceCalculator,
IAdresseCacheService adresseCacheService)
{
_context = context;
_repository = repository;
_vehiculeService = vehiculeService;
_distanceCalculator = distanceCalculator;
_adresseCacheService = adresseCacheService;
}
/// <inheritdoc />
public async Task<TrajetCreationResult> CreateAsync(
CreateTrajetDto dto, CancellationToken cancellationToken = default)
{
// La forme de la date (JJ/MM/AAAA) et la validité des champs sont garanties par le
// validateur FluentValidation exécuté côté MVC avant d'atteindre le service.
var date = ParseDate(dto.Date);
// --- Association du véhicule (Exigences 1.2, 10.14) ---
// VehiculeId explicite : on l'utilise et on copie sa PuissanceFiscale.
// Sinon : véhicule courant à la date du trajet (via le repository).
var trajet = new Trajet
{
Date = date,
AdresseDepart = dto.AdresseDepart,
AdresseArrivee = dto.AdresseArrivee,
Commentaire = dto.Commentaire,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
await AssocierVehiculeAsync(trajet, dto.VehiculeId, date, cancellationToken);
// --- Calcul de la distance (BAN puis ORS, fallback Haversine) (Exigences 2.4, 2.5) ---
var distanceResult = await _distanceCalculator.CalculerAsync(
dto.AdresseDepart,
dto.AdresseArrivee,
dto.DepartLatitude,
dto.DepartLongitude,
dto.ArriveeLatitude,
dto.ArriveeLongitude,
cancellationToken);
// Distance nulle possible (aucune coordonnée résoluble) : on persiste tout de même
// le Trajet avec Distance = null + avertissement (Exigence 2.4).
trajet.Distance = distanceResult.DistanceKm;
// Géométrie de l'itinéraire routier (paire départ→arrivée) pour le tracé carte.
trajet.GeometrieJson = distanceResult.GeometrieJson;
// --- Persistance transactionnelle ---
await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
try
{
// Position en fin de journée : max(Position) + 1.0 (ou 1.0 si journée vide).
trajet.Position = await _repository.CalculerPositionFinDeJourneeAsync(date, cancellationToken);
await _repository.AddAsync(trajet, cancellationToken);
await _repository.SauvegarderAsync(cancellationToken);
// Mise en cache des adresses résolues (coordonnées connues) — après persistance
// du trajet, dans la même transaction (Exigences 6.7, 6.8, 9.1).
await MettreEnCacheAdressesResoluesAsync(
dto.AdresseDepart, dto.AdresseArrivee, distanceResult, cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
return new TrajetCreationResult(
Trajet: ToDto(trajet),
Avertissement: distanceResult.Avertissement,
Message: distanceResult.Avertissement ? distanceResult.Message : null);
}
/// <inheritdoc />
public async Task<TrajetUpdateResult> UpdateAsync(
int id, UpdateTrajetDto dto, CancellationToken cancellationToken = default)
{
// 404 si le trajet n'existe pas : l'opération est bloquée, aucun résumé n'est mis à jour
// (Exigence 5.6).
var trajet = await _repository.GetByIdAsync(id, cancellationToken)
?? throw new TrajetNotFoundException(id);
var nouvelleDate = ParseDate(dto.Date);
// Détermine s'il faut recalculer la distance :
// - au moins une adresse a changé (comparaison sur le texte normalisé) (Exigence 5.2), OU
// - le trajet avait une Distance nulle et de nouvelles coordonnées sont fournies : on tente
// un recalcul (Exigence 5.10).
// Sinon (adresses inchangées) : on met à jour les autres champs SANS recalcul (Exigence 5.3/5.9).
var adresseModifiee =
!AdressesEquivalentes(trajet.AdresseDepart, dto.AdresseDepart) ||
!AdressesEquivalentes(trajet.AdresseArrivee, dto.AdresseArrivee);
var tenterRecalculDistanceNulle =
trajet.Distance is null &&
(dto.DepartLatitude.HasValue || dto.ArriveeLatitude.HasValue ||
!string.IsNullOrWhiteSpace(dto.AdresseDepart) || !string.IsNullOrWhiteSpace(dto.AdresseArrivee));
var doitRecalculer = adresseModifiee || tenterRecalculDistanceNulle;
// Sauvegarde des valeurs existantes pour préservation en cas d'échec (Exigence 5.8).
var distanceExistante = trajet.Distance;
DistanceCalculationResult? distanceResult = null;
bool avertissement = false;
string? messageAvertissement = null;
await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
try
{
if (doitRecalculer)
{
try
{
distanceResult = await _distanceCalculator.CalculerAsync(
dto.AdresseDepart,
dto.AdresseArrivee,
dto.DepartLatitude,
dto.DepartLongitude,
dto.ArriveeLatitude,
dto.ArriveeLongitude,
cancellationToken);
}
catch (Exception ex)
{
// Le recalcul a échoué : annuler la modification et préserver les données
// existantes du Trajet (Exigence 5.8).
await transaction.RollbackAsync(cancellationToken);
throw new DistanceRecalculException(
"Le recalcul de la distance a échoué ; la modification a été annulée et les données du trajet sont conservées.",
ex);
}
if (adresseModifiee)
{
// Adresse(s) modifiée(s) : la nouvelle distance remplace l'ancienne (peut être null
// si aucune coordonnée n'est résoluble → avertissement, Exigence 2.4).
trajet.Distance = distanceResult.DistanceKm;
// La géométrie routière suit la nouvelle paire d'adresses (null si vol d'oiseau).
trajet.GeometrieJson = distanceResult.GeometrieJson;
}
else if (distanceResult.DistanceKm.HasValue)
{
// Distance était nulle et de nouvelles coordonnées ont permis un calcul (Exigence 5.10).
trajet.Distance = distanceResult.DistanceKm;
trajet.GeometrieJson = distanceResult.GeometrieJson;
}
avertissement = distanceResult.Avertissement;
messageAvertissement = distanceResult.Avertissement ? distanceResult.Message : null;
}
// Mise à jour des champs (commentaire, adresses, date, véhicule) — sans recalcul si
// les adresses n'ont pas changé (Exigence 5.3/5.9).
trajet.AdresseDepart = dto.AdresseDepart;
trajet.AdresseArrivee = dto.AdresseArrivee;
trajet.Commentaire = dto.Commentaire;
trajet.UpdatedAt = DateTime.UtcNow;
if (trajet.Date != nouvelleDate)
{
trajet.Date = nouvelleDate;
}
await AssocierVehiculeAsync(trajet, dto.VehiculeId, nouvelleDate, cancellationToken);
_repository.Update(trajet);
await _repository.SauvegarderAsync(cancellationToken);
// Mise en cache des adresses résolues (coordonnées connues) (Exigences 6.7, 6.8).
if (distanceResult is not null)
{
await MettreEnCacheAdressesResoluesAsync(
dto.AdresseDepart, dto.AdresseArrivee, distanceResult, cancellationToken);
}
await transaction.CommitAsync(cancellationToken);
}
catch (DistanceRecalculException)
{
// Déjà rollbacké ci-dessus ; préserver les données existantes en mémoire.
trajet.Distance = distanceExistante;
throw;
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
return new TrajetUpdateResult(
Trajet: ToDto(trajet),
Avertissement: avertissement,
Message: messageAvertissement);
}
/// <inheritdoc />
public async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
{
var trajet = await _repository.GetByIdAsync(id, cancellationToken)
?? throw new TrajetNotFoundException(id);
await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
try
{
_repository.Delete(trajet);
await _repository.SauvegarderAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
/// <inheritdoc />
public async Task ReordonnerAsync(
ReordonnerTrajetsDto dto, CancellationToken cancellationToken = default)
{
// Le repository met à jour uniquement la Position des trajets fournis, en transaction,
// sans altérer les autres champs (Exigence 11.9). Les identifiants inconnus sont ignorés.
var positions = (dto.Trajets ?? Array.Empty<TrajetPositionDto>())
.Select(t => (t.Id, t.Position))
.ToList();
if (positions.Count == 0)
{
return;
}
await _repository.ReordonnerAsync(positions, cancellationToken);
}
/// <inheritdoc />
public async Task<int> RecalculerToutesLesDistancesAsync(CancellationToken cancellationToken = default)
{
// On ne (re)sollicite ORS que là où c'est utile : uniquement les trajets SANS géométrie
// routière (GeometrieJson NULL). Un trajet ayant déjà une géométrie signifie que le calcul
// routier a réussi précédemment ; on ne le recalcule pas, ce qui limite les appels ORS.
var trajets = await _context.Trajets
.Where(t => t.GeometrieJson == null)
.ToListAsync(cancellationToken);
var recalcules = 0;
foreach (var trajet in trajets)
{
DistanceCalculationResult result;
try
{
// Recalcul systématique via BAN + ORS (fallback Haversine géré en interne).
// On ne fournit pas de coordonnées : le calculateur les résout depuis les adresses.
result = await _distanceCalculator.CalculerAsync(
trajet.AdresseDepart,
trajet.AdresseArrivee,
departLatitude: null,
departLongitude: null,
arriveeLatitude: null,
arriveeLongitude: null,
cancellationToken);
}
catch
{
// Un échec sur un trajet ne doit pas interrompre le lot : on le laisse tel quel.
continue;
}
// On ne remplace la distance que si un calcul a abouti (sinon on conserve l'existant).
if (result.DistanceKm.HasValue)
{
trajet.Distance = result.DistanceKm;
// Géométrie routière recalculée (null si ORS a échoué → tracé ligne droite).
trajet.GeometrieJson = result.GeometrieJson;
trajet.UpdatedAt = DateTime.UtcNow;
_repository.Update(trajet);
await _repository.SauvegarderAsync(cancellationToken);
// Met à jour le cache d'adresses (coordonnées + géométrie d'itinéraire).
await MettreEnCacheAdressesResoluesAsync(
trajet.AdresseDepart, trajet.AdresseArrivee, result, cancellationToken);
recalcules++;
}
}
return recalcules;
}
/// <summary>
/// Compare deux textes d'adresse après normalisation (trim + minuscules + suppression des accents),
/// de façon cohérente avec le Cache_Adresses. Deux adresses équivalentes ne déclenchent pas de recalcul.
/// </summary>
private static bool AdressesEquivalentes(string? a, string? b) =>
string.Equals(Normaliser(a), Normaliser(b), StringComparison.Ordinal);
/// <summary>
/// Normalise un texte d'adresse : suppression des espaces de bord, passage en minuscules et
/// suppression des accents (diacritiques). Cohérent avec <see cref="AdresseCacheService"/>.
/// </summary>
private static string Normaliser(string? valeur)
{
if (string.IsNullOrWhiteSpace(valeur))
{
return string.Empty;
}
var trimmee = valeur.Trim().ToLowerInvariant();
var decomposee = trimmee.Normalize(NormalizationForm.FormD);
var builder = new StringBuilder(decomposee.Length);
foreach (var caractere in decomposee)
{
var categorie = CharUnicodeInfo.GetUnicodeCategory(caractere);
if (categorie != UnicodeCategory.NonSpacingMark)
{
builder.Append(caractere);
}
}
return builder.ToString().Normalize(NormalizationForm.FormC);
}
/// <summary>
/// Associe le véhicule au trajet : si <paramref name="vehiculeId"/> est fourni, on l'utilise
/// et on copie sa puissance fiscale ; sinon on applique le véhicule courant à la date du trajet
/// (dénormalisation de la PuissanceFiscale, Exigence 10.14).
/// </summary>
private async Task AssocierVehiculeAsync(
Trajet trajet, int? vehiculeId, DateOnly date, CancellationToken cancellationToken)
{
if (vehiculeId.HasValue)
{
var historique = await _vehiculeService.GetHistoriqueAsync(cancellationToken);
var vehicule = historique.FirstOrDefault(v => v.Id == vehiculeId.Value)
?? throw new VehiculeNotFoundException(
$"Le véhicule d'identifiant {vehiculeId.Value} est introuvable.");
trajet.VehiculeId = vehicule.Id;
trajet.PuissanceFiscale = vehicule.PuissanceFiscale;
return;
}
// Véhicule courant à la date du trajet : le repository résout et copie la puissance fiscale.
await _repository.AppliquerVehiculeADateAsync(trajet, cancellationToken);
// Aucun véhicule applicable à cette date : VehiculeId reste 0 (non initialisé).
if (trajet.VehiculeId == 0)
{
throw new VehiculeNotFoundException(
$"Aucun véhicule n'est applicable à la date {date:dd/MM/yyyy} ; renseignez un véhicule avant de saisir ce trajet.");
}
}
/// <summary>
/// Met en cache les adresses de départ et d'arrivée dont les coordonnées ont été résolues
/// (via BAN/ORS). Les adresses non résolues ne sont pas mises en cache.
/// La géométrie GeoJSON de l'itinéraire, lorsqu'elle existe, est associée à l'adresse d'arrivée.
/// </summary>
private async Task MettreEnCacheAdressesResoluesAsync(
string adresseDepart,
string adresseArrivee,
DistanceCalculationResult result,
CancellationToken cancellationToken)
{
if (result.DepartLatitude.HasValue && result.DepartLongitude.HasValue)
{
await _adresseCacheService.UpsertAdresseAsync(
adresseDepart,
result.DepartLatitude.Value,
result.DepartLongitude.Value,
geometrieJson: null,
cancellationToken);
}
if (result.ArriveeLatitude.HasValue && result.ArriveeLongitude.HasValue)
{
await _adresseCacheService.UpsertAdresseAsync(
adresseArrivee,
result.ArriveeLatitude.Value,
result.ArriveeLongitude.Value,
result.GeometrieJson,
cancellationToken);
}
}
/// <summary>
/// Parse une date au format JJ/MM/AAAA. La validité est déjà garantie par le validateur MVC ;
/// ce parse défensif lève <see cref="FormatException"/> si la contrainte n'a pas été appliquée.
/// </summary>
private static DateOnly ParseDate(string date)
{
if (!DateOnly.TryParseExact(
date,
"dd/MM/yyyy",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var parsed))
{
throw new FormatException(
$"La date « {date} » n'est pas au format attendu JJ/MM/AAAA.");
}
return parsed;
}
private static TrajetDto ToDto(Trajet t) =>
new(
t.Id,
t.Date,
t.AdresseDepart,
t.AdresseArrivee,
t.Distance,
t.Commentaire,
t.Position,
t.VehiculeId,
t.PuissanceFiscale);
}