Files
SchotzIAD/backend/TrajetKmTracker.Api/Services/AdresseNommeeService.cs
T
MadJax e3f946f7d4 SchotzIAD KM Tracker : suivi des trajets kilométriques (Angular + .NET 8)
Application complète de saisie et de suivi des trajets kilométriques avec
calcul de distance routière (OpenRouteService), barème kilométrique, résumés
et régularisation annuelle.

Backend (ASP.NET Core .NET 8, EF Core, SQLite) :
- Trajets (CRUD, réordonnancement, insertion), calcul de distance ORS avec
  fallback Haversine, géométrie d'itinéraire stockée par trajet, recalcul en masse.
- Profil, véhicules (avec dénomination), carnet d'adresses nommées, cache d'adresses.
- Barème kilométrique : tranches multiples par puissance fiscale, borne max
  exclusive avec 'illimité' (KmMax nullable), coefficient à 3 décimales,
  recopie du dernier barème connu.
- Résumés mensuel/annuel, montants réglés, régularisation, export PDF.

Frontend (Angular 22, zoneless + signals) :
- Consultation par jour, navigation temporelle synchronisée, carte Leaflet,
  formulaire de trajet (adresses nommées, domicile, autocomplétion BAN).
- Détection de changement zoneless (indépendante de zone.js).

Hébergement : Docker Compose (nginx + backend), scripts run-dev.sh / run-docker.sh.
2026-09-14 02:08:27 +02:00

310 lines
12 KiB
C#

using System.Globalization;
using System.Text;
using Microsoft.EntityFrameworkCore;
using TrajetKmTracker.Api.Data;
using TrajetKmTracker.Api.Models.DTOs;
using TrajetKmTracker.Api.Models.Entities;
using TrajetKmTracker.Api.Services.Exceptions;
namespace TrajetKmTracker.Api.Services;
/// <summary>
/// Implémentation du Carnet_Adresses (Exigence 15). Gère le CRUD des Adresses_Nommées avec :
/// <list type="bullet">
/// <item>normalisation de la Dénomination (trim + minuscules + suppression des accents) pour
/// garantir l'unicité (15.6/15.7) ;</item>
/// <item>réutilisation du Cache_Adresses si l'adresse normalisée y est présente (15.10),
/// sinon géocodage/re-géocodage via l'API BAN avec un délai de 5 s (15.11) ;</item>
/// <item>persistance sans coordonnées + <c>GeocodageReussi = false</c> si BAN est
/// indisponible ou dépasse le délai (15.12) ;</item>
/// <item>mise en cache des coordonnées obtenues (15.9).</item>
/// </list>
/// </summary>
public class AdresseNommeeService : IAdresseNommeeService
{
private const int DenominationMaxLength = 100;
private const int AdresseMaxLength = 255;
private readonly AppDbContext _db;
private readonly IBANClient _banClient;
private readonly IAdresseCacheService _cache;
public AdresseNommeeService(AppDbContext db, IBANClient banClient, IAdresseCacheService cache)
{
_db = db;
_banClient = banClient;
_cache = cache;
}
/// <inheritdoc />
public async Task<IReadOnlyList<AdresseNommeeDto>> GetAllAsync(CancellationToken cancellationToken = default)
{
var adresses = await _db.AdressesNommees
.AsNoTracking()
.OrderBy(a => a.Denomination)
.ToListAsync(cancellationToken);
return adresses.Select(ToDto).ToList();
}
/// <inheritdoc />
public async Task<AdresseNommeeDto> CreateAsync(
SaveAdresseNommeeDto dto,
CancellationToken cancellationToken = default)
{
ValiderDenomination(dto.Denomination);
ValiderAdresse(dto.Adresse);
var denominationNormalisee = Normaliser(dto.Denomination);
// Contrôle d'unicité avant persistance (15.7 / Property 28) ; l'index unique
// UX_AdresseNommee_Denomination reste le dernier rempart au niveau SQL.
var collision = await _db.AdressesNommees
.AnyAsync(a => a.DenominationNormalisee == denominationNormalisee, cancellationToken);
if (collision)
{
throw new DenominationDupliqueeException(
$"La dénomination « {dto.Denomination.Trim()} » est déjà utilisée.");
}
var (latitude, longitude, geocodageReussi) =
await ResoudreCoordonneesAsync(dto.Adresse, dto.Latitude, dto.Longitude, cancellationToken);
var maintenant = DateTime.UtcNow;
var entite = new AdresseNommee
{
Denomination = dto.Denomination.Trim(),
DenominationNormalisee = denominationNormalisee,
Adresse = dto.Adresse.Trim(),
Latitude = latitude,
Longitude = longitude,
GeocodageReussi = geocodageReussi,
CreatedAt = maintenant,
UpdatedAt = maintenant
};
await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken);
try
{
_db.AdressesNommees.Add(entite);
await _db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch (DbUpdateException ex)
{
await transaction.RollbackAsync(cancellationToken);
// Dernier rempart : violation de l'index unique (course concurrente).
throw new DenominationDupliqueeException(
$"La dénomination « {dto.Denomination.Trim()} » est déjà utilisée.", ex);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
// Mise en cache des coordonnées obtenues si absentes du Cache_Adresses (15.9).
if (geocodageReussi && latitude.HasValue && longitude.HasValue)
{
await _cache.UpsertAdresseAsync(entite.Adresse, latitude.Value, longitude.Value,
cancellationToken: cancellationToken);
}
return ToDto(entite);
}
/// <inheritdoc />
public async Task<AdresseNommeeDto> UpdateAsync(
int id,
SaveAdresseNommeeDto dto,
CancellationToken cancellationToken = default)
{
ValiderDenomination(dto.Denomination);
ValiderAdresse(dto.Adresse);
var entite = await _db.AdressesNommees.FirstOrDefaultAsync(a => a.Id == id, cancellationToken)
?? throw new AdresseNommeeNotFoundException(
$"L'adresse nommée d'identifiant {id} est introuvable.");
var denominationNormalisee = Normaliser(dto.Denomination);
// Unicité : une autre Adresse_Nommee ne doit pas porter la même Dénomination normalisée (15.7).
var collision = await _db.AdressesNommees
.AnyAsync(a => a.Id != id && a.DenominationNormalisee == denominationNormalisee, cancellationToken);
if (collision)
{
throw new DenominationDupliqueeException(
$"La dénomination « {dto.Denomination.Trim()} » est déjà utilisée.");
}
var adresseTrim = dto.Adresse.Trim();
var adresseAChange = !string.Equals(
Normaliser(entite.Adresse), Normaliser(adresseTrim), StringComparison.Ordinal);
double? latitude = entite.Latitude;
double? longitude = entite.Longitude;
var geocodageReussi = entite.GeocodageReussi;
// Re-géocodage uniquement si le texte d'adresse a changé (15.11).
if (adresseAChange)
{
(latitude, longitude, geocodageReussi) =
await ResoudreCoordonneesAsync(adresseTrim, dto.Latitude, dto.Longitude, cancellationToken);
}
entite.Denomination = dto.Denomination.Trim();
entite.DenominationNormalisee = denominationNormalisee;
entite.Adresse = adresseTrim;
entite.Latitude = latitude;
entite.Longitude = longitude;
entite.GeocodageReussi = geocodageReussi;
entite.UpdatedAt = DateTime.UtcNow;
await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken);
try
{
await _db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch (DbUpdateException ex)
{
await transaction.RollbackAsync(cancellationToken);
throw new DenominationDupliqueeException(
$"La dénomination « {dto.Denomination.Trim()} » est déjà utilisée.", ex);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
if (geocodageReussi && latitude.HasValue && longitude.HasValue)
{
await _cache.UpsertAdresseAsync(entite.Adresse, latitude.Value, longitude.Value,
cancellationToken: cancellationToken);
}
return ToDto(entite);
}
/// <inheritdoc />
public async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
{
var entite = await _db.AdressesNommees.FirstOrDefaultAsync(a => a.Id == id, cancellationToken)
?? throw new AdresseNommeeNotFoundException(
$"L'adresse nommée d'identifiant {id} est introuvable.");
// Suppression du seul enregistrement du Carnet_Adresses : les Trajets ne référencent que
// le texte d'adresse et les coordonnées copiés, donc rien d'autre n'est modifié (15.14).
await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken);
try
{
_db.AdressesNommees.Remove(entite);
await _db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
/// <summary>
/// Résout les coordonnées d'un texte d'adresse : réutilisation du Cache_Adresses si l'adresse
/// normalisée y figure (15.10), sinon coordonnées fournies par le frontend (sélection BAN),
/// sinon géocodage via l'API BAN (15.11). En cas d'indisponibilité/timeout de la BAN, aucune
/// coordonnée n'est retournée et <c>GeocodageReussi</c> vaut <c>false</c> (15.12).
/// </summary>
private async Task<(double? Latitude, double? Longitude, bool GeocodageReussi)> ResoudreCoordonneesAsync(
string adresse,
double? latitudeFournie,
double? longitudeFournie,
CancellationToken cancellationToken)
{
// 1) Réutilisation du Cache_Adresses sans appel BAN (15.10).
var enCache = await _cache.ObtenirParAdresseAsync(adresse, cancellationToken);
if (enCache is not null)
{
return (enCache.Latitude, enCache.Longitude, true);
}
// 2) Coordonnées déjà résolues côté frontend via une sélection BAN : on les conserve
// directement, ce qui évite un appel BAN redondant (15.5).
if (latitudeFournie.HasValue && longitudeFournie.HasValue)
{
return (latitudeFournie, longitudeFournie, true);
}
// 3) Géocodage via l'API BAN (timeout 5 s configuré sur le HttpClient) (15.11).
try
{
var resultats = await _banClient.RechercherAsync(adresse, cancellationToken);
var premier = resultats.FirstOrDefault();
if (premier is not null)
{
return (premier.Latitude, premier.Longitude, true);
}
// Aucune correspondance BAN : persistance sans coordonnées.
return (null, null, false);
}
catch (BANIndisponibleException)
{
// BAN indisponible/timeout : persistance sans coordonnées (15.12).
return (null, null, false);
}
}
private static void ValiderDenomination(string? denomination)
{
var trimmee = denomination?.Trim() ?? string.Empty;
if (trimmee.Length == 0 || trimmee.Length > DenominationMaxLength)
{
throw new DenominationInvalideException(
"La dénomination est absente ou invalide (1 à 100 caractères requis).");
}
}
private static void ValiderAdresse(string? adresse)
{
var trimmee = adresse?.Trim() ?? string.Empty;
if (trimmee.Length == 0 || trimmee.Length > AdresseMaxLength)
{
throw new AdresseNommeeInvalideException(
"L'adresse est absente ou invalide (1 à 255 caractères requis).");
}
}
private static AdresseNommeeDto ToDto(AdresseNommee a) =>
new(a.Id, a.Denomination, a.Adresse, a.Latitude, a.Longitude);
/// <summary>
/// Normalise un texte : suppression des espaces de bord, passage en minuscules et suppression
/// des accents (diacritiques). Cohérent avec <see cref="AdresseCacheService"/> afin d'assurer
/// un matching identique entre Carnet_Adresses et Cache_Adresses.
/// </summary>
private static string Normaliser(string? valeur)
{
if (string.IsNullOrWhiteSpace(valeur))
{
return string.Empty;
}
var trimmee = valeur.Trim().ToLowerInvariant();
var decomposee = trimmee.Normalize(NormalizationForm.FormD);
var builder = new StringBuilder(decomposee.Length);
foreach (var caractere in decomposee)
{
var categorie = CharUnicodeInfo.GetUnicodeCategory(caractere);
if (categorie != UnicodeCategory.NonSpacingMark)
{
builder.Append(caractere);
}
}
return builder.ToString().Normalize(NormalizationForm.FormC);
}
}