Expose les coordonnées déjà géocodées dans ProfilDto (DomicileLatitude/Longitude) et TrajetDto (DepartLatitude/Longitude, ArriveeLatitude/Longitude) afin que le Frontend les réutilise sans déclencher un nouveau géocodage BAN : - Bouton « Utiliser mon domicile » (trajet-form) : utilise désormais les coordonnées du Profil au lieu de forcer un re-géocodage. - Actions « insérer avant/après » (trajet-list) : transmettent les coordonnées déjà connues du point de fin repris au formulaire d'insertion. Le résumé mensuel (ResumeService) résout les coordonnées via le Cache_Adresses pour chaque trajet (nouvelle méthode ConstruireResumeMensuelAvecCoordonneesAsync) ; le résumé annuel reste inchangé pour éviter des lookups de cache inutiles sur une vue qui n'affiche pas le détail des trajets. Met à jour design.md et design-requirements.md (Requirements 10 et 11) pour refléter le nouveau contrat d'API.
212 lines
8.7 KiB
C#
212 lines
8.7 KiB
C#
using Microsoft.EntityFrameworkCore;
|
||
using SchotzIadKmTracker.Api.Data;
|
||
using SchotzIadKmTracker.Api.Models.DTOs;
|
||
using SchotzIadKmTracker.Api.Models.Entities;
|
||
using SchotzIadKmTracker.Api.Services.Exceptions;
|
||
|
||
namespace SchotzIadKmTracker.Api.Services;
|
||
|
||
/// <inheritdoc />
|
||
public class ResumeService : IResumeService
|
||
{
|
||
private const int MoisMin = 1;
|
||
private const int MoisMax = 12;
|
||
private const int AnneeMin = 2000;
|
||
private const int AnneeMax = 2100;
|
||
|
||
private readonly AppDbContext _context;
|
||
private readonly IPrecisionDistanceService _precisionDistanceService;
|
||
private readonly IAdresseCacheService _adresseCacheService;
|
||
|
||
public ResumeService(
|
||
AppDbContext context,
|
||
IPrecisionDistanceService precisionDistanceService,
|
||
IAdresseCacheService adresseCacheService)
|
||
{
|
||
_context = context;
|
||
_precisionDistanceService = precisionDistanceService;
|
||
_adresseCacheService = adresseCacheService;
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task<ResumeMensuelDto> GetResumeMensuelAsync(
|
||
int annee, int mois, int? vehiculeId = null, CancellationToken cancellationToken = default)
|
||
{
|
||
ValiderAnnee(annee);
|
||
ValiderMois(mois);
|
||
|
||
var precision = await _precisionDistanceService.ObtenirAsync(cancellationToken);
|
||
var trajets = await ChargerTrajetsDuMoisAsync(annee, mois, vehiculeId, cancellationToken);
|
||
|
||
// Coordonnées incluses : ce résumé alimente l'écran de consultation/saisie des Trajets,
|
||
// dont l'insertion (« Insérer avant/après ») réutilise les coordonnées déjà géocodées
|
||
// pour éviter un re-géocodage BAN.
|
||
return await ConstruireResumeMensuelAvecCoordonneesAsync(annee, mois, trajets, precision, cancellationToken);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task<ResumeAnnuelDto> GetResumeAnnuelAsync(
|
||
int annee, int? vehiculeId = null, CancellationToken cancellationToken = default)
|
||
{
|
||
ValiderAnnee(annee);
|
||
|
||
var precision = await _precisionDistanceService.ObtenirAsync(cancellationToken);
|
||
|
||
// Charge une seule fois tous les trajets de l'année, puis répartit par mois.
|
||
var query = _context.Trajets
|
||
.AsNoTracking()
|
||
.Where(t => t.Date.Year == annee);
|
||
|
||
if (vehiculeId.HasValue)
|
||
{
|
||
query = query.Where(t => t.VehiculeId == vehiculeId.Value);
|
||
}
|
||
|
||
var trajetsAnnee = await query.ToListAsync(cancellationToken);
|
||
|
||
var resumesMensuels = new List<ResumeMensuelDto>(MoisMax);
|
||
for (var mois = MoisMin; mois <= MoisMax; mois++)
|
||
{
|
||
var trajetsDuMois = trajetsAnnee
|
||
.Where(t => t.Date.Month == mois)
|
||
.ToList();
|
||
|
||
resumesMensuels.Add(ConstruireResumeMensuel(annee, mois, trajetsDuMois, precision));
|
||
}
|
||
|
||
// Total annuel = somme des 12 totaux mensuels, déjà arrondis (Exigences 4.3, 4.7, 6.6, 5.2, 5.3).
|
||
var totalAnnuel = resumesMensuels.Sum(r => r.TotalKm);
|
||
|
||
return new ResumeAnnuelDto(annee, resumesMensuels, totalAnnuel);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Charge les Trajets d'un mois donné, filtrés éventuellement par véhicule, triés par
|
||
/// date croissante puis par Position croissante (Exigence 3.2).
|
||
/// </summary>
|
||
private async Task<List<Trajet>> ChargerTrajetsDuMoisAsync(
|
||
int annee, int mois, int? vehiculeId, CancellationToken cancellationToken)
|
||
{
|
||
var query = _context.Trajets
|
||
.AsNoTracking()
|
||
.Where(t => t.Date.Year == annee && t.Date.Month == mois);
|
||
|
||
if (vehiculeId.HasValue)
|
||
{
|
||
query = query.Where(t => t.VehiculeId == vehiculeId.Value);
|
||
}
|
||
|
||
// Tri par Position (decimal) non traduisible par SQLite : ordonnancement côté client.
|
||
var trajets = await query.ToListAsync(cancellationToken);
|
||
return trajets
|
||
.OrderBy(t => t.Date)
|
||
.ThenBy(t => t.Position)
|
||
.ToList();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Construit un <see cref="ResumeMensuelDto"/> à partir d'une liste de Trajets déjà triée.
|
||
/// Chaque distance est arrondie (Precision_Distance) AVANT doublement aller/retour ; le total
|
||
/// est la somme de ces valeurs déjà arrondies, sans second arrondi (Property 1, Property 2, Property 3).
|
||
/// </summary>
|
||
private ResumeMensuelDto ConstruireResumeMensuel(
|
||
int annee, int mois, IReadOnlyList<Trajet> trajets, int precision)
|
||
{
|
||
// Calculée une seule fois par trajet, puis réutilisée pour le DTO et la somme du total
|
||
// afin de garantir la même valeur (Distance_Effective_Arrondie) aux deux endroits.
|
||
var distancesArrondies = trajets.Select(t => (t, distance: DistanceEffectiveArrondie(t, precision))).ToList();
|
||
|
||
var trajetsDto = distancesArrondies.Select(x => ToDto(x.t, x.distance)).ToList();
|
||
// Total = somme des Distance_Effective_Arrondie déjà calculées par trajet, null → 0 (Property 1, Property 3).
|
||
var totalKm = distancesArrondies.Sum(x => x.distance ?? 0m);
|
||
|
||
return new ResumeMensuelDto(annee, mois, trajetsDto, totalKm);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Variante de <see cref="ConstruireResumeMensuel"/> enrichissant chaque <see cref="TrajetDto"/>
|
||
/// des coordonnées de ses adresses, résolues via le Cache_Adresses (même mécanisme que
|
||
/// <c>TrajetsController.GetJournee</c>). Réservée au résumé MENSUEL (écran de consultation/
|
||
/// saisie des Trajets, dont l'insertion réutilise ces coordonnées) : le résumé ANNUEL continue
|
||
/// d'utiliser <see cref="ConstruireResumeMensuel"/> sans lookup, les totaux annuels n'ayant pas
|
||
/// besoin du détail des coordonnées de chaque trajet (évite 2×N lookups cache inutiles).
|
||
/// </summary>
|
||
private async Task<ResumeMensuelDto> ConstruireResumeMensuelAvecCoordonneesAsync(
|
||
int annee, int mois, IReadOnlyList<Trajet> trajets, int precision, CancellationToken cancellationToken)
|
||
{
|
||
var distancesArrondies = trajets.Select(t => (t, distance: DistanceEffectiveArrondie(t, precision))).ToList();
|
||
|
||
var trajetsDto = new List<TrajetDto>(distancesArrondies.Count);
|
||
foreach (var (t, distance) in distancesArrondies)
|
||
{
|
||
var depart = await _adresseCacheService.ObtenirParAdresseAsync(t.AdresseDepart, cancellationToken);
|
||
var arrivee = await _adresseCacheService.ObtenirParAdresseAsync(t.AdresseArrivee, cancellationToken);
|
||
|
||
trajetsDto.Add(ToDto(t, distance, depart?.Latitude, depart?.Longitude, arrivee?.Latitude, arrivee?.Longitude));
|
||
}
|
||
|
||
var totalKm = distancesArrondies.Sum(x => x.distance ?? 0m);
|
||
|
||
return new ResumeMensuelDto(annee, mois, trajetsDto, totalKm);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Calcule la Distance_Effective_Arrondie d'un trajet : arrondi (Precision_Distance) appliqué
|
||
/// à la distance SIMPLE brute AVANT doublement aller/retour (Exigence 4.1, 4.2, 4.3). null si la
|
||
/// distance brute n'a pas pu être calculée (Exigence 2.4 du résumé existant).
|
||
/// </summary>
|
||
private decimal? DistanceEffectiveArrondie(Trajet t, int precision)
|
||
{
|
||
if (!t.Distance.HasValue)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
var distanceArrondie = _precisionDistanceService.ArrondirDistance(t.Distance.Value, precision);
|
||
return t.AllerRetour ? distanceArrondie * 2m : distanceArrondie;
|
||
}
|
||
|
||
private void ValiderMois(int mois)
|
||
{
|
||
if (mois < MoisMin || mois > MoisMax)
|
||
{
|
||
throw new ResumeParametreInvalideException(
|
||
$"Le mois doit être compris entre {MoisMin} et {MoisMax} inclus ; valeur reçue : {mois}.");
|
||
}
|
||
}
|
||
|
||
private void ValiderAnnee(int annee)
|
||
{
|
||
if (annee < AnneeMin || annee > AnneeMax)
|
||
{
|
||
throw new ResumeParametreInvalideException(
|
||
$"L'année doit être comprise entre {AnneeMin} et {AnneeMax} inclus ; valeur reçue : {annee}.");
|
||
}
|
||
}
|
||
|
||
private static TrajetDto ToDto(
|
||
Trajet t,
|
||
decimal? distanceEffectiveArrondie,
|
||
double? departLatitude = null,
|
||
double? departLongitude = null,
|
||
double? arriveeLatitude = null,
|
||
double? arriveeLongitude = null) =>
|
||
new(
|
||
t.Id,
|
||
t.Date,
|
||
t.AdresseDepart,
|
||
t.AdresseArrivee,
|
||
// Distance_Effective_Arrondie (arrondi Precision_Distance puis doublement si aller/retour),
|
||
// alignée sur le TrajetDto commun (au lieu de DistanceEffective brut).
|
||
distanceEffectiveArrondie,
|
||
t.Commentaire,
|
||
t.Position,
|
||
t.VehiculeId,
|
||
t.PuissanceFiscale,
|
||
t.AllerRetour,
|
||
departLatitude,
|
||
departLongitude,
|
||
arriveeLatitude,
|
||
arriveeLongitude);
|
||
}
|