Files
SchotzIAD/backend/TrajetKmTracker.Api/Controllers/TrajetsController.cs
T
MadJax e3f946f7d4 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.
2026-09-14 02:08:27 +02:00

241 lines
9.8 KiB
C#

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);
}
}