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:
2026-09-14 07:50:26 +02:00
parent e3f946f7d4
commit 0e6a97ca61
133 changed files with 462 additions and 503 deletions
@@ -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}&amp;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 &lt;= b2 ET b1 &lt;= 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)
{
}
}
@@ -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)
{
}
}
@@ -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 &lt;= 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 &lt;= <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 &gt;= 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 &gt;= 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);
}