- 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.
310 lines
12 KiB
C#
310 lines
12 KiB
C#
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);
|
|
}
|
|
}
|