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.
194 lines
7.2 KiB
C#
194 lines
7.2 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using TrajetKmTracker.Api.Services.Exceptions;
|
|
|
|
namespace TrajetKmTracker.Api.Services;
|
|
|
|
/// <summary>
|
|
/// Implémentation de <see cref="IDistanceCalculator"/>.
|
|
///
|
|
/// Résout d'abord les coordonnées manquantes via BAN (priorité BAN sur ORS,
|
|
/// Property 16), puis calcule la distance routière via ORS. En cas de timeout ou
|
|
/// d'échec ORS, applique le fallback orthodromique (Haversine) sans erreur. Si les
|
|
/// coordonnées ne peuvent pas être résolues (BAN sans résultat / indisponible), retourne
|
|
/// une distance nulle assortie d'un avertissement, sans lever d'exception bloquante.
|
|
///
|
|
/// _Requirements: 2.1, 2.2, 2.3, 2.4, 2.6_
|
|
/// </summary>
|
|
public class DistanceCalculatorService : IDistanceCalculator
|
|
{
|
|
/// <summary>Distance minimale stockable (km).</summary>
|
|
private const decimal DistanceMinKm = 0.01m;
|
|
|
|
/// <summary>Distance maximale stockable (km).</summary>
|
|
private const decimal DistanceMaxKm = 99_999.99m;
|
|
|
|
private readonly IBANClient _banClient;
|
|
private readonly IORSClient _orsClient;
|
|
private readonly ILogger<DistanceCalculatorService> _logger;
|
|
|
|
public DistanceCalculatorService(
|
|
IBANClient banClient,
|
|
IORSClient orsClient,
|
|
ILogger<DistanceCalculatorService> logger)
|
|
{
|
|
_banClient = banClient;
|
|
_orsClient = orsClient;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<DistanceCalculationResult> CalculerAsync(
|
|
string adresseDepart,
|
|
string adresseArrivee,
|
|
double? departLatitude,
|
|
double? departLongitude,
|
|
double? arriveeLatitude,
|
|
double? arriveeLongitude,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
// Coordonnées de départ connues seulement si latitude ET longitude sont présentes.
|
|
double? depLat = departLatitude;
|
|
double? depLon = departLongitude;
|
|
double? arrLat = arriveeLatitude;
|
|
double? arrLon = arriveeLongitude;
|
|
|
|
// Résolution BAN AVANT tout appel ORS pour les coordonnées manquantes (Property 16).
|
|
if (!EstCoordonneeConnue(depLat, depLon))
|
|
{
|
|
var (lat, lon) = await GeocoderAsync(adresseDepart, cancellationToken);
|
|
depLat = lat;
|
|
depLon = lon;
|
|
}
|
|
|
|
if (!EstCoordonneeConnue(arrLat, arrLon))
|
|
{
|
|
var (lat, lon) = await GeocoderAsync(adresseArrivee, cancellationToken);
|
|
arrLat = lat;
|
|
arrLon = lon;
|
|
}
|
|
|
|
// Si une coordonnée reste absente après géocodage BAN → distance nulle + avertissement.
|
|
if (!EstCoordonneeConnue(depLat, depLon) || !EstCoordonneeConnue(arrLat, arrLon))
|
|
{
|
|
return new DistanceCalculationResult(
|
|
DepartLatitude: depLat,
|
|
DepartLongitude: depLon,
|
|
ArriveeLatitude: arrLat,
|
|
ArriveeLongitude: arrLon,
|
|
DistanceKm: null,
|
|
GeometrieJson: null,
|
|
Avertissement: true,
|
|
Message: "Le trajet a été enregistré mais la distance n'a pas pu être "
|
|
+ "calculée faute de coordonnées géographiques.");
|
|
}
|
|
|
|
// À ce stade, les quatre coordonnées sont non-null : jamais d'appel ORS avec des coordonnées nulles.
|
|
var lat1 = depLat!.Value;
|
|
var lon1 = depLon!.Value;
|
|
var lat2 = arrLat!.Value;
|
|
var lon2 = arrLon!.Value;
|
|
|
|
decimal distanceKm;
|
|
string? geometrieJson = null;
|
|
var avertissement = false;
|
|
string? message = null;
|
|
|
|
try
|
|
{
|
|
var route = await _orsClient.CalculateRouteAsync(lat1, lon1, lat2, lon2, cancellationToken);
|
|
distanceKm = route.DistanceKm;
|
|
geometrieJson = route.GeometrieJson;
|
|
}
|
|
catch (ORSTimeoutException ex)
|
|
{
|
|
_logger.LogWarning(ex,
|
|
"Timeout ORS : application du fallback Haversine pour le calcul de distance.");
|
|
distanceKm = HaversineKm(lat1, lon1, lat2, lon2);
|
|
}
|
|
catch (ORSException ex)
|
|
{
|
|
_logger.LogWarning(ex,
|
|
"Échec ORS : application du fallback Haversine pour le calcul de distance.");
|
|
distanceKm = HaversineKm(lat1, lon1, lat2, lon2);
|
|
}
|
|
|
|
// Arrondi 2 décimales + bornage [0,01 ; 99 999,99] km.
|
|
distanceKm = BornerDistance(Math.Round(distanceKm, 2, MidpointRounding.AwayFromZero));
|
|
|
|
return new DistanceCalculationResult(
|
|
DepartLatitude: lat1,
|
|
DepartLongitude: lon1,
|
|
ArriveeLatitude: lat2,
|
|
ArriveeLongitude: lon2,
|
|
DistanceKm: distanceKm,
|
|
GeometrieJson: geometrieJson,
|
|
Avertissement: avertissement,
|
|
Message: message);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Géocode une adresse via BAN et retourne les coordonnées du meilleur (premier) résultat.
|
|
/// Retourne <c>(null, null)</c> si aucune adresse n'est trouvée ou si BAN est indisponible,
|
|
/// sans propager d'exception (échec BAN non bloquant).
|
|
/// </summary>
|
|
private async Task<(double? Latitude, double? Longitude)> GeocoderAsync(
|
|
string adresse,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(adresse))
|
|
{
|
|
return (null, null);
|
|
}
|
|
|
|
try
|
|
{
|
|
var resultats = await _banClient.RechercherAsync(adresse, cancellationToken);
|
|
if (resultats.Count == 0)
|
|
{
|
|
return (null, null);
|
|
}
|
|
|
|
// Meilleur / premier résultat (la BAN retourne les résultats par pertinence décroissante).
|
|
var meilleur = resultats[0];
|
|
return (meilleur.Latitude, meilleur.Longitude);
|
|
}
|
|
catch (BANIndisponibleException ex)
|
|
{
|
|
_logger.LogWarning(ex,
|
|
"BAN indisponible lors du géocodage de l'adresse manquante ; distance non calculable.");
|
|
return (null, null);
|
|
}
|
|
}
|
|
|
|
/// <summary>Vrai si latitude ET longitude sont toutes deux renseignées.</summary>
|
|
private static bool EstCoordonneeConnue(double? latitude, double? longitude)
|
|
=> latitude.HasValue && longitude.HasValue;
|
|
|
|
/// <summary>Borne une distance dans l'intervalle stockable [0,01 ; 99 999,99] km.</summary>
|
|
private static decimal BornerDistance(decimal distanceKm)
|
|
{
|
|
if (distanceKm < DistanceMinKm)
|
|
{
|
|
return DistanceMinKm;
|
|
}
|
|
|
|
return distanceKm > DistanceMaxKm ? DistanceMaxKm : distanceKm;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calcule la distance orthodromique (formule de Haversine) entre deux points, en
|
|
/// kilomètres, arrondie à 2 décimales. R = 6371 km (rayon moyen de la Terre).
|
|
/// </summary>
|
|
private static decimal HaversineKm(double lat1, double lon1, double lat2, double lon2)
|
|
{
|
|
const double R = 6371.0;
|
|
var dLat = (lat2 - lat1) * Math.PI / 180;
|
|
var dLon = (lon2 - lon1) * Math.PI / 180;
|
|
var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2)
|
|
+ Math.Cos(lat1 * Math.PI / 180) * Math.Cos(lat2 * Math.PI / 180)
|
|
* Math.Sin(dLon / 2) * Math.Sin(dLon / 2);
|
|
var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
|
|
return Math.Round((decimal)(R * c), 2);
|
|
}
|
|
}
|