Files
SchotzIAD/backend/TrajetKmTracker.Api/Services/AdresseCacheService.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

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