Files
SchotzIAD/backend/SchotzIadKmTracker.Api/Services/ResumeService.cs
T
MadJax d015ca1c98 feat: réutilisation des coordonnées déjà géocodées (domicile, insertion de trajets)
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.
2026-10-13 20:52:04 +02:00

212 lines
8.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}