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,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);
}
}