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