Renommage : TrajetKmTracker -> SchotzIadKmTracker (backend, specs, frontend)
- Backend : projets, solution, namespaces et références TrajetKmTracker.Api/.Tests -> SchotzIadKmTracker.Api/.Tests (dossiers, .csproj, .sln, .http, namespaces C#). GUID UserSecretsId préservé. - Specs : dossier .kiro/specs/trajet-km-tracker -> schotziad-km-tracker et mise à jour des références internes (chemins, tags de propriétés). - Frontend : projet Angular trajet-km-tracker -> schotziad-km-tracker (package.json, angular.json, chemin dist, Dockerfile), titre applicatif 'SchotzIAD KM Tracker'. - Docker et scripts (backend/Dockerfile, frontend/Dockerfile, run-dev.sh) mis à jour. - Suppression du fichier résiduel package.json.bak. Backend (solution) et frontend (build production) compilent sans erreur.
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SchotzIadKmTracker.Api.Data;
|
||||
using SchotzIadKmTracker.Api.Models.DTOs;
|
||||
using SchotzIadKmTracker.Api.Models.Entities;
|
||||
using SchotzIadKmTracker.Api.Repositories;
|
||||
|
||||
namespace SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Data;
|
||||
using SchotzIadKmTracker.Api.Models.DTOs;
|
||||
using SchotzIadKmTracker.Api.Models.Entities;
|
||||
using SchotzIadKmTracker.Api.Services.Exceptions;
|
||||
|
||||
namespace SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Models.DTOs;
|
||||
using SchotzIadKmTracker.Api.Services.Exceptions;
|
||||
|
||||
namespace SchotzIadKmTracker.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 SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Data;
|
||||
using SchotzIadKmTracker.Api.Models.DTOs;
|
||||
using SchotzIadKmTracker.Api.Models.Entities;
|
||||
using SchotzIadKmTracker.Api.Services.Exceptions;
|
||||
|
||||
namespace SchotzIadKmTracker.Api.Services;
|
||||
|
||||
/// <inheritdoc />
|
||||
public class 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 SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Services.Exceptions;
|
||||
|
||||
namespace SchotzIadKmTracker.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 SchotzIadKmTracker.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 SchotzIadKmTracker.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 SchotzIadKmTracker.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 SchotzIadKmTracker.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 SchotzIadKmTracker.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 SchotzIadKmTracker.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 SchotzIadKmTracker.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 SchotzIadKmTracker.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 SchotzIadKmTracker.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 SchotzIadKmTracker.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)
|
||||
{
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
namespace SchotzIadKmTracker.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 SchotzIadKmTracker.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 SchotzIadKmTracker.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 SchotzIadKmTracker.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 SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace SchotzIadKmTracker.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 SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace SchotzIadKmTracker.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 SchotzIadKmTracker.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 SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Data;
|
||||
using SchotzIadKmTracker.Api.Models.DTOs;
|
||||
using SchotzIadKmTracker.Api.Models.Entities;
|
||||
using SchotzIadKmTracker.Api.Services.Exceptions;
|
||||
|
||||
namespace SchotzIadKmTracker.Api.Services;
|
||||
|
||||
/// <inheritdoc />
|
||||
public class 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 SchotzIadKmTracker.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 SchotzIadKmTracker.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 SchotzIadKmTracker.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 SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Data;
|
||||
using SchotzIadKmTracker.Api.Models.DTOs;
|
||||
using SchotzIadKmTracker.Api.Services.Exceptions;
|
||||
|
||||
namespace SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Data;
|
||||
using SchotzIadKmTracker.Api.Models.DTOs;
|
||||
using SchotzIadKmTracker.Api.Models.Entities;
|
||||
using SchotzIadKmTracker.Api.Services.Exceptions;
|
||||
|
||||
namespace SchotzIadKmTracker.Api.Services;
|
||||
|
||||
/// <inheritdoc />
|
||||
public class 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 SchotzIadKmTracker.Api.Data;
|
||||
using SchotzIadKmTracker.Api.Models.DTOs;
|
||||
using SchotzIadKmTracker.Api.Services.Exceptions;
|
||||
|
||||
namespace SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Data;
|
||||
using SchotzIadKmTracker.Api.Models.DTOs;
|
||||
using SchotzIadKmTracker.Api.Models.Entities;
|
||||
using SchotzIadKmTracker.Api.Services.Exceptions;
|
||||
|
||||
namespace SchotzIadKmTracker.Api.Services;
|
||||
|
||||
/// <inheritdoc />
|
||||
public class ResumeService : IResumeService
|
||||
{
|
||||
private const int MoisMin = 1;
|
||||
private const int MoisMax = 12;
|
||||
private const int AnneeMin = 2000;
|
||||
private const int AnneeMax = 2100;
|
||||
|
||||
private readonly AppDbContext _context;
|
||||
|
||||
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 SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Data;
|
||||
using SchotzIadKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Data;
|
||||
using SchotzIadKmTracker.Api.Models.DTOs;
|
||||
using SchotzIadKmTracker.Api.Models.Entities;
|
||||
using SchotzIadKmTracker.Api.Repositories;
|
||||
using SchotzIadKmTracker.Api.Services.Exceptions;
|
||||
|
||||
namespace SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Data;
|
||||
using SchotzIadKmTracker.Api.Models.DTOs;
|
||||
using SchotzIadKmTracker.Api.Models.Entities;
|
||||
using SchotzIadKmTracker.Api.Repositories;
|
||||
using SchotzIadKmTracker.Api.Services.Exceptions;
|
||||
|
||||
namespace SchotzIadKmTracker.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