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.
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user