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:
2026-09-14 02:08:27 +02:00
commit e3f946f7d4
225 changed files with 38168 additions and 0 deletions
@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Mvc;
using TrajetKmTracker.Api.Models.DTOs;
using TrajetKmTracker.Api.Services;
namespace TrajetKmTracker.Api.Controllers;
/// <summary>
/// Endpoint d'autocomplétion locale des adresses déjà géocodées (Cache_Adresses).
/// La recherche exige au moins 3 caractères ; en deçà, une liste vide est retournée
/// (Exigence 9.3).
/// </summary>
[ApiController]
[Route("api/adresses")]
public class AdressesController : ControllerBase
{
private readonly IAdresseCacheService _adresseCacheService;
public AdressesController(IAdresseCacheService adresseCacheService)
{
_adresseCacheService = adresseCacheService;
}
/// <summary>
/// Recherche dans le Cache_Adresses les suggestions correspondant au fragment <paramref name="q"/>
/// (min. 3 caractères), ordonnées de la plus récemment utilisée à la plus ancienne.
/// </summary>
[HttpGet("search")]
[ProducesResponseType(typeof(IReadOnlyList<AdresseCacheDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<AdresseCacheDto>>> Search(
[FromQuery] string? q,
CancellationToken cancellationToken)
{
var suggestions = await _adresseCacheService.RechercherLocalAsync(
q ?? string.Empty,
cancellationToken: cancellationToken);
return Ok(suggestions);
}
}
@@ -0,0 +1,116 @@
using Microsoft.AspNetCore.Mvc;
using TrajetKmTracker.Api.Models.DTOs;
using TrajetKmTracker.Api.Services;
using TrajetKmTracker.Api.Services.Exceptions;
namespace TrajetKmTracker.Api.Controllers;
/// <summary>
/// Endpoints de gestion du Carnet_Adresses : liste, création, modification et suppression
/// des Adresses_Nommées (Exigence 15).
/// </summary>
[ApiController]
[Route("api/adresses-nommees")]
public class AdressesNommeesController : ControllerBase
{
private readonly IAdresseNommeeService _service;
public AdressesNommeesController(IAdresseNommeeService service)
{
_service = service;
}
/// <summary>Liste toutes les Adresses_Nommées, triées par Dénomination (15.1).</summary>
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyList<AdresseNommeeDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<AdresseNommeeDto>>> GetAll(CancellationToken cancellationToken)
{
var adresses = await _service.GetAllAsync(cancellationToken);
return Ok(adresses);
}
/// <summary>
/// Crée une Adresse_Nommee. Rejette une Dénomination invalide/dupliquée ou une adresse
/// invalide (400).
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(AdresseNommeeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<AdresseNommeeDto>> Create(
[FromBody] SaveAdresseNommeeDto dto,
CancellationToken cancellationToken)
{
try
{
var cree = await _service.CreateAsync(dto, cancellationToken);
return CreatedAtAction(nameof(GetAll), null, cree);
}
catch (DenominationInvalideException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest);
}
catch (DenominationDupliqueeException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest);
}
catch (AdresseNommeeInvalideException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest);
}
}
/// <summary>
/// Modifie une Adresse_Nommee. Rejette une Dénomination invalide/dupliquée ou une adresse
/// invalide (400) et une Adresse_Nommee introuvable (404).
/// </summary>
[HttpPut("{id:int}")]
[ProducesResponseType(typeof(AdresseNommeeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AdresseNommeeDto>> Update(
int id,
[FromBody] SaveAdresseNommeeDto dto,
CancellationToken cancellationToken)
{
try
{
var maj = await _service.UpdateAsync(id, dto, cancellationToken);
return Ok(maj);
}
catch (AdresseNommeeNotFoundException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status404NotFound);
}
catch (DenominationInvalideException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest);
}
catch (DenominationDupliqueeException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest);
}
catch (AdresseNommeeInvalideException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest);
}
}
/// <summary>
/// Supprime une Adresse_Nommee. Les Trajets existants restent inchangés (15.14).
/// </summary>
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
try
{
await _service.DeleteAsync(id, cancellationToken);
return NoContent();
}
catch (AdresseNommeeNotFoundException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status404NotFound);
}
}
}
@@ -0,0 +1,119 @@
using Microsoft.AspNetCore.Mvc;
using TrajetKmTracker.Api.Models.DTOs;
using TrajetKmTracker.Api.Services;
using TrajetKmTracker.Api.Services.Exceptions;
namespace TrajetKmTracker.Api.Controllers;
/// <summary>
/// Endpoints de gestion du barème kilométrique : consultation par année et CRUD des tranches.
/// La validation du non-chevauchement des tranches ([KmMin, KmMax]) par (année, puissance fiscale)
/// est appliquée à l'ajout et à la modification (Exigences 13.1–13.2).
/// </summary>
[ApiController]
[Route("api/bareme")]
public class BaremeController : ControllerBase
{
private readonly IBaremeService _baremeService;
public BaremeController(IBaremeService baremeService)
{
_baremeService = baremeService;
}
/// <summary>Retourne toutes les tranches du barème pour l'année indiquée.</summary>
[HttpGet("{annee:int}")]
[ProducesResponseType(typeof(IReadOnlyList<TrancheBaremeDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<TrancheBaremeDto>>> GetBareme(
int annee, CancellationToken cancellationToken)
{
var tranches = await _baremeService.GetBaremeAsync(annee, cancellationToken);
return Ok(tranches);
}
/// <summary>
/// Ajoute une tranche du barème. Rejette (400) si l'intervalle chevauche une tranche existante
/// pour la même (année, puissance fiscale).
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(TrancheBaremeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<TrancheBaremeDto>> AddTranche(
[FromBody] SaveTrancheBaremeDto dto, CancellationToken cancellationToken)
{
try
{
var tranche = await _baremeService.AddTrancheAsync(dto, cancellationToken);
return CreatedAtAction(nameof(GetBareme), new { annee = tranche.Annee }, tranche);
}
catch (TranchesBaremeChevauchantesException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest);
}
}
/// <summary>
/// Met à jour une tranche existante. Rejette (400) en cas de chevauchement, 404 si la tranche est absente.
/// </summary>
[HttpPut("{id:int}")]
[ProducesResponseType(typeof(TrancheBaremeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TrancheBaremeDto>> UpdateTranche(
int id, [FromBody] SaveTrancheBaremeDto dto, CancellationToken cancellationToken)
{
try
{
var tranche = await _baremeService.UpdateTrancheAsync(id, dto, cancellationToken);
return Ok(tranche);
}
catch (TrancheBaremeNotFoundException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status404NotFound);
}
catch (TranchesBaremeChevauchantesException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest);
}
}
/// <summary>Années pour lesquelles un barème (au moins une tranche) est défini, triées croissantes.</summary>
[HttpGet("annees-disponibles")]
[ProducesResponseType(typeof(IReadOnlyList<int>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<int>>> GetAnneesDisponibles(CancellationToken cancellationToken)
{
var annees = await _baremeService.GetAnneesAvecBaremeAsync(cancellationToken);
return Ok(annees);
}
/// <summary>
/// Copie le dernier barème connu (année la plus récente antérieure ayant un barème) vers
/// l'année cible. Remplace les tranches existantes de l'année cible (annule et remplace).
/// Retourne le nombre de tranches copiées (0 si aucun barème antérieur n'existe).
/// </summary>
[HttpPost("copier/{anneeCible:int}")]
[ProducesResponseType(typeof(CopieBaremeResultDto), StatusCodes.Status200OK)]
public async Task<ActionResult<CopieBaremeResultDto>> CopierDernierBareme(
int anneeCible, CancellationToken cancellationToken)
{
var nombre = await _baremeService.CopierDernierBaremeAsync(anneeCible, cancellationToken);
return Ok(new CopieBaremeResultDto(nombre));
}
/// <summary>Supprime une tranche du barème. Retourne 404 si la tranche est absente.</summary>
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteTranche(int id, CancellationToken cancellationToken)
{
try
{
await _baremeService.DeleteTrancheAsync(id, cancellationToken);
return NoContent();
}
catch (TrancheBaremeNotFoundException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status404NotFound);
}
}
}
@@ -0,0 +1,126 @@
using System.Net.Mime;
using Microsoft.AspNetCore.Mvc;
using TrajetKmTracker.Api.Services;
using TrajetKmTracker.Api.Services.Exceptions;
namespace TrajetKmTracker.Api.Controllers;
/// <summary>
/// Endpoints d'Export_PDF : rapport mensuel et synthèse annuelle (Exigences 7.3, 7.4, 7.5).
/// Chaque réponse renvoie un unique fichier <c>application/pdf</c> avec l'en-tête
/// <c>Content-Disposition: attachment</c> et le nom de fichier suggéré par le service
/// (<c>trajets_MM_AAAA.pdf</c> pour le mensuel, <c>trajets_AAAA.pdf</c> pour l'annuel).
///
/// Le filtre par véhicule est optionnel : si <c>vehiculeId</c> est omis, le Véhicule_Courant
/// est utilisé par défaut.
///
/// <para>
/// Note (Exigence 7.9) : une réponse HTTP unique ne peut transporter qu'un seul fichier.
/// Ces endpoints produisent donc le rapport du véhicule sélectionné (ou courant). La génération
/// d'un rapport distinct par véhicule lorsqu'une période comporte plusieurs véhicules est assurée
/// par les variantes de service <c>GenererRapports…ParVehiculeAsync</c>, réservées à un usage
/// multi-fichiers ultérieur (p. ex. archive). Ici, un appel = un fichier filtré par véhicule.
/// </para>
///
/// <para>
/// Erreurs : un dépassement du délai de génération ou un échec de rendu lève une
/// <see cref="PdfGenerationException"/> mappée vers <c>503 Service Unavailable</c> ; un véhicule
/// introuvable lève une <see cref="VehiculeNotFoundException"/> mappée vers <c>404 Not Found</c>
/// (réponses ProblemDetails).
/// </para>
/// </summary>
[ApiController]
[Route("api/exports")]
public class ExportsPdfController : ControllerBase
{
private readonly IPdfService _pdfService;
private readonly IVehiculeService _vehiculeService;
public ExportsPdfController(IPdfService pdfService, IVehiculeService vehiculeService)
{
_pdfService = pdfService;
_vehiculeService = vehiculeService;
}
/// <summary>
/// Génère et télécharge le rapport PDF mensuel d'un (année, mois) pour le véhicule indiqué
/// (Véhicule_Courant si <paramref name="vehiculeId"/> est omis). Retourne les octets du PDF
/// avec <c>Content-Type: application/pdf</c> et <c>Content-Disposition: attachment;
/// filename="trajets_MM_AAAA.pdf"</c> (Exigences 7.3, 7.4).
/// </summary>
[HttpGet("mensuel/{annee:int}/{mois:int}")]
[Produces(MediaTypeNames.Application.Pdf)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
public async Task<IActionResult> ExporterMensuel(
int annee, int mois, [FromQuery] int? vehiculeId, CancellationToken cancellationToken)
{
try
{
var vehicule = await ResoudreVehiculeIdAsync(vehiculeId, cancellationToken);
var pdf = await _pdfService.GenererRapportMensuelAsync(annee, mois, vehicule, cancellationToken);
return FichierPdf(pdf);
}
catch (VehiculeNotFoundException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status404NotFound);
}
catch (PdfGenerationException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status503ServiceUnavailable);
}
}
/// <summary>
/// Génère et télécharge la synthèse PDF annuelle d'une année pour le véhicule indiqué
/// (Véhicule_Courant si <paramref name="vehiculeId"/> est omis). Retourne les octets du PDF
/// avec <c>Content-Type: application/pdf</c> et <c>Content-Disposition: attachment;
/// filename="trajets_AAAA.pdf"</c> (Exigences 7.3, 7.4).
/// </summary>
[HttpGet("annuel/{annee:int}")]
[Produces(MediaTypeNames.Application.Pdf)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
public async Task<IActionResult> ExporterAnnuel(
int annee, [FromQuery] int? vehiculeId, CancellationToken cancellationToken)
{
try
{
var vehicule = await ResoudreVehiculeIdAsync(vehiculeId, cancellationToken);
var pdf = await _pdfService.GenererRapportAnnuelAsync(annee, vehicule, cancellationToken);
return FichierPdf(pdf);
}
catch (VehiculeNotFoundException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status404NotFound);
}
catch (PdfGenerationException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status503ServiceUnavailable);
}
}
/// <summary>
/// Résout l'identifiant du véhicule à utiliser : celui fourni s'il est présent, sinon
/// le Véhicule_Courant (Date_Effet la plus récente &lt;= aujourd'hui).
/// </summary>
private async Task<int> ResoudreVehiculeIdAsync(int? vehiculeId, CancellationToken cancellationToken)
{
if (vehiculeId.HasValue)
{
return vehiculeId.Value;
}
var courant = await _vehiculeService.GetVehiculeCourantAsync(cancellationToken);
return courant.Id;
}
/// <summary>
/// Renvoie le PDF en pièce jointe téléchargeable, en s'appuyant sur le nom de fichier
/// suggéré par le <see cref="PdfResult"/> (<c>Content-Disposition: attachment</c>).
/// </summary>
private FileContentResult FichierPdf(PdfResult pdf)
=> File(pdf.Contenu, MediaTypeNames.Application.Pdf, pdf.NomFichier);
}
@@ -0,0 +1,86 @@
using System.Globalization;
using Microsoft.AspNetCore.Mvc;
using TrajetKmTracker.Api.Models.DTOs;
using TrajetKmTracker.Api.Services;
namespace TrajetKmTracker.Api.Controllers;
/// <summary>
/// Endpoints de navigation temporelle et de totaux du Panneau_Totaux :
/// années/mois/jours disponibles (périodes ayant des données) et totaux
/// jour/mois/année partiel pour une date donnée (Exigences 12.15, 12.16).
/// </summary>
[ApiController]
[Route("api")]
public class NavigationController : ControllerBase
{
private readonly ITotauxService _totauxService;
public NavigationController(ITotauxService totauxService)
{
_totauxService = totauxService;
}
/// <summary>
/// Retourne les années comportant au moins un Trajet, complétées par l'année
/// courante, triées croissantes (Exigence 12.15).
/// </summary>
[HttpGet("annees-disponibles")]
[ProducesResponseType(typeof(IReadOnlyList<int>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<int>>> GetAnneesDisponibles(
CancellationToken cancellationToken)
{
var annees = await _totauxService.GetAnneesDisponiblesAsync(cancellationToken);
return Ok(annees);
}
/// <summary>
/// Retourne les mois (1..12) de l'année comportant au moins un Trajet,
/// triés croissants (Exigence 12.16).
/// </summary>
[HttpGet("mois-disponibles/{annee:int}")]
[ProducesResponseType(typeof(IReadOnlyList<int>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<int>>> GetMoisDisponibles(
int annee, CancellationToken cancellationToken)
{
var mois = await _totauxService.GetMoisDisponiblesAsync(annee, cancellationToken);
return Ok(mois);
}
/// <summary>
/// Retourne les jours du mois de l'année comportant au moins un Trajet,
/// triés croissants (Exigence 12.16).
/// </summary>
[HttpGet("jours-disponibles/{annee:int}/{mois:int}")]
[ProducesResponseType(typeof(IReadOnlyList<int>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<int>>> GetJoursDisponibles(
int annee, int mois, CancellationToken cancellationToken)
{
var jours = await _totauxService.GetJoursDisponiblesAsync(annee, mois, cancellationToken);
return Ok(jours);
}
/// <summary>
/// Retourne les totaux jour/mois/année partiel du Panneau_Totaux pour la date
/// indiquée et le véhicule (Véhicule_Courant si <paramref name="vehiculeId"/> omis).
/// La date est attendue au format ISO <c>yyyy-MM-dd</c> ; un format invalide est
/// rejeté avec un code HTTP 400.
/// </summary>
[HttpGet("totaux/{date}")]
[ProducesResponseType(typeof(TotauxDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<TotauxDto>> GetTotaux(
string date, [FromQuery] int? vehiculeId, CancellationToken cancellationToken)
{
if (!DateOnly.TryParseExact(date, "yyyy-MM-dd", CultureInfo.InvariantCulture,
DateTimeStyles.None, out var jour))
{
return Problem(
detail: $"La date '{date}' est invalide. Format attendu : yyyy-MM-dd.",
statusCode: StatusCodes.Status400BadRequest);
}
var totaux = await _totauxService.GetTotauxAsync(jour, vehiculeId, cancellationToken);
return Ok(totaux);
}
}
@@ -0,0 +1,53 @@
using Microsoft.AspNetCore.Mvc;
using TrajetKmTracker.Api.Models.DTOs;
using TrajetKmTracker.Api.Services;
namespace TrajetKmTracker.Api.Controllers;
/// <summary>
/// Endpoints de gestion du profil utilisateur (singleton) et de son état de configuration.
/// </summary>
[ApiController]
[Route("api/profil")]
public class ProfilController : ControllerBase
{
private readonly IProfilService _profilService;
public ProfilController(IProfilService profilService)
{
_profilService = profilService;
}
/// <summary>Récupère le profil (créé à la volée s'il est absent).</summary>
[HttpGet]
[ProducesResponseType(typeof(ProfilDto), StatusCodes.Status200OK)]
public async Task<ActionResult<ProfilDto>> GetProfil(CancellationToken cancellationToken)
{
var profil = await _profilService.GetProfilAsync(cancellationToken);
return Ok(profil);
}
/// <summary>
/// Sauvegarde le profil. La validation du matricule (≤ 8 car.) est assurée par FluentValidation.
/// Le géocodage de l'Adresse_Domicile est effectué au mieux : un échec BAN n'empêche pas la sauvegarde.
/// </summary>
[HttpPut]
[ProducesResponseType(typeof(ProfilDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<ProfilDto>> SaveProfil(
[FromBody] SaveProfilDto dto,
CancellationToken cancellationToken)
{
var profil = await _profilService.SaveProfilAsync(dto, cancellationToken);
return Ok(profil);
}
/// <summary>État de configuration du profil (onboarding).</summary>
[HttpGet("etat")]
[ProducesResponseType(typeof(EtatProfilDto), StatusCodes.Status200OK)]
public async Task<ActionResult<EtatProfilDto>> GetEtat(CancellationToken cancellationToken)
{
var etat = await _profilService.GetEtatAsync(cancellationToken);
return Ok(etat);
}
}
@@ -0,0 +1,103 @@
using Microsoft.AspNetCore.Mvc;
using TrajetKmTracker.Api.Models.DTOs;
using TrajetKmTracker.Api.Services;
using TrajetKmTracker.Api.Services.Exceptions;
namespace TrajetKmTracker.Api.Controllers;
/// <summary>
/// Endpoints « Barème et remboursements » : montant prévisionnel (80% indicatif),
/// montants réglés (saisie/consultation) et régularisation annuelle (barème plein).
/// </summary>
[ApiController]
[Route("api")]
public class RemboursementController : ControllerBase
{
private readonly IMontantRegleService _montantRegleService;
private readonly IRemboursementService _remboursementService;
public RemboursementController(
IMontantRegleService montantRegleService,
IRemboursementService remboursementService)
{
_montantRegleService = montantRegleService;
_remboursementService = remboursementService;
}
/// <summary>
/// Retourne le Montant_Previsionnel indicatif (80% du barème plein sur le total annuel partiel)
/// pour un (année, mois, véhicule). Purement informatif — hors régularisation (Exigences 13.5/13.6).
/// </summary>
[HttpGet("montant-previsionnel/{annee:int}/{mois:int}")]
[ProducesResponseType(typeof(MontantPrevisionnelDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<MontantPrevisionnelDto>> GetMontantPrevisionnel(
int annee, int mois, [FromQuery] int vehiculeId, CancellationToken cancellationToken)
{
try
{
var dto = await _montantRegleService.GetMontantPrevisionnelAsync(
annee, mois, vehiculeId, cancellationToken);
return Ok(dto);
}
catch (VehiculeNotFoundException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status404NotFound);
}
}
/// <summary>
/// Retourne les Montant_Regle saisis pour une (année, véhicule), triés par mois croissant.
/// </summary>
[HttpGet("montants-regles/{annee:int}")]
[ProducesResponseType(typeof(IReadOnlyList<MontantRegleDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<MontantRegleDto>>> GetMontantsRegles(
int annee, [FromQuery] int vehiculeId, CancellationToken cancellationToken)
{
var montants = await _montantRegleService.GetMontantsReglesAsync(annee, vehiculeId, cancellationToken);
return Ok(montants);
}
/// <summary>
/// Enregistre ou met à jour (upsert) un Montant_Regle par (année, mois, véhicule).
/// Rejette un montant négatif avec un code HTTP 400 (Exigence 14.4).
/// </summary>
[HttpPut("montants-regles")]
[ProducesResponseType(typeof(MontantRegleDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<MontantRegleDto>> UpsertMontantRegle(
[FromBody] MontantRegleDto dto, CancellationToken cancellationToken)
{
try
{
var result = await _montantRegleService.UpsertMontantRegleAsync(dto, cancellationToken);
return Ok(result);
}
catch (MontantRegleNegatifException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest);
}
}
/// <summary>
/// Retourne la synthèse annuelle de régularisation (barème plein) pour une (année, véhicule) :
/// tranche annuelle, 12 lignes mensuelles et totaux (km annuel, réglé, Montant_Reel, Regularisation).
/// </summary>
[HttpGet("regularisation/{annee:int}")]
[ProducesResponseType(typeof(RegularisationAnnuelleDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RegularisationAnnuelleDto>> GetRegularisation(
int annee, [FromQuery] int vehiculeId, CancellationToken cancellationToken)
{
try
{
var dto = await _remboursementService.GetRegularisationAnnuelleAsync(
annee, vehiculeId, cancellationToken);
return Ok(dto);
}
catch (VehiculeNotFoundException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status404NotFound);
}
}
}
@@ -0,0 +1,68 @@
using Microsoft.AspNetCore.Mvc;
using TrajetKmTracker.Api.Models.DTOs;
using TrajetKmTracker.Api.Services;
using TrajetKmTracker.Api.Services.Exceptions;
namespace TrajetKmTracker.Api.Controllers;
/// <summary>
/// Endpoints des résumés kilométriques : Résumé_Mensuel et Résumé_Annuel.
/// Le filtre par véhicule est optionnel (Véhicule_Courant utilisé si omis).
/// Les paramètres hors bornes (mois ∉ [1, 12], année ∉ [2000, 2100]) sont
/// rejetés avec un code HTTP 400 (Exigences 3.6, 3.8, 4.1).
/// </summary>
[ApiController]
[Route("api/resumes")]
public class ResumesController : ControllerBase
{
private readonly IResumeService _resumeService;
public ResumesController(IResumeService resumeService)
{
_resumeService = resumeService;
}
/// <summary>
/// Retourne le Résumé_Mensuel d'un (année, mois) pour le véhicule indiqué
/// (Véhicule_Courant si <paramref name="vehiculeId"/> est omis). Rejette (400)
/// les paramètres hors bornes (Exigences 3.6, 3.8).
/// </summary>
[HttpGet("mensuel/{annee:int}/{mois:int}")]
[ProducesResponseType(typeof(ResumeMensuelDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<ResumeMensuelDto>> GetResumeMensuel(
int annee, int mois, [FromQuery] int? vehiculeId, CancellationToken cancellationToken)
{
try
{
var resume = await _resumeService.GetResumeMensuelAsync(annee, mois, vehiculeId, cancellationToken);
return Ok(resume);
}
catch (ResumeParametreInvalideException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest);
}
}
/// <summary>
/// Retourne le Résumé_Annuel d'une année (12 résumés mensuels) pour le véhicule
/// indiqué (Véhicule_Courant si <paramref name="vehiculeId"/> est omis). Rejette (400)
/// une année hors bornes (Exigences 3.6, 4.1).
/// </summary>
[HttpGet("annuel/{annee:int}")]
[ProducesResponseType(typeof(ResumeAnnuelDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<ResumeAnnuelDto>> GetResumeAnnuel(
int annee, [FromQuery] int? vehiculeId, CancellationToken cancellationToken)
{
try
{
var resume = await _resumeService.GetResumeAnnuelAsync(annee, vehiculeId, cancellationToken);
return Ok(resume);
}
catch (ResumeParametreInvalideException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest);
}
}
}
@@ -0,0 +1,240 @@
using System.Globalization;
using Microsoft.AspNetCore.Mvc;
using TrajetKmTracker.Api.Models.DTOs;
using TrajetKmTracker.Api.Repositories;
using TrajetKmTracker.Api.Services;
using TrajetKmTracker.Api.Services.Exceptions;
namespace TrajetKmTracker.Api.Controllers;
/// <summary>
/// Endpoints CRUD des Trajets, réordonnancement manuel et récupération des trajets d'une
/// journée enrichis des coordonnées et géométries issues du Cache_Adresses (Exigences 1.3,
/// 5.5, 5.6, 8.7, 9.3).
/// </summary>
[ApiController]
[Route("api/trajets")]
public class TrajetsController : ControllerBase
{
/// <summary>
/// En-tête de réponse portant l'avertissement lorsqu'un Trajet a été enregistré sans
/// distance calculable (Exigence 2.4). Le corps reste le <see cref="TrajetDto"/> attendu.
/// </summary>
private const string WarningHeader = "X-Trajet-Warning";
private readonly ITrajetService _trajetService;
private readonly ITrajetRepository _trajetRepository;
private readonly IAdresseCacheService _adresseCacheService;
public TrajetsController(
ITrajetService trajetService,
ITrajetRepository trajetRepository,
IAdresseCacheService adresseCacheService)
{
_trajetService = trajetService;
_trajetRepository = trajetRepository;
_adresseCacheService = adresseCacheService;
}
/// <summary>
/// Crée un Trajet (Exigence 1.3). Retourne 201 + <see cref="TrajetDto"/>. Si la distance n'a
/// pas pu être calculée faute de coordonnées, la réponse reste 201 et un avertissement est
/// ajouté dans l'en-tête <c>X-Trajet-Warning</c> (Exigence 2.4).
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(TrajetDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TrajetDto>> Create(
[FromBody] CreateTrajetDto dto,
CancellationToken cancellationToken)
{
try
{
var resultat = await _trajetService.CreateAsync(dto, cancellationToken);
if (resultat.Avertissement && !string.IsNullOrWhiteSpace(resultat.Message))
{
Response.Headers[WarningHeader] = resultat.Message;
}
return CreatedAtAction(
nameof(GetById),
new { id = resultat.Trajet.Id },
resultat.Trajet);
}
catch (VehiculeNotFoundException ex)
{
// Aucun véhicule applicable à la date et aucun VehiculeId fourni.
return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest);
}
}
/// <summary>
/// Recalcule la distance de TOUS les trajets via OpenRouteService (BAN + ORS), indépendamment
/// d'un changement d'adresse. Sert à corriger des distances calculées en mode dégradé (ex. clé
/// ORS invalide → distances à vol d'oiseau) une fois ORS de nouveau opérationnel.
/// Retourne 200 + le nombre de trajets recalculés.
/// </summary>
[HttpPost("recalculer-distances")]
[ProducesResponseType(typeof(RecalculDistancesResultDto), StatusCodes.Status200OK)]
public async Task<ActionResult<RecalculDistancesResultDto>> RecalculerDistances(
CancellationToken cancellationToken)
{
var nombre = await _trajetService.RecalculerToutesLesDistancesAsync(cancellationToken);
return Ok(new RecalculDistancesResultDto(nombre));
}
/// <summary>Récupère un Trajet par identifiant. 404 s'il est introuvable.</summary>
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(TrajetDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TrajetDto>> GetById(int id, CancellationToken cancellationToken)
{
var trajet = await _trajetRepository.GetByIdAsync(id, cancellationToken);
if (trajet is null)
{
return Problem(
detail: $"Le trajet {id} n'existe pas.",
statusCode: StatusCodes.Status404NotFound);
}
return Ok(new TrajetDto(
trajet.Id,
trajet.Date,
trajet.AdresseDepart,
trajet.AdresseArrivee,
trajet.Distance,
trajet.Commentaire,
trajet.Position,
trajet.VehiculeId,
trajet.PuissanceFiscale));
}
/// <summary>
/// Modifie un Trajet (Exigences 5.5, 5.6). Retourne 200 + <see cref="TrajetDto"/>. Un
/// avertissement de distance non calculable est porté par l'en-tête <c>X-Trajet-Warning</c>.
/// 404 si le Trajet est introuvable ; 503 si le recalcul de distance échoue (Exigence 5.8).
/// </summary>
[HttpPut("{id:int}")]
[ProducesResponseType(typeof(TrajetDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
public async Task<ActionResult<TrajetDto>> Update(
int id,
[FromBody] UpdateTrajetDto dto,
CancellationToken cancellationToken)
{
try
{
var resultat = await _trajetService.UpdateAsync(id, dto, cancellationToken);
if (resultat.Avertissement && !string.IsNullOrWhiteSpace(resultat.Message))
{
Response.Headers[WarningHeader] = resultat.Message;
}
return Ok(resultat.Trajet);
}
catch (TrajetNotFoundException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status404NotFound);
}
catch (VehiculeNotFoundException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest);
}
catch (DistanceRecalculException ex)
{
// Échec du recalcul sur des coordonnées connues : modification annulée (rollback),
// données existantes préservées (Exigence 5.8). Mappé sur 503 conformément à la table
// d'erreurs de la conception (DistanceCalculationException → 503).
return Problem(detail: ex.Message, statusCode: StatusCodes.Status503ServiceUnavailable);
}
}
/// <summary>Supprime un Trajet (Exigence 5.5). 204 si succès, 404 s'il est introuvable (5.6).</summary>
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
try
{
await _trajetService.DeleteAsync(id, cancellationToken);
return NoContent();
}
catch (TrajetNotFoundException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status404NotFound);
}
}
/// <summary>
/// Réordonne manuellement les trajets d'une journée : met à jour uniquement leurs positions
/// sans altérer les autres champs (Exigence 11.9). 204 en cas de succès.
/// </summary>
[HttpPatch("reordonner")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Reordonner(
[FromBody] ReordonnerTrajetsDto dto,
CancellationToken cancellationToken)
{
await _trajetService.ReordonnerAsync(dto, cancellationToken);
return NoContent();
}
/// <summary>
/// Retourne les trajets d'une journée enrichis des coordonnées et géométries connues via le
/// Cache_Adresses (Exigences 8.7, 9.3). Le paramètre <paramref name="date"/> est attendu au
/// format ISO <c>yyyy-MM-dd</c> pour la sûreté du routage. Les coordonnées/géométries absentes
/// du cache restent à <c>null</c>.
/// </summary>
[HttpGet("journee/{date}")]
[ProducesResponseType(typeof(IReadOnlyList<TrajetCarteDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<IReadOnlyList<TrajetCarteDto>>> GetJournee(
string date,
CancellationToken cancellationToken)
{
if (!DateOnly.TryParseExact(date, "yyyy-MM-dd", CultureInfo.InvariantCulture,
DateTimeStyles.None, out var jour))
{
return Problem(
detail: $"La date '{date}' est invalide. Format attendu : yyyy-MM-dd.",
statusCode: StatusCodes.Status400BadRequest);
}
var trajets = await _trajetRepository.GetByDateAsync(jour, cancellationToken);
var resultats = new List<TrajetCarteDto>(trajets.Count);
foreach (var trajet in trajets)
{
var depart = await _adresseCacheService.ObtenirParAdresseAsync(
trajet.AdresseDepart, cancellationToken);
var arrivee = await _adresseCacheService.ObtenirParAdresseAsync(
trajet.AdresseArrivee, cancellationToken);
// Géométrie de l'itinéraire : portée par le Trajet lui-même (paire départ→arrivée),
// seule source correcte. Fallback sur le cache d'adresses pour d'éventuels trajets
// hérités dont la géométrie n'a pas encore été recalculée (rétrocompatibilité).
var geometrie = trajet.GeometrieJson ?? depart?.GeometrieJson ?? arrivee?.GeometrieJson;
resultats.Add(new TrajetCarteDto(
trajet.Id,
trajet.Date,
trajet.AdresseDepart,
trajet.AdresseArrivee,
trajet.Distance ?? 0m,
depart?.Latitude,
depart?.Longitude,
arrivee?.Latitude,
arrivee?.Longitude,
geometrie));
}
return Ok(resultats);
}
}
@@ -0,0 +1,88 @@
using Microsoft.AspNetCore.Mvc;
using TrajetKmTracker.Api.Models.DTOs;
using TrajetKmTracker.Api.Services;
using TrajetKmTracker.Api.Services.Exceptions;
namespace TrajetKmTracker.Api.Controllers;
/// <summary>
/// Endpoints de gestion des véhicules : historique, véhicule courant, création et réaffectation.
/// </summary>
[ApiController]
[Route("api/vehicules")]
public class VehiculesController : ControllerBase
{
private readonly IVehiculeService _vehiculeService;
public VehiculesController(IVehiculeService vehiculeService)
{
_vehiculeService = vehiculeService;
}
/// <summary>Historique complet des véhicules (trié par date d'effet décroissante).</summary>
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyList<VehiculeDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<VehiculeDto>>> GetHistorique(CancellationToken cancellationToken)
{
var vehicules = await _vehiculeService.GetHistoriqueAsync(cancellationToken);
return Ok(vehicules);
}
/// <summary>Véhicule courant (date d'effet la plus récente ≤ aujourd'hui).</summary>
[HttpGet("courant")]
[ProducesResponseType(typeof(VehiculeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<VehiculeDto>> GetCourant(CancellationToken cancellationToken)
{
try
{
var vehicule = await _vehiculeService.GetVehiculeCourantAsync(cancellationToken);
return Ok(vehicule);
}
catch (VehiculeNotFoundException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status404NotFound);
}
}
/// <summary>
/// Enregistre un véhicule. Retourne <see cref="VehiculeCreeDto"/> indiquant si une réaffectation
/// des trajets doit être proposée. Rejette une date d'effet future (400).
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(VehiculeCreeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<VehiculeCreeDto>> Create(
[FromBody] CreateVehiculeDto dto,
CancellationToken cancellationToken)
{
try
{
var resultat = await _vehiculeService.CreateAsync(dto, cancellationToken);
return CreatedAtAction(nameof(GetHistorique), null, resultat);
}
catch (DateEffetFutureException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest);
}
}
/// <summary>Accepte ou refuse la réaffectation des trajets au véhicule indiqué.</summary>
[HttpPost("reaffectation")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Reaffecter(
[FromBody] ReaffectationDto dto,
CancellationToken cancellationToken)
{
try
{
await _vehiculeService.ReaffecterAsync(dto, cancellationToken);
return NoContent();
}
catch (VehiculeNotFoundException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status404NotFound);
}
}
}