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,78 @@
using Microsoft.EntityFrameworkCore;
using SchotzIadKmTracker.Api.Data;
using SchotzIadKmTracker.Api.Models.Entities;
namespace SchotzIadKmTracker.Api.Repositories;
/// <summary>
/// Implémentation EF Core de <see cref="IAdresseCacheRepository"/> au-dessus d'<see cref="AppDbContext"/>.
/// </summary>
public class AdresseCacheRepository : IAdresseCacheRepository
{
private readonly AppDbContext _db;
public AdresseCacheRepository(AppDbContext db)
{
_db = db;
}
public async Task<IReadOnlyList<AdresseCache>> RechercherAsync(
string fragmentNormalise,
int limite,
CancellationToken cancellationToken = default)
{
// La colonne Adresse stocke déjà la forme normalisée : la comparaison se fait donc
// directement sur celle-ci. EF.Functions.Like assure une comparaison indépendante
// de la casse côté SQLite.
var motif = $"%{fragmentNormalise}%";
return await _db.AdresseCache
.Where(a => EF.Functions.Like(a.Adresse, motif))
.OrderByDescending(a => a.DerniereUtilisation)
.Take(limite)
.ToListAsync(cancellationToken);
}
public async Task<AdresseCache?> ObtenirParAdresseNormaliseeAsync(
string adresseNormalisee,
CancellationToken cancellationToken = default)
{
return await _db.AdresseCache
.FirstOrDefaultAsync(a => a.Adresse == adresseNormalisee, cancellationToken);
}
public async Task AjouterAsync(AdresseCache entree, CancellationToken cancellationToken = default)
{
await _db.AdresseCache.AddAsync(entree, cancellationToken);
}
public Task<int> CompterAsync(CancellationToken cancellationToken = default)
{
return _db.AdresseCache.CountAsync(cancellationToken);
}
public async Task<IReadOnlyList<AdresseCache>> ObtenirPlusAnciennesAsync(
int nombre,
CancellationToken cancellationToken = default)
{
if (nombre <= 0)
{
return Array.Empty<AdresseCache>();
}
return await _db.AdresseCache
.OrderBy(a => a.DerniereUtilisation)
.Take(nombre)
.ToListAsync(cancellationToken);
}
public void Supprimer(IEnumerable<AdresseCache> entrees)
{
_db.AdresseCache.RemoveRange(entrees);
}
public Task<int> SauvegarderAsync(CancellationToken cancellationToken = default)
{
return _db.SaveChangesAsync(cancellationToken);
}
}
@@ -0,0 +1,45 @@
using SchotzIadKmTracker.Api.Models.Entities;
namespace SchotzIadKmTracker.Api.Repositories;
/// <summary>
/// Accès aux données du Cache_Adresses (table <c>AdresseCache</c>).
/// Toutes les correspondances d'adresse s'effectuent sur le texte normalisé
/// (voir <see cref="AdresseCacheService"/>).
/// </summary>
public interface IAdresseCacheRepository
{
/// <summary>
/// Retourne les entrées dont l'adresse normalisée contient le fragment normalisé fourni,
/// triées de la plus récemment utilisée à la plus ancienne, limitées à <paramref name="limite"/>.
/// </summary>
Task<IReadOnlyList<AdresseCache>> RechercherAsync(
string fragmentNormalise,
int limite,
CancellationToken cancellationToken = default);
/// <summary>Retourne l'entrée correspondant exactement à l'adresse normalisée, ou <c>null</c>.</summary>
Task<AdresseCache?> ObtenirParAdresseNormaliseeAsync(
string adresseNormalisee,
CancellationToken cancellationToken = default);
/// <summary>Ajoute une nouvelle entrée au cache.</summary>
Task AjouterAsync(AdresseCache entree, CancellationToken cancellationToken = default);
/// <summary>Compte le nombre total d'entrées du cache.</summary>
Task<int> CompterAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Retourne les entrées les plus anciennes (par <c>DerniereUtilisation</c> croissante),
/// limitées à <paramref name="nombre"/>. Utilisé pour l'éviction LRU.
/// </summary>
Task<IReadOnlyList<AdresseCache>> ObtenirPlusAnciennesAsync(
int nombre,
CancellationToken cancellationToken = default);
/// <summary>Supprime les entrées fournies du cache.</summary>
void Supprimer(IEnumerable<AdresseCache> entrees);
/// <summary>Persiste les modifications en attente.</summary>
Task<int> SauvegarderAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,87 @@
using SchotzIadKmTracker.Api.Models.Entities;
namespace SchotzIadKmTracker.Api.Repositories;
/// <summary>
/// Accès aux données des trajets et gestion des positions au sein d'une journée.
/// <para>
/// La <see cref="Trajet.Position"/> est une valeur décimale (REAL) permettant l'insertion
/// sans réordonnancement systématique : ajout en fin de journée → <c>max(Position) + 1.0</c>,
/// insertion entre deux positions P₁ et P₂ → <c>(P₁ + P₂) / 2</c>. Lorsque l'écart devient
/// trop faible (&lt; <see cref="SeuilRenumerotation"/>), la journée est renumérotée par pas de
/// 1.0 en transaction en préservant l'ordre relatif (Property 15, Exigences 11.7/11.8).
/// </para>
/// <para>
/// Ce repository fournit uniquement les primitives d'accès et de calcul de position ;
/// la logique métier (validation, calcul de distance, mise en cache) relève de TrajetService.
/// </para>
/// </summary>
public interface ITrajetRepository
{
/// <summary>
/// Seuil de précision en deçà duquel une renumérotation de la journée est déclenchée.
/// Conforme à la conception : écart &lt; 0.001.
/// </summary>
const decimal SeuilRenumerotation = 0.001m;
/// <summary>Retourne le trajet par identifiant, ou <c>null</c> s'il n'existe pas.</summary>
Task<Trajet?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>Retourne les trajets d'une journée triés par <see cref="Trajet.Position"/> croissante.</summary>
Task<List<Trajet>> GetByDateAsync(DateOnly date, CancellationToken cancellationToken = default);
/// <summary>
/// Résout le véhicule applicable à la date du trajet et copie sa <c>PuissanceFiscale</c>
/// sur le trajet (dénormalisation, Exigence 10.14). Ne modifie ni <c>VehiculeId</c> ni
/// <c>PuissanceFiscale</c> si aucun véhicule n'est applicable à cette date.
/// Coordonné avec <see cref="Services.IVehiculeService.GetVehiculeADateAsync"/>.
/// </summary>
Task AppliquerVehiculeADateAsync(Trajet trajet, CancellationToken cancellationToken = default);
/// <summary>
/// Calcule la position d'un trajet ajouté en fin de journée : <c>max(Position) + 1.0</c>
/// pour la date, ou <c>1.0</c> si la journée est vide.
/// </summary>
Task<decimal> CalculerPositionFinDeJourneeAsync(DateOnly date, CancellationToken cancellationToken = default);
/// <summary>
/// Calcule la position d'un trajet inséré entre les positions <paramref name="positionPrecedente"/>
/// (P₁) et <paramref name="positionSuivante"/> (P₂) : <c>(P₁ + P₂) / 2</c>.
/// </summary>
/// <exception cref="ArgumentException">Si P₁ n'est pas strictement inférieure à P₂.</exception>
decimal CalculerPositionEntre(decimal positionPrecedente, decimal positionSuivante);
/// <summary>
/// Indique si l'écart entre deux positions adjacentes est devenu trop faible
/// (&lt; <see cref="SeuilRenumerotation"/>) pour insérer une nouvelle valeur intermédiaire.
/// </summary>
bool NecessiteRenumerotation(decimal positionPrecedente, decimal positionSuivante);
/// <summary>Ajoute un trajet (sans persister). L'appelant gère la transaction et l'appel à <see cref="SauvegarderAsync"/>.</summary>
Task AddAsync(Trajet trajet, CancellationToken cancellationToken = default);
/// <summary>Marque un trajet existant comme modifié (sans persister).</summary>
void Update(Trajet trajet);
/// <summary>Supprime un trajet (sans persister).</summary>
void Delete(Trajet trajet);
/// <summary>Persiste les changements en attente.</summary>
Task<int> SauvegarderAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Applique un réordonnancement manuel : met à jour uniquement la <see cref="Trajet.Position"/>
/// des trajets fournis (par identifiant) sans altérer leurs autres champs, en transaction.
/// Les identifiants inconnus sont ignorés.
/// </summary>
Task ReordonnerAsync(
IReadOnlyList<(int Id, decimal Position)> positions,
CancellationToken cancellationToken = default);
/// <summary>
/// Renumérote les trajets d'une journée par pas de 1.0 (1.0, 2.0, 3.0, …) en préservant
/// l'ordre relatif courant (tri par Position puis Id), en transaction. Retourne la liste
/// renumérotée triée par Position croissante (Property 15).
/// </summary>
Task<List<Trajet>> RenumeroterJourneeAsync(DateOnly date, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,35 @@
using SchotzIadKmTracker.Api.Models.Entities;
namespace SchotzIadKmTracker.Api.Repositories;
/// <summary>
/// Accès aux données des véhicules et aux trajets impactés par une réaffectation.
/// L'historique des véhicules est conservé (aucune suppression) — Exigence 10.12 / Property 22.
/// </summary>
public interface IVehiculeRepository
{
/// <summary>Retourne l'historique complet des véhicules, trié par Date_Effet décroissante puis Id décroissant.</summary>
Task<List<Vehicule>> GetHistoriqueAsync(CancellationToken cancellationToken = default);
/// <summary>Retourne le véhicule par identifiant, ou <c>null</c> s'il n'existe pas.</summary>
Task<Vehicule?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>
/// Résout le véhicule courant à une date donnée : Date_Effet la plus récente parmi
/// celles &lt;= <paramref name="date"/> (ORDER BY DateEffet DESC, Id DESC LIMIT 1).
/// Retourne <c>null</c> si aucun véhicule n'a de Date_Effet &lt;= <paramref name="date"/>.
/// </summary>
Task<Vehicule?> GetVehiculeCourantAsync(DateOnly date, CancellationToken cancellationToken = default);
/// <summary>Ajoute un nouveau véhicule (sans persister). L'appelant gère la transaction.</summary>
Task AddAsync(Vehicule vehicule, CancellationToken cancellationToken = default);
/// <summary>
/// Retourne les trajets dont la Date est &gt;= <paramref name="dateEffet"/> et dont la
/// PuissanceFiscale diffère de <paramref name="puissanceFiscale"/> (candidats à la réaffectation).
/// </summary>
Task<List<Trajet>> GetTrajetsAReaffecterAsync(
DateOnly dateEffet,
int puissanceFiscale,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,193 @@
using Microsoft.EntityFrameworkCore;
using SchotzIadKmTracker.Api.Data;
using SchotzIadKmTracker.Api.Models.Entities;
using SchotzIadKmTracker.Api.Services;
namespace SchotzIadKmTracker.Api.Repositories;
/// <inheritdoc />
public class TrajetRepository : ITrajetRepository
{
private readonly AppDbContext _context;
private readonly IVehiculeService _vehiculeService;
public TrajetRepository(AppDbContext context, IVehiculeService vehiculeService)
{
_context = context;
_vehiculeService = vehiculeService;
}
/// <inheritdoc />
public async Task<Trajet?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.Trajets
.FirstOrDefaultAsync(t => t.Id == id, cancellationToken);
}
/// <inheritdoc />
public async Task<List<Trajet>> GetByDateAsync(DateOnly date, CancellationToken cancellationToken = default)
{
// SQLite ne sait pas trier (ORDER BY) sur une colonne decimal : on ramène les
// trajets du jour puis on trie côté client (volume par journée très réduit).
var trajets = await _context.Trajets
.Where(t => t.Date == date)
.ToListAsync(cancellationToken);
return trajets
.OrderBy(t => t.Position)
.ThenBy(t => t.Id)
.ToList();
}
/// <inheritdoc />
public async Task AppliquerVehiculeADateAsync(Trajet trajet, CancellationToken cancellationToken = default)
{
// Résolution du véhicule applicable à la date du trajet (Date_Effet la plus récente <= Date).
var vehicule = await _vehiculeService.GetVehiculeADateAsync(trajet.Date, cancellationToken);
if (vehicule is null)
{
// Aucun véhicule applicable à cette date : on laisse la logique métier (TrajetService) décider.
return;
}
// Dénormalisation : copie de la puissance fiscale du véhicule au moment de l'association (Exigence 10.14).
trajet.VehiculeId = vehicule.Id;
trajet.PuissanceFiscale = vehicule.PuissanceFiscale;
}
/// <inheritdoc />
public async Task<decimal> CalculerPositionFinDeJourneeAsync(DateOnly date, CancellationToken cancellationToken = default)
{
// SQLite ne sait pas appliquer MAX sur une colonne decimal : on ramène les positions
// du jour et on calcule le maximum côté client (volume par journée très réduit).
var positions = await _context.Trajets
.Where(t => t.Date == date)
.Select(t => t.Position)
.ToListAsync(cancellationToken);
if (positions.Count == 0)
{
return 1.0m;
}
return positions.Max() + 1.0m;
}
/// <inheritdoc />
public decimal CalculerPositionEntre(decimal positionPrecedente, decimal positionSuivante)
{
if (positionPrecedente >= positionSuivante)
{
throw new ArgumentException(
$"La position précédente ({positionPrecedente}) doit être strictement inférieure à la position suivante ({positionSuivante}).",
nameof(positionPrecedente));
}
return (positionPrecedente + positionSuivante) / 2.0m;
}
/// <inheritdoc />
public bool NecessiteRenumerotation(decimal positionPrecedente, decimal positionSuivante)
{
return positionSuivante - positionPrecedente < ITrajetRepository.SeuilRenumerotation;
}
/// <inheritdoc />
public Task AddAsync(Trajet trajet, CancellationToken cancellationToken = default)
{
_context.Trajets.Add(trajet);
return Task.CompletedTask;
}
/// <inheritdoc />
public void Update(Trajet trajet)
{
_context.Trajets.Update(trajet);
}
/// <inheritdoc />
public void Delete(Trajet trajet)
{
_context.Trajets.Remove(trajet);
}
/// <inheritdoc />
public Task<int> SauvegarderAsync(CancellationToken cancellationToken = default)
{
return _context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public async Task ReordonnerAsync(
IReadOnlyList<(int Id, decimal Position)> positions,
CancellationToken cancellationToken = default)
{
if (positions.Count == 0)
{
return;
}
await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
try
{
var ids = positions.Select(p => p.Id).ToList();
var trajets = await _context.Trajets
.Where(t => ids.Contains(t.Id))
.ToListAsync(cancellationToken);
var positionParId = positions.ToDictionary(p => p.Id, p => p.Position);
foreach (var trajet in trajets)
{
if (positionParId.TryGetValue(trajet.Id, out var nouvellePosition))
{
// Seule la Position est modifiée ; aucun autre champ n'est altéré (Exigence 11.9).
trajet.Position = nouvellePosition;
trajet.UpdatedAt = DateTime.UtcNow;
}
}
await _context.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
/// <inheritdoc />
public async Task<List<Trajet>> RenumeroterJourneeAsync(DateOnly date, CancellationToken cancellationToken = default)
{
await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
try
{
// Préservation de l'ordre relatif courant (Position croissante, Id en départage) — Property 15.
var trajets = (await _context.Trajets
.Where(t => t.Date == date)
.ToListAsync(cancellationToken))
.OrderBy(t => t.Position)
.ThenBy(t => t.Id)
.ToList();
decimal position = 1.0m;
foreach (var trajet in trajets)
{
trajet.Position = position;
trajet.UpdatedAt = DateTime.UtcNow;
position += 1.0m;
}
await _context.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return trajets;
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
}
@@ -0,0 +1,61 @@
using Microsoft.EntityFrameworkCore;
using SchotzIadKmTracker.Api.Data;
using SchotzIadKmTracker.Api.Models.Entities;
namespace SchotzIadKmTracker.Api.Repositories;
/// <inheritdoc />
public class VehiculeRepository : IVehiculeRepository
{
private readonly AppDbContext _context;
public VehiculeRepository(AppDbContext context)
{
_context = context;
}
/// <inheritdoc />
public async Task<List<Vehicule>> GetHistoriqueAsync(CancellationToken cancellationToken = default)
{
return await _context.Vehicules
.OrderByDescending(v => v.DateEffet)
.ThenByDescending(v => v.Id)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<Vehicule?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.Vehicules
.FirstOrDefaultAsync(v => v.Id == id, cancellationToken);
}
/// <inheritdoc />
public async Task<Vehicule?> GetVehiculeCourantAsync(DateOnly date, CancellationToken cancellationToken = default)
{
// Design SQL : SELECT * FROM Vehicules WHERE DateEffet <= date ORDER BY DateEffet DESC, Id DESC LIMIT 1
return await _context.Vehicules
.Where(v => v.DateEffet <= date)
.OrderByDescending(v => v.DateEffet)
.ThenByDescending(v => v.Id)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddAsync(Vehicule vehicule, CancellationToken cancellationToken = default)
{
_context.Vehicules.Add(vehicule);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task<List<Trajet>> GetTrajetsAReaffecterAsync(
DateOnly dateEffet,
int puissanceFiscale,
CancellationToken cancellationToken = default)
{
return await _context.Trajets
.Where(t => t.Date >= dateEffet && t.PuissanceFiscale != puissanceFiscale)
.ToListAsync(cancellationToken);
}
}