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.
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrajetKmTracker.Api.Data;
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
using TrajetKmTracker.Api.Models.Entities;
|
||||
using TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <inheritdoc />
|
||||
public class ProfilService : IProfilService
|
||||
{
|
||||
// Le profil est un singleton : une seule ligne dont l'identifiant est toujours 1.
|
||||
private const int ProfilId = 1;
|
||||
|
||||
private readonly AppDbContext _context;
|
||||
private readonly IBANClient _banClient;
|
||||
private readonly IAdresseCacheService _adresseCacheService;
|
||||
private readonly ILogger<ProfilService> _logger;
|
||||
|
||||
public ProfilService(
|
||||
AppDbContext context,
|
||||
IBANClient banClient,
|
||||
IAdresseCacheService adresseCacheService,
|
||||
ILogger<ProfilService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_banClient = banClient;
|
||||
_adresseCacheService = adresseCacheService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ProfilDto> GetProfilAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var profil = await GetOrCreateProfilAsync(cancellationToken);
|
||||
return ToDto(profil);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ProfilDto> SaveProfilAsync(SaveProfilDto dto, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var profil = await GetOrCreateProfilAsync(cancellationToken);
|
||||
|
||||
profil.Nom = dto.Nom;
|
||||
profil.Prenom = dto.Prenom;
|
||||
profil.Matricule = dto.Matricule;
|
||||
profil.AdresseDomicile = dto.AdresseDomicile;
|
||||
profil.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Géocodage BAN de l'Adresse_Domicile. En cas de succès on stocke les coordonnées et
|
||||
// DomicileGeocode = true ; en cas d'échec on persiste l'adresse textuelle sans coordonnées
|
||||
// et DomicileGeocode = false (aucune exception propagée) — Requirements 10.7, 10.8.
|
||||
await GeocoderDomicileAsync(profil, dto.AdresseDomicile, cancellationToken);
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return ToDto(profil);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<EtatProfilDto> GetEtatAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// On ne crée pas le profil ici : l'état doit refléter la base telle quelle (Property 31).
|
||||
var profil = await _context.Profil
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.Id == ProfilId, cancellationToken);
|
||||
|
||||
var nomPrenomRenseignes = profil is not null
|
||||
&& !string.IsNullOrWhiteSpace(profil.Nom)
|
||||
&& !string.IsNullOrWhiteSpace(profil.Prenom);
|
||||
|
||||
var auMoinsUnVehicule = await _context.Vehicules.AnyAsync(cancellationToken);
|
||||
|
||||
return new EtatProfilDto(nomPrenomRenseignes && auMoinsUnVehicule);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tente de résoudre les coordonnées de l'Adresse_Domicile via la BAN. Met à jour le profil
|
||||
/// (coordonnées + DomicileGeocode) et alimente le Cache_Adresses en cas de succès. Ne lève jamais :
|
||||
/// tout échec BAN est traité comme un géocodage non résolu.
|
||||
/// </summary>
|
||||
private async Task GeocoderDomicileAsync(Profil profil, string? adresseDomicile, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(adresseDomicile))
|
||||
{
|
||||
// Pas d'adresse à géocoder : on repart d'un état non géocodé.
|
||||
profil.DomicileLatitude = null;
|
||||
profil.DomicileLongitude = null;
|
||||
profil.DomicileGeocode = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var resultats = await _banClient.RechercherAsync(adresseDomicile, cancellationToken);
|
||||
var meilleur = resultats.Count > 0 ? resultats[0] : null;
|
||||
|
||||
if (meilleur is null)
|
||||
{
|
||||
// BAN a répondu mais aucune adresse ne correspond : adresse conservée sans coordonnées.
|
||||
profil.DomicileLatitude = null;
|
||||
profil.DomicileLongitude = null;
|
||||
profil.DomicileGeocode = false;
|
||||
return;
|
||||
}
|
||||
|
||||
profil.DomicileLatitude = meilleur.Latitude;
|
||||
profil.DomicileLongitude = meilleur.Longitude;
|
||||
profil.DomicileGeocode = true;
|
||||
|
||||
// Mise en cache de l'adresse géocodée pour réutilisation ultérieure (Requirement 9.1).
|
||||
await _adresseCacheService.UpsertAdresseAsync(
|
||||
adresseDomicile,
|
||||
meilleur.Latitude,
|
||||
meilleur.Longitude,
|
||||
geometrieJson: null,
|
||||
cancellationToken);
|
||||
}
|
||||
catch (BANIndisponibleException ex)
|
||||
{
|
||||
// BAN indisponible : l'adresse est conservée sans coordonnées, sans échec de la sauvegarde.
|
||||
_logger.LogWarning(ex,
|
||||
"Géocodage BAN de l'adresse de domicile indisponible ; l'adresse est conservée sans coordonnées.");
|
||||
profil.DomicileLatitude = null;
|
||||
profil.DomicileLongitude = null;
|
||||
profil.DomicileGeocode = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Récupère le profil singleton (Id = 1) ou le crée à la volée s'il est absent.
|
||||
/// </summary>
|
||||
private async Task<Profil> GetOrCreateProfilAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var profil = await _context.Profil.FirstOrDefaultAsync(p => p.Id == ProfilId, cancellationToken);
|
||||
if (profil is not null)
|
||||
{
|
||||
return profil;
|
||||
}
|
||||
|
||||
profil = new Profil { Id = ProfilId };
|
||||
await _context.Profil.AddAsync(profil, cancellationToken);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
return profil;
|
||||
}
|
||||
|
||||
private static ProfilDto ToDto(Profil p) =>
|
||||
new(p.Nom, p.Prenom, p.Matricule, p.AdresseDomicile, p.DomicileGeocode);
|
||||
}
|
||||
Reference in New Issue
Block a user