using System.Globalization; using System.Text; using Microsoft.EntityFrameworkCore; using SchotzIadKmTracker.Api.Data; using SchotzIadKmTracker.Api.Models.DTOs; using SchotzIadKmTracker.Api.Models.Entities; using SchotzIadKmTracker.Api.Repositories; using SchotzIadKmTracker.Api.Services.Exceptions; namespace SchotzIadKmTracker.Api.Services; /// 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; } /// public async Task 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); } /// public async Task 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); } /// 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; } } /// 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()) .Select(t => (t.Id, t.Position)) .ToList(); if (positions.Count == 0) { return; } await _repository.ReordonnerAsync(positions, cancellationToken); } /// public async Task 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; } /// /// 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. /// private static bool AdressesEquivalentes(string? a, string? b) => string.Equals(Normaliser(a), Normaliser(b), StringComparison.Ordinal); /// /// Normalise un texte d'adresse : suppression des espaces de bord, passage en minuscules et /// suppression des accents (diacritiques). Cohérent avec . /// 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); } /// /// Associe le véhicule au trajet : si 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). /// 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."); } } /// /// 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. /// 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); } } /// /// Parse une date au format JJ/MM/AAAA. La validité est déjà garantie par le validateur MVC ; /// ce parse défensif lève si la contrainte n'a pas été appliquée. /// 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); }