Renommage : TrajetKmTracker -> SchotzIadKmTracker (backend, specs, frontend)
- Backend : projets, solution, namespaces et références TrajetKmTracker.Api/.Tests -> SchotzIadKmTracker.Api/.Tests (dossiers, .csproj, .sln, .http, namespaces C#). GUID UserSecretsId préservé. - Specs : dossier .kiro/specs/trajet-km-tracker -> schotziad-km-tracker et mise à jour des références internes (chemins, tags de propriétés). - Frontend : projet Angular trajet-km-tracker -> schotziad-km-tracker (package.json, angular.json, chemin dist, Dockerfile), titre applicatif 'SchotzIAD KM Tracker'. - Docker et scripts (backend/Dockerfile, frontend/Dockerfile, run-dev.sh) mis à jour. - Suppression du fichier résiduel package.json.bak. Backend (solution) et frontend (build production) compilent sans erreur.
This commit is contained in:
@@ -0,0 +1,446 @@
|
||||
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;
|
||||
|
||||
/// <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);
|
||||
}
|
||||
Reference in New Issue
Block a user