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.
447 lines
18 KiB
C#
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);
|
|
}
|