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,39 @@
using Microsoft.AspNetCore.Mvc;
using SchotzIadKmTracker.Api.Models.DTOs;
using SchotzIadKmTracker.Api.Services;
namespace SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Models.DTOs;
using SchotzIadKmTracker.Api.Services;
using SchotzIadKmTracker.Api.Services.Exceptions;
namespace SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Models.DTOs;
using SchotzIadKmTracker.Api.Services;
using SchotzIadKmTracker.Api.Services.Exceptions;
namespace SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Services;
using SchotzIadKmTracker.Api.Services.Exceptions;
namespace SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Models.DTOs;
using SchotzIadKmTracker.Api.Services;
namespace SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Models.DTOs;
using SchotzIadKmTracker.Api.Services;
namespace SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Models.DTOs;
using SchotzIadKmTracker.Api.Services;
using SchotzIadKmTracker.Api.Services.Exceptions;
namespace SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Models.DTOs;
using SchotzIadKmTracker.Api.Services;
using SchotzIadKmTracker.Api.Services.Exceptions;
namespace SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Models.DTOs;
using SchotzIadKmTracker.Api.Repositories;
using SchotzIadKmTracker.Api.Services;
using SchotzIadKmTracker.Api.Services.Exceptions;
namespace SchotzIadKmTracker.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 SchotzIadKmTracker.Api.Models.DTOs;
using SchotzIadKmTracker.Api.Services;
using SchotzIadKmTracker.Api.Services.Exceptions;
namespace SchotzIadKmTracker.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);
}
}
}