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,184 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrajetKmTracker.Api.Data;
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
using TrajetKmTracker.Api.Models.Entities;
|
||||
using TrajetKmTracker.Api.Repositories;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implémentation du Cache_Adresses. Les adresses sont stockées et comparées sous forme
|
||||
/// normalisée (trim + minuscules + suppression des accents) afin d'assurer un matching robuste.
|
||||
/// Les écritures (upsert + éviction LRU) sont exécutées en transaction.
|
||||
/// </summary>
|
||||
public class AdresseCacheService : IAdresseCacheService
|
||||
{
|
||||
/// <summary>Longueur minimale d'une requête de recherche locale (Requirement 9.3).</summary>
|
||||
private const int LongueurMinimaleRecherche = 3;
|
||||
|
||||
/// <summary>Taille maximale du cache avant éviction LRU (Requirement 9.5).</summary>
|
||||
private const int TailleMaximaleCache = 1_000;
|
||||
|
||||
private readonly IAdresseCacheRepository _repository;
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public AdresseCacheService(IAdresseCacheRepository repository, AppDbContext db)
|
||||
{
|
||||
_repository = repository;
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AdresseCacheDto>> RechercherLocalAsync(
|
||||
string query,
|
||||
int limite = 5,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var fragment = Normaliser(query);
|
||||
if (fragment.Length < LongueurMinimaleRecherche)
|
||||
{
|
||||
return Array.Empty<AdresseCacheDto>();
|
||||
}
|
||||
|
||||
if (limite <= 0)
|
||||
{
|
||||
limite = 5;
|
||||
}
|
||||
|
||||
var entrees = await _repository.RechercherAsync(fragment, limite, cancellationToken);
|
||||
|
||||
return entrees
|
||||
.Select(e => new AdresseCacheDto(e.Adresse, e.Latitude, e.Longitude))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<AdresseCacheEntreeDto?> ObtenirParAdresseAsync(
|
||||
string adresse,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalisee = Normaliser(adresse);
|
||||
if (normalisee.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var entree = await _repository.ObtenirParAdresseNormaliseeAsync(normalisee, cancellationToken);
|
||||
if (entree is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Toute utilisation d'une adresse en cache rafraîchit sa date de dernière utilisation.
|
||||
// Si une transaction est déjà ouverte par l'appelant (ex. création d'un Trajet),
|
||||
// on y participe sans en ouvrir une nouvelle : SQLite interdit les transactions imbriquées.
|
||||
entree.DerniereUtilisation = DateTime.UtcNow;
|
||||
if (_db.Database.CurrentTransaction is null)
|
||||
{
|
||||
await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken);
|
||||
await _repository.SauvegarderAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _repository.SauvegarderAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return new AdresseCacheEntreeDto(
|
||||
entree.Adresse,
|
||||
entree.Latitude,
|
||||
entree.Longitude,
|
||||
entree.GeometrieJson);
|
||||
}
|
||||
|
||||
public async Task UpsertAdresseAsync(
|
||||
string adresse,
|
||||
double latitude,
|
||||
double longitude,
|
||||
string? geometrieJson = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalisee = Normaliser(adresse);
|
||||
if (normalisee.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Si une transaction est déjà ouverte par l'appelant (ex. TrajetService.CreateAsync),
|
||||
// on y participe sans en ouvrir une nouvelle (SQLite interdit les transactions imbriquées).
|
||||
var transactionAmbiante = _db.Database.CurrentTransaction is not null;
|
||||
var transaction = transactionAmbiante
|
||||
? null
|
||||
: await _db.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
var existante = await _repository.ObtenirParAdresseNormaliseeAsync(normalisee, cancellationToken);
|
||||
if (existante is not null)
|
||||
{
|
||||
// Adresse déjà présente : coordonnées inchangées, seule la date d'utilisation est
|
||||
// rafraîchie (Requirements 9.1, 9.6).
|
||||
existante.DerniereUtilisation = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Nouvelle adresse : insertion avec les coordonnées fournies.
|
||||
var nouvelle = new AdresseCache
|
||||
{
|
||||
Adresse = normalisee,
|
||||
Latitude = latitude,
|
||||
Longitude = longitude,
|
||||
GeometrieJson = geometrieJson,
|
||||
DerniereUtilisation = DateTime.UtcNow
|
||||
};
|
||||
await _repository.AjouterAsync(nouvelle, cancellationToken);
|
||||
}
|
||||
|
||||
await _repository.SauvegarderAsync(cancellationToken);
|
||||
|
||||
// Éviction LRU : conserver au plus les 1 000 entrées les plus récemment utilisées
|
||||
// et supprimer les plus anciennes (Requirement 9.5).
|
||||
var total = await _repository.CompterAsync(cancellationToken);
|
||||
if (total > TailleMaximaleCache)
|
||||
{
|
||||
var aSupprimer = await _repository.ObtenirPlusAnciennesAsync(
|
||||
total - TailleMaximaleCache, cancellationToken);
|
||||
|
||||
_repository.Supprimer(aSupprimer);
|
||||
await _repository.SauvegarderAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// On ne valide que la transaction que l'on a nous-mêmes ouverte ; sinon c'est
|
||||
// l'appelant qui validera la transaction ambiante.
|
||||
if (transaction is not null)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
await transaction.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalise un texte d'adresse : suppression des espaces de bord, passage en minuscules
|
||||
/// et suppression des accents (diacritiques). Cohérent avec la normalisation des Adresses_Nommées.
|
||||
/// </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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrajetKmTracker.Api.Data;
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
using TrajetKmTracker.Api.Models.Entities;
|
||||
using TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implémentation du Carnet_Adresses (Exigence 15). Gère le CRUD des Adresses_Nommées avec :
|
||||
/// <list type="bullet">
|
||||
/// <item>normalisation de la Dénomination (trim + minuscules + suppression des accents) pour
|
||||
/// garantir l'unicité (15.6/15.7) ;</item>
|
||||
/// <item>réutilisation du Cache_Adresses si l'adresse normalisée y est présente (15.10),
|
||||
/// sinon géocodage/re-géocodage via l'API BAN avec un délai de 5 s (15.11) ;</item>
|
||||
/// <item>persistance sans coordonnées + <c>GeocodageReussi = false</c> si BAN est
|
||||
/// indisponible ou dépasse le délai (15.12) ;</item>
|
||||
/// <item>mise en cache des coordonnées obtenues (15.9).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public class AdresseNommeeService : IAdresseNommeeService
|
||||
{
|
||||
private const int DenominationMaxLength = 100;
|
||||
private const int AdresseMaxLength = 255;
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IBANClient _banClient;
|
||||
private readonly IAdresseCacheService _cache;
|
||||
|
||||
public AdresseNommeeService(AppDbContext db, IBANClient banClient, IAdresseCacheService cache)
|
||||
{
|
||||
_db = db;
|
||||
_banClient = banClient;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<AdresseNommeeDto>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var adresses = await _db.AdressesNommees
|
||||
.AsNoTracking()
|
||||
.OrderBy(a => a.Denomination)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return adresses.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AdresseNommeeDto> CreateAsync(
|
||||
SaveAdresseNommeeDto dto,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValiderDenomination(dto.Denomination);
|
||||
ValiderAdresse(dto.Adresse);
|
||||
|
||||
var denominationNormalisee = Normaliser(dto.Denomination);
|
||||
|
||||
// Contrôle d'unicité avant persistance (15.7 / Property 28) ; l'index unique
|
||||
// UX_AdresseNommee_Denomination reste le dernier rempart au niveau SQL.
|
||||
var collision = await _db.AdressesNommees
|
||||
.AnyAsync(a => a.DenominationNormalisee == denominationNormalisee, cancellationToken);
|
||||
if (collision)
|
||||
{
|
||||
throw new DenominationDupliqueeException(
|
||||
$"La dénomination « {dto.Denomination.Trim()} » est déjà utilisée.");
|
||||
}
|
||||
|
||||
var (latitude, longitude, geocodageReussi) =
|
||||
await ResoudreCoordonneesAsync(dto.Adresse, dto.Latitude, dto.Longitude, cancellationToken);
|
||||
|
||||
var maintenant = DateTime.UtcNow;
|
||||
var entite = new AdresseNommee
|
||||
{
|
||||
Denomination = dto.Denomination.Trim(),
|
||||
DenominationNormalisee = denominationNormalisee,
|
||||
Adresse = dto.Adresse.Trim(),
|
||||
Latitude = latitude,
|
||||
Longitude = longitude,
|
||||
GeocodageReussi = geocodageReussi,
|
||||
CreatedAt = maintenant,
|
||||
UpdatedAt = maintenant
|
||||
};
|
||||
|
||||
await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
_db.AdressesNommees.Add(entite);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException ex)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
// Dernier rempart : violation de l'index unique (course concurrente).
|
||||
throw new DenominationDupliqueeException(
|
||||
$"La dénomination « {dto.Denomination.Trim()} » est déjà utilisée.", ex);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
|
||||
// Mise en cache des coordonnées obtenues si absentes du Cache_Adresses (15.9).
|
||||
if (geocodageReussi && latitude.HasValue && longitude.HasValue)
|
||||
{
|
||||
await _cache.UpsertAdresseAsync(entite.Adresse, latitude.Value, longitude.Value,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
return ToDto(entite);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AdresseNommeeDto> UpdateAsync(
|
||||
int id,
|
||||
SaveAdresseNommeeDto dto,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValiderDenomination(dto.Denomination);
|
||||
ValiderAdresse(dto.Adresse);
|
||||
|
||||
var entite = await _db.AdressesNommees.FirstOrDefaultAsync(a => a.Id == id, cancellationToken)
|
||||
?? throw new AdresseNommeeNotFoundException(
|
||||
$"L'adresse nommée d'identifiant {id} est introuvable.");
|
||||
|
||||
var denominationNormalisee = Normaliser(dto.Denomination);
|
||||
|
||||
// Unicité : une autre Adresse_Nommee ne doit pas porter la même Dénomination normalisée (15.7).
|
||||
var collision = await _db.AdressesNommees
|
||||
.AnyAsync(a => a.Id != id && a.DenominationNormalisee == denominationNormalisee, cancellationToken);
|
||||
if (collision)
|
||||
{
|
||||
throw new DenominationDupliqueeException(
|
||||
$"La dénomination « {dto.Denomination.Trim()} » est déjà utilisée.");
|
||||
}
|
||||
|
||||
var adresseTrim = dto.Adresse.Trim();
|
||||
var adresseAChange = !string.Equals(
|
||||
Normaliser(entite.Adresse), Normaliser(adresseTrim), StringComparison.Ordinal);
|
||||
|
||||
double? latitude = entite.Latitude;
|
||||
double? longitude = entite.Longitude;
|
||||
var geocodageReussi = entite.GeocodageReussi;
|
||||
|
||||
// Re-géocodage uniquement si le texte d'adresse a changé (15.11).
|
||||
if (adresseAChange)
|
||||
{
|
||||
(latitude, longitude, geocodageReussi) =
|
||||
await ResoudreCoordonneesAsync(adresseTrim, dto.Latitude, dto.Longitude, cancellationToken);
|
||||
}
|
||||
|
||||
entite.Denomination = dto.Denomination.Trim();
|
||||
entite.DenominationNormalisee = denominationNormalisee;
|
||||
entite.Adresse = adresseTrim;
|
||||
entite.Latitude = latitude;
|
||||
entite.Longitude = longitude;
|
||||
entite.GeocodageReussi = geocodageReussi;
|
||||
entite.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException ex)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw new DenominationDupliqueeException(
|
||||
$"La dénomination « {dto.Denomination.Trim()} » est déjà utilisée.", ex);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
|
||||
if (geocodageReussi && latitude.HasValue && longitude.HasValue)
|
||||
{
|
||||
await _cache.UpsertAdresseAsync(entite.Adresse, latitude.Value, longitude.Value,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
return ToDto(entite);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entite = await _db.AdressesNommees.FirstOrDefaultAsync(a => a.Id == id, cancellationToken)
|
||||
?? throw new AdresseNommeeNotFoundException(
|
||||
$"L'adresse nommée d'identifiant {id} est introuvable.");
|
||||
|
||||
// Suppression du seul enregistrement du Carnet_Adresses : les Trajets ne référencent que
|
||||
// le texte d'adresse et les coordonnées copiés, donc rien d'autre n'est modifié (15.14).
|
||||
await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
_db.AdressesNommees.Remove(entite);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Résout les coordonnées d'un texte d'adresse : réutilisation du Cache_Adresses si l'adresse
|
||||
/// normalisée y figure (15.10), sinon coordonnées fournies par le frontend (sélection BAN),
|
||||
/// sinon géocodage via l'API BAN (15.11). En cas d'indisponibilité/timeout de la BAN, aucune
|
||||
/// coordonnée n'est retournée et <c>GeocodageReussi</c> vaut <c>false</c> (15.12).
|
||||
/// </summary>
|
||||
private async Task<(double? Latitude, double? Longitude, bool GeocodageReussi)> ResoudreCoordonneesAsync(
|
||||
string adresse,
|
||||
double? latitudeFournie,
|
||||
double? longitudeFournie,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1) Réutilisation du Cache_Adresses sans appel BAN (15.10).
|
||||
var enCache = await _cache.ObtenirParAdresseAsync(adresse, cancellationToken);
|
||||
if (enCache is not null)
|
||||
{
|
||||
return (enCache.Latitude, enCache.Longitude, true);
|
||||
}
|
||||
|
||||
// 2) Coordonnées déjà résolues côté frontend via une sélection BAN : on les conserve
|
||||
// directement, ce qui évite un appel BAN redondant (15.5).
|
||||
if (latitudeFournie.HasValue && longitudeFournie.HasValue)
|
||||
{
|
||||
return (latitudeFournie, longitudeFournie, true);
|
||||
}
|
||||
|
||||
// 3) Géocodage via l'API BAN (timeout 5 s configuré sur le HttpClient) (15.11).
|
||||
try
|
||||
{
|
||||
var resultats = await _banClient.RechercherAsync(adresse, cancellationToken);
|
||||
var premier = resultats.FirstOrDefault();
|
||||
if (premier is not null)
|
||||
{
|
||||
return (premier.Latitude, premier.Longitude, true);
|
||||
}
|
||||
|
||||
// Aucune correspondance BAN : persistance sans coordonnées.
|
||||
return (null, null, false);
|
||||
}
|
||||
catch (BANIndisponibleException)
|
||||
{
|
||||
// BAN indisponible/timeout : persistance sans coordonnées (15.12).
|
||||
return (null, null, false);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValiderDenomination(string? denomination)
|
||||
{
|
||||
var trimmee = denomination?.Trim() ?? string.Empty;
|
||||
if (trimmee.Length == 0 || trimmee.Length > DenominationMaxLength)
|
||||
{
|
||||
throw new DenominationInvalideException(
|
||||
"La dénomination est absente ou invalide (1 à 100 caractères requis).");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValiderAdresse(string? adresse)
|
||||
{
|
||||
var trimmee = adresse?.Trim() ?? string.Empty;
|
||||
if (trimmee.Length == 0 || trimmee.Length > AdresseMaxLength)
|
||||
{
|
||||
throw new AdresseNommeeInvalideException(
|
||||
"L'adresse est absente ou invalide (1 à 255 caractères requis).");
|
||||
}
|
||||
}
|
||||
|
||||
private static AdresseNommeeDto ToDto(AdresseNommee a) =>
|
||||
new(a.Id, a.Denomination, a.Adresse, a.Latitude, a.Longitude);
|
||||
|
||||
/// <summary>
|
||||
/// Normalise un texte : suppression des espaces de bord, passage en minuscules et suppression
|
||||
/// des accents (diacritiques). Cohérent avec <see cref="AdresseCacheService"/> afin d'assurer
|
||||
/// un matching identique entre Carnet_Adresses et Cache_Adresses.
|
||||
/// </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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
using TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client typé de l'API BAN. Appelle <c>GET /search/?q={query}&limit=5</c>,
|
||||
/// parse la réponse GeoJSON (FeatureCollection) et mappe les features en
|
||||
/// <see cref="BANAdresseDto"/>. Lève <see cref="BANIndisponibleException"/>
|
||||
/// en cas d'échec ou de timeout.
|
||||
/// </summary>
|
||||
public class BANClient : IBANClient
|
||||
{
|
||||
private const int MaxResultats = 5;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public BANClient(HttpClient httpClient)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<BANAdresseDto>> RechercherAsync(
|
||||
string query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return Array.Empty<BANAdresseDto>();
|
||||
}
|
||||
|
||||
var requestUri = $"/search/?q={Uri.EscapeDataString(query)}&limit={MaxResultats}";
|
||||
|
||||
BANFeatureCollection? collection;
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(requestUri, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
collection = await response.Content.ReadFromJsonAsync<BANFeatureCollection>(
|
||||
JsonOptions, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Le timeout configuré sur le HttpClient déclenche une annulation
|
||||
// non demandée par l'appelant.
|
||||
throw new BANIndisponibleException(
|
||||
"L'appel à l'API BAN a dépassé le délai imparti (timeout).");
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
throw new BANIndisponibleException(
|
||||
"L'API BAN est indisponible ou a renvoyé une réponse en erreur.", ex);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new BANIndisponibleException(
|
||||
"La réponse de l'API BAN n'a pas pu être interprétée.", ex);
|
||||
}
|
||||
|
||||
if (collection?.Features is null || collection.Features.Count == 0)
|
||||
{
|
||||
return Array.Empty<BANAdresseDto>();
|
||||
}
|
||||
|
||||
var resultats = new List<BANAdresseDto>(collection.Features.Count);
|
||||
foreach (var feature in collection.Features)
|
||||
{
|
||||
var coordinates = feature.Geometry?.Coordinates;
|
||||
if (coordinates is null || coordinates.Count < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// GeoJSON : coordinates = [longitude, latitude].
|
||||
var longitude = coordinates[0];
|
||||
var latitude = coordinates[1];
|
||||
|
||||
resultats.Add(new BANAdresseDto(
|
||||
Label: feature.Properties?.Label ?? string.Empty,
|
||||
Latitude: latitude,
|
||||
Longitude: longitude,
|
||||
Postcode: feature.Properties?.Postcode ?? string.Empty,
|
||||
City: feature.Properties?.City ?? string.Empty));
|
||||
}
|
||||
|
||||
return resultats;
|
||||
}
|
||||
|
||||
// --- Modèles internes de désérialisation de la réponse GeoJSON BAN ---
|
||||
|
||||
private sealed class BANFeatureCollection
|
||||
{
|
||||
[JsonPropertyName("features")]
|
||||
public List<BANFeature>? Features { get; set; }
|
||||
}
|
||||
|
||||
private sealed class BANFeature
|
||||
{
|
||||
[JsonPropertyName("properties")]
|
||||
public BANProperties? Properties { get; set; }
|
||||
|
||||
[JsonPropertyName("geometry")]
|
||||
public BANGeometry? Geometry { get; set; }
|
||||
}
|
||||
|
||||
private sealed class BANProperties
|
||||
{
|
||||
[JsonPropertyName("label")]
|
||||
public string? Label { get; set; }
|
||||
|
||||
[JsonPropertyName("postcode")]
|
||||
public string? Postcode { get; set; }
|
||||
|
||||
[JsonPropertyName("city")]
|
||||
public string? City { get; set; }
|
||||
}
|
||||
|
||||
private sealed class BANGeometry
|
||||
{
|
||||
// [longitude, latitude]
|
||||
[JsonPropertyName("coordinates")]
|
||||
public List<double>? Coordinates { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Résultat du calcul du montant barème pour un (année, puissance fiscale, total km) donné.
|
||||
/// Lorsque aucune tranche n'est configurée, <see cref="BaremeConfigure"/> vaut <c>false</c>,
|
||||
/// le <see cref="Montant"/> est nul et un <see cref="Avertissement"/> décrit la situation
|
||||
/// (Exigence 13.4) — le calcul n'est jamais interrompu par une exception bloquante.
|
||||
/// </summary>
|
||||
/// <param name="Montant">Montant calculé : total_km × Coefficient + Base de la tranche, ou 0 si non configuré.</param>
|
||||
/// <param name="BaremeConfigure">Vrai si une tranche a été trouvée et appliquée.</param>
|
||||
/// <param name="Coefficient">Coefficient €/km de la tranche appliquée (0 si non configuré).</param>
|
||||
/// <param name="Base">Base fixe € de la tranche appliquée (0 si non configuré).</param>
|
||||
/// <param name="Avertissement">Message d'avertissement lorsque le barème n'est pas configuré, sinon <c>null</c>.</param>
|
||||
public record BaremeCalculResult(
|
||||
decimal Montant,
|
||||
bool BaremeConfigure,
|
||||
decimal Coefficient,
|
||||
decimal Base,
|
||||
string? Avertissement);
|
||||
@@ -0,0 +1,248 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrajetKmTracker.Api.Data;
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
using TrajetKmTracker.Api.Models.Entities;
|
||||
using TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <inheritdoc />
|
||||
public class BaremeService : IBaremeService
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
|
||||
public BaremeService(AppDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TrancheBaremeDto>> GetBaremeAsync(
|
||||
int annee, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// KmMin est un decimal : SQLite ne peut pas l'utiliser dans un ORDER BY, on trie côté client.
|
||||
var tranches = (await _context.TranchesBareme
|
||||
.AsNoTracking()
|
||||
.Where(t => t.Annee == annee)
|
||||
.ToListAsync(cancellationToken))
|
||||
.OrderBy(t => t.PuissanceFiscale)
|
||||
.ThenBy(t => t.KmMin)
|
||||
.ToList();
|
||||
|
||||
return tranches.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TrancheBaremeDto> AddTrancheAsync(
|
||||
SaveTrancheBaremeDto dto, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureNoOverlapAsync(dto.Annee, dto.PuissanceFiscale, dto.KmMin, dto.KmMax, excludeId: null, cancellationToken);
|
||||
|
||||
var tranche = new TrancheBareme
|
||||
{
|
||||
Annee = dto.Annee,
|
||||
PuissanceFiscale = dto.PuissanceFiscale,
|
||||
KmMin = dto.KmMin,
|
||||
KmMax = dto.KmMax,
|
||||
Coefficient = dto.Coefficient,
|
||||
Base = dto.Base,
|
||||
};
|
||||
|
||||
_context.TranchesBareme.Add(tranche);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return ToDto(tranche);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TrancheBaremeDto> UpdateTrancheAsync(
|
||||
int id, SaveTrancheBaremeDto dto, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tranche = await _context.TranchesBareme
|
||||
.FirstOrDefaultAsync(t => t.Id == id, cancellationToken)
|
||||
?? throw new TrancheBaremeNotFoundException($"La tranche de barème d'identifiant {id} est introuvable.");
|
||||
|
||||
await EnsureNoOverlapAsync(dto.Annee, dto.PuissanceFiscale, dto.KmMin, dto.KmMax, excludeId: id, cancellationToken);
|
||||
|
||||
tranche.Annee = dto.Annee;
|
||||
tranche.PuissanceFiscale = dto.PuissanceFiscale;
|
||||
tranche.KmMin = dto.KmMin;
|
||||
tranche.KmMax = dto.KmMax;
|
||||
tranche.Coefficient = dto.Coefficient;
|
||||
tranche.Base = dto.Base;
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return ToDto(tranche);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteTrancheAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tranche = await _context.TranchesBareme
|
||||
.FirstOrDefaultAsync(t => t.Id == id, cancellationToken)
|
||||
?? throw new TrancheBaremeNotFoundException($"La tranche de barème d'identifiant {id} est introuvable.");
|
||||
|
||||
_context.TranchesBareme.Remove(tranche);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BaremeCalculResult> CalculerMontantAsync(
|
||||
int annee, int puissanceFiscale, decimal totalKm, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tranches = await _context.TranchesBareme
|
||||
.AsNoTracking()
|
||||
.Where(t => t.Annee == annee && t.PuissanceFiscale == puissanceFiscale)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Aucune tranche configurée : montant zéro + signal (Exigence 13.4), sans erreur bloquante.
|
||||
if (tranches.Count == 0)
|
||||
{
|
||||
return new BaremeCalculResult(
|
||||
Montant: 0m,
|
||||
BaremeConfigure: false,
|
||||
Coefficient: 0m,
|
||||
Base: 0m,
|
||||
Avertissement: $"Aucun barème configuré pour la puissance fiscale {puissanceFiscale} CV et l'année {annee} ; le montant de remboursement est nul.");
|
||||
}
|
||||
|
||||
// Sélection de la tranche dont [KmMin, KmMax[ contient le total annuel (Property 24/27).
|
||||
// Borne max EXCLUSIVE ; KmMax == null = illimité. Les tranches ne se chevauchant pas,
|
||||
// au plus une est sélectionnable.
|
||||
var tranche = tranches.FirstOrDefault(t =>
|
||||
totalKm >= t.KmMin && (t.KmMax == null || totalKm < t.KmMax.Value));
|
||||
|
||||
if (tranche is null)
|
||||
{
|
||||
// Des tranches existent mais aucune ne couvre ce total : traité comme non couvert (montant nul).
|
||||
return new BaremeCalculResult(
|
||||
Montant: 0m,
|
||||
BaremeConfigure: false,
|
||||
Coefficient: 0m,
|
||||
Base: 0m,
|
||||
Avertissement: $"Aucune tranche du barème ne couvre un total de {totalKm} km pour la puissance fiscale {puissanceFiscale} CV et l'année {annee} ; le montant de remboursement est nul.");
|
||||
}
|
||||
|
||||
var montant = totalKm * tranche.Coefficient + tranche.Base;
|
||||
|
||||
return new BaremeCalculResult(
|
||||
Montant: montant,
|
||||
BaremeConfigure: true,
|
||||
Coefficient: tranche.Coefficient,
|
||||
Base: tranche.Base,
|
||||
Avertissement: null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> CopierDernierBaremeAsync(int anneeCible, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Année source = année la plus récente STRICTEMENT antérieure à l'année cible qui a un barème.
|
||||
var anneesAvecBareme = (await _context.TranchesBareme
|
||||
.AsNoTracking()
|
||||
.Select(t => t.Annee)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken))
|
||||
.Where(a => a < anneeCible)
|
||||
.OrderByDescending(a => a)
|
||||
.ToList();
|
||||
|
||||
if (anneesAvecBareme.Count == 0)
|
||||
{
|
||||
return 0; // Aucun barème antérieur : rien à recopier.
|
||||
}
|
||||
|
||||
var anneeSource = anneesAvecBareme.First();
|
||||
|
||||
var source = await _context.TranchesBareme
|
||||
.AsNoTracking()
|
||||
.Where(t => t.Annee == anneeSource)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
// Annule et remplace : on supprime d'abord les tranches existantes de l'année cible.
|
||||
var existantes = await _context.TranchesBareme
|
||||
.Where(t => t.Annee == anneeCible)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (existantes.Count > 0)
|
||||
{
|
||||
_context.TranchesBareme.RemoveRange(existantes);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// Copie des tranches de l'année source vers l'année cible.
|
||||
var copies = source.Select(t => new TrancheBareme
|
||||
{
|
||||
Annee = anneeCible,
|
||||
PuissanceFiscale = t.PuissanceFiscale,
|
||||
KmMin = t.KmMin,
|
||||
KmMax = t.KmMax,
|
||||
Coefficient = t.Coefficient,
|
||||
Base = t.Base,
|
||||
}).ToList();
|
||||
|
||||
_context.TranchesBareme.AddRange(copies);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return copies.Count;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vérifie qu'aucune tranche existante pour la même (année, puissance fiscale) ne chevauche
|
||||
/// l'intervalle [<paramref name="kmMin"/>, <paramref name="kmMax"/>]. La tranche d'identifiant
|
||||
/// <paramref name="excludeId"/> (en cours de mise à jour) est exclue de la comparaison.
|
||||
/// Deux intervalles [a1,a2] et [b1,b2] se chevauchent ssi a1 <= b2 ET b1 <= a2.
|
||||
/// </summary>
|
||||
private async Task EnsureNoOverlapAsync(
|
||||
int annee, int puissanceFiscale, decimal kmMin, decimal? kmMax, int? excludeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var existantes = await _context.TranchesBareme
|
||||
.AsNoTracking()
|
||||
.Where(t => t.Annee == annee
|
||||
&& t.PuissanceFiscale == puissanceFiscale
|
||||
&& (excludeId == null || t.Id != excludeId.Value))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Convention [KmMin, KmMax[ (borne max EXCLUSIVE). KmMax == null = illimité (+infini).
|
||||
// Deux intervalles [a1,a2[ et [b1,b2[ se chevauchent ssi a1 < b2 ET b1 < a2,
|
||||
// en traitant une borne max nulle comme +infini.
|
||||
var chevauche = existantes.Any(t =>
|
||||
{
|
||||
var aMaxInfini = kmMax == null;
|
||||
var bMaxInfini = t.KmMax == null;
|
||||
var condA = bMaxInfini || kmMin < t.KmMax!.Value; // a1 < b2
|
||||
var condB = aMaxInfini || t.KmMin < kmMax!.Value; // b1 < a2
|
||||
return condA && condB;
|
||||
});
|
||||
|
||||
if (chevauche)
|
||||
{
|
||||
throw new TranchesBaremeChevauchantesException(
|
||||
"Les tranches du barème pour cette puissance fiscale et cette année se chevauchent.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<int>> GetAnneesAvecBaremeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var annees = await _context.TranchesBareme
|
||||
.AsNoTracking()
|
||||
.Select(t => t.Annee)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
annees.Sort();
|
||||
return annees;
|
||||
}
|
||||
|
||||
private static TrancheBaremeDto ToDto(TrancheBareme t) =>
|
||||
new(t.Id, t.Annee, t.PuissanceFiscale, t.KmMin, t.KmMax, t.Coefficient, t.Base);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Résultat d'un calcul de distance orchestré par <see cref="DistanceCalculatorService"/>.
|
||||
/// Porte les coordonnées résolues des adresses de départ et d'arrivée, la distance
|
||||
/// éventuellement calculée (routière via ORS ou orthodromique via Haversine), la
|
||||
/// géométrie GeoJSON de l'itinéraire lorsqu'elle est disponible, et un éventuel
|
||||
/// avertissement lorsque la distance n'a pas pu être calculée.
|
||||
/// </summary>
|
||||
/// <param name="DepartLatitude">Latitude résolue du point de départ (null si non résoluble).</param>
|
||||
/// <param name="DepartLongitude">Longitude résolue du point de départ (null si non résoluble).</param>
|
||||
/// <param name="ArriveeLatitude">Latitude résolue du point d'arrivée (null si non résoluble).</param>
|
||||
/// <param name="ArriveeLongitude">Longitude résolue du point d'arrivée (null si non résoluble).</param>
|
||||
/// <param name="DistanceKm">
|
||||
/// Distance en kilomètres, arrondie à 2 décimales et bornée entre 0,01 et 99 999,99 km ;
|
||||
/// <c>null</c> lorsque la distance n'a pas pu être calculée (aucune coordonnée résoluble).
|
||||
/// </param>
|
||||
/// <param name="GeometrieJson">
|
||||
/// Géométrie GeoJSON de l'itinéraire routier (fournie par ORS) ; <c>null</c> en cas de
|
||||
/// fallback Haversine ou d'absence de calcul.
|
||||
/// </param>
|
||||
/// <param name="Avertissement">
|
||||
/// Vrai lorsque la distance n'a pas pu être calculée faute de coordonnées résolubles ;
|
||||
/// dans ce cas l'appelant stocke une distance nulle avec un avertissement.
|
||||
/// </param>
|
||||
/// <param name="Message">Message d'avertissement associé, ou <c>null</c>.</param>
|
||||
public record DistanceCalculationResult(
|
||||
double? DepartLatitude,
|
||||
double? DepartLongitude,
|
||||
double? ArriveeLatitude,
|
||||
double? ArriveeLongitude,
|
||||
decimal? DistanceKm,
|
||||
string? GeometrieJson,
|
||||
bool Avertissement,
|
||||
string? Message);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsque le texte d'adresse d'une Adresse_Nommee est absent, vide ou dépasse
|
||||
/// 255 caractères (Exigence 15.8). Convertie en réponse HTTP 400 (Bad Request).
|
||||
/// </summary>
|
||||
public class AdresseNommeeInvalideException : Exception
|
||||
{
|
||||
public AdresseNommeeInvalideException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public AdresseNommeeInvalideException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsqu'aucune Adresse_Nommee ne correspond à l'identifiant demandé
|
||||
/// (modification, suppression). Convertie en réponse HTTP 404 (Not Found).
|
||||
/// </summary>
|
||||
public class AdresseNommeeNotFoundException : Exception
|
||||
{
|
||||
public AdresseNommeeNotFoundException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public AdresseNommeeNotFoundException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsque l'API BAN (Base Adresses Nationale) est indisponible :
|
||||
/// échec réseau, réponse HTTP non réussie ou dépassement du délai (timeout).
|
||||
/// </summary>
|
||||
public class BANIndisponibleException : Exception
|
||||
{
|
||||
public BANIndisponibleException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public BANIndisponibleException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Signale l'absence de barème configuré pour une (puissance fiscale, année) lors d'un calcul de montant.
|
||||
/// Ce n'est pas une erreur bloquante : le calcul retourne un montant zéro accompagné d'un avertissement
|
||||
/// (Exigence 13.4). Le service de calcul n'a donc pas à lever cette exception pour interrompre le calcul ;
|
||||
/// elle est fournie pour les cas où un appelant souhaite explicitement signaler la non-configuration.
|
||||
/// </summary>
|
||||
public class BaremeNonConfigureException : Exception
|
||||
{
|
||||
public BaremeNonConfigureException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public BaremeNonConfigureException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsqu'une Date_Effet fournie pour un véhicule est postérieure à la date du jour.
|
||||
/// Convertie en réponse HTTP 400 (Bad Request) — Exigence 10.11.
|
||||
/// </summary>
|
||||
public class DateEffetFutureException : Exception
|
||||
{
|
||||
public DateEffetFutureException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public DateEffetFutureException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsqu'une Dénomination d'Adresse_Nommee, après normalisation (trim + minuscules +
|
||||
/// suppression des accents), correspond à une Adresse_Nommee déjà enregistrée (Exigence 15.7).
|
||||
/// Convertie en réponse HTTP 400 (Bad Request).
|
||||
/// </summary>
|
||||
public class DenominationDupliqueeException : Exception
|
||||
{
|
||||
public DenominationDupliqueeException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public DenominationDupliqueeException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsqu'une Dénomination d'Adresse_Nommee est absente, vide ou dépasse 100 caractères
|
||||
/// (Exigence 15.6). Convertie en réponse HTTP 400 (Bad Request).
|
||||
/// </summary>
|
||||
public class DenominationInvalideException : Exception
|
||||
{
|
||||
public DenominationInvalideException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public DenominationInvalideException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsqu'un recalcul de la Distance échoue lors de la modification d'un Trajet.
|
||||
/// Le service annule (rollback) la modification et conserve les données existantes du Trajet
|
||||
/// sans modification, puis retourne cette erreur descriptive (Exigence 5.8).
|
||||
/// Convertie en réponse HTTP 502 (Bad Gateway) / erreur descriptive par le contrôleur.
|
||||
/// </summary>
|
||||
public class DistanceRecalculException : Exception
|
||||
{
|
||||
public DistanceRecalculException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public DistanceRecalculException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsqu'un Montant_Regle soumis est négatif. Convertie en réponse HTTP 400
|
||||
/// (Bad Request) — Exigence 14.4 : « Le montant réglé ne peut pas être négatif. »
|
||||
/// </summary>
|
||||
public class MontantRegleNegatifException : Exception
|
||||
{
|
||||
public MontantRegleNegatifException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public MontantRegleNegatifException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsque la génération d'un Export_PDF échoue : erreur de rendu QuestPDF
|
||||
/// ou dépassement du délai de 15 secondes (Exigence 7.5). Le controller la mappe
|
||||
/// vers une réponse <c>503 Service Unavailable</c>.
|
||||
/// </summary>
|
||||
public class PdfGenerationException : Exception
|
||||
{
|
||||
public PdfGenerationException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public PdfGenerationException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsqu'un paramètre de résumé (mois ou année) est hors des plages autorisées :
|
||||
/// mois ∈ [1, 12] et année ∈ [2000, 2100]. Convertie en réponse HTTP 400 (Bad Request)
|
||||
/// par le controller — Exigence 3.6.
|
||||
/// </summary>
|
||||
public class ResumeParametreInvalideException : Exception
|
||||
{
|
||||
public ResumeParametreInvalideException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public ResumeParametreInvalideException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsqu'une demande de modification ou de suppression cible un Trajet inexistant.
|
||||
/// Convertie en réponse HTTP 404 (Not Found) ; l'opération est bloquée et aucun Résumé_Mensuel
|
||||
/// n'est mis à jour (Exigences 5.6).
|
||||
/// </summary>
|
||||
public class TrajetNotFoundException : Exception
|
||||
{
|
||||
public TrajetNotFoundException(int id)
|
||||
: base($"Le trajet {id} n'existe pas.")
|
||||
{
|
||||
}
|
||||
|
||||
public TrajetNotFoundException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public TrajetNotFoundException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsqu'aucune tranche de barème ne correspond à l'identifiant demandé
|
||||
/// (mise à jour ou suppression). Convertie en réponse HTTP 404 (Not Found).
|
||||
/// </summary>
|
||||
public class TrancheBaremeNotFoundException : Exception
|
||||
{
|
||||
public TrancheBaremeNotFoundException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public TrancheBaremeNotFoundException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
namespace TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsqu'une opération sur le barème produirait des tranches dont les intervalles
|
||||
/// [KmMin, KmMax] se chevauchent pour une même (année, puissance fiscale).
|
||||
/// Convertie en réponse HTTP 400 (Bad Request) — Exigence 13.2.
|
||||
/// </summary>
|
||||
public class TranchesBaremeChevauchantesException : Exception
|
||||
{
|
||||
public TranchesBaremeChevauchantesException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public TranchesBaremeChevauchantesException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsqu'aucun véhicule ne correspond à l'identifiant demandé
|
||||
/// (résolution du véhicule courant, réaffectation, etc.).
|
||||
/// Convertie en réponse HTTP 404 (Not Found).
|
||||
/// </summary>
|
||||
public class VehiculeNotFoundException : Exception
|
||||
{
|
||||
public VehiculeNotFoundException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public VehiculeNotFoundException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Gestion du Cache_Adresses : recherche locale des adresses déjà connues, mise en cache
|
||||
/// (upsert) des adresses géocodées et politique d'éviction LRU.
|
||||
/// </summary>
|
||||
public interface IAdresseCacheService
|
||||
{
|
||||
/// <summary>
|
||||
/// Recherche locale des adresses en cache correspondant au fragment fourni.
|
||||
/// Retourne une liste vide si le fragment fait moins de 3 caractères (Requirement 9.3).
|
||||
/// Les suggestions sont ordonnées de la plus récemment utilisée à la plus ancienne.
|
||||
/// </summary>
|
||||
/// <param name="query">Fragment de recherche (min. 3 caractères).</param>
|
||||
/// <param name="limite">Nombre maximal de suggestions retournées (par défaut 5).</param>
|
||||
Task<IReadOnlyList<AdresseCacheDto>> RechercherLocalAsync(
|
||||
string query,
|
||||
int limite = 5,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Recherche l'entrée du cache correspondant exactement à l'adresse fournie (après normalisation).
|
||||
/// Utilisé lors de la création d'un Trajet pour éviter un appel BAN (Requirement 9.2).
|
||||
/// Met à jour la date de dernière utilisation de l'entrée trouvée.
|
||||
/// </summary>
|
||||
/// <returns>Les coordonnées et la géométrie si l'adresse est en cache, sinon <c>null</c>.</returns>
|
||||
Task<AdresseCacheEntreeDto?> ObtenirParAdresseAsync(
|
||||
string adresse,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Insère l'adresse dans le cache si elle est absente, sinon met uniquement à jour sa date de
|
||||
/// dernière utilisation sans modifier ses coordonnées (Requirements 9.1, 9.6). Applique ensuite
|
||||
/// la politique d'éviction LRU (Requirement 9.5). L'ensemble s'exécute en transaction.
|
||||
/// </summary>
|
||||
/// <param name="adresse">Libellé de l'adresse (sera normalisé pour le matching).</param>
|
||||
/// <param name="latitude">Latitude à utiliser si l'adresse est nouvelle.</param>
|
||||
/// <param name="longitude">Longitude à utiliser si l'adresse est nouvelle.</param>
|
||||
/// <param name="geometrieJson">Géométrie GeoJSON optionnelle si l'adresse est nouvelle.</param>
|
||||
Task UpsertAdresseAsync(
|
||||
string adresse,
|
||||
double latitude,
|
||||
double longitude,
|
||||
string? geometrieJson = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Gestion du Carnet_Adresses : CRUD des Adresses_Nommées avec normalisation de la Dénomination
|
||||
/// (unicité) et résolution des coordonnées via le Cache_Adresses ou l'API BAN (Exigence 15).
|
||||
/// </summary>
|
||||
public interface IAdresseNommeeService
|
||||
{
|
||||
/// <summary>
|
||||
/// Liste toutes les Adresses_Nommées du Carnet_Adresses, triées par Dénomination (Exigence 15.1).
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<AdresseNommeeDto>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Crée une Adresse_Nommee : vérifie l'unicité de la Dénomination normalisée (15.6/15.7),
|
||||
/// réutilise le Cache_Adresses si l'adresse y est déjà présente (15.10) ou géocode via BAN (15.11),
|
||||
/// persiste avec ou sans coordonnées (15.5/15.12) et met en cache les coordonnées obtenues (15.9).
|
||||
/// </summary>
|
||||
/// <exception cref="Exceptions.DenominationDupliqueeException">Dénomination normalisée déjà utilisée (400).</exception>
|
||||
Task<AdresseNommeeDto> CreateAsync(SaveAdresseNommeeDto dto, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Modifie une Adresse_Nommee existante. Si le texte d'adresse a changé et n'est pas en cache,
|
||||
/// re-géocode via BAN (15.11). Applique le contrôle d'unicité de la Dénomination (15.7).
|
||||
/// </summary>
|
||||
/// <exception cref="Exceptions.AdresseNommeeNotFoundException">Adresse_Nommee introuvable (404).</exception>
|
||||
/// <exception cref="Exceptions.DenominationDupliqueeException">Dénomination normalisée déjà utilisée (400).</exception>
|
||||
Task<AdresseNommeeDto> UpdateAsync(int id, SaveAdresseNommeeDto dto, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Supprime une Adresse_Nommee du Carnet_Adresses. Les Trajets existants ne sont pas modifiés
|
||||
/// (ils conservent leur texte d'adresse et leurs coordonnées) (Exigence 15.14).
|
||||
/// </summary>
|
||||
/// <exception cref="Exceptions.AdresseNommeeNotFoundException">Adresse_Nommee introuvable (404).</exception>
|
||||
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client de l'API BAN (Base Adresses Nationale) pour l'autocomplétion
|
||||
/// et le géocodage des adresses françaises.
|
||||
/// </summary>
|
||||
public interface IBANClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Recherche des adresses correspondant à <paramref name="query"/> via l'endpoint
|
||||
/// <c>/search/</c> de la BAN (limité à 5 résultats).
|
||||
/// </summary>
|
||||
/// <param name="query">Texte de recherche (adresse partielle ou complète).</param>
|
||||
/// <param name="cancellationToken">Jeton d'annulation.</param>
|
||||
/// <returns>La liste des adresses trouvées, mappées en <see cref="BANAdresseDto"/>.</returns>
|
||||
/// <exception cref="Exceptions.BANIndisponibleException">
|
||||
/// Levée en cas d'échec réseau, de réponse non réussie ou de dépassement du délai.
|
||||
/// </exception>
|
||||
Task<IReadOnlyList<BANAdresseDto>> RechercherAsync(string query, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Logique métier du barème kilométrique : CRUD des tranches, validation du non-chevauchement
|
||||
/// par (année, puissance fiscale), sélection de tranche et calcul de montant.
|
||||
/// </summary>
|
||||
public interface IBaremeService
|
||||
{
|
||||
/// <summary>Retourne toutes les tranches d'une année (toutes puissances fiscales confondues).</summary>
|
||||
Task<IReadOnlyList<TrancheBaremeDto>> GetBaremeAsync(int annee, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Ajoute une tranche. Rejette l'ajout s'il crée un chevauchement d'intervalle [KmMin, KmMax]
|
||||
/// pour la même (année, puissance fiscale) via <see cref="Exceptions.TranchesBaremeChevauchantesException"/>.
|
||||
/// </summary>
|
||||
Task<TrancheBaremeDto> AddTrancheAsync(SaveTrancheBaremeDto dto, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Met à jour une tranche existante. Rejette la mise à jour si elle crée un chevauchement
|
||||
/// (hors la tranche elle-même) via <see cref="Exceptions.TranchesBaremeChevauchantesException"/>.
|
||||
/// Lève <see cref="Exceptions.TrancheBaremeNotFoundException"/> si la tranche n'existe pas.
|
||||
/// </summary>
|
||||
Task<TrancheBaremeDto> UpdateTrancheAsync(int id, SaveTrancheBaremeDto dto, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Supprime une tranche. Lève <see cref="Exceptions.TrancheBaremeNotFoundException"/> si elle n'existe pas.
|
||||
/// </summary>
|
||||
Task DeleteTrancheAsync(int id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sélectionne la tranche dont [KmMin, KmMax] contient <paramref name="totalKm"/> pour la
|
||||
/// (année, puissance fiscale) donnée et calcule le montant `totalKm × Coefficient + Base`
|
||||
/// (Property 24/27). Si aucune tranche n'existe pour cette combinaison, retourne un montant nul
|
||||
/// et un signal « barème non configuré » sans lever d'erreur bloquante (Exigence 13.4).
|
||||
/// </summary>
|
||||
Task<BaremeCalculResult> CalculerMontantAsync(
|
||||
int annee, int puissanceFiscale, decimal totalKm, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Copie toutes les tranches de l'année la plus récente (strictement antérieure à
|
||||
/// <paramref name="anneeCible"/>) qui possède un barème, vers <paramref name="anneeCible"/>.
|
||||
/// Les tranches existantes de l'année cible sont d'abord supprimées (annule et remplace),
|
||||
/// en transaction. Retourne le nombre de tranches copiées. Retourne 0 si aucun barème
|
||||
/// antérieur n'existe (rien n'est modifié).
|
||||
/// </summary>
|
||||
Task<int> CopierDernierBaremeAsync(int anneeCible, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Retourne les années (triées croissantes) pour lesquelles au moins une tranche de barème existe.</summary>
|
||||
Task<IReadOnlyList<int>> GetAnneesAvecBaremeAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestre le calcul de la distance d'un trajet à partir des adresses de départ et
|
||||
/// d'arrivée et de leurs coordonnées éventuelles.
|
||||
///
|
||||
/// Logique :
|
||||
/// <list type="bullet">
|
||||
/// <item>Si les deux coordonnées sont connues → appel ORS ; en cas de timeout/échec ORS
|
||||
/// → fallback orthodromique (Haversine), sans erreur pour l'utilisateur.</item>
|
||||
/// <item>Si une coordonnée est absente → géocodage BAN de l'adresse manquante
|
||||
/// (meilleur résultat) AVANT tout appel ORS ; ORS n'est jamais appelé avec des
|
||||
/// coordonnées nulles (Property 16).</item>
|
||||
/// <item>Si BAN ne peut pas résoudre (aucun résultat ou indisponible) → distance nulle
|
||||
/// + avertissement, sans erreur bloquante, pour que l'appelant stocke la distance null.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public interface IDistanceCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Calcule la distance entre <paramref name="adresseDepart"/> et
|
||||
/// <paramref name="adresseArrivee"/>, en résolvant au besoin les coordonnées manquantes
|
||||
/// via BAN, puis en appelant ORS (avec fallback Haversine).
|
||||
/// </summary>
|
||||
/// <param name="adresseDepart">Texte de l'adresse de départ.</param>
|
||||
/// <param name="adresseArrivee">Texte de l'adresse d'arrivée.</param>
|
||||
/// <param name="departLatitude">Latitude de départ connue, ou <c>null</c>.</param>
|
||||
/// <param name="departLongitude">Longitude de départ connue, ou <c>null</c>.</param>
|
||||
/// <param name="arriveeLatitude">Latitude d'arrivée connue, ou <c>null</c>.</param>
|
||||
/// <param name="arriveeLongitude">Longitude d'arrivée connue, ou <c>null</c>.</param>
|
||||
/// <param name="cancellationToken">Jeton d'annulation.</param>
|
||||
/// <returns>Un <see cref="DistanceCalculationResult"/> (jamais d'exception bloquante).</returns>
|
||||
Task<DistanceCalculationResult> CalculerAsync(
|
||||
string adresseDepart,
|
||||
string adresseArrivee,
|
||||
double? departLatitude,
|
||||
double? departLongitude,
|
||||
double? arriveeLatitude,
|
||||
double? arriveeLongitude,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Logique métier des Montant_Regle (montants effectivement versés, saisis par l'utilisateur)
|
||||
/// et du Montant_Previsionnel (indication de versement à 80% du barème, purement informative).
|
||||
/// </summary>
|
||||
public interface IMontantRegleService
|
||||
{
|
||||
/// <summary>
|
||||
/// Enregistre ou met à jour (upsert) le Montant_Regle pour un couple (année, mois, véhicule).
|
||||
/// S'il existe déjà une ligne pour la même (Annee, Mois, VehiculeId), elle est mise à jour ;
|
||||
/// sinon une nouvelle ligne est insérée (Property 26, Exigences 14.1/14.2/14.3).
|
||||
/// Rejette un montant négatif via <see cref="Exceptions.MontantRegleNegatifException"/> (Exigence 14.4).
|
||||
/// </summary>
|
||||
Task<MontantRegleDto> UpsertMontantRegleAsync(
|
||||
MontantRegleDto dto, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retourne les Montant_Regle saisis pour une (année, véhicule), triés par mois croissant.
|
||||
/// Destiné au controller et au service de régularisation.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<MontantRegleDto>> GetMontantsReglesAsync(
|
||||
int annee, int vehiculeId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Calcule le Montant_Previsionnel indicatif pour un (année, mois, véhicule) :
|
||||
/// 80% du Bareme_Kilometrique plein appliqué au Total_Annuel_Partiel du véhicule
|
||||
/// (somme des distances de l'année pour ce véhicule jusqu'au mois indiqué inclus),
|
||||
/// arrondi à deux décimales (Property 23, Exigences 13.5/13.6).
|
||||
/// Cette valeur est purement informative et n'entre jamais dans la régularisation.
|
||||
/// </summary>
|
||||
Task<MontantPrevisionnelDto> GetMontantPrevisionnelAsync(
|
||||
int annee, int mois, int vehiculeId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client de l'API OpenRouteService (ORS) — calcul d'itinéraire routier uniquement.
|
||||
/// ORS n'est jamais appelé pour du géocodage ni avec des coordonnées nulles :
|
||||
/// les coordonnées de départ et d'arrivée doivent être connues (non-null).
|
||||
/// </summary>
|
||||
public interface IORSClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Calcule l'itinéraire routier entre deux points géographiques connus via
|
||||
/// <c>POST /v2/directions/driving-car</c> et retourne la distance (km) et la
|
||||
/// géométrie GeoJSON de l'itinéraire.
|
||||
/// </summary>
|
||||
/// <param name="lat1">Latitude du point de départ.</param>
|
||||
/// <param name="lon1">Longitude du point de départ.</param>
|
||||
/// <param name="lat2">Latitude du point d'arrivée.</param>
|
||||
/// <param name="lon2">Longitude du point d'arrivée.</param>
|
||||
/// <param name="cancellationToken">Jeton d'annulation.</param>
|
||||
/// <returns>Un <see cref="RouteResult"/> (distance en km + géométrie GeoJSON).</returns>
|
||||
/// <exception cref="ORSTimeoutException">
|
||||
/// Levée si l'appel dépasse le délai imparti (voir <c>ORSOptions.TimeoutSeconds</c>),
|
||||
/// afin que l'appelant applique le fallback orthodromique (Haversine).
|
||||
/// </exception>
|
||||
/// <exception cref="ORSException">
|
||||
/// Levée en cas d'échec général (erreur réseau, réponse invalide, corps illisible).
|
||||
/// </exception>
|
||||
Task<RouteResult> CalculateRouteAsync(
|
||||
double lat1,
|
||||
double lon1,
|
||||
double lat2,
|
||||
double lon2,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Génération des Export_PDF (QuestPDF). Compose les services métier existants
|
||||
/// (résumé, profil, véhicule, barème, montants réglés) pour produire les documents.
|
||||
/// La génération est enveloppée dans un délai de 15 secondes ; en cas de dépassement
|
||||
/// ou d'erreur de rendu, une <see cref="Exceptions.PdfGenerationException"/> est levée
|
||||
/// (Exigence 7.5) — le controller la mappe vers <c>503 Service Unavailable</c>.
|
||||
/// </summary>
|
||||
public interface IPdfService
|
||||
{
|
||||
/// <summary>
|
||||
/// Génère le rapport PDF mensuel d'un (année, mois) pour le véhicule indiqué (Exigences 7.1,
|
||||
/// 7.4, 7.6, 7.8, 7.10, 7.11). L'en-tête contient l'identité de l'utilisateur, le véhicule
|
||||
/// (plaque, puissance fiscale, coefficient et base de la tranche annuelle applicable), la
|
||||
/// période, l'adresse de départ du profil, le total kilométrique du mois, le total kilométrique
|
||||
/// annuel cumulé, le TOTAL IK du mois (km du mois × coefficient de la tranche annuelle) et le
|
||||
/// Montant_Regle du mois. Le corps liste les trajets (date, motif/commentaire, départ → arrivée, km).
|
||||
/// Le nom de fichier suggéré est <c>trajets_MM_AAAA.pdf</c>.
|
||||
/// </summary>
|
||||
/// <exception cref="Exceptions.PdfGenerationException">
|
||||
/// Levée en cas de dépassement du délai de 15 s ou d'échec de rendu (Exigence 7.5).
|
||||
/// </exception>
|
||||
Task<PdfResult> GenererRapportMensuelAsync(
|
||||
int annee, int mois, int vehiculeId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Génère la synthèse annuelle de régularisation d'une <paramref name="annee"/> pour le véhicule
|
||||
/// indiqué (Exigences 7.2, 7.4, 7.6, 7.7, 7.8, 7.12). La page de synthèse contient le nom et le
|
||||
/// prénom de l'utilisateur, l'année, l'adresse de domicile, le barème applicable de l'année (les
|
||||
/// trois tranches coefficient/base par puissance fiscale du véhicule), le véhicule et sa puissance
|
||||
/// fiscale, la tranche annuelle applicable ; puis pour chacun des douze mois : les kilomètres
|
||||
/// parcourus, le Montant_Regle (frais payé) et le Frais_Reel_Mensuel (frais réel). Les totaux
|
||||
/// (km annuel, total réglé, Montant_Reel, Regularisation) et la « formule réelle » sont indiqués.
|
||||
/// Le nom de fichier suggéré est <c>trajets_AAAA.pdf</c>.
|
||||
/// </summary>
|
||||
/// <exception cref="Exceptions.PdfGenerationException">
|
||||
/// Levée en cas de dépassement du délai de 15 s ou d'échec de rendu (Exigence 7.5).
|
||||
/// </exception>
|
||||
Task<PdfResult> GenererRapportAnnuelAsync(
|
||||
int annee, int vehiculeId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Détermine les véhicules ayant des Trajets pour l'<paramref name="annee"/> et génère un rapport
|
||||
/// annuel PDF distinct par véhicule (Exigence 7.9). Retourne un PDF par véhicule ayant des données,
|
||||
/// trié par identifiant de véhicule. Retourne une liste vide si aucun trajet n'existe pour l'année.
|
||||
/// </summary>
|
||||
/// <exception cref="Exceptions.PdfGenerationException">
|
||||
/// Levée en cas de dépassement du délai de 15 s ou d'échec de rendu (Exigence 7.5).
|
||||
/// </exception>
|
||||
Task<IReadOnlyList<PdfResult>> GenererRapportsAnnuelsParVehiculeAsync(
|
||||
int annee, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Détermine les véhicules ayant des Trajets pour le couple (<paramref name="annee"/>,
|
||||
/// <paramref name="mois"/>) et génère un rapport mensuel PDF distinct par véhicule (Exigence 7.9).
|
||||
/// Retourne un PDF par véhicule ayant des données, trié par identifiant de véhicule. Retourne une
|
||||
/// liste vide si aucun trajet n'existe pour la période.
|
||||
/// </summary>
|
||||
/// <exception cref="Exceptions.PdfGenerationException">
|
||||
/// Levée en cas de dépassement du délai de 15 s ou d'échec de rendu (Exigence 7.5).
|
||||
/// </exception>
|
||||
Task<IReadOnlyList<PdfResult>> GenererRapportsMensuelsParVehiculeAsync(
|
||||
int annee, int mois, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Logique métier du profil utilisateur (singleton Id = 1) : lecture, sauvegarde avec
|
||||
/// géocodage BAN de l'Adresse_Domicile et détermination de l'état de configuration (onboarding).
|
||||
/// </summary>
|
||||
public interface IProfilService
|
||||
{
|
||||
/// <summary>
|
||||
/// Retourne le profil (singleton Id = 1). Crée l'enregistrement à la volée s'il est absent
|
||||
/// afin de garantir qu'un profil vide soit toujours disponible à la lecture.
|
||||
/// </summary>
|
||||
Task<ProfilDto> GetProfilAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sauvegarde le profil. Géocode l'Adresse_Domicile via la BAN : en cas de succès, stocke les
|
||||
/// coordonnées et positionne <c>DomicileGeocode = true</c> (avec mise en cache de l'adresse) ;
|
||||
/// en cas d'échec BAN, persiste l'adresse textuelle sans coordonnées et <c>DomicileGeocode = false</c>
|
||||
/// sans lever d'exception (Requirements 10.2, 10.7, 10.8).
|
||||
/// </summary>
|
||||
Task<ProfilDto> SaveProfilAsync(SaveProfilDto dto, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Détermine l'état de configuration du profil (onboarding) :
|
||||
/// <c>EstConfigure = true</c> si et seulement si un Profil existe avec un Nom ET un Prénom non vides
|
||||
/// (après trim) ET qu'au moins un Véhicule existe (Property 31, Requirements 16.1, 16.2).
|
||||
/// </summary>
|
||||
Task<EtatProfilDto> GetEtatAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Logique métier de la régularisation annuelle (barème plein, 100%).
|
||||
///
|
||||
/// Contrairement au Montant_Previsionnel (80% indicatif, hors régularisation), la régularisation
|
||||
/// applique le Bareme_Kilometrique plein au total kilométrique annuel COMPLET du véhicule :
|
||||
/// <list type="bullet">
|
||||
/// <item>Montant_Reel = total_km_annuel × Coefficient + Base de la tranche déterminée par ce
|
||||
/// total annuel (Exigence 14.5, Property 25/27).</item>
|
||||
/// <item>Frais_Reel_Mensuel[m] = km_du_mois[m] × Coefficient de la tranche annuelle, de sorte que
|
||||
/// la somme des douze Frais_Reel_Mensuel + la Base de la tranche = Montant_Reel (Exigence 14.6).</item>
|
||||
/// <item>Regularisation = Montant_Reel − total des Montant_Regle de l'année (Exigence 14.7).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public interface IRemboursementService
|
||||
{
|
||||
/// <summary>
|
||||
/// Construit la synthèse annuelle de régularisation pour un couple (année, véhicule) :
|
||||
/// identité de la tranche annuelle applicable, 12 lignes mensuelles
|
||||
/// (<see cref="LigneSyntheseMoisDto"/>) et totaux (km annuel, réglé, Montant_Reel, Regularisation).
|
||||
/// </summary>
|
||||
Task<RegularisationAnnuelleDto> GetRegularisationAnnuelleAsync(
|
||||
int annee, int vehiculeId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Logique métier des résumés kilométriques mensuels et annuels.
|
||||
/// Les distances nulles (Trajet non calculé, Exigence 2.4) sont traitées comme 0 dans les totaux.
|
||||
/// </summary>
|
||||
public interface IResumeService
|
||||
{
|
||||
/// <summary>
|
||||
/// Construit le Résumé_Mensuel d'un mois : liste des Trajets triés par date croissante puis
|
||||
/// par Position croissante, chacun avec sa Distance, et total kilométrique (somme des distances,
|
||||
/// null → 0) arrondi à 2 décimales (Property 1 — Exigences 3.2, 3.3, 6.5).
|
||||
/// Filtre par véhicule lorsque <paramref name="vehiculeId"/> est fourni ; sinon inclut tous les véhicules.
|
||||
/// </summary>
|
||||
/// <exception cref="Exceptions.ResumeParametreInvalideException">
|
||||
/// Levée si <paramref name="mois"/> ∉ [1, 12] ou <paramref name="annee"/> ∉ [2000, 2100] (Exigence 3.6).
|
||||
/// </exception>
|
||||
Task<ResumeMensuelDto> GetResumeMensuelAsync(
|
||||
int annee, int mois, int? vehiculeId = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Construit le Résumé_Annuel d'une année : toujours exactement 12 Résumés_Mensuels
|
||||
/// (mois 1..12, les mois vides ayant un total de 0,00), et total annuel égal à la somme
|
||||
/// des 12 totaux mensuels (Property 2, Property 7 — Exigences 4.2, 4.3, 4.4, 4.7, 6.6).
|
||||
/// Filtre par véhicule lorsque <paramref name="vehiculeId"/> est fourni ; sinon inclut tous les véhicules.
|
||||
/// </summary>
|
||||
/// <exception cref="Exceptions.ResumeParametreInvalideException">
|
||||
/// Levée si <paramref name="annee"/> ∉ [2000, 2100] (Exigence 3.6).
|
||||
/// </exception>
|
||||
Task<ResumeAnnuelDto> GetResumeAnnuelAsync(
|
||||
int annee, int? vehiculeId = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Calcul des totaux du Panneau_Totaux (jour, mois, Total_Annuel_Partiel) filtrés par véhicule
|
||||
/// et fourniture des données de navigation temporelle (années, mois et jours disponibles).
|
||||
/// </summary>
|
||||
public interface ITotauxService
|
||||
{
|
||||
/// <summary>
|
||||
/// Calcule, pour la <paramref name="date"/> sélectionnée et le véhicule indiqué, les totaux
|
||||
/// kilométriques affichés dans le Panneau_Totaux (Exigences 12.7, 12.8, 12.19) :
|
||||
/// <list type="bullet">
|
||||
/// <item><description>TotalJour : somme des distances des Trajets de la journée.</description></item>
|
||||
/// <item><description>TotalMois : somme des distances des Trajets du mois de la date.</description></item>
|
||||
/// <item><description>TotalAnnuelPartiel : cumul de l'année jusqu'au mois de la date inclus,
|
||||
/// à l'exclusion des mois strictement postérieurs (Property 17).</description></item>
|
||||
/// </list>
|
||||
/// Les distances nulles sont comptées comme 0 et tous les totaux sont arrondis à 2 décimales.
|
||||
/// Seuls les Trajets du véhicule sont additionnés (Property 21).
|
||||
/// Si <paramref name="vehiculeId"/> vaut <c>null</c>, le Véhicule_Courant est utilisé
|
||||
/// (via <see cref="IVehiculeService.GetVehiculeCourantAsync"/>).
|
||||
/// </summary>
|
||||
Task<TotauxDto> GetTotauxAsync(DateOnly date, int? vehiculeId = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retourne la liste des années comportant au moins un Trajet, à laquelle est ajoutée
|
||||
/// l'année courante si elle n'y figure pas déjà (Exigence 12.15). Liste triée croissante.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<int>> GetAnneesDisponiblesAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retourne la liste des mois (1..12) de l'année <paramref name="annee"/> comportant au moins
|
||||
/// un Trajet (Exigence 12.16). Liste triée croissante.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<int>> GetMoisDisponiblesAsync(int annee, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retourne la liste des jours du mois <paramref name="mois"/> de l'année <paramref name="annee"/>
|
||||
/// comportant au moins un Trajet (Exigence 12.16). Liste triée croissante.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<int>> GetJoursDisponiblesAsync(int annee, int mois, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Logique métier de gestion des Trajets. Cette interface couvre pour l'instant la
|
||||
/// création (tâche 8.2) ; la modification, la suppression et le réordonnancement
|
||||
/// (tâche 8.5) l'étendront ultérieurement.
|
||||
/// </summary>
|
||||
public interface ITrajetService
|
||||
{
|
||||
/// <summary>
|
||||
/// Crée un Trajet à partir de <paramref name="dto"/> :
|
||||
/// <list type="bullet">
|
||||
/// <item>parse la date au format JJ/MM/AAAA (la validation de forme est faite par le validateur MVC) ;</item>
|
||||
/// <item>associe le véhicule : <c>VehiculeId</c> fourni sinon le véhicule courant à la date
|
||||
/// du trajet, en copiant sa <c>PuissanceFiscale</c> (dénormalisation, Exigence 10.14) ;</item>
|
||||
/// <item>calcule la distance via <see cref="IDistanceCalculator"/> (BAN puis ORS, fallback Haversine) ;</item>
|
||||
/// <item>met en cache les adresses résolues via <see cref="IAdresseCacheService"/> ;</item>
|
||||
/// <item>attribue la position en fin de journée (<c>max(Position)+1.0</c>) ;</item>
|
||||
/// <item>persiste le Trajet en transaction. Lorsque la distance n'a pas pu être calculée,
|
||||
/// le Trajet est tout de même persisté avec une Distance nulle et un avertissement (Exigence 2.4).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <param name="dto">Données de création transmises par le contrôleur (déjà validées côté MVC).</param>
|
||||
/// <param name="cancellationToken">Jeton d'annulation.</param>
|
||||
/// <returns>Le <see cref="TrajetCreationResult"/> (trajet créé + éventuel avertissement).</returns>
|
||||
/// <exception cref="Exceptions.VehiculeNotFoundException">
|
||||
/// Si aucun véhicule n'est applicable à la date du trajet et qu'aucun <c>VehiculeId</c> n'est fourni.
|
||||
/// </exception>
|
||||
Task<TrajetCreationResult> CreateAsync(CreateTrajetDto dto, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Modifie un Trajet existant à partir de <paramref name="dto"/> :
|
||||
/// <list type="bullet">
|
||||
/// <item>lève <see cref="Exceptions.TrajetNotFoundException"/> (404) si le Trajet est introuvable
|
||||
/// (Exigence 5.6) ;</item>
|
||||
/// <item>recalcule la Distance <b>uniquement</b> si au moins une adresse a changé (comparaison sur
|
||||
/// le texte d'adresse normalisé) ou si le Trajet avait une Distance nulle et que les nouvelles
|
||||
/// adresses deviennent résolubles (Exigences 5.2, 5.9, 5.10) ; sinon les autres champs (commentaire,
|
||||
/// véhicule, date) sont mis à jour <b>sans</b> recalcul de distance (Exigence 5.3) ;</item>
|
||||
/// <item>en cas d'échec du recalcul (coordonnées connues mais calcul impossible), annule la
|
||||
/// modification (rollback) et lève <see cref="Exceptions.DistanceRecalculException"/> en préservant
|
||||
/// les données existantes du Trajet (Exigence 5.8) ;</item>
|
||||
/// <item>met en cache les adresses résolues (Exigences 6.7, 6.8) ;</item>
|
||||
/// <item>persiste la modification en transaction.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <param name="id">Identifiant du Trajet à modifier.</param>
|
||||
/// <param name="dto">Données de modification (déjà validées côté MVC).</param>
|
||||
/// <param name="cancellationToken">Jeton d'annulation.</param>
|
||||
/// <returns>Le <see cref="TrajetUpdateResult"/> (trajet mis à jour + éventuel avertissement).</returns>
|
||||
/// <exception cref="Exceptions.TrajetNotFoundException">Si le Trajet n'existe pas.</exception>
|
||||
/// <exception cref="Exceptions.DistanceRecalculException">Si le recalcul de la distance échoue.</exception>
|
||||
/// <exception cref="Exceptions.VehiculeNotFoundException">
|
||||
/// Si le <c>VehiculeId</c> fourni est introuvable, ou qu'aucun véhicule n'est applicable à la date.
|
||||
/// </exception>
|
||||
Task<TrajetUpdateResult> UpdateAsync(int id, UpdateTrajetDto dto, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Supprime un Trajet existant de manière transactionnelle (Exigence 5.5).
|
||||
/// Lève <see cref="Exceptions.TrajetNotFoundException"/> (404) si le Trajet est introuvable (Exigence 5.6).
|
||||
/// </summary>
|
||||
/// <param name="id">Identifiant du Trajet à supprimer.</param>
|
||||
/// <param name="cancellationToken">Jeton d'annulation.</param>
|
||||
/// <exception cref="Exceptions.TrajetNotFoundException">Si le Trajet n'existe pas.</exception>
|
||||
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Applique un réordonnancement manuel des trajets d'une journée : met à jour uniquement la
|
||||
/// <c>Position</c> des trajets fournis via <see cref="ITrajetRepository.ReordonnerAsync"/>,
|
||||
/// sans altérer leurs autres champs (Exigence 11.9). Les identifiants inconnus sont ignorés.
|
||||
/// </summary>
|
||||
/// <param name="dto">Nouvelles positions des trajets à réordonner.</param>
|
||||
/// <param name="cancellationToken">Jeton d'annulation.</param>
|
||||
Task ReordonnerAsync(ReordonnerTrajetsDto dto, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Recalcule la Distance (et la géométrie d'itinéraire mise en cache) de TOUS les trajets
|
||||
/// via <see cref="IDistanceCalculator"/> (BAN + ORS, fallback Haversine), indépendamment de
|
||||
/// tout changement d'adresse. Utile pour « réparer » des distances calculées en mode dégradé
|
||||
/// (ex. clé ORS invalide → distances à vol d'oiseau) une fois ORS de nouveau opérationnel.
|
||||
/// Chaque trajet est traité de façon indépendante : un échec sur l'un n'interrompt pas les autres.
|
||||
/// </summary>
|
||||
/// <returns>Le nombre de trajets dont la distance a effectivement été recalculée.</returns>
|
||||
Task<int> RecalculerToutesLesDistancesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Logique métier de gestion des véhicules : historique, véhicule courant,
|
||||
/// création (avec détection de réaffectation) et réaffectation des trajets.
|
||||
/// </summary>
|
||||
public interface IVehiculeService
|
||||
{
|
||||
/// <summary>Retourne l'historique complet des véhicules (plaque, puissance fiscale, date d'effet), trié par Date_Effet décroissante.</summary>
|
||||
Task<IReadOnlyList<VehiculeDto>> GetHistoriqueAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retourne le Véhicule_Courant : Date_Effet la plus récente parmi celles <= aujourd'hui.
|
||||
/// Lève <see cref="Exceptions.VehiculeNotFoundException"/> si aucun véhicule n'est applicable.
|
||||
/// </summary>
|
||||
Task<VehiculeDto> GetVehiculeCourantAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Résout le véhicule applicable à une date donnée (pour l'association d'un trajet) :
|
||||
/// Date_Effet la plus récente <= <paramref name="date"/>.
|
||||
/// Retourne <c>null</c> si aucun véhicule n'est applicable à cette date.
|
||||
/// </summary>
|
||||
Task<VehiculeDto?> GetVehiculeADateAsync(DateOnly date, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Crée un véhicule, conserve l'historique et détecte les trajets à réaffecter
|
||||
/// (date >= Date_Effet et puissance fiscale différente).
|
||||
/// Lève <see cref="Exceptions.DateEffetFutureException"/> si la Date_Effet est postérieure à aujourd'hui.
|
||||
/// </summary>
|
||||
Task<VehiculeCreeDto> CreateAsync(CreateVehiculeDto dto, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Applique (ou non) la réaffectation des trajets au véhicule indiqué.
|
||||
/// Si <see cref="ReaffectationDto.Accepter"/> est vrai, met à jour VehiculeId + PuissanceFiscale
|
||||
/// de tous les trajets dont la Date >= Date_Effet du véhicule ; sinon ne change rien.
|
||||
/// Lève <see cref="Exceptions.VehiculeNotFoundException"/> si le véhicule n'existe pas.
|
||||
/// </summary>
|
||||
Task ReaffecterAsync(ReaffectationDto dto, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrajetKmTracker.Api.Data;
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
using TrajetKmTracker.Api.Models.Entities;
|
||||
using TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <inheritdoc />
|
||||
public class MontantRegleService : IMontantRegleService
|
||||
{
|
||||
private const decimal TauxPrevisionnel = 0.80m;
|
||||
|
||||
private readonly AppDbContext _context;
|
||||
private readonly IBaremeService _baremeService;
|
||||
|
||||
public MontantRegleService(AppDbContext context, IBaremeService baremeService)
|
||||
{
|
||||
_context = context;
|
||||
_baremeService = baremeService;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MontantRegleDto> UpsertMontantRegleAsync(
|
||||
MontantRegleDto dto, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Défense en profondeur : le validateur FluentValidation impose déjà Montant >= 0,
|
||||
// mais le service refuse aussi les montants négatifs (Exigence 14.4).
|
||||
if (dto.Montant < 0m)
|
||||
{
|
||||
throw new MontantRegleNegatifException("Le montant réglé ne peut pas être négatif.");
|
||||
}
|
||||
|
||||
// Upsert par la clé métier (Annee, Mois, VehiculeId) — index unique
|
||||
// UX_MontantsRegles_Periode_Vehicule garantit l'unicité (Property 26, Exigence 14.3).
|
||||
var existant = await _context.MontantsRegles
|
||||
.FirstOrDefaultAsync(
|
||||
m => m.Annee == dto.Annee && m.Mois == dto.Mois && m.VehiculeId == dto.VehiculeId,
|
||||
cancellationToken);
|
||||
|
||||
if (existant is null)
|
||||
{
|
||||
var montantRegle = new MontantRegle
|
||||
{
|
||||
Annee = dto.Annee,
|
||||
Mois = dto.Mois,
|
||||
VehiculeId = dto.VehiculeId,
|
||||
Montant = dto.Montant,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
_context.MontantsRegles.Add(montantRegle);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return ToDto(montantRegle);
|
||||
}
|
||||
|
||||
existant.Montant = dto.Montant;
|
||||
existant.UpdatedAt = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return ToDto(existant);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MontantRegleDto>> GetMontantsReglesAsync(
|
||||
int annee, int vehiculeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var montants = await _context.MontantsRegles
|
||||
.AsNoTracking()
|
||||
.Where(m => m.Annee == annee && m.VehiculeId == vehiculeId)
|
||||
.OrderBy(m => m.Mois)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return montants.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MontantPrevisionnelDto> GetMontantPrevisionnelAsync(
|
||||
int annee, int mois, int vehiculeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Puissance fiscale du véhicule concerné : détermine la tranche du barème.
|
||||
var vehicule = await _context.Vehicules
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(v => v.Id == vehiculeId, cancellationToken)
|
||||
?? throw new VehiculeNotFoundException($"Le véhicule d'identifiant {vehiculeId} est introuvable.");
|
||||
|
||||
// Total_Annuel_Partiel : somme des distances de l'année pour ce véhicule,
|
||||
// jusqu'au mois sélectionné inclus (les mois postérieurs sont exclus).
|
||||
// SQLite ne sait pas appliquer SUM sur une colonne decimal : on ramène les distances
|
||||
// concernées et on somme côté client (null traité comme 0).
|
||||
var distancesPartielles = await _context.Trajets
|
||||
.AsNoTracking()
|
||||
.Where(t => t.VehiculeId == vehiculeId
|
||||
&& t.Date.Year == annee
|
||||
&& t.Date.Month <= mois)
|
||||
.Select(t => t.Distance)
|
||||
.ToListAsync(cancellationToken);
|
||||
var totalAnnuelPartiel = distancesPartielles.Sum(d => d ?? 0m);
|
||||
|
||||
// Barème plein appliqué au total partiel (sans minoration).
|
||||
var bareme = await _baremeService.CalculerMontantAsync(
|
||||
annee, vehicule.PuissanceFiscale, totalAnnuelPartiel, cancellationToken);
|
||||
|
||||
var montantBaremePlein = Math.Round(bareme.Montant, 2, MidpointRounding.AwayFromZero);
|
||||
|
||||
// Montant_Previsionnel = 80% du barème plein, arrondi à 2 décimales (indicatif — Exigences 13.5/13.6).
|
||||
var montantPrevisionnel = Math.Round(bareme.Montant * TauxPrevisionnel, 2, MidpointRounding.AwayFromZero);
|
||||
|
||||
return new MontantPrevisionnelDto(
|
||||
Annee: annee,
|
||||
Mois: mois,
|
||||
VehiculeId: vehiculeId,
|
||||
TotalAnnuelPartielKm: Math.Round(totalAnnuelPartiel, 2, MidpointRounding.AwayFromZero),
|
||||
MontantBaremePlein: montantBaremePlein,
|
||||
MontantPrevisionnel: montantPrevisionnel);
|
||||
}
|
||||
|
||||
private static MontantRegleDto ToDto(MontantRegle m) =>
|
||||
new(m.Annee, m.Mois, m.VehiculeId, m.Montant);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client typé (HttpClient) de l'API OpenRouteService (ORS) pour le calcul
|
||||
/// d'itinéraire routier. La <c>BaseAddress</c>, le <c>Timeout</c> et l'en-tête
|
||||
/// de clé d'API sont configurés lors de l'enregistrement du client typé dans
|
||||
/// <c>Program.cs</c> à partir de <c>ORSOptions</c>.
|
||||
/// </summary>
|
||||
public class ORSClient : IORSClient
|
||||
{
|
||||
// Point d'accès GeoJSON : retourne une FeatureCollection avec la géométrie
|
||||
// de l'itinéraire au format GeoJSON et le résumé (distance en mètres).
|
||||
private const string DirectionsGeoJsonPath = "v2/directions/driving-car/geojson";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public ORSClient(HttpClient httpClient)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RouteResult> CalculateRouteAsync(
|
||||
double lat1,
|
||||
double lon1,
|
||||
double lat2,
|
||||
double lon2,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// ORS attend les coordonnées au format [longitude, latitude].
|
||||
var body = new
|
||||
{
|
||||
coordinates = new[]
|
||||
{
|
||||
new[] { lon1, lat1 },
|
||||
new[] { lon2, lat2 }
|
||||
}
|
||||
};
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await _httpClient.PostAsJsonAsync(DirectionsGeoJsonPath, body, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Le HttpClient annule via son Timeout : OperationCanceledException est levée
|
||||
// sans que le jeton de l'appelant soit annulé -> il s'agit d'un dépassement de délai.
|
||||
throw new ORSTimeoutException(
|
||||
"L'appel à OpenRouteService a dépassé le délai imparti.", ex);
|
||||
}
|
||||
catch (TaskCanceledException ex)
|
||||
{
|
||||
throw new ORSTimeoutException(
|
||||
"L'appel à OpenRouteService a dépassé le délai imparti.", ex);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
throw new ORSException(
|
||||
"Échec de l'appel réseau à OpenRouteService.", ex);
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new ORSException(
|
||||
$"OpenRouteService a retourné un statut non conforme : {(int)response.StatusCode}.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
|
||||
return ParseRouteResult(document.RootElement);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new ORSException(
|
||||
"La réponse d'OpenRouteService n'a pas pu être analysée.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extrait la distance (mètres -> km) et la géométrie GeoJSON de la réponse ORS.
|
||||
/// La réponse GeoJSON est une FeatureCollection dont la première feature contient
|
||||
/// <c>properties.summary.distance</c> (mètres) et <c>geometry</c> (GeoJSON).
|
||||
/// </summary>
|
||||
private static RouteResult ParseRouteResult(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("features", out var features)
|
||||
|| features.ValueKind != JsonValueKind.Array
|
||||
|| features.GetArrayLength() == 0)
|
||||
{
|
||||
throw new ORSException(
|
||||
"La réponse d'OpenRouteService ne contient aucun itinéraire.");
|
||||
}
|
||||
|
||||
var feature = features[0];
|
||||
|
||||
double distanceMeters;
|
||||
if (feature.TryGetProperty("properties", out var properties)
|
||||
&& properties.TryGetProperty("summary", out var summary)
|
||||
&& summary.TryGetProperty("distance", out var distanceElement)
|
||||
&& distanceElement.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
distanceMeters = distanceElement.GetDouble();
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new ORSException(
|
||||
"La distance est absente de la réponse d'OpenRouteService.");
|
||||
}
|
||||
|
||||
// Conversion mètres -> km, arrondi à 2 décimales.
|
||||
var distanceKm = Math.Round((decimal)(distanceMeters / 1000.0), 2, MidpointRounding.AwayFromZero);
|
||||
|
||||
string? geometrieJson = null;
|
||||
if (feature.TryGetProperty("geometry", out var geometry)
|
||||
&& geometry.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
geometrieJson = geometry.GetRawText();
|
||||
}
|
||||
|
||||
return new RouteResult(distanceKm, geometrieJson);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Levée en cas d'échec général d'un appel à OpenRouteService (ORS) qui n'est pas
|
||||
/// un dépassement de délai : erreur réseau, réponse HTTP non conforme, corps de
|
||||
/// réponse illisible, etc. Distincte de <see cref="ORSTimeoutException"/> afin que
|
||||
/// l'appelant puisse différencier un timeout (fallback Haversine) d'une défaillance
|
||||
/// générale du service de calcul d'itinéraire.
|
||||
/// </summary>
|
||||
public class ORSException : Exception
|
||||
{
|
||||
public ORSException()
|
||||
: base("Erreur lors de l'appel à OpenRouteService.")
|
||||
{
|
||||
}
|
||||
|
||||
public ORSException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public ORSException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsqu'un appel à OpenRouteService (ORS) dépasse le délai imparti
|
||||
/// (voir <see cref="Configuration.ORSOptions.TimeoutSeconds"/>).
|
||||
/// Le service appelant (DistanceCalculatorService) intercepte cette exception
|
||||
/// pour appliquer le fallback orthodromique (Haversine) sans erreur bloquante
|
||||
/// pour l'utilisateur. Voir Requirement 2.6.
|
||||
/// </summary>
|
||||
public class ORSTimeoutException : Exception
|
||||
{
|
||||
public ORSTimeoutException()
|
||||
: base("L'appel à OpenRouteService a dépassé le délai imparti.")
|
||||
{
|
||||
}
|
||||
|
||||
public ORSTimeoutException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public ORSTimeoutException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Résultat de la génération d'un Export_PDF : les octets du document et le nom de fichier
|
||||
/// suggéré (format <c>trajets_MM_AAAA.pdf</c> pour le mensuel, <c>trajets_AAAA.pdf</c> pour l'annuel).
|
||||
/// Le câblage de l'en-tête HTTP <c>Content-Disposition</c> relève du controller (tâche 11.3).
|
||||
/// </summary>
|
||||
/// <param name="Contenu">Octets du fichier PDF (<c>application/pdf</c>).</param>
|
||||
/// <param name="NomFichier">Nom de fichier suggéré, ex. <c>trajets_01_2025.pdf</c>.</param>
|
||||
public record PdfResult(
|
||||
byte[] Contenu,
|
||||
string NomFichier);
|
||||
@@ -0,0 +1,745 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
using TrajetKmTracker.Api.Data;
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
using TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Génération des Export_PDF via QuestPDF. Ce service compose les services métier existants
|
||||
/// (résumé, profil, véhicule, barème, montants réglés) et n'accède pas directement à la base.
|
||||
/// Le rapport mensuel est produit par <see cref="GenererRapportMensuelAsync"/> ; la synthèse annuelle
|
||||
/// par <see cref="GenererRapportAnnuelAsync"/>. Les variantes « par véhicule » génèrent un PDF distinct
|
||||
/// pour chaque véhicule ayant des trajets sur la période (Exigence 7.9).
|
||||
/// </summary>
|
||||
public class PdfService : IPdfService
|
||||
{
|
||||
/// <summary>Délai maximal de génération d'un PDF (Exigence 7.5).</summary>
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(15);
|
||||
|
||||
private static readonly CultureInfo FrCulture = CultureInfo.GetCultureInfo("fr-FR");
|
||||
|
||||
private readonly IResumeService _resumeService;
|
||||
private readonly IProfilService _profilService;
|
||||
private readonly IVehiculeService _vehiculeService;
|
||||
private readonly IBaremeService _baremeService;
|
||||
private readonly IMontantRegleService _montantRegleService;
|
||||
private readonly IRemboursementService _remboursementService;
|
||||
private readonly AppDbContext _context;
|
||||
|
||||
public PdfService(
|
||||
IResumeService resumeService,
|
||||
IProfilService profilService,
|
||||
IVehiculeService vehiculeService,
|
||||
IBaremeService baremeService,
|
||||
IMontantRegleService montantRegleService,
|
||||
IRemboursementService remboursementService,
|
||||
AppDbContext context)
|
||||
{
|
||||
_resumeService = resumeService;
|
||||
_profilService = profilService;
|
||||
_vehiculeService = vehiculeService;
|
||||
_baremeService = baremeService;
|
||||
_montantRegleService = montantRegleService;
|
||||
_remboursementService = remboursementService;
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PdfResult> GenererRapportMensuelAsync(
|
||||
int annee, int mois, int vehiculeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Enveloppe la génération dans un délai de 15 s (Exigence 7.5) — le dépassement ou toute
|
||||
// erreur de rendu se traduit par une PdfGenerationException que le controller mappe en 503.
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(Timeout);
|
||||
var token = timeoutCts.Token;
|
||||
|
||||
return await ExecuterAvecTimeoutAsync(cancellationToken, async token =>
|
||||
{
|
||||
var modele = await AssemblerModeleMensuelAsync(annee, mois, vehiculeId, token);
|
||||
var octets = RendreMensuel(modele, token);
|
||||
|
||||
var nomFichier = $"trajets_{mois:D2}_{annee:D4}.pdf";
|
||||
return new PdfResult(octets, nomFichier);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PdfResult> GenererRapportAnnuelAsync(
|
||||
int annee, int vehiculeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await ExecuterAvecTimeoutAsync(cancellationToken, async token =>
|
||||
{
|
||||
var modele = await AssemblerModeleAnnuelAsync(annee, vehiculeId, token);
|
||||
var octets = RendreAnnuel(modele, token);
|
||||
|
||||
var nomFichier = $"trajets_{annee:D4}.pdf";
|
||||
return new PdfResult(octets, nomFichier);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PdfResult>> GenererRapportsAnnuelsParVehiculeAsync(
|
||||
int annee, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var vehiculeIds = await VehiculesAvecTrajetsAnneeAsync(annee, cancellationToken);
|
||||
|
||||
var resultats = new List<PdfResult>(vehiculeIds.Count);
|
||||
foreach (var vehiculeId in vehiculeIds)
|
||||
{
|
||||
resultats.Add(await GenererRapportAnnuelAsync(annee, vehiculeId, cancellationToken));
|
||||
}
|
||||
|
||||
return resultats;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PdfResult>> GenererRapportsMensuelsParVehiculeAsync(
|
||||
int annee, int mois, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var vehiculeIds = await VehiculesAvecTrajetsMoisAsync(annee, mois, cancellationToken);
|
||||
|
||||
var resultats = new List<PdfResult>(vehiculeIds.Count);
|
||||
foreach (var vehiculeId in vehiculeIds)
|
||||
{
|
||||
resultats.Add(await GenererRapportMensuelAsync(annee, mois, vehiculeId, cancellationToken));
|
||||
}
|
||||
|
||||
return resultats;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enveloppe une opération de génération PDF dans un délai de 15 s (Exigence 7.5). Tout
|
||||
/// dépassement du délai propre au PDF (et non une annulation demandée par l'appelant) ou toute
|
||||
/// erreur de rendu est traduit en <see cref="PdfGenerationException"/> — que le controller mappe
|
||||
/// vers <c>503 Service Unavailable</c>.
|
||||
/// </summary>
|
||||
private static async Task<PdfResult> ExecuterAvecTimeoutAsync(
|
||||
CancellationToken cancellationToken, Func<CancellationToken, Task<PdfResult>> operation)
|
||||
{
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(Timeout);
|
||||
var token = timeoutCts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
return await operation(token);
|
||||
}
|
||||
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Dépassement du délai propre à la génération PDF (et non annulation par l'appelant).
|
||||
throw new PdfGenerationException(
|
||||
"La génération du PDF a échoué : délai de 15 secondes dépassé.");
|
||||
}
|
||||
catch (PdfGenerationException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Annulation demandée par l'appelant : propagée telle quelle.
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new PdfGenerationException(
|
||||
"La génération du PDF a échoué.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retourne, triés par identifiant, les véhicules ayant au moins un Trajet sur l'année.
|
||||
/// </summary>
|
||||
private async Task<IReadOnlyList<int>> VehiculesAvecTrajetsAnneeAsync(
|
||||
int annee, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.Trajets
|
||||
.AsNoTracking()
|
||||
.Where(t => t.Date.Year == annee)
|
||||
.Select(t => t.VehiculeId)
|
||||
.Distinct()
|
||||
.OrderBy(id => id)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retourne, triés par identifiant, les véhicules ayant au moins un Trajet sur le mois donné.
|
||||
/// </summary>
|
||||
private async Task<IReadOnlyList<int>> VehiculesAvecTrajetsMoisAsync(
|
||||
int annee, int mois, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.Trajets
|
||||
.AsNoTracking()
|
||||
.Where(t => t.Date.Year == annee && t.Date.Month == mois)
|
||||
.Select(t => t.VehiculeId)
|
||||
.Distinct()
|
||||
.OrderBy(id => id)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Agrège, via les services métier existants, toutes les données nécessaires au rapport mensuel.
|
||||
/// </summary>
|
||||
private async Task<ModeleRapportMensuel> AssemblerModeleMensuelAsync(
|
||||
int annee, int mois, int vehiculeId, CancellationToken cancellationToken)
|
||||
{
|
||||
// Identité et adresse de départ (profil).
|
||||
var profil = await _profilService.GetProfilAsync(cancellationToken);
|
||||
|
||||
// Véhicule concerné (plaque, puissance fiscale).
|
||||
var vehicule = await ResoudreVehiculeAsync(vehiculeId, cancellationToken);
|
||||
|
||||
// Résumé mensuel filtré sur le véhicule (trajets triés + total km du mois).
|
||||
var resumeMensuel = await _resumeService.GetResumeMensuelAsync(annee, mois, vehiculeId, cancellationToken);
|
||||
|
||||
// Total kilométrique annuel cumulé (tous mois de l'année, même véhicule).
|
||||
var resumeAnnuel = await _resumeService.GetResumeAnnuelAsync(annee, vehiculeId, cancellationToken);
|
||||
var totalKmAnnuel = resumeAnnuel.TotalKm;
|
||||
|
||||
// Coefficient et base de la tranche ANNUELLE applicable (déterminée par le total annuel).
|
||||
var bareme = await _baremeService.CalculerMontantAsync(
|
||||
annee, vehicule.PuissanceFiscale, totalKmAnnuel, cancellationToken);
|
||||
|
||||
// TOTAL IK du mois = km du mois × coefficient de la tranche annuelle applicable.
|
||||
var totalIkMois = Math.Round(resumeMensuel.TotalKm * bareme.Coefficient, 2, MidpointRounding.AwayFromZero);
|
||||
|
||||
// Montant_Regle saisi pour ce (année, mois, véhicule) — 0 si absent.
|
||||
var montantsRegles = await _montantRegleService.GetMontantsReglesAsync(annee, vehiculeId, cancellationToken);
|
||||
var montantRegle = montantsRegles
|
||||
.Where(m => m.Mois == mois)
|
||||
.Select(m => m.Montant)
|
||||
.FirstOrDefault();
|
||||
|
||||
return new ModeleRapportMensuel(
|
||||
Annee: annee,
|
||||
Mois: mois,
|
||||
Profil: profil,
|
||||
Vehicule: vehicule,
|
||||
Trajets: resumeMensuel.Trajets,
|
||||
TotalKmMois: resumeMensuel.TotalKm,
|
||||
TotalKmAnnuel: totalKmAnnuel,
|
||||
Coefficient: bareme.Coefficient,
|
||||
Base: bareme.Base,
|
||||
TotalIkMois: totalIkMois,
|
||||
MontantRegle: montantRegle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Agrège, via les services métier existants, les données nécessaires à la synthèse annuelle :
|
||||
/// profil (identité + domicile), régularisation annuelle (12 lignes + totaux + tranche) et les
|
||||
/// trois tranches du barème de l'année pour la puissance fiscale du véhicule.
|
||||
/// </summary>
|
||||
private async Task<ModeleRapportAnnuel> AssemblerModeleAnnuelAsync(
|
||||
int annee, int vehiculeId, CancellationToken cancellationToken)
|
||||
{
|
||||
// Identité et adresse de domicile (profil).
|
||||
var profil = await _profilService.GetProfilAsync(cancellationToken);
|
||||
|
||||
// Synthèse annuelle de régularisation (barème plein, 12 lignes, totaux, tranche applicable).
|
||||
var regularisation = await _remboursementService.GetRegularisationAnnuelleAsync(
|
||||
annee, vehiculeId, cancellationToken);
|
||||
|
||||
// Barème de l'année filtré sur la puissance fiscale du véhicule (les trois tranches).
|
||||
var toutesTranches = await _baremeService.GetBaremeAsync(annee, cancellationToken);
|
||||
var tranchesVehicule = toutesTranches
|
||||
.Where(t => t.PuissanceFiscale == regularisation.PuissanceFiscale)
|
||||
.OrderBy(t => t.KmMin)
|
||||
.ToList();
|
||||
|
||||
return new ModeleRapportAnnuel(
|
||||
Annee: annee,
|
||||
Profil: profil,
|
||||
Regularisation: regularisation,
|
||||
TranchesBareme: tranchesVehicule);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Résout le véhicule ciblé par son identifiant à partir de l'historique.
|
||||
/// Lève <see cref="VehiculeNotFoundException"/> si aucun véhicule ne correspond.
|
||||
/// </summary>
|
||||
private async Task<VehiculeDto> ResoudreVehiculeAsync(int vehiculeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var historique = await _vehiculeService.GetHistoriqueAsync(cancellationToken);
|
||||
var vehicule = historique.FirstOrDefault(v => v.Id == vehiculeId);
|
||||
|
||||
return vehicule
|
||||
?? throw new VehiculeNotFoundException($"Le véhicule d'identifiant {vehiculeId} est introuvable.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rend le document mensuel en octets PDF selon la maquette « PDF Mensuel » du design.
|
||||
/// </summary>
|
||||
private static byte[] RendreMensuel(ModeleRapportMensuel modele, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var document = Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.A4);
|
||||
page.Margin(30);
|
||||
page.DefaultTextStyle(x => x.FontSize(9).FontColor(Colors.Black));
|
||||
|
||||
page.Header().Element(c => ComposerEntete(c, modele));
|
||||
page.Content().PaddingTop(10).Element(c => ComposerTableTrajets(c, modele));
|
||||
page.Footer().AlignCenter().Text(txt =>
|
||||
{
|
||||
txt.Span("Page ");
|
||||
txt.CurrentPageNumber();
|
||||
txt.Span(" / ");
|
||||
txt.TotalPages();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return document.GeneratePdf();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compose l'en-tête obligatoire (Exigences 7.6, 7.8, 7.10) : titre fixe, identité, véhicule,
|
||||
/// période, adresse de départ, totaux mois/année, TOTAL IK et Montant_Regle.
|
||||
/// </summary>
|
||||
private static void ComposerEntete(IContainer container, ModeleRapportMensuel modele)
|
||||
{
|
||||
var periode = $"{NomMois(modele.Mois).ToUpper(FrCulture)} {modele.Annee}";
|
||||
var identite = FormatIdentite(modele.Profil);
|
||||
var matricule = string.IsNullOrWhiteSpace(modele.Profil.Matricule)
|
||||
? "—"
|
||||
: modele.Profil.Matricule!;
|
||||
var adresseDepart = string.IsNullOrWhiteSpace(modele.Profil.AdresseDomicile)
|
||||
? "—"
|
||||
: modele.Profil.AdresseDomicile!;
|
||||
var vehiculeLibelle =
|
||||
$"{modele.Vehicule.Plaque} — {modele.Vehicule.PuissanceFiscale} CV " +
|
||||
$"(Coef. : {FormatDecimal(modele.Coefficient, 3)} / Base : {FormatDecimal(modele.Base, 0)})";
|
||||
|
||||
container.Column(col =>
|
||||
{
|
||||
col.Spacing(2);
|
||||
|
||||
// Titre fixe imposé par l'Exigence 7.6.
|
||||
col.Item().Text("Rapport de trajets").FontSize(16).Bold();
|
||||
col.Item().Text("Feuille d'indemnité kilométrique mensuelle").FontSize(11).SemiBold();
|
||||
|
||||
// Identité (nom, prénom, matricule) — Exigence 7.8.
|
||||
col.Item().PaddingTop(4).Text(txt =>
|
||||
{
|
||||
txt.Span("Utilisateur : ").SemiBold();
|
||||
txt.Span($"{identite} — Matricule : {matricule}");
|
||||
});
|
||||
|
||||
// Véhicule (plaque, puissance fiscale, coefficient/base de la tranche annuelle) — Exigences 7.8/7.10.
|
||||
col.Item().Text(txt =>
|
||||
{
|
||||
txt.Span("Véhicule : ").SemiBold();
|
||||
txt.Span(vehiculeLibelle);
|
||||
});
|
||||
|
||||
// Période et adresse de départ du profil — Exigences 7.6/7.10.
|
||||
col.Item().Text(txt =>
|
||||
{
|
||||
txt.Span("Période : ").SemiBold();
|
||||
txt.Span(periode);
|
||||
});
|
||||
col.Item().Text(txt =>
|
||||
{
|
||||
txt.Span("Adresse de départ : ").SemiBold();
|
||||
txt.Span(adresseDepart);
|
||||
});
|
||||
|
||||
// Totaux : mois, année cumulée, TOTAL IK du mois, Montant_Regle — Exigences 7.6/7.10.
|
||||
col.Item().PaddingTop(6).Row(row =>
|
||||
{
|
||||
row.RelativeItem().Column(c =>
|
||||
{
|
||||
c.Item().Text(txt =>
|
||||
{
|
||||
txt.Span("Total parcouru sur le mois : ").SemiBold();
|
||||
txt.Span($"{FormatKm(modele.TotalKmMois)} km");
|
||||
});
|
||||
c.Item().Text(txt =>
|
||||
{
|
||||
txt.Span("Total parcouru sur l'année : ").SemiBold();
|
||||
txt.Span($"{FormatKm(modele.TotalKmAnnuel)} km");
|
||||
});
|
||||
});
|
||||
row.RelativeItem().Column(c =>
|
||||
{
|
||||
c.Item().Text(txt =>
|
||||
{
|
||||
txt.Span("TOTAL IK du mois : ").SemiBold();
|
||||
txt.Span($"{FormatMontant(modele.TotalIkMois)} €");
|
||||
});
|
||||
c.Item().Text(txt =>
|
||||
{
|
||||
txt.Span("Réglé : ").SemiBold();
|
||||
txt.Span($"{FormatMontant(modele.MontantRegle)} €");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
col.Item().PaddingTop(6).LineHorizontal(1).LineColor(Colors.Grey.Medium);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compose la table des trajets du mois (Exigence 7.11) : date, motif (commentaire),
|
||||
/// adresse de départ → arrivée, kilomètres.
|
||||
/// </summary>
|
||||
private static void ComposerTableTrajets(IContainer container, ModeleRapportMensuel modele)
|
||||
{
|
||||
container.Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(columns =>
|
||||
{
|
||||
columns.ConstantColumn(70); // Date
|
||||
columns.RelativeColumn(2); // Motif (commentaire)
|
||||
columns.RelativeColumn(3); // Adresse départ → arrivée
|
||||
columns.ConstantColumn(55); // Kms
|
||||
});
|
||||
|
||||
// En-tête de table.
|
||||
table.Header(header =>
|
||||
{
|
||||
header.Cell().Element(CelluleEntete).Text("Date");
|
||||
header.Cell().Element(CelluleEntete).Text("Motifs");
|
||||
header.Cell().Element(CelluleEntete).Text("Départ → Arrivée");
|
||||
header.Cell().Element(CelluleEntete).AlignRight().Text("Kms");
|
||||
});
|
||||
|
||||
if (modele.Trajets.Count == 0)
|
||||
{
|
||||
table.Cell().ColumnSpan(4).Element(Cellule).Text("Aucun trajet pour ce mois.").Italic();
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var trajet in modele.Trajets)
|
||||
{
|
||||
var motif = string.IsNullOrWhiteSpace(trajet.Commentaire) ? "—" : trajet.Commentaire!;
|
||||
var itineraire = $"{trajet.AdresseDepart} → {trajet.AdresseArrivee}";
|
||||
var km = trajet.Distance ?? 0m;
|
||||
|
||||
table.Cell().Element(Cellule).Text(trajet.Date.ToString("dd/MM/yyyy", FrCulture));
|
||||
table.Cell().Element(Cellule).Text(motif);
|
||||
table.Cell().Element(Cellule).Text(itineraire);
|
||||
table.Cell().Element(Cellule).AlignRight().Text(FormatKm(km));
|
||||
}
|
||||
|
||||
// Ligne de total du mois.
|
||||
table.Cell().ColumnSpan(3).Element(CelluleTotal).AlignRight().Text("Total du mois :").SemiBold();
|
||||
table.Cell().Element(CelluleTotal).AlignRight().Text($"{FormatKm(modele.TotalKmMois)}").SemiBold();
|
||||
}
|
||||
});
|
||||
|
||||
static IContainer CelluleEntete(IContainer c) =>
|
||||
c.Background(Colors.Grey.Lighten2).BorderBottom(1).BorderColor(Colors.Grey.Medium)
|
||||
.PaddingVertical(4).PaddingHorizontal(3);
|
||||
|
||||
static IContainer Cellule(IContainer c) =>
|
||||
c.BorderBottom(1).BorderColor(Colors.Grey.Lighten2).PaddingVertical(3).PaddingHorizontal(3);
|
||||
|
||||
static IContainer CelluleTotal(IContainer c) =>
|
||||
c.BorderTop(1).BorderColor(Colors.Grey.Medium).PaddingVertical(4).PaddingHorizontal(3);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rend le document de synthèse annuelle en octets PDF selon la maquette « PDF Annuel » du design.
|
||||
/// </summary>
|
||||
private static byte[] RendreAnnuel(ModeleRapportAnnuel modele, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var document = Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.A4);
|
||||
page.Margin(30);
|
||||
page.DefaultTextStyle(x => x.FontSize(9).FontColor(Colors.Black));
|
||||
|
||||
page.Header().Element(c => ComposerEnteteAnnuel(c, modele));
|
||||
page.Content().PaddingTop(10).Column(col =>
|
||||
{
|
||||
col.Spacing(10);
|
||||
col.Item().Element(c => ComposerTableBaremeAnnuel(c, modele));
|
||||
col.Item().Element(c => ComposerTableSyntheseAnnuelle(c, modele));
|
||||
col.Item().Element(c => ComposerTotauxAnnuels(c, modele));
|
||||
});
|
||||
page.Footer().AlignCenter().Text(txt =>
|
||||
{
|
||||
txt.Span("Page ");
|
||||
txt.CurrentPageNumber();
|
||||
txt.Span(" / ");
|
||||
txt.TotalPages();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return document.GeneratePdf();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compose l'en-tête de la synthèse annuelle (Exigences 7.6, 7.7, 7.8, 7.12) : titre fixe,
|
||||
/// identité, année, domicile, véhicule (plaque, puissance fiscale) et tranche annuelle applicable.
|
||||
/// </summary>
|
||||
private static void ComposerEnteteAnnuel(IContainer container, ModeleRapportAnnuel modele)
|
||||
{
|
||||
var reg = modele.Regularisation;
|
||||
var identite = FormatIdentite(modele.Profil);
|
||||
var matricule = string.IsNullOrWhiteSpace(modele.Profil.Matricule)
|
||||
? "—"
|
||||
: modele.Profil.Matricule!;
|
||||
var domicile = string.IsNullOrWhiteSpace(modele.Profil.AdresseDomicile)
|
||||
? "—"
|
||||
: modele.Profil.AdresseDomicile!;
|
||||
|
||||
container.Column(col =>
|
||||
{
|
||||
col.Spacing(2);
|
||||
|
||||
// Titre fixe imposé par l'Exigence 7.6.
|
||||
col.Item().Text("Rapport de trajets").FontSize(16).Bold();
|
||||
col.Item().Text($"Synthèse {modele.Annee} d'indemnité kilométrique").FontSize(11).SemiBold();
|
||||
|
||||
// Identité (nom, prénom, matricule) — Exigence 7.8.
|
||||
col.Item().PaddingTop(4).Text(txt =>
|
||||
{
|
||||
txt.Span("Utilisateur : ").SemiBold();
|
||||
txt.Span($"{identite} — Matricule : {matricule}");
|
||||
});
|
||||
|
||||
// Année (période couverte) — Exigence 7.6.
|
||||
col.Item().Text(txt =>
|
||||
{
|
||||
txt.Span("Année : ").SemiBold();
|
||||
txt.Span(modele.Annee.ToString(CultureInfo.InvariantCulture));
|
||||
});
|
||||
|
||||
// Adresse de domicile — Exigence 7.12.
|
||||
col.Item().Text(txt =>
|
||||
{
|
||||
txt.Span("Domicile : ").SemiBold();
|
||||
txt.Span(domicile);
|
||||
});
|
||||
|
||||
// Véhicule (plaque, puissance fiscale) et tranche annuelle applicable — Exigences 7.7/7.8/7.12.
|
||||
col.Item().Text(txt =>
|
||||
{
|
||||
txt.Span("Véhicule : ").SemiBold();
|
||||
txt.Span($"{reg.Plaque} — {reg.PuissanceFiscale} CV");
|
||||
});
|
||||
col.Item().Text(txt =>
|
||||
{
|
||||
txt.Span("Barème applicable : ").SemiBold();
|
||||
txt.Span(reg.TrancheLibelle);
|
||||
});
|
||||
|
||||
// Total kilométrique de la période (Exigence 7.6).
|
||||
col.Item().Text(txt =>
|
||||
{
|
||||
txt.Span("Total kilométrique de l'année : ").SemiBold();
|
||||
txt.Span($"{FormatKm(reg.TotalKmAnnuel)} km");
|
||||
});
|
||||
|
||||
col.Item().PaddingTop(6).LineHorizontal(1).LineColor(Colors.Grey.Medium);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compose la table du barème applicable de l'année pour la puissance fiscale du véhicule
|
||||
/// (les trois tranches coefficient/base) — Exigence 7.12.
|
||||
/// </summary>
|
||||
private static void ComposerTableBaremeAnnuel(IContainer container, ModeleRapportAnnuel modele)
|
||||
{
|
||||
container.Column(col =>
|
||||
{
|
||||
col.Spacing(3);
|
||||
col.Item().Text(txt =>
|
||||
{
|
||||
txt.Span("Barème applicable pour l'année ").SemiBold();
|
||||
txt.Span($"({modele.Regularisation.PuissanceFiscale} CV)").SemiBold();
|
||||
});
|
||||
|
||||
if (modele.TranchesBareme.Count == 0)
|
||||
{
|
||||
col.Item().Text("Barème non configuré pour cette année et cette puissance fiscale.").Italic();
|
||||
return;
|
||||
}
|
||||
|
||||
col.Item().Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(columns =>
|
||||
{
|
||||
columns.RelativeColumn(3); // Tranche (bornes km)
|
||||
columns.RelativeColumn(2); // Coefficient
|
||||
columns.RelativeColumn(2); // Base
|
||||
});
|
||||
|
||||
table.Header(header =>
|
||||
{
|
||||
header.Cell().Element(CelluleEnteteBareme).Text("Tranche (km)");
|
||||
header.Cell().Element(CelluleEnteteBareme).AlignRight().Text("Coefficient");
|
||||
header.Cell().Element(CelluleEnteteBareme).AlignRight().Text("Base (€)");
|
||||
});
|
||||
|
||||
foreach (var tranche in modele.TranchesBareme)
|
||||
{
|
||||
var bornes = tranche.KmMax.HasValue
|
||||
? $"{FormatDecimal(tranche.KmMin, 0)} à {FormatDecimal(tranche.KmMax.Value, 0)}"
|
||||
: $"{FormatDecimal(tranche.KmMin, 0)} et plus";
|
||||
table.Cell().Element(CelluleBareme).Text(bornes);
|
||||
table.Cell().Element(CelluleBareme).AlignRight().Text(FormatDecimal(tranche.Coefficient, 3));
|
||||
table.Cell().Element(CelluleBareme).AlignRight().Text(FormatDecimal(tranche.Base, 0));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
static IContainer CelluleEnteteBareme(IContainer c) =>
|
||||
c.Background(Colors.Grey.Lighten2).BorderBottom(1).BorderColor(Colors.Grey.Medium)
|
||||
.PaddingVertical(4).PaddingHorizontal(3);
|
||||
|
||||
static IContainer CelluleBareme(IContainer c) =>
|
||||
c.BorderBottom(1).BorderColor(Colors.Grey.Lighten2).PaddingVertical(3).PaddingHorizontal(3);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compose la table des douze mois (kilomètres parcourus, frais payé, frais réel) — Exigence 7.12.
|
||||
/// Les données proviennent de <see cref="RegularisationAnnuelleDto.Mois"/>.
|
||||
/// </summary>
|
||||
private static void ComposerTableSyntheseAnnuelle(IContainer container, ModeleRapportAnnuel modele)
|
||||
{
|
||||
var reg = modele.Regularisation;
|
||||
|
||||
container.Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(columns =>
|
||||
{
|
||||
columns.RelativeColumn(2); // Mois
|
||||
columns.RelativeColumn(2); // Kms parcourus
|
||||
columns.RelativeColumn(2); // Frais payé (réglé)
|
||||
columns.RelativeColumn(2); // Frais réel
|
||||
});
|
||||
|
||||
table.Header(header =>
|
||||
{
|
||||
header.Cell().Element(CelluleEntete).Text("Mois");
|
||||
header.Cell().Element(CelluleEntete).AlignRight().Text("Kms parcourus");
|
||||
header.Cell().Element(CelluleEntete).AlignRight().Text("Frais payé");
|
||||
header.Cell().Element(CelluleEntete).AlignRight().Text("Frais réel");
|
||||
});
|
||||
|
||||
foreach (var ligne in reg.Mois)
|
||||
{
|
||||
table.Cell().Element(Cellule).Text(NomMois(ligne.Mois));
|
||||
table.Cell().Element(Cellule).AlignRight().Text($"{FormatKm(ligne.KmMois)}");
|
||||
table.Cell().Element(Cellule).AlignRight().Text($"{FormatMontant(ligne.FraisPaye)} €");
|
||||
table.Cell().Element(Cellule).AlignRight().Text($"{FormatMontant(ligne.FraisReelMensuel)} €");
|
||||
}
|
||||
|
||||
// Ligne de total annuel.
|
||||
table.Cell().Element(CelluleTotal).Text("TOTAL").SemiBold();
|
||||
table.Cell().Element(CelluleTotal).AlignRight().Text($"{FormatKm(reg.TotalKmAnnuel)}").SemiBold();
|
||||
table.Cell().Element(CelluleTotal).AlignRight().Text($"{FormatMontant(reg.TotalPaye)} €").SemiBold();
|
||||
table.Cell().Element(CelluleTotal).AlignRight().Text($"{FormatMontant(reg.MontantReel)} €").SemiBold();
|
||||
});
|
||||
|
||||
static IContainer CelluleEntete(IContainer c) =>
|
||||
c.Background(Colors.Grey.Lighten2).BorderBottom(1).BorderColor(Colors.Grey.Medium)
|
||||
.PaddingVertical(4).PaddingHorizontal(3);
|
||||
|
||||
static IContainer Cellule(IContainer c) =>
|
||||
c.BorderBottom(1).BorderColor(Colors.Grey.Lighten2).PaddingVertical(3).PaddingHorizontal(3);
|
||||
|
||||
static IContainer CelluleTotal(IContainer c) =>
|
||||
c.BorderTop(1).BorderColor(Colors.Grey.Medium).PaddingVertical(4).PaddingHorizontal(3);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compose le bloc « formule réelle » et la régularisation (réel − payé) — Exigence 7.12.
|
||||
/// </summary>
|
||||
private static void ComposerTotauxAnnuels(IContainer container, ModeleRapportAnnuel modele)
|
||||
{
|
||||
var reg = modele.Regularisation;
|
||||
|
||||
// Formule réelle : total_km × coefficient + base = Montant_Reel (tranche annuelle).
|
||||
var formule =
|
||||
$"{FormatKm(reg.TotalKmAnnuel)} km × {FormatDecimal(reg.CoefficientTranche, 3)} " +
|
||||
$"+ {FormatDecimal(reg.BaseTranche, 0)} = {FormatMontant(reg.MontantReel)} € " +
|
||||
$"(tranche {reg.TrancheLibelle}, {reg.PuissanceFiscale} CV)";
|
||||
|
||||
container.Column(col =>
|
||||
{
|
||||
col.Spacing(3);
|
||||
col.Item().Text(txt =>
|
||||
{
|
||||
txt.Span("Formule réelle : ").SemiBold();
|
||||
txt.Span(formule);
|
||||
});
|
||||
col.Item().Text(txt =>
|
||||
{
|
||||
txt.Span("Régularisation (réel − payé) : ").SemiBold();
|
||||
txt.Span($"{FormatMontant(reg.Regularisation)} €");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static string FormatIdentite(ProfilDto profil)
|
||||
{
|
||||
var prenom = (profil.Prenom ?? string.Empty).Trim();
|
||||
var nom = (profil.Nom ?? string.Empty).Trim();
|
||||
var identite = string.Join(" ", new[] { prenom, nom }.Where(s => s.Length > 0));
|
||||
return identite.Length > 0 ? identite : "—";
|
||||
}
|
||||
|
||||
/// <summary>Kilomètres : séparateur de milliers, 2 décimales.</summary>
|
||||
private static string FormatKm(decimal km) =>
|
||||
Math.Round(km, 2, MidpointRounding.AwayFromZero).ToString("N2", FrCulture);
|
||||
|
||||
/// <summary>Montant : séparateur de milliers, 2 décimales.</summary>
|
||||
private static string FormatMontant(decimal montant) =>
|
||||
Math.Round(montant, 2, MidpointRounding.AwayFromZero).ToString("N2", FrCulture);
|
||||
|
||||
private static string FormatDecimal(decimal valeur, int decimales) =>
|
||||
Math.Round(valeur, decimales, MidpointRounding.AwayFromZero)
|
||||
.ToString("N" + decimales, FrCulture);
|
||||
|
||||
private static string NomMois(int mois)
|
||||
{
|
||||
if (mois < 1 || mois > 12)
|
||||
{
|
||||
return mois.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
var nom = FrCulture.DateTimeFormat.GetMonthName(mois);
|
||||
return nom.Length > 0 ? char.ToUpper(nom[0], FrCulture) + nom[1..] : nom;
|
||||
}
|
||||
|
||||
/// <summary>Modèle de données agrégé pour le rendu du rapport mensuel.</summary>
|
||||
private sealed record ModeleRapportMensuel(
|
||||
int Annee,
|
||||
int Mois,
|
||||
ProfilDto Profil,
|
||||
VehiculeDto Vehicule,
|
||||
IReadOnlyList<TrajetDto> Trajets,
|
||||
decimal TotalKmMois,
|
||||
decimal TotalKmAnnuel,
|
||||
decimal Coefficient,
|
||||
decimal Base,
|
||||
decimal TotalIkMois,
|
||||
decimal MontantRegle);
|
||||
|
||||
/// <summary>Modèle de données agrégé pour le rendu de la synthèse annuelle.</summary>
|
||||
private sealed record ModeleRapportAnnuel(
|
||||
int Annee,
|
||||
ProfilDto Profil,
|
||||
RegularisationAnnuelleDto Regularisation,
|
||||
IReadOnlyList<TrancheBaremeDto> TranchesBareme);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrajetKmTracker.Api.Data;
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
using TrajetKmTracker.Api.Models.Entities;
|
||||
using TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <inheritdoc />
|
||||
public class ProfilService : IProfilService
|
||||
{
|
||||
// Le profil est un singleton : une seule ligne dont l'identifiant est toujours 1.
|
||||
private const int ProfilId = 1;
|
||||
|
||||
private readonly AppDbContext _context;
|
||||
private readonly IBANClient _banClient;
|
||||
private readonly IAdresseCacheService _adresseCacheService;
|
||||
private readonly ILogger<ProfilService> _logger;
|
||||
|
||||
public ProfilService(
|
||||
AppDbContext context,
|
||||
IBANClient banClient,
|
||||
IAdresseCacheService adresseCacheService,
|
||||
ILogger<ProfilService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_banClient = banClient;
|
||||
_adresseCacheService = adresseCacheService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ProfilDto> GetProfilAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var profil = await GetOrCreateProfilAsync(cancellationToken);
|
||||
return ToDto(profil);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ProfilDto> SaveProfilAsync(SaveProfilDto dto, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var profil = await GetOrCreateProfilAsync(cancellationToken);
|
||||
|
||||
profil.Nom = dto.Nom;
|
||||
profil.Prenom = dto.Prenom;
|
||||
profil.Matricule = dto.Matricule;
|
||||
profil.AdresseDomicile = dto.AdresseDomicile;
|
||||
profil.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Géocodage BAN de l'Adresse_Domicile. En cas de succès on stocke les coordonnées et
|
||||
// DomicileGeocode = true ; en cas d'échec on persiste l'adresse textuelle sans coordonnées
|
||||
// et DomicileGeocode = false (aucune exception propagée) — Requirements 10.7, 10.8.
|
||||
await GeocoderDomicileAsync(profil, dto.AdresseDomicile, cancellationToken);
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return ToDto(profil);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<EtatProfilDto> GetEtatAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// On ne crée pas le profil ici : l'état doit refléter la base telle quelle (Property 31).
|
||||
var profil = await _context.Profil
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.Id == ProfilId, cancellationToken);
|
||||
|
||||
var nomPrenomRenseignes = profil is not null
|
||||
&& !string.IsNullOrWhiteSpace(profil.Nom)
|
||||
&& !string.IsNullOrWhiteSpace(profil.Prenom);
|
||||
|
||||
var auMoinsUnVehicule = await _context.Vehicules.AnyAsync(cancellationToken);
|
||||
|
||||
return new EtatProfilDto(nomPrenomRenseignes && auMoinsUnVehicule);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tente de résoudre les coordonnées de l'Adresse_Domicile via la BAN. Met à jour le profil
|
||||
/// (coordonnées + DomicileGeocode) et alimente le Cache_Adresses en cas de succès. Ne lève jamais :
|
||||
/// tout échec BAN est traité comme un géocodage non résolu.
|
||||
/// </summary>
|
||||
private async Task GeocoderDomicileAsync(Profil profil, string? adresseDomicile, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(adresseDomicile))
|
||||
{
|
||||
// Pas d'adresse à géocoder : on repart d'un état non géocodé.
|
||||
profil.DomicileLatitude = null;
|
||||
profil.DomicileLongitude = null;
|
||||
profil.DomicileGeocode = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var resultats = await _banClient.RechercherAsync(adresseDomicile, cancellationToken);
|
||||
var meilleur = resultats.Count > 0 ? resultats[0] : null;
|
||||
|
||||
if (meilleur is null)
|
||||
{
|
||||
// BAN a répondu mais aucune adresse ne correspond : adresse conservée sans coordonnées.
|
||||
profil.DomicileLatitude = null;
|
||||
profil.DomicileLongitude = null;
|
||||
profil.DomicileGeocode = false;
|
||||
return;
|
||||
}
|
||||
|
||||
profil.DomicileLatitude = meilleur.Latitude;
|
||||
profil.DomicileLongitude = meilleur.Longitude;
|
||||
profil.DomicileGeocode = true;
|
||||
|
||||
// Mise en cache de l'adresse géocodée pour réutilisation ultérieure (Requirement 9.1).
|
||||
await _adresseCacheService.UpsertAdresseAsync(
|
||||
adresseDomicile,
|
||||
meilleur.Latitude,
|
||||
meilleur.Longitude,
|
||||
geometrieJson: null,
|
||||
cancellationToken);
|
||||
}
|
||||
catch (BANIndisponibleException ex)
|
||||
{
|
||||
// BAN indisponible : l'adresse est conservée sans coordonnées, sans échec de la sauvegarde.
|
||||
_logger.LogWarning(ex,
|
||||
"Géocodage BAN de l'adresse de domicile indisponible ; l'adresse est conservée sans coordonnées.");
|
||||
profil.DomicileLatitude = null;
|
||||
profil.DomicileLongitude = null;
|
||||
profil.DomicileGeocode = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Récupère le profil singleton (Id = 1) ou le crée à la volée s'il est absent.
|
||||
/// </summary>
|
||||
private async Task<Profil> GetOrCreateProfilAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var profil = await _context.Profil.FirstOrDefaultAsync(p => p.Id == ProfilId, cancellationToken);
|
||||
if (profil is not null)
|
||||
{
|
||||
return profil;
|
||||
}
|
||||
|
||||
profil = new Profil { Id = ProfilId };
|
||||
await _context.Profil.AddAsync(profil, cancellationToken);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
return profil;
|
||||
}
|
||||
|
||||
private static ProfilDto ToDto(Profil p) =>
|
||||
new(p.Nom, p.Prenom, p.Matricule, p.AdresseDomicile, p.DomicileGeocode);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrajetKmTracker.Api.Data;
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
using TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <inheritdoc />
|
||||
public class RemboursementService : IRemboursementService
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
private readonly IBaremeService _baremeService;
|
||||
private readonly IMontantRegleService _montantRegleService;
|
||||
|
||||
public RemboursementService(
|
||||
AppDbContext context,
|
||||
IBaremeService baremeService,
|
||||
IMontantRegleService montantRegleService)
|
||||
{
|
||||
_context = context;
|
||||
_baremeService = baremeService;
|
||||
_montantRegleService = montantRegleService;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RegularisationAnnuelleDto> GetRegularisationAnnuelleAsync(
|
||||
int annee, int vehiculeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var vehicule = await _context.Vehicules
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(v => v.Id == vehiculeId, cancellationToken)
|
||||
?? throw new VehiculeNotFoundException($"Le véhicule d'identifiant {vehiculeId} est introuvable.");
|
||||
|
||||
// Kilomètres du véhicule par mois pour l'année (Distance nullable → 0 quand null).
|
||||
// Le GROUP BY + SUM sur decimal n'est pas traduisible par SQLite : on ramène les
|
||||
// trajets de l'année puis on agrège côté client.
|
||||
var trajetsAnnee = await _context.Trajets
|
||||
.AsNoTracking()
|
||||
.Where(t => t.VehiculeId == vehiculeId && t.Date.Year == annee)
|
||||
.Select(t => new { t.Date.Month, t.Distance })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var kmMoisMap = trajetsAnnee
|
||||
.GroupBy(t => t.Month)
|
||||
.ToDictionary(g => g.Key, g => g.Sum(t => t.Distance ?? 0m));
|
||||
|
||||
// Total kilométrique annuel COMPLET (détermine la tranche — Exigence 14.5, Property 27).
|
||||
var totalKmAnnuel = kmMoisMap.Values.Sum();
|
||||
|
||||
// Barème plein (100%) appliqué au total annuel : Montant_Reel = total_km × coef + base.
|
||||
var bareme = await _baremeService.CalculerMontantAsync(
|
||||
annee, vehicule.PuissanceFiscale, totalKmAnnuel, cancellationToken);
|
||||
|
||||
var coefficient = bareme.Coefficient;
|
||||
var baseTranche = bareme.Base;
|
||||
var montantReel = Math.Round(bareme.Montant, 2, MidpointRounding.AwayFromZero);
|
||||
|
||||
// Libellé de la tranche annuelle applicable (ex. « 5 001 à 20 000 km »).
|
||||
var trancheLibelle = await BuildTrancheLibelleAsync(
|
||||
annee, vehicule.PuissanceFiscale, totalKmAnnuel, bareme.BaremeConfigure, cancellationToken);
|
||||
|
||||
// Montant_Regle par mois pour ce véhicule/année.
|
||||
var montantsRegles = await _montantRegleService.GetMontantsReglesAsync(annee, vehiculeId, cancellationToken);
|
||||
var reglesMap = montantsRegles.ToDictionary(m => m.Mois, m => m.Montant);
|
||||
|
||||
// 12 lignes mensuelles :
|
||||
// FraisReelMensuel = km_du_mois × coefficient de la tranche annuelle (Exigence 14.6),
|
||||
// FraisPaye = Montant_Regle saisi du mois (0 si absent).
|
||||
var lignes = new List<LigneSyntheseMoisDto>(12);
|
||||
for (var mois = 1; mois <= 12; mois++)
|
||||
{
|
||||
var kmMois = kmMoisMap.TryGetValue(mois, out var km) ? km : 0m;
|
||||
var fraisPaye = reglesMap.TryGetValue(mois, out var paye) ? paye : 0m;
|
||||
var fraisReelMensuel = Math.Round(kmMois * coefficient, 2, MidpointRounding.AwayFromZero);
|
||||
|
||||
lignes.Add(new LigneSyntheseMoisDto(
|
||||
Mois: mois,
|
||||
KmMois: Math.Round(kmMois, 2, MidpointRounding.AwayFromZero),
|
||||
FraisPaye: fraisPaye,
|
||||
FraisReelMensuel: fraisReelMensuel));
|
||||
}
|
||||
|
||||
var totalPaye = Math.Round(montantsRegles.Sum(m => m.Montant), 2, MidpointRounding.AwayFromZero);
|
||||
|
||||
// Regularisation = Montant_Reel − total des Montant_Regle (Exigence 14.7).
|
||||
var regularisation = Math.Round(montantReel - totalPaye, 2, MidpointRounding.AwayFromZero);
|
||||
|
||||
return new RegularisationAnnuelleDto(
|
||||
Annee: annee,
|
||||
VehiculeId: vehiculeId,
|
||||
Plaque: vehicule.Plaque,
|
||||
PuissanceFiscale: vehicule.PuissanceFiscale,
|
||||
CoefficientTranche: coefficient,
|
||||
BaseTranche: baseTranche,
|
||||
TrancheLibelle: trancheLibelle,
|
||||
Mois: lignes,
|
||||
TotalKmAnnuel: Math.Round(totalKmAnnuel, 2, MidpointRounding.AwayFromZero),
|
||||
TotalPaye: totalPaye,
|
||||
MontantReel: montantReel,
|
||||
Regularisation: regularisation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Construit un libellé lisible de la tranche annuelle dont l'intervalle contient
|
||||
/// <paramref name="totalKmAnnuel"/>. Retourne un libellé « non configuré » si aucune tranche
|
||||
/// ne s'applique (barème absent ou total hors des intervalles définis).
|
||||
/// </summary>
|
||||
private async Task<string> BuildTrancheLibelleAsync(
|
||||
int annee, int puissanceFiscale, decimal totalKmAnnuel, bool baremeConfigure, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!baremeConfigure)
|
||||
{
|
||||
return "Barème non configuré";
|
||||
}
|
||||
|
||||
var tranches = await _context.TranchesBareme
|
||||
.AsNoTracking()
|
||||
.Where(t => t.Annee == annee && t.PuissanceFiscale == puissanceFiscale)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var tranche = tranches
|
||||
.FirstOrDefault(t => totalKmAnnuel >= t.KmMin && (t.KmMax == null || totalKmAnnuel < t.KmMax.Value));
|
||||
|
||||
if (tranche is null)
|
||||
{
|
||||
return "Barème non configuré";
|
||||
}
|
||||
|
||||
return tranche.KmMax.HasValue
|
||||
? $"{FormatKm(tranche.KmMin)} à {FormatKm(tranche.KmMax.Value)} km"
|
||||
: $"{FormatKm(tranche.KmMin)} km et plus";
|
||||
}
|
||||
|
||||
private static string FormatKm(decimal km)
|
||||
{
|
||||
// Espace comme séparateur de milliers, sans décimales (bornes de tranche entières).
|
||||
var fr = CultureInfo.GetCultureInfo("fr-FR");
|
||||
return Math.Round(km, 0, MidpointRounding.AwayFromZero).ToString("N0", fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrajetKmTracker.Api.Data;
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
using TrajetKmTracker.Api.Models.Entities;
|
||||
using TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
namespace TrajetKmTracker.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;
|
||||
|
||||
public ResumeService(AppDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ResumeMensuelDto> GetResumeMensuelAsync(
|
||||
int annee, int mois, int? vehiculeId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValiderAnnee(annee);
|
||||
ValiderMois(mois);
|
||||
|
||||
var trajets = await ChargerTrajetsDuMoisAsync(annee, mois, vehiculeId, cancellationToken);
|
||||
|
||||
return ConstruireResumeMensuel(annee, mois, trajets);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ResumeAnnuelDto> GetResumeAnnuelAsync(
|
||||
int annee, int? vehiculeId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValiderAnnee(annee);
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
// Total annuel = somme des 12 totaux mensuels (Exigences 4.3, 4.7, 6.6).
|
||||
var totalAnnuel = Arrondir(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.
|
||||
/// Le total est la somme des distances (null → 0) arrondie à 2 décimales (Property 1).
|
||||
/// </summary>
|
||||
private static ResumeMensuelDto ConstruireResumeMensuel(int annee, int mois, IReadOnlyList<Trajet> trajets)
|
||||
{
|
||||
var trajetsDto = trajets.Select(ToDto).ToList();
|
||||
var totalKm = Arrondir(trajets.Sum(t => t.Distance ?? 0m));
|
||||
|
||||
return new ResumeMensuelDto(annee, mois, trajetsDto, totalKm);
|
||||
}
|
||||
|
||||
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 decimal Arrondir(decimal valeur) =>
|
||||
Math.Round(valeur, 2, MidpointRounding.AwayFromZero);
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Résultat d'un calcul d'itinéraire routier via OpenRouteService (ORS).
|
||||
/// </summary>
|
||||
/// <param name="DistanceKm">
|
||||
/// Distance routière en kilomètres (ORS retourne des mètres, convertis ici en km).
|
||||
/// </param>
|
||||
/// <param name="GeometrieJson">
|
||||
/// Géométrie de l'itinéraire au format GeoJSON (chaîne JSON), ou <c>null</c>
|
||||
/// si ORS ne l'a pas fournie.
|
||||
/// </param>
|
||||
public record RouteResult(decimal DistanceKm, string? GeometrieJson);
|
||||
@@ -0,0 +1,110 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrajetKmTracker.Api.Data;
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <inheritdoc />
|
||||
public class TotauxService : ITotauxService
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
private readonly IVehiculeService _vehiculeService;
|
||||
|
||||
public TotauxService(AppDbContext context, IVehiculeService vehiculeService)
|
||||
{
|
||||
_context = context;
|
||||
_vehiculeService = vehiculeService;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TotauxDto> GetTotauxAsync(
|
||||
DateOnly date, int? vehiculeId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Véhicule cible : celui fourni, sinon le Véhicule_Courant (Exigence 12.19).
|
||||
var vehiculeCible = vehiculeId
|
||||
?? (await _vehiculeService.GetVehiculeCourantAsync(cancellationToken)).Id;
|
||||
|
||||
// Trajets du véhicule pour l'année sélectionnée (Property 21 : filtrage par véhicule).
|
||||
// Distance nullable → 0 lors des sommes (traitée côté client après projection).
|
||||
var trajetsAnnee = await _context.Trajets
|
||||
.AsNoTracking()
|
||||
.Where(t => t.VehiculeId == vehiculeCible && t.Date.Year == date.Year)
|
||||
.Select(t => new { t.Date, Distance = (decimal?)t.Distance ?? 0m })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// TotalJour : somme des distances de la journée sélectionnée (Exigence 12.7).
|
||||
var totalJour = trajetsAnnee
|
||||
.Where(t => t.Date == date)
|
||||
.Sum(t => t.Distance);
|
||||
|
||||
// TotalMois : somme des distances du mois de la date sélectionnée (Exigence 12.7).
|
||||
var totalMois = trajetsAnnee
|
||||
.Where(t => t.Date.Month == date.Month)
|
||||
.Sum(t => t.Distance);
|
||||
|
||||
// Total_Annuel_Partiel : cumul de l'année jusqu'au mois sélectionné INCLUS,
|
||||
// exclusion des mois strictement postérieurs (Exigence 12.8, Property 17).
|
||||
var totalAnnuelPartiel = trajetsAnnee
|
||||
.Where(t => t.Date.Month <= date.Month)
|
||||
.Sum(t => t.Distance);
|
||||
|
||||
return new TotauxDto(
|
||||
Date: date,
|
||||
VehiculeId: vehiculeCible,
|
||||
TotalJour: Math.Round(totalJour, 2, MidpointRounding.AwayFromZero),
|
||||
TotalMois: Math.Round(totalMois, 2, MidpointRounding.AwayFromZero),
|
||||
TotalAnnuelPartiel: Math.Round(totalAnnuelPartiel, 2, MidpointRounding.AwayFromZero));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<int>> GetAnneesDisponiblesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Années comportant au moins un Trajet (Exigence 12.15).
|
||||
var annees = await _context.Trajets
|
||||
.AsNoTracking()
|
||||
.Select(t => t.Date.Year)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Ajout de l'année courante si absente.
|
||||
var anneeCourante = DateOnly.FromDateTime(DateTime.Today).Year;
|
||||
if (!annees.Contains(anneeCourante))
|
||||
{
|
||||
annees.Add(anneeCourante);
|
||||
}
|
||||
|
||||
annees.Sort();
|
||||
return annees;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<int>> GetMoisDisponiblesAsync(int annee, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Mois de l'année comportant au moins un Trajet (Exigence 12.16).
|
||||
var mois = await _context.Trajets
|
||||
.AsNoTracking()
|
||||
.Where(t => t.Date.Year == annee)
|
||||
.Select(t => t.Date.Month)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
mois.Sort();
|
||||
return mois;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<int>> GetJoursDisponiblesAsync(
|
||||
int annee, int mois, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Jours du mois comportant au moins un Trajet (Exigence 12.16).
|
||||
var jours = await _context.Trajets
|
||||
.AsNoTracking()
|
||||
.Where(t => t.Date.Year == annee && t.Date.Month == mois)
|
||||
.Select(t => t.Date.Day)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
jours.Sort();
|
||||
return jours;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Résultat de la création d'un Trajet par <see cref="TrajetService.CreateAsync"/>.
|
||||
/// Porte le <see cref="TrajetDto"/> persisté et un éventuel avertissement lorsque la
|
||||
/// distance n'a pas pu être calculée (aucune coordonnée résoluble). Dans ce cas, le
|
||||
/// Trajet est tout de même persisté avec une Distance nulle et le contrôleur retourne
|
||||
/// une réponse HTTP 201 accompagnée du message d'avertissement (Exigence 2.4).
|
||||
/// </summary>
|
||||
/// <param name="Trajet">Le trajet créé, tel qu'il sera exposé au client.</param>
|
||||
/// <param name="Avertissement">
|
||||
/// Vrai lorsque la distance n'a pas pu être calculée (Distance nulle persistée).
|
||||
/// </param>
|
||||
/// <param name="Message">Message d'avertissement associé, ou <c>null</c>.</param>
|
||||
public record TrajetCreationResult(
|
||||
TrajetDto Trajet,
|
||||
bool Avertissement,
|
||||
string? Message);
|
||||
@@ -0,0 +1,446 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrajetKmTracker.Api.Data;
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
using TrajetKmTracker.Api.Models.Entities;
|
||||
using TrajetKmTracker.Api.Repositories;
|
||||
using TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
namespace TrajetKmTracker.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);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Résultat de la modification d'un Trajet par <see cref="TrajetService.UpdateAsync"/>.
|
||||
/// Porte le <see cref="TrajetDto"/> mis à jour et un éventuel avertissement lorsque la
|
||||
/// distance n'a pas pu être (re)calculée faute de coordonnées résolubles. Dans ce cas, le
|
||||
/// Trajet est tout de même mis à jour avec une Distance nulle et un message d'avertissement
|
||||
/// (Exigence 2.4). En revanche, un échec du calcul lui-même (indisponibilité ORS/BAN sur des
|
||||
/// coordonnées connues) provoque un rollback et la levée d'une
|
||||
/// <see cref="Exceptions.DistanceRecalculException"/> (Exigence 5.8).
|
||||
/// </summary>
|
||||
/// <param name="Trajet">Le trajet mis à jour, tel qu'il sera exposé au client.</param>
|
||||
/// <param name="Avertissement">
|
||||
/// Vrai lorsque la distance n'a pas pu être calculée (Distance nulle persistée).
|
||||
/// </param>
|
||||
/// <param name="Message">Message d'avertissement associé, ou <c>null</c>.</param>
|
||||
public record TrajetUpdateResult(
|
||||
TrajetDto Trajet,
|
||||
bool Avertissement,
|
||||
string? Message);
|
||||
@@ -0,0 +1,143 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrajetKmTracker.Api.Data;
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
using TrajetKmTracker.Api.Models.Entities;
|
||||
using TrajetKmTracker.Api.Repositories;
|
||||
using TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <inheritdoc />
|
||||
public class VehiculeService : IVehiculeService
|
||||
{
|
||||
private const string DateFormat = "dd/MM/yyyy";
|
||||
|
||||
private readonly IVehiculeRepository _repository;
|
||||
private readonly AppDbContext _context;
|
||||
|
||||
public VehiculeService(IVehiculeRepository repository, AppDbContext context)
|
||||
{
|
||||
_repository = repository;
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<VehiculeDto>> GetHistoriqueAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var vehicules = await _repository.GetHistoriqueAsync(cancellationToken);
|
||||
return vehicules.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<VehiculeDto> GetVehiculeCourantAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var aujourdHui = DateOnly.FromDateTime(DateTime.Today);
|
||||
var vehicule = await _repository.GetVehiculeCourantAsync(aujourdHui, cancellationToken)
|
||||
?? throw new VehiculeNotFoundException(
|
||||
"Aucun véhicule courant : aucun véhicule n'a de date d'effet inférieure ou égale à aujourd'hui.");
|
||||
|
||||
return ToDto(vehicule);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<VehiculeDto?> GetVehiculeADateAsync(DateOnly date, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var vehicule = await _repository.GetVehiculeCourantAsync(date, cancellationToken);
|
||||
return vehicule is null ? null : ToDto(vehicule);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<VehiculeCreeDto> CreateAsync(CreateVehiculeDto dto, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var dateEffet = ParseDateEffet(dto.DateEffet);
|
||||
|
||||
// Rejet d'une date d'effet dans le futur (Exigence 10.11).
|
||||
if (dateEffet > DateOnly.FromDateTime(DateTime.Today))
|
||||
{
|
||||
throw new DateEffetFutureException("La date d'effet ne peut pas être postérieure à aujourd'hui.");
|
||||
}
|
||||
|
||||
var vehicule = new Vehicule
|
||||
{
|
||||
Denomination = dto.Denomination,
|
||||
Plaque = dto.Plaque,
|
||||
PuissanceFiscale = dto.PuissanceFiscale,
|
||||
DateEffet = dateEffet,
|
||||
};
|
||||
|
||||
// Conservation de l'historique : on ajoute sans supprimer/modifier les véhicules existants (Property 22).
|
||||
await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await _repository.AddAsync(vehicule, cancellationToken);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
|
||||
// Détection des trajets à réaffecter : date >= DateEffet et puissance fiscale différente.
|
||||
var trajetsConcernes = await _repository.GetTrajetsAReaffecterAsync(
|
||||
dateEffet, vehicule.PuissanceFiscale, cancellationToken);
|
||||
|
||||
return new VehiculeCreeDto(
|
||||
ToDto(vehicule),
|
||||
ReaffectationProposee: trajetsConcernes.Count > 0,
|
||||
NombreTrajetsConcernes: trajetsConcernes.Count);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ReaffecterAsync(ReaffectationDto dto, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var vehicule = await _repository.GetByIdAsync(dto.VehiculeId, cancellationToken)
|
||||
?? throw new VehiculeNotFoundException($"Le véhicule d'identifiant {dto.VehiculeId} est introuvable.");
|
||||
|
||||
// Réaffectation refusée : aucun changement (Exigence 10.17).
|
||||
if (!dto.Accepter)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Réaffectation acceptée : mise à jour bornée à la date d'effet (Exigence 10.16 / Property 20).
|
||||
var trajets = await _repository.GetTrajetsAReaffecterAsync(
|
||||
vehicule.DateEffet, vehicule.PuissanceFiscale, cancellationToken);
|
||||
|
||||
if (trajets.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var maintenant = DateTime.UtcNow;
|
||||
foreach (var trajet in trajets)
|
||||
{
|
||||
trajet.VehiculeId = vehicule.Id;
|
||||
trajet.PuissanceFiscale = vehicule.PuissanceFiscale;
|
||||
trajet.UpdatedAt = maintenant;
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static DateOnly ParseDateEffet(string value)
|
||||
{
|
||||
// La validation FluentValidation garantit normalement le format ; on reste défensif ici.
|
||||
return DateOnly.ParseExact(value, DateFormat, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static VehiculeDto ToDto(Vehicule v) =>
|
||||
new(v.Id, v.Denomination, v.Plaque, v.PuissanceFiscale, v.DateEffet);
|
||||
}
|
||||
Reference in New Issue
Block a user