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