Files
MadJax 0e6a97ca61 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.
2026-09-14 07:50:26 +02:00

185 lines
6.5 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.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);
}
}