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,16 @@
|
||||
# Build output
|
||||
bin/
|
||||
obj/
|
||||
|
||||
# SQLite database files
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# User-specific files
|
||||
*.user
|
||||
*.suo
|
||||
|
||||
# Secrets
|
||||
.env
|
||||
secrets.json
|
||||
@@ -0,0 +1,35 @@
|
||||
# ---- Étape build (SDK) ----
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
WORKDIR /src
|
||||
COPY *.sln ./
|
||||
COPY TrajetKmTracker.Api/*.csproj TrajetKmTracker.Api/
|
||||
RUN dotnet restore TrajetKmTracker.Api/TrajetKmTracker.Api.csproj
|
||||
COPY . .
|
||||
RUN dotnet publish TrajetKmTracker.Api/TrajetKmTracker.Api.csproj -c Release -o /app/publish
|
||||
|
||||
# ---- Étape runtime (ASP.NET) ----
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
# curl est requis pour le HEALTHCHECK Docker (l'image aspnet ne l'inclut pas par défaut).
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
# Répertoire du volume pour la base SQLite persistée
|
||||
RUN mkdir -p /data
|
||||
VOLUME ["/data"]
|
||||
|
||||
# La base SQLite est stockée sur le volume monté ; le chemin est fourni via la chaîne de connexion
|
||||
ENV ConnectionStrings__Default="Data Source=/data/trajets.db"
|
||||
ENV ASPNETCORE_URLS="http://+:8080"
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
# Sonde de santé intégrée à l'image (utilisée aussi par Docker Compose).
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=20s \
|
||||
CMD curl -f http://localhost:8080/health || exit 1
|
||||
|
||||
ENTRYPOINT ["dotnet", "TrajetKmTracker.Api.dll"]
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace TrajetKmTracker.Api.Configuration;
|
||||
|
||||
/// <summary>Options de configuration de l'API BAN (Base Adresses Nationale).</summary>
|
||||
public class BANOptions
|
||||
{
|
||||
public const string SectionName = "BAN";
|
||||
|
||||
public string BaseUrl { get; set; } = "https://api-adresse.data.gouv.fr";
|
||||
public int TimeoutSeconds { get; set; } = 5;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace TrajetKmTracker.Api.Configuration;
|
||||
|
||||
/// <summary>Options de configuration de l'API OpenRouteService (ORS).</summary>
|
||||
public class ORSOptions
|
||||
{
|
||||
public const string SectionName = "ORS";
|
||||
|
||||
public string BaseUrl { get; set; } = string.Empty;
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
public int TimeoutSeconds { get; set; } = 10;
|
||||
}
|
||||
@@ -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 <= 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrajetKmTracker.Api.Models.Entities;
|
||||
|
||||
namespace TrajetKmTracker.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Contexte EF Core de l'application. Déclare les <see cref="DbSet{TEntity}"/>, les contraintes CHECK,
|
||||
/// les index et les index uniques conformément à la section « Schéma SQLite » de la conception.
|
||||
/// </summary>
|
||||
public class AppDbContext : DbContext
|
||||
{
|
||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<Trajet> Trajets => Set<Trajet>();
|
||||
public DbSet<Vehicule> Vehicules => Set<Vehicule>();
|
||||
public DbSet<Profil> Profil => Set<Profil>();
|
||||
public DbSet<AdresseCache> AdresseCache => Set<AdresseCache>();
|
||||
public DbSet<AdresseNommee> AdressesNommees => Set<AdresseNommee>();
|
||||
public DbSet<TrancheBareme> TranchesBareme => Set<TrancheBareme>();
|
||||
public DbSet<MontantRegle> MontantsRegles => Set<MontantRegle>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// --- Trajets ---
|
||||
modelBuilder.Entity<Trajet>(entity =>
|
||||
{
|
||||
entity.ToTable("Trajets", t =>
|
||||
{
|
||||
// Distance nulle autorisée (Exigence 2.4 : distance non calculable) ; sinon strictement positive.
|
||||
t.HasCheckConstraint("CK_Trajets_Distance", "\"Distance\" IS NULL OR \"Distance\" > 0");
|
||||
t.HasCheckConstraint("CK_Trajets_PuissanceFiscale", "\"PuissanceFiscale\" > 0");
|
||||
});
|
||||
|
||||
entity.HasIndex(t => t.Date, "IX_Trajets_Date");
|
||||
entity.HasIndex(t => new { t.Date, t.Position }, "IX_Trajets_Date_Position");
|
||||
entity.HasIndex(t => t.VehiculeId, "IX_Trajets_Vehicule");
|
||||
|
||||
entity.HasOne(t => t.Vehicule)
|
||||
.WithMany(v => v.Trajets)
|
||||
.HasForeignKey(t => t.VehiculeId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
// --- Vehicules ---
|
||||
modelBuilder.Entity<Vehicule>(entity =>
|
||||
{
|
||||
entity.ToTable("Vehicules", t =>
|
||||
t.HasCheckConstraint("CK_Vehicules_PuissanceFiscale", "\"PuissanceFiscale\" > 0"));
|
||||
|
||||
entity.HasIndex(v => v.DateEffet, "IX_Vehicules_DateEffet");
|
||||
});
|
||||
|
||||
// --- Profil (singleton Id = 1) ---
|
||||
modelBuilder.Entity<Profil>(entity =>
|
||||
{
|
||||
entity.ToTable("Profil");
|
||||
entity.HasKey(p => p.Id);
|
||||
entity.Property(p => p.Id).ValueGeneratedNever();
|
||||
entity.HasIndex(p => p.Id, "UX_Profil_Singleton").IsUnique();
|
||||
});
|
||||
|
||||
// --- AdresseCache (Adresse unique) ---
|
||||
modelBuilder.Entity<AdresseCache>(entity =>
|
||||
{
|
||||
entity.ToTable("AdresseCache");
|
||||
entity.HasIndex(a => a.Adresse).IsUnique();
|
||||
});
|
||||
|
||||
// --- AdressesNommees (unicité sur DenominationNormalisee) ---
|
||||
modelBuilder.Entity<AdresseNommee>(entity =>
|
||||
{
|
||||
entity.ToTable("AdressesNommees");
|
||||
entity.HasIndex(a => a.DenominationNormalisee, "UX_AdresseNommee_Denomination").IsUnique();
|
||||
});
|
||||
|
||||
// --- TranchesBareme ---
|
||||
modelBuilder.Entity<TrancheBareme>(entity =>
|
||||
{
|
||||
// Convention [KmMin, KmMax[ : borne max EXCLUSIVE, ou NULL = illimité.
|
||||
// La contrainte tolère KmMax NULL et impose sinon KmMin < KmMax.
|
||||
// Convention [KmMin, KmMax[ : borne max EXCLUSIVE, ou NULL = illimité.
|
||||
// Les décimaux sont stockés en TEXT par SQLite : on CAST en REAL pour comparer
|
||||
// numériquement (sinon "5000" > "20000" en comparaison lexicographique de chaînes).
|
||||
entity.ToTable("TranchesBareme", t =>
|
||||
t.HasCheckConstraint(
|
||||
"CK_TranchesBareme_Km",
|
||||
"\"KmMax\" IS NULL OR CAST(\"KmMin\" AS REAL) < CAST(\"KmMax\" AS REAL)"));
|
||||
|
||||
entity.HasIndex(t => new { t.Annee, t.PuissanceFiscale }, "IX_TranchesBareme_Annee_PF");
|
||||
});
|
||||
|
||||
// --- MontantsRegles ---
|
||||
modelBuilder.Entity<MontantRegle>(entity =>
|
||||
{
|
||||
entity.ToTable("MontantsRegles", t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_MontantsRegles_Montant", "\"Montant\" >= 0");
|
||||
t.HasCheckConstraint("CK_MontantsRegles_Mois", "\"Mois\" BETWEEN 1 AND 12");
|
||||
});
|
||||
|
||||
entity.HasIndex(m => new { m.Annee, m.Mois, m.VehiculeId }, "UX_MontantsRegles_Periode_Vehicule")
|
||||
.IsUnique();
|
||||
|
||||
entity.HasOne(m => m.Vehicule)
|
||||
.WithMany()
|
||||
.HasForeignKey(m => m.VehiculeId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
}
|
||||
}
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using TrajetKmTracker.Api.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrajetKmTracker.Api.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260913083053_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.8");
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.AdresseCache", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Adresse")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DerniereUtilisation")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("GeometrieJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<double>("Latitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<double>("Longitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Adresse")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AdresseCache", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.AdresseNommee", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Adresse")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Denomination")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DenominationNormalisee")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("GeocodageReussi")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double?>("Latitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<double?>("Longitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "DenominationNormalisee" }, "UX_AdresseNommee_Denomination")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AdressesNommees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.MontantRegle", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Annee")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Mois")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Montant")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VehiculeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("VehiculeId");
|
||||
|
||||
b.HasIndex(new[] { "Annee", "Mois", "VehiculeId" }, "UX_MontantsRegles_Periode_Vehicule")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("MontantsRegles", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_MontantsRegles_Mois", "\"Mois\" BETWEEN 1 AND 12");
|
||||
|
||||
t.HasCheckConstraint("CK_MontantsRegles_Montant", "\"Montant\" >= 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Profil", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AdresseDomicile")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("DomicileGeocode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double?>("DomicileLatitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<double?>("DomicileLongitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<string>("Matricule")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Nom")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Prenom")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Id" }, "UX_Profil_Singleton")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Profil", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Trajet", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AdresseArrivee")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AdresseDepart")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Commentaire")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Distance")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Position")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PuissanceFiscale")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VehiculeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Date" }, "IX_Trajets_Date");
|
||||
|
||||
b.HasIndex(new[] { "Date", "Position" }, "IX_Trajets_Date_Position");
|
||||
|
||||
b.HasIndex(new[] { "VehiculeId" }, "IX_Trajets_Vehicule");
|
||||
|
||||
b.ToTable("Trajets", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_Trajets_Distance", "\"Distance\" > 0");
|
||||
|
||||
t.HasCheckConstraint("CK_Trajets_PuissanceFiscale", "\"PuissanceFiscale\" > 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.TrancheBareme", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Annee")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Base")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Coefficient")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("KmMax")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("KmMin")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PuissanceFiscale")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Annee", "PuissanceFiscale" }, "IX_TranchesBareme_Annee_PF");
|
||||
|
||||
b.ToTable("TranchesBareme", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_TranchesBareme_Km", "\"KmMin\" <= \"KmMax\"");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Vehicule", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("DateEffet")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Plaque")
|
||||
.IsRequired()
|
||||
.HasMaxLength(15)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PuissanceFiscale")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "DateEffet" }, "IX_Vehicules_DateEffet");
|
||||
|
||||
b.ToTable("Vehicules", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_Vehicules_PuissanceFiscale", "\"PuissanceFiscale\" > 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.MontantRegle", b =>
|
||||
{
|
||||
b.HasOne("TrajetKmTracker.Api.Models.Entities.Vehicule", "Vehicule")
|
||||
.WithMany()
|
||||
.HasForeignKey("VehiculeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Vehicule");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Trajet", b =>
|
||||
{
|
||||
b.HasOne("TrajetKmTracker.Api.Models.Entities.Vehicule", "Vehicule")
|
||||
.WithMany("Trajets")
|
||||
.HasForeignKey("VehiculeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Vehicule");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Vehicule", b =>
|
||||
{
|
||||
b.Navigation("Trajets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrajetKmTracker.Api.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AdresseCache",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Adresse = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
|
||||
Latitude = table.Column<double>(type: "REAL", nullable: false),
|
||||
Longitude = table.Column<double>(type: "REAL", nullable: false),
|
||||
GeometrieJson = table.Column<string>(type: "TEXT", nullable: true),
|
||||
DerniereUtilisation = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AdresseCache", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AdressesNommees",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Denomination = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
|
||||
DenominationNormalisee = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
|
||||
Adresse = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
|
||||
Latitude = table.Column<double>(type: "REAL", nullable: true),
|
||||
Longitude = table.Column<double>(type: "REAL", nullable: true),
|
||||
GeocodageReussi = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AdressesNommees", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Profil",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Nom = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
|
||||
Prenom = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
|
||||
Matricule = table.Column<string>(type: "TEXT", maxLength: 8, nullable: true),
|
||||
AdresseDomicile = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
|
||||
DomicileLatitude = table.Column<double>(type: "REAL", nullable: true),
|
||||
DomicileLongitude = table.Column<double>(type: "REAL", nullable: true),
|
||||
DomicileGeocode = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Profil", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TranchesBareme",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Annee = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
PuissanceFiscale = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
KmMin = table.Column<decimal>(type: "TEXT", nullable: false),
|
||||
KmMax = table.Column<decimal>(type: "TEXT", nullable: false),
|
||||
Coefficient = table.Column<decimal>(type: "TEXT", nullable: false),
|
||||
Base = table.Column<decimal>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TranchesBareme", x => x.Id);
|
||||
table.CheckConstraint("CK_TranchesBareme_Km", "\"KmMin\" <= \"KmMax\"");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Vehicules",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Plaque = table.Column<string>(type: "TEXT", maxLength: 15, nullable: false),
|
||||
PuissanceFiscale = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
DateEffet = table.Column<DateOnly>(type: "TEXT", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Vehicules", x => x.Id);
|
||||
table.CheckConstraint("CK_Vehicules_PuissanceFiscale", "\"PuissanceFiscale\" > 0");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MontantsRegles",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Annee = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Mois = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
VehiculeId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Montant = table.Column<decimal>(type: "TEXT", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MontantsRegles", x => x.Id);
|
||||
table.CheckConstraint("CK_MontantsRegles_Mois", "\"Mois\" BETWEEN 1 AND 12");
|
||||
table.CheckConstraint("CK_MontantsRegles_Montant", "\"Montant\" >= 0");
|
||||
table.ForeignKey(
|
||||
name: "FK_MontantsRegles_Vehicules_VehiculeId",
|
||||
column: x => x.VehiculeId,
|
||||
principalTable: "Vehicules",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Trajets",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Date = table.Column<DateOnly>(type: "TEXT", nullable: false),
|
||||
AdresseDepart = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
|
||||
AdresseArrivee = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
|
||||
Distance = table.Column<decimal>(type: "TEXT", nullable: false),
|
||||
Commentaire = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
|
||||
Position = table.Column<decimal>(type: "TEXT", nullable: false),
|
||||
VehiculeId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
PuissanceFiscale = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Trajets", x => x.Id);
|
||||
table.CheckConstraint("CK_Trajets_Distance", "\"Distance\" > 0");
|
||||
table.CheckConstraint("CK_Trajets_PuissanceFiscale", "\"PuissanceFiscale\" > 0");
|
||||
table.ForeignKey(
|
||||
name: "FK_Trajets_Vehicules_VehiculeId",
|
||||
column: x => x.VehiculeId,
|
||||
principalTable: "Vehicules",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AdresseCache_Adresse",
|
||||
table: "AdresseCache",
|
||||
column: "Adresse",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_AdresseNommee_Denomination",
|
||||
table: "AdressesNommees",
|
||||
column: "DenominationNormalisee",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MontantsRegles_VehiculeId",
|
||||
table: "MontantsRegles",
|
||||
column: "VehiculeId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_MontantsRegles_Periode_Vehicule",
|
||||
table: "MontantsRegles",
|
||||
columns: new[] { "Annee", "Mois", "VehiculeId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_Profil_Singleton",
|
||||
table: "Profil",
|
||||
column: "Id",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Trajets_Date",
|
||||
table: "Trajets",
|
||||
column: "Date");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Trajets_Date_Position",
|
||||
table: "Trajets",
|
||||
columns: new[] { "Date", "Position" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Trajets_Vehicule",
|
||||
table: "Trajets",
|
||||
column: "VehiculeId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TranchesBareme_Annee_PF",
|
||||
table: "TranchesBareme",
|
||||
columns: new[] { "Annee", "PuissanceFiscale" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Vehicules_DateEffet",
|
||||
table: "Vehicules",
|
||||
column: "DateEffet");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AdresseCache");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AdressesNommees");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "MontantsRegles");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Profil");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Trajets");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "TranchesBareme");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Vehicules");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+325
@@ -0,0 +1,325 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using TrajetKmTracker.Api.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrajetKmTracker.Api.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260913085051_TrajetDistanceNullable")]
|
||||
partial class TrajetDistanceNullable
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.8");
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.AdresseCache", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Adresse")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DerniereUtilisation")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("GeometrieJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<double>("Latitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<double>("Longitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Adresse")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AdresseCache", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.AdresseNommee", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Adresse")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Denomination")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DenominationNormalisee")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("GeocodageReussi")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double?>("Latitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<double?>("Longitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "DenominationNormalisee" }, "UX_AdresseNommee_Denomination")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AdressesNommees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.MontantRegle", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Annee")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Mois")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Montant")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VehiculeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("VehiculeId");
|
||||
|
||||
b.HasIndex(new[] { "Annee", "Mois", "VehiculeId" }, "UX_MontantsRegles_Periode_Vehicule")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("MontantsRegles", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_MontantsRegles_Mois", "\"Mois\" BETWEEN 1 AND 12");
|
||||
|
||||
t.HasCheckConstraint("CK_MontantsRegles_Montant", "\"Montant\" >= 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Profil", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AdresseDomicile")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("DomicileGeocode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double?>("DomicileLatitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<double?>("DomicileLongitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<string>("Matricule")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Nom")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Prenom")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Id" }, "UX_Profil_Singleton")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Profil", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Trajet", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AdresseArrivee")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AdresseDepart")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Commentaire")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal?>("Distance")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Position")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PuissanceFiscale")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VehiculeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Date" }, "IX_Trajets_Date");
|
||||
|
||||
b.HasIndex(new[] { "Date", "Position" }, "IX_Trajets_Date_Position");
|
||||
|
||||
b.HasIndex(new[] { "VehiculeId" }, "IX_Trajets_Vehicule");
|
||||
|
||||
b.ToTable("Trajets", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_Trajets_Distance", "\"Distance\" IS NULL OR \"Distance\" > 0");
|
||||
|
||||
t.HasCheckConstraint("CK_Trajets_PuissanceFiscale", "\"PuissanceFiscale\" > 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.TrancheBareme", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Annee")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Base")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Coefficient")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("KmMax")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("KmMin")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PuissanceFiscale")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Annee", "PuissanceFiscale" }, "IX_TranchesBareme_Annee_PF");
|
||||
|
||||
b.ToTable("TranchesBareme", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_TranchesBareme_Km", "\"KmMin\" <= \"KmMax\"");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Vehicule", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("DateEffet")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Plaque")
|
||||
.IsRequired()
|
||||
.HasMaxLength(15)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PuissanceFiscale")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "DateEffet" }, "IX_Vehicules_DateEffet");
|
||||
|
||||
b.ToTable("Vehicules", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_Vehicules_PuissanceFiscale", "\"PuissanceFiscale\" > 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.MontantRegle", b =>
|
||||
{
|
||||
b.HasOne("TrajetKmTracker.Api.Models.Entities.Vehicule", "Vehicule")
|
||||
.WithMany()
|
||||
.HasForeignKey("VehiculeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Vehicule");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Trajet", b =>
|
||||
{
|
||||
b.HasOne("TrajetKmTracker.Api.Models.Entities.Vehicule", "Vehicule")
|
||||
.WithMany("Trajets")
|
||||
.HasForeignKey("VehiculeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Vehicule");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Vehicule", b =>
|
||||
{
|
||||
b.Navigation("Trajets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrajetKmTracker.Api.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class TrajetDistanceNullable : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropCheckConstraint(
|
||||
name: "CK_Trajets_Distance",
|
||||
table: "Trajets");
|
||||
|
||||
migrationBuilder.AlterColumn<decimal>(
|
||||
name: "Distance",
|
||||
table: "Trajets",
|
||||
type: "TEXT",
|
||||
nullable: true,
|
||||
oldClrType: typeof(decimal),
|
||||
oldType: "TEXT");
|
||||
|
||||
migrationBuilder.AddCheckConstraint(
|
||||
name: "CK_Trajets_Distance",
|
||||
table: "Trajets",
|
||||
sql: "\"Distance\" IS NULL OR \"Distance\" > 0");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropCheckConstraint(
|
||||
name: "CK_Trajets_Distance",
|
||||
table: "Trajets");
|
||||
|
||||
migrationBuilder.AlterColumn<decimal>(
|
||||
name: "Distance",
|
||||
table: "Trajets",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: 0m,
|
||||
oldClrType: typeof(decimal),
|
||||
oldType: "TEXT",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AddCheckConstraint(
|
||||
name: "CK_Trajets_Distance",
|
||||
table: "Trajets",
|
||||
sql: "\"Distance\" > 0");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+328
@@ -0,0 +1,328 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using TrajetKmTracker.Api.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrajetKmTracker.Api.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260913224032_TrajetGeometrieJson")]
|
||||
partial class TrajetGeometrieJson
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.8");
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.AdresseCache", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Adresse")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DerniereUtilisation")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("GeometrieJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<double>("Latitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<double>("Longitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Adresse")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AdresseCache", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.AdresseNommee", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Adresse")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Denomination")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DenominationNormalisee")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("GeocodageReussi")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double?>("Latitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<double?>("Longitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "DenominationNormalisee" }, "UX_AdresseNommee_Denomination")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AdressesNommees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.MontantRegle", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Annee")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Mois")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Montant")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VehiculeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("VehiculeId");
|
||||
|
||||
b.HasIndex(new[] { "Annee", "Mois", "VehiculeId" }, "UX_MontantsRegles_Periode_Vehicule")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("MontantsRegles", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_MontantsRegles_Mois", "\"Mois\" BETWEEN 1 AND 12");
|
||||
|
||||
t.HasCheckConstraint("CK_MontantsRegles_Montant", "\"Montant\" >= 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Profil", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AdresseDomicile")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("DomicileGeocode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double?>("DomicileLatitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<double?>("DomicileLongitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<string>("Matricule")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Nom")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Prenom")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Id" }, "UX_Profil_Singleton")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Profil", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Trajet", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AdresseArrivee")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AdresseDepart")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Commentaire")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal?>("Distance")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("GeometrieJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Position")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PuissanceFiscale")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VehiculeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Date" }, "IX_Trajets_Date");
|
||||
|
||||
b.HasIndex(new[] { "Date", "Position" }, "IX_Trajets_Date_Position");
|
||||
|
||||
b.HasIndex(new[] { "VehiculeId" }, "IX_Trajets_Vehicule");
|
||||
|
||||
b.ToTable("Trajets", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_Trajets_Distance", "\"Distance\" IS NULL OR \"Distance\" > 0");
|
||||
|
||||
t.HasCheckConstraint("CK_Trajets_PuissanceFiscale", "\"PuissanceFiscale\" > 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.TrancheBareme", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Annee")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Base")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Coefficient")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("KmMax")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("KmMin")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PuissanceFiscale")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Annee", "PuissanceFiscale" }, "IX_TranchesBareme_Annee_PF");
|
||||
|
||||
b.ToTable("TranchesBareme", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_TranchesBareme_Km", "\"KmMin\" <= \"KmMax\"");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Vehicule", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("DateEffet")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Plaque")
|
||||
.IsRequired()
|
||||
.HasMaxLength(15)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PuissanceFiscale")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "DateEffet" }, "IX_Vehicules_DateEffet");
|
||||
|
||||
b.ToTable("Vehicules", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_Vehicules_PuissanceFiscale", "\"PuissanceFiscale\" > 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.MontantRegle", b =>
|
||||
{
|
||||
b.HasOne("TrajetKmTracker.Api.Models.Entities.Vehicule", "Vehicule")
|
||||
.WithMany()
|
||||
.HasForeignKey("VehiculeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Vehicule");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Trajet", b =>
|
||||
{
|
||||
b.HasOne("TrajetKmTracker.Api.Models.Entities.Vehicule", "Vehicule")
|
||||
.WithMany("Trajets")
|
||||
.HasForeignKey("VehiculeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Vehicule");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Vehicule", b =>
|
||||
{
|
||||
b.Navigation("Trajets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrajetKmTracker.Api.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class TrajetGeometrieJson : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "GeometrieJson",
|
||||
table: "Trajets",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GeometrieJson",
|
||||
table: "Trajets");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+333
@@ -0,0 +1,333 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using TrajetKmTracker.Api.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrajetKmTracker.Api.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260913231424_VehiculeDenomination")]
|
||||
partial class VehiculeDenomination
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.8");
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.AdresseCache", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Adresse")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DerniereUtilisation")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("GeometrieJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<double>("Latitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<double>("Longitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Adresse")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AdresseCache", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.AdresseNommee", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Adresse")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Denomination")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DenominationNormalisee")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("GeocodageReussi")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double?>("Latitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<double?>("Longitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "DenominationNormalisee" }, "UX_AdresseNommee_Denomination")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AdressesNommees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.MontantRegle", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Annee")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Mois")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Montant")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VehiculeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("VehiculeId");
|
||||
|
||||
b.HasIndex(new[] { "Annee", "Mois", "VehiculeId" }, "UX_MontantsRegles_Periode_Vehicule")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("MontantsRegles", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_MontantsRegles_Mois", "\"Mois\" BETWEEN 1 AND 12");
|
||||
|
||||
t.HasCheckConstraint("CK_MontantsRegles_Montant", "\"Montant\" >= 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Profil", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AdresseDomicile")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("DomicileGeocode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double?>("DomicileLatitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<double?>("DomicileLongitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<string>("Matricule")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Nom")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Prenom")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Id" }, "UX_Profil_Singleton")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Profil", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Trajet", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AdresseArrivee")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AdresseDepart")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Commentaire")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal?>("Distance")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("GeometrieJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Position")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PuissanceFiscale")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VehiculeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Date" }, "IX_Trajets_Date");
|
||||
|
||||
b.HasIndex(new[] { "Date", "Position" }, "IX_Trajets_Date_Position");
|
||||
|
||||
b.HasIndex(new[] { "VehiculeId" }, "IX_Trajets_Vehicule");
|
||||
|
||||
b.ToTable("Trajets", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_Trajets_Distance", "\"Distance\" IS NULL OR \"Distance\" > 0");
|
||||
|
||||
t.HasCheckConstraint("CK_Trajets_PuissanceFiscale", "\"PuissanceFiscale\" > 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.TrancheBareme", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Annee")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Base")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Coefficient")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("KmMax")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("KmMin")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PuissanceFiscale")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Annee", "PuissanceFiscale" }, "IX_TranchesBareme_Annee_PF");
|
||||
|
||||
b.ToTable("TranchesBareme", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_TranchesBareme_Km", "\"KmMin\" <= \"KmMax\"");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Vehicule", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("DateEffet")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Denomination")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Plaque")
|
||||
.IsRequired()
|
||||
.HasMaxLength(15)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PuissanceFiscale")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "DateEffet" }, "IX_Vehicules_DateEffet");
|
||||
|
||||
b.ToTable("Vehicules", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_Vehicules_PuissanceFiscale", "\"PuissanceFiscale\" > 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.MontantRegle", b =>
|
||||
{
|
||||
b.HasOne("TrajetKmTracker.Api.Models.Entities.Vehicule", "Vehicule")
|
||||
.WithMany()
|
||||
.HasForeignKey("VehiculeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Vehicule");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Trajet", b =>
|
||||
{
|
||||
b.HasOne("TrajetKmTracker.Api.Models.Entities.Vehicule", "Vehicule")
|
||||
.WithMany("Trajets")
|
||||
.HasForeignKey("VehiculeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Vehicule");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Vehicule", b =>
|
||||
{
|
||||
b.Navigation("Trajets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrajetKmTracker.Api.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class VehiculeDenomination : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Denomination",
|
||||
table: "Vehicules",
|
||||
type: "TEXT",
|
||||
maxLength: 100,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
// Véhicules déjà existants : dénomination par défaut = leur plaque, pour éviter
|
||||
// une dénomination vide (le champ est désormais obligatoire côté saisie).
|
||||
migrationBuilder.Sql(
|
||||
"UPDATE \"Vehicules\" SET \"Denomination\" = \"Plaque\" " +
|
||||
"WHERE \"Denomination\" IS NULL OR \"Denomination\" = '';");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Denomination",
|
||||
table: "Vehicules");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+333
@@ -0,0 +1,333 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using TrajetKmTracker.Api.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrajetKmTracker.Api.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260913233718_TrancheBaremeKmMaxNullable")]
|
||||
partial class TrancheBaremeKmMaxNullable
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.8");
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.AdresseCache", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Adresse")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DerniereUtilisation")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("GeometrieJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<double>("Latitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<double>("Longitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Adresse")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AdresseCache", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.AdresseNommee", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Adresse")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Denomination")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DenominationNormalisee")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("GeocodageReussi")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double?>("Latitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<double?>("Longitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "DenominationNormalisee" }, "UX_AdresseNommee_Denomination")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AdressesNommees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.MontantRegle", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Annee")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Mois")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Montant")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VehiculeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("VehiculeId");
|
||||
|
||||
b.HasIndex(new[] { "Annee", "Mois", "VehiculeId" }, "UX_MontantsRegles_Periode_Vehicule")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("MontantsRegles", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_MontantsRegles_Mois", "\"Mois\" BETWEEN 1 AND 12");
|
||||
|
||||
t.HasCheckConstraint("CK_MontantsRegles_Montant", "\"Montant\" >= 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Profil", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AdresseDomicile")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("DomicileGeocode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double?>("DomicileLatitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<double?>("DomicileLongitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<string>("Matricule")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Nom")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Prenom")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Id" }, "UX_Profil_Singleton")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Profil", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Trajet", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AdresseArrivee")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AdresseDepart")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Commentaire")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal?>("Distance")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("GeometrieJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Position")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PuissanceFiscale")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VehiculeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Date" }, "IX_Trajets_Date");
|
||||
|
||||
b.HasIndex(new[] { "Date", "Position" }, "IX_Trajets_Date_Position");
|
||||
|
||||
b.HasIndex(new[] { "VehiculeId" }, "IX_Trajets_Vehicule");
|
||||
|
||||
b.ToTable("Trajets", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_Trajets_Distance", "\"Distance\" IS NULL OR \"Distance\" > 0");
|
||||
|
||||
t.HasCheckConstraint("CK_Trajets_PuissanceFiscale", "\"PuissanceFiscale\" > 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.TrancheBareme", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Annee")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Base")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Coefficient")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal?>("KmMax")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("KmMin")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PuissanceFiscale")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Annee", "PuissanceFiscale" }, "IX_TranchesBareme_Annee_PF");
|
||||
|
||||
b.ToTable("TranchesBareme", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_TranchesBareme_Km", "\"KmMin\" <= \"KmMax\"");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Vehicule", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("DateEffet")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Denomination")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Plaque")
|
||||
.IsRequired()
|
||||
.HasMaxLength(15)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PuissanceFiscale")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "DateEffet" }, "IX_Vehicules_DateEffet");
|
||||
|
||||
b.ToTable("Vehicules", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_Vehicules_PuissanceFiscale", "\"PuissanceFiscale\" > 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.MontantRegle", b =>
|
||||
{
|
||||
b.HasOne("TrajetKmTracker.Api.Models.Entities.Vehicule", "Vehicule")
|
||||
.WithMany()
|
||||
.HasForeignKey("VehiculeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Vehicule");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Trajet", b =>
|
||||
{
|
||||
b.HasOne("TrajetKmTracker.Api.Models.Entities.Vehicule", "Vehicule")
|
||||
.WithMany("Trajets")
|
||||
.HasForeignKey("VehiculeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Vehicule");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Vehicule", b =>
|
||||
{
|
||||
b.Navigation("Trajets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrajetKmTracker.Api.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class TrancheBaremeKmMaxNullable : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<decimal>(
|
||||
name: "KmMax",
|
||||
table: "TranchesBareme",
|
||||
type: "TEXT",
|
||||
nullable: true,
|
||||
oldClrType: typeof(decimal),
|
||||
oldType: "TEXT");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<decimal>(
|
||||
name: "KmMax",
|
||||
table: "TranchesBareme",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: 0m,
|
||||
oldClrType: typeof(decimal),
|
||||
oldType: "TEXT",
|
||||
oldNullable: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+333
@@ -0,0 +1,333 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using TrajetKmTracker.Api.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrajetKmTracker.Api.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260913234022_TrancheBaremeKmCheckExclusif")]
|
||||
partial class TrancheBaremeKmCheckExclusif
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.8");
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.AdresseCache", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Adresse")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DerniereUtilisation")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("GeometrieJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<double>("Latitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<double>("Longitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Adresse")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AdresseCache", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.AdresseNommee", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Adresse")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Denomination")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DenominationNormalisee")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("GeocodageReussi")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double?>("Latitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<double?>("Longitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "DenominationNormalisee" }, "UX_AdresseNommee_Denomination")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AdressesNommees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.MontantRegle", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Annee")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Mois")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Montant")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VehiculeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("VehiculeId");
|
||||
|
||||
b.HasIndex(new[] { "Annee", "Mois", "VehiculeId" }, "UX_MontantsRegles_Periode_Vehicule")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("MontantsRegles", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_MontantsRegles_Mois", "\"Mois\" BETWEEN 1 AND 12");
|
||||
|
||||
t.HasCheckConstraint("CK_MontantsRegles_Montant", "\"Montant\" >= 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Profil", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AdresseDomicile")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("DomicileGeocode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double?>("DomicileLatitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<double?>("DomicileLongitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<string>("Matricule")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Nom")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Prenom")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Id" }, "UX_Profil_Singleton")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Profil", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Trajet", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AdresseArrivee")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AdresseDepart")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Commentaire")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal?>("Distance")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("GeometrieJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Position")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PuissanceFiscale")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VehiculeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Date" }, "IX_Trajets_Date");
|
||||
|
||||
b.HasIndex(new[] { "Date", "Position" }, "IX_Trajets_Date_Position");
|
||||
|
||||
b.HasIndex(new[] { "VehiculeId" }, "IX_Trajets_Vehicule");
|
||||
|
||||
b.ToTable("Trajets", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_Trajets_Distance", "\"Distance\" IS NULL OR \"Distance\" > 0");
|
||||
|
||||
t.HasCheckConstraint("CK_Trajets_PuissanceFiscale", "\"PuissanceFiscale\" > 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.TrancheBareme", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Annee")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Base")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Coefficient")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal?>("KmMax")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("KmMin")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PuissanceFiscale")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Annee", "PuissanceFiscale" }, "IX_TranchesBareme_Annee_PF");
|
||||
|
||||
b.ToTable("TranchesBareme", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_TranchesBareme_Km", "\"KmMax\" IS NULL OR \"KmMin\" < \"KmMax\"");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Vehicule", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("DateEffet")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Denomination")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Plaque")
|
||||
.IsRequired()
|
||||
.HasMaxLength(15)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PuissanceFiscale")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "DateEffet" }, "IX_Vehicules_DateEffet");
|
||||
|
||||
b.ToTable("Vehicules", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_Vehicules_PuissanceFiscale", "\"PuissanceFiscale\" > 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.MontantRegle", b =>
|
||||
{
|
||||
b.HasOne("TrajetKmTracker.Api.Models.Entities.Vehicule", "Vehicule")
|
||||
.WithMany()
|
||||
.HasForeignKey("VehiculeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Vehicule");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Trajet", b =>
|
||||
{
|
||||
b.HasOne("TrajetKmTracker.Api.Models.Entities.Vehicule", "Vehicule")
|
||||
.WithMany("Trajets")
|
||||
.HasForeignKey("VehiculeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Vehicule");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Vehicule", b =>
|
||||
{
|
||||
b.Navigation("Trajets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrajetKmTracker.Api.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class TrancheBaremeKmCheckExclusif : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropCheckConstraint(
|
||||
name: "CK_TranchesBareme_Km",
|
||||
table: "TranchesBareme");
|
||||
|
||||
migrationBuilder.AddCheckConstraint(
|
||||
name: "CK_TranchesBareme_Km",
|
||||
table: "TranchesBareme",
|
||||
sql: "\"KmMax\" IS NULL OR \"KmMin\" < \"KmMax\"");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropCheckConstraint(
|
||||
name: "CK_TranchesBareme_Km",
|
||||
table: "TranchesBareme");
|
||||
|
||||
migrationBuilder.AddCheckConstraint(
|
||||
name: "CK_TranchesBareme_Km",
|
||||
table: "TranchesBareme",
|
||||
sql: "\"KmMin\" <= \"KmMax\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
backend/TrajetKmTracker.Api/Data/Migrations/20260913234153_TrancheBaremeKmCheckNumerique.Designer.cs
Generated
+333
@@ -0,0 +1,333 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using TrajetKmTracker.Api.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrajetKmTracker.Api.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260913234153_TrancheBaremeKmCheckNumerique")]
|
||||
partial class TrancheBaremeKmCheckNumerique
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.8");
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.AdresseCache", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Adresse")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DerniereUtilisation")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("GeometrieJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<double>("Latitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<double>("Longitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Adresse")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AdresseCache", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.AdresseNommee", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Adresse")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Denomination")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DenominationNormalisee")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("GeocodageReussi")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double?>("Latitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<double?>("Longitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "DenominationNormalisee" }, "UX_AdresseNommee_Denomination")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AdressesNommees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.MontantRegle", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Annee")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Mois")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Montant")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VehiculeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("VehiculeId");
|
||||
|
||||
b.HasIndex(new[] { "Annee", "Mois", "VehiculeId" }, "UX_MontantsRegles_Periode_Vehicule")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("MontantsRegles", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_MontantsRegles_Mois", "\"Mois\" BETWEEN 1 AND 12");
|
||||
|
||||
t.HasCheckConstraint("CK_MontantsRegles_Montant", "\"Montant\" >= 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Profil", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AdresseDomicile")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("DomicileGeocode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double?>("DomicileLatitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<double?>("DomicileLongitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<string>("Matricule")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Nom")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Prenom")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Id" }, "UX_Profil_Singleton")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Profil", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Trajet", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AdresseArrivee")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AdresseDepart")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Commentaire")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal?>("Distance")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("GeometrieJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Position")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PuissanceFiscale")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VehiculeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Date" }, "IX_Trajets_Date");
|
||||
|
||||
b.HasIndex(new[] { "Date", "Position" }, "IX_Trajets_Date_Position");
|
||||
|
||||
b.HasIndex(new[] { "VehiculeId" }, "IX_Trajets_Vehicule");
|
||||
|
||||
b.ToTable("Trajets", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_Trajets_Distance", "\"Distance\" IS NULL OR \"Distance\" > 0");
|
||||
|
||||
t.HasCheckConstraint("CK_Trajets_PuissanceFiscale", "\"PuissanceFiscale\" > 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.TrancheBareme", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Annee")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Base")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Coefficient")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal?>("KmMax")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("KmMin")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PuissanceFiscale")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Annee", "PuissanceFiscale" }, "IX_TranchesBareme_Annee_PF");
|
||||
|
||||
b.ToTable("TranchesBareme", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_TranchesBareme_Km", "\"KmMax\" IS NULL OR CAST(\"KmMin\" AS REAL) < CAST(\"KmMax\" AS REAL)");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Vehicule", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("DateEffet")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Denomination")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Plaque")
|
||||
.IsRequired()
|
||||
.HasMaxLength(15)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PuissanceFiscale")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "DateEffet" }, "IX_Vehicules_DateEffet");
|
||||
|
||||
b.ToTable("Vehicules", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_Vehicules_PuissanceFiscale", "\"PuissanceFiscale\" > 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.MontantRegle", b =>
|
||||
{
|
||||
b.HasOne("TrajetKmTracker.Api.Models.Entities.Vehicule", "Vehicule")
|
||||
.WithMany()
|
||||
.HasForeignKey("VehiculeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Vehicule");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Trajet", b =>
|
||||
{
|
||||
b.HasOne("TrajetKmTracker.Api.Models.Entities.Vehicule", "Vehicule")
|
||||
.WithMany("Trajets")
|
||||
.HasForeignKey("VehiculeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Vehicule");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Vehicule", b =>
|
||||
{
|
||||
b.Navigation("Trajets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrajetKmTracker.Api.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class TrancheBaremeKmCheckNumerique : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropCheckConstraint(
|
||||
name: "CK_TranchesBareme_Km",
|
||||
table: "TranchesBareme");
|
||||
|
||||
migrationBuilder.AddCheckConstraint(
|
||||
name: "CK_TranchesBareme_Km",
|
||||
table: "TranchesBareme",
|
||||
sql: "\"KmMax\" IS NULL OR CAST(\"KmMin\" AS REAL) < CAST(\"KmMax\" AS REAL)");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropCheckConstraint(
|
||||
name: "CK_TranchesBareme_Km",
|
||||
table: "TranchesBareme");
|
||||
|
||||
migrationBuilder.AddCheckConstraint(
|
||||
name: "CK_TranchesBareme_Km",
|
||||
table: "TranchesBareme",
|
||||
sql: "\"KmMax\" IS NULL OR \"KmMin\" < \"KmMax\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using TrajetKmTracker.Api.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrajetKmTracker.Api.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
partial class AppDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.8");
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.AdresseCache", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Adresse")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DerniereUtilisation")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("GeometrieJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<double>("Latitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<double>("Longitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Adresse")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AdresseCache", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.AdresseNommee", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Adresse")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Denomination")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DenominationNormalisee")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("GeocodageReussi")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double?>("Latitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<double?>("Longitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "DenominationNormalisee" }, "UX_AdresseNommee_Denomination")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AdressesNommees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.MontantRegle", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Annee")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Mois")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Montant")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VehiculeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("VehiculeId");
|
||||
|
||||
b.HasIndex(new[] { "Annee", "Mois", "VehiculeId" }, "UX_MontantsRegles_Periode_Vehicule")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("MontantsRegles", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_MontantsRegles_Mois", "\"Mois\" BETWEEN 1 AND 12");
|
||||
|
||||
t.HasCheckConstraint("CK_MontantsRegles_Montant", "\"Montant\" >= 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Profil", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AdresseDomicile")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("DomicileGeocode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double?>("DomicileLatitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<double?>("DomicileLongitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<string>("Matricule")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Nom")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Prenom")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Id" }, "UX_Profil_Singleton")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Profil", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Trajet", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AdresseArrivee")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AdresseDepart")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Commentaire")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal?>("Distance")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("GeometrieJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Position")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PuissanceFiscale")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VehiculeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Date" }, "IX_Trajets_Date");
|
||||
|
||||
b.HasIndex(new[] { "Date", "Position" }, "IX_Trajets_Date_Position");
|
||||
|
||||
b.HasIndex(new[] { "VehiculeId" }, "IX_Trajets_Vehicule");
|
||||
|
||||
b.ToTable("Trajets", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_Trajets_Distance", "\"Distance\" IS NULL OR \"Distance\" > 0");
|
||||
|
||||
t.HasCheckConstraint("CK_Trajets_PuissanceFiscale", "\"PuissanceFiscale\" > 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.TrancheBareme", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Annee")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Base")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Coefficient")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal?>("KmMax")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("KmMin")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PuissanceFiscale")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Annee", "PuissanceFiscale" }, "IX_TranchesBareme_Annee_PF");
|
||||
|
||||
b.ToTable("TranchesBareme", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_TranchesBareme_Km", "\"KmMax\" IS NULL OR CAST(\"KmMin\" AS REAL) < CAST(\"KmMax\" AS REAL)");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Vehicule", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("DateEffet")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Denomination")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Plaque")
|
||||
.IsRequired()
|
||||
.HasMaxLength(15)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PuissanceFiscale")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "DateEffet" }, "IX_Vehicules_DateEffet");
|
||||
|
||||
b.ToTable("Vehicules", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_Vehicules_PuissanceFiscale", "\"PuissanceFiscale\" > 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.MontantRegle", b =>
|
||||
{
|
||||
b.HasOne("TrajetKmTracker.Api.Models.Entities.Vehicule", "Vehicule")
|
||||
.WithMany()
|
||||
.HasForeignKey("VehiculeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Vehicule");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Trajet", b =>
|
||||
{
|
||||
b.HasOne("TrajetKmTracker.Api.Models.Entities.Vehicule", "Vehicule")
|
||||
.WithMany("Trajets")
|
||||
.HasForeignKey("VehiculeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Vehicule");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Vehicule", b =>
|
||||
{
|
||||
b.Navigation("Trajets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
// Résultat de recherche BAN (proxied via backend si nécessaire)
|
||||
public record BANAdresseDto(
|
||||
string Label,
|
||||
double Latitude,
|
||||
double Longitude,
|
||||
string Postcode,
|
||||
string City
|
||||
);
|
||||
|
||||
// Suggestion autocomplétion
|
||||
public record AdresseCacheDto(
|
||||
string Adresse,
|
||||
double Latitude,
|
||||
double Longitude
|
||||
);
|
||||
|
||||
// Entrée complète du Cache_Adresses (lookup exact par la création de Trajet)
|
||||
public record AdresseCacheEntreeDto(
|
||||
string Adresse,
|
||||
double Latitude,
|
||||
double Longitude,
|
||||
string? GeometrieJson
|
||||
);
|
||||
|
||||
// Carnet d'adresses — Adresse_Nommee retournée par le backend
|
||||
public record AdresseNommeeDto(
|
||||
int Id,
|
||||
string Denomination,
|
||||
string Adresse,
|
||||
double? Latitude, // null si le géocodage a échoué
|
||||
double? Longitude // null si le géocodage a échoué
|
||||
);
|
||||
|
||||
// Carnet d'adresses — création / mise à jour d'une Adresse_Nommee
|
||||
public record SaveAdresseNommeeDto(
|
||||
string Denomination, // 1 à 100 car., unique après normalisation
|
||||
string Adresse, // 1 à 255 car.
|
||||
double? Latitude, // fournies par le frontend si sélection BAN
|
||||
double? Longitude
|
||||
);
|
||||
@@ -0,0 +1,63 @@
|
||||
namespace TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
// Barème
|
||||
public record TrancheBaremeDto(
|
||||
int Id,
|
||||
int Annee,
|
||||
int PuissanceFiscale,
|
||||
decimal KmMin,
|
||||
decimal? KmMax, // null = illimité (borne max exclusive : [KmMin, KmMax[)
|
||||
decimal Coefficient,
|
||||
decimal Base
|
||||
);
|
||||
|
||||
public record SaveTrancheBaremeDto(
|
||||
int Annee,
|
||||
int PuissanceFiscale,
|
||||
decimal KmMin,
|
||||
decimal? KmMax, // null = illimité (borne max exclusive : [KmMin, KmMax[)
|
||||
decimal Coefficient,
|
||||
decimal Base
|
||||
);
|
||||
|
||||
// Montant réglé
|
||||
public record MontantRegleDto(
|
||||
int Annee,
|
||||
int Mois,
|
||||
int VehiculeId,
|
||||
decimal Montant
|
||||
);
|
||||
|
||||
// Indication de versement mensuel (informatif) affichée à la saisie
|
||||
public record MontantPrevisionnelDto(
|
||||
int Annee,
|
||||
int Mois,
|
||||
int VehiculeId,
|
||||
decimal TotalAnnuelPartielKm,
|
||||
decimal MontantBaremePlein, // barème plein sur le total partiel
|
||||
decimal MontantPrevisionnel // = 80% (indicatif uniquement)
|
||||
);
|
||||
|
||||
// Ligne mensuelle de la synthèse annuelle (aligne sur la synthèse réelle)
|
||||
public record LigneSyntheseMoisDto(
|
||||
int Mois,
|
||||
decimal KmMois, // kilomètres parcourus dans le mois
|
||||
decimal FraisPaye, // Montant_Regle du mois (saisi)
|
||||
decimal FraisReelMensuel // km_du_mois × coefficient de la tranche annuelle
|
||||
);
|
||||
|
||||
// Synthèse annuelle de régularisation pour un véhicule (page 1 du rapport)
|
||||
public record RegularisationAnnuelleDto(
|
||||
int Annee,
|
||||
int VehiculeId,
|
||||
string Plaque,
|
||||
int PuissanceFiscale,
|
||||
decimal CoefficientTranche, // coefficient de la tranche annuelle applicable
|
||||
decimal BaseTranche, // base de la tranche annuelle applicable
|
||||
string TrancheLibelle, // ex. "5 001 à 20 000 km — D2"
|
||||
IReadOnlyList<LigneSyntheseMoisDto> Mois, // 12 lignes
|
||||
decimal TotalKmAnnuel,
|
||||
decimal TotalPaye, // somme des Montant_Regle
|
||||
decimal MontantReel, // barème plein : total_km × coef + base
|
||||
decimal Regularisation // MontantReel − TotalPaye
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
/// <summary>Résultat de la copie du dernier barème vers une année cible.</summary>
|
||||
/// <param name="TranchesCopiees">Nombre de tranches copiées (0 si aucun barème antérieur).</param>
|
||||
public record CopieBaremeResultDto(int TranchesCopiees);
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
// Profil
|
||||
public record ProfilDto(
|
||||
string? Nom,
|
||||
string? Prenom,
|
||||
string? Matricule,
|
||||
string? AdresseDomicile,
|
||||
bool DomicileGeocode
|
||||
);
|
||||
|
||||
public record SaveProfilDto(
|
||||
string? Nom,
|
||||
string? Prenom,
|
||||
string? Matricule, // max 8 car.
|
||||
string AdresseDomicile
|
||||
);
|
||||
|
||||
// État de configuration du profil (onboarding première utilisation)
|
||||
public record EtatProfilDto(
|
||||
bool EstConfigure // true ssi Profil.Nom et Profil.Prenom non vides ET au moins un Vehicule existe
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
/// <summary>Résultat du recalcul en masse des distances des trajets.</summary>
|
||||
/// <param name="TrajetsRecalcules">Nombre de trajets dont la distance a été recalculée.</param>
|
||||
public record RecalculDistancesResultDto(int TrajetsRecalcules);
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
// Résumé mensuel
|
||||
public record ResumeMensuelDto(
|
||||
int Annee,
|
||||
int Mois,
|
||||
IReadOnlyList<TrajetDto> Trajets,
|
||||
decimal TotalKm // somme distances, 2 décimales
|
||||
);
|
||||
|
||||
// Résumé annuel
|
||||
public record ResumeAnnuelDto(
|
||||
int Annee,
|
||||
IReadOnlyList<ResumeMensuelDto> Mois, // toujours 12 éléments
|
||||
decimal TotalKm
|
||||
);
|
||||
|
||||
// Totaux affichés dans le Panneau_Totaux
|
||||
public record TotauxDto(
|
||||
DateOnly Date,
|
||||
int VehiculeId, // véhicule sur lequel portent les totaux
|
||||
decimal TotalJour,
|
||||
decimal TotalMois,
|
||||
decimal TotalAnnuelPartiel
|
||||
);
|
||||
@@ -0,0 +1,64 @@
|
||||
namespace TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
// Création — les coordonnées sont transmises par le Frontend (issues de la sélection BAN)
|
||||
public record CreateTrajetDto(
|
||||
string Date, // JJ/MM/AAAA en entrée
|
||||
string AdresseDepart,
|
||||
double? DepartLatitude, // null si adresse saisie manuellement sans BAN
|
||||
double? DepartLongitude,
|
||||
string AdresseArrivee,
|
||||
double? ArriveeLatitude, // null si adresse saisie manuellement sans BAN
|
||||
double? ArriveeLongitude,
|
||||
string? Commentaire, // optionnel, max 500 car.
|
||||
int? VehiculeId // optionnel ; null → véhicule courant à la date du trajet
|
||||
);
|
||||
|
||||
// Mise à jour
|
||||
public record UpdateTrajetDto(
|
||||
string Date,
|
||||
string AdresseDepart,
|
||||
double? DepartLatitude,
|
||||
double? DepartLongitude,
|
||||
string AdresseArrivee,
|
||||
double? ArriveeLatitude,
|
||||
double? ArriveeLongitude,
|
||||
string? Commentaire,
|
||||
int? VehiculeId // optionnel ; null → véhicule courant à la date du trajet
|
||||
);
|
||||
|
||||
// Réponse trajet
|
||||
public record TrajetDto(
|
||||
int Id,
|
||||
DateOnly Date,
|
||||
string AdresseDepart,
|
||||
string AdresseArrivee,
|
||||
decimal? Distance, // km, 2 décimales ; null si la distance n'a pas pu être calculée (Exigence 2.4)
|
||||
string? Commentaire,
|
||||
decimal Position, // rang dans la journée
|
||||
int VehiculeId,
|
||||
int PuissanceFiscale
|
||||
);
|
||||
|
||||
// Trajet enrichi pour la carte
|
||||
public record TrajetCarteDto(
|
||||
int Id,
|
||||
DateOnly Date,
|
||||
string AdresseDepart,
|
||||
string AdresseArrivee,
|
||||
decimal Distance,
|
||||
double? DepartLatitude,
|
||||
double? DepartLongitude,
|
||||
double? ArriveeLatitude,
|
||||
double? ArriveeLongitude,
|
||||
string? GeometrieJson // GeoJSON LineString ou null
|
||||
);
|
||||
|
||||
// Réordonnancement manuel des trajets d'une journée
|
||||
public record ReordonnerTrajetsDto(
|
||||
IReadOnlyList<TrajetPositionDto> Trajets
|
||||
);
|
||||
|
||||
public record TrajetPositionDto(
|
||||
int Id,
|
||||
decimal Position
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
// Véhicules
|
||||
public record VehiculeDto(
|
||||
int Id,
|
||||
string Denomination,
|
||||
string Plaque,
|
||||
int PuissanceFiscale,
|
||||
DateOnly DateEffet
|
||||
);
|
||||
|
||||
public record CreateVehiculeDto(
|
||||
string Denomination,
|
||||
string Plaque,
|
||||
int PuissanceFiscale,
|
||||
string DateEffet // JJ/MM/AAAA, <= date du jour
|
||||
);
|
||||
|
||||
// Réponse à la création d'un véhicule : indique s'il faut proposer une réaffectation
|
||||
public record VehiculeCreeDto(
|
||||
VehiculeDto Vehicule,
|
||||
bool ReaffectationProposee, // true si des trajets >= DateEffet ont une autre puissance fiscale
|
||||
int NombreTrajetsConcernes
|
||||
);
|
||||
|
||||
// Requête de réaffectation
|
||||
public record ReaffectationDto(
|
||||
int VehiculeId, // nouveau véhicule
|
||||
bool Accepter // true = réaffecter les trajets >= DateEffet ; false = ne rien changer
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TrajetKmTracker.Api.Models.Entities;
|
||||
|
||||
/// <summary>Cache des adresses géocodées via ORS.</summary>
|
||||
public class AdresseCache
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required, MaxLength(255)]
|
||||
public string Adresse { get; set; } = string.Empty; // texte normalisé
|
||||
|
||||
public double Latitude { get; set; } // précision 6 décimales
|
||||
public double Longitude { get; set; } // précision 6 décimales
|
||||
|
||||
/// <summary>Géométrie GeoJSON de l'itinéraire (null si indisponible).</summary>
|
||||
public string? GeometrieJson { get; set; }
|
||||
|
||||
public DateTime DerniereUtilisation { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TrajetKmTracker.Api.Models.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Adresse enregistrée dans le Carnet_Adresses, identifiée par une Dénomination unique
|
||||
/// et réutilisable par sa Dénomination lors de la saisie/insertion d'un Trajet.
|
||||
/// </summary>
|
||||
public class AdresseNommee
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>Libellé libre et unique identifiant l'adresse (1 à 100 caractères).</summary>
|
||||
[Required, MaxLength(100)]
|
||||
public string Denomination { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Forme normalisée de la Dénomination (trim + minuscules + sans accents) servant de clé
|
||||
/// d'unicité via l'index unique UX_AdresseNommee_Denomination. Calculée par le backend.
|
||||
/// </summary>
|
||||
[Required, MaxLength(100)]
|
||||
public string DenominationNormalisee { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Texte d'adresse résolu via l'autocomplétion BAN (1 à 255 caractères).</summary>
|
||||
[Required, MaxLength(255)]
|
||||
public string Adresse { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Latitude (précision 6 décimales) ; null si le géocodage a échoué.</summary>
|
||||
public double? Latitude { get; set; }
|
||||
|
||||
/// <summary>Longitude (précision 6 décimales) ; null si le géocodage a échoué.</summary>
|
||||
public double? Longitude { get; set; }
|
||||
|
||||
/// <summary>Vrai si les coordonnées ont été obtenues (cache ou BAN) ; faux sinon.</summary>
|
||||
public bool GeocodageReussi { get; set; } = false;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TrajetKmTracker.Api.Models.Entities;
|
||||
|
||||
/// <summary>Montant réglé (remboursement reçu) pour un mois, une année et un véhicule.</summary>
|
||||
public class MontantRegle
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
public int Annee { get; set; }
|
||||
|
||||
[Required]
|
||||
public int Mois { get; set; } // 1–12
|
||||
|
||||
[Required]
|
||||
public int VehiculeId { get; set; }
|
||||
public Vehicule? Vehicule { get; set; }
|
||||
|
||||
/// <summary>Montant réglé en euros (>= 0), deux décimales.</summary>
|
||||
[Required]
|
||||
public decimal Montant { get; set; }
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TrajetKmTracker.Api.Models.Entities;
|
||||
|
||||
/// <summary>Profil utilisateur (singleton — une seule ligne).</summary>
|
||||
public class Profil
|
||||
{
|
||||
public int Id { get; set; } = 1; // toujours 1
|
||||
|
||||
[MaxLength(100)]
|
||||
public string? Nom { get; set; }
|
||||
|
||||
[MaxLength(100)]
|
||||
public string? Prenom { get; set; }
|
||||
|
||||
/// <summary>Matricule (8 caractères maximum).</summary>
|
||||
[MaxLength(8)]
|
||||
public string? Matricule { get; set; }
|
||||
|
||||
[MaxLength(255)]
|
||||
public string? AdresseDomicile { get; set; }
|
||||
|
||||
public double? DomicileLatitude { get; set; }
|
||||
public double? DomicileLongitude { get; set; }
|
||||
public bool DomicileGeocode { get; set; } = false;
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TrajetKmTracker.Api.Models.Entities;
|
||||
|
||||
/// <summary>Trajet kilométrique saisi par l'utilisateur.</summary>
|
||||
public class Trajet
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
public DateOnly Date { get; set; }
|
||||
|
||||
[Required, MaxLength(255)]
|
||||
public string AdresseDepart { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(255)]
|
||||
public string AdresseArrivee { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Distance en kilomètres, arrondie à 2 décimales, strictement positive lorsqu'elle est
|
||||
/// connue. <c>null</c> lorsque la distance n'a pas pu être calculée (aucune coordonnée
|
||||
/// résoluble via le Cache_Adresses / la BAN) : le Trajet est alors persisté avec une
|
||||
/// Distance nulle et un avertissement (Exigence 2.4). La modification ultérieure peut
|
||||
/// recalculer la Distance si de nouvelles coordonnées deviennent disponibles (Exigence 5.10).
|
||||
/// </summary>
|
||||
public decimal? Distance { get; set; }
|
||||
|
||||
/// <summary>Commentaire optionnel (0–500 caractères).</summary>
|
||||
[MaxLength(500)]
|
||||
public string? Commentaire { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Géométrie GeoJSON (LineString) de l'itinéraire routier départ→arrivée, telle que
|
||||
/// renvoyée par OpenRouteService, pour le tracé exact sur la carte. <c>null</c> lorsque
|
||||
/// l'itinéraire routier n'a pas pu être obtenu (fallback vol d'oiseau) : la carte trace
|
||||
/// alors une ligne droite. La géométrie dépend de la PAIRE départ→arrivée, c'est pourquoi
|
||||
/// elle est portée par le Trajet et non par le Cache_Adresses (indexé par adresse seule).
|
||||
/// </summary>
|
||||
public string? GeometrieJson { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Rang du trajet dans la liste de la journée. Valeur décimale (ex. 1.0, 1.5, 2.0)
|
||||
/// permettant l'insertion sans réordonnancement systématique.
|
||||
/// Trié par ordre croissant pour l'affichage.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public decimal Position { get; set; }
|
||||
|
||||
/// <summary>Référence au véhicule associé au trajet (véhicule courant à la date de création par défaut).</summary>
|
||||
[Required]
|
||||
public int VehiculeId { get; set; }
|
||||
public Vehicule? Vehicule { get; set; }
|
||||
|
||||
/// <summary>Puissance fiscale (CV) du véhicule au moment de l'association ; stockée pour l'historique.</summary>
|
||||
[Required]
|
||||
public int PuissanceFiscale { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TrajetKmTracker.Api.Models.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Tranche du barème kilométrique URSSAF pour une année et une puissance fiscale.
|
||||
/// Trois tranches par (année, puissance fiscale) : ≤5000 km, 5001–20000 km, >20000 km.
|
||||
/// </summary>
|
||||
public class TrancheBareme
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
public int Annee { get; set; }
|
||||
|
||||
[Required]
|
||||
public int PuissanceFiscale { get; set; }
|
||||
|
||||
/// <summary>Borne minimale de kilométrage annuel (incluse).</summary>
|
||||
[Required]
|
||||
public decimal KmMin { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Borne maximale de kilométrage annuel, EXCLUSIVE : la tranche couvre [KmMin, KmMax[.
|
||||
/// <c>null</c> signifie « illimité » (dernière tranche, sans borne supérieure).
|
||||
/// </summary>
|
||||
public decimal? KmMax { get; set; }
|
||||
|
||||
/// <summary>Coefficient en euros par kilomètre.</summary>
|
||||
[Required]
|
||||
public decimal Coefficient { get; set; }
|
||||
|
||||
/// <summary>Base fixe en euros ajoutée au montant (nulle pour les tranches 1 et 3). Formule : km × Coefficient + Base.</summary>
|
||||
public decimal Base { get; set; } = 0m;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TrajetKmTracker.Api.Models.Entities;
|
||||
|
||||
/// <summary>Véhicule de l'utilisateur avec date d'effet. Historique conservé.</summary>
|
||||
public class Vehicule
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>Dénomination libre du véhicule (ex. « Voiture personnelle »), obligatoire (1–100 caractères).</summary>
|
||||
[Required, MaxLength(100)]
|
||||
public string Denomination { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Plaque d'immatriculation (1–15 caractères).</summary>
|
||||
[Required, MaxLength(15)]
|
||||
public string Plaque { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Puissance fiscale en chevaux fiscaux (entier positif).</summary>
|
||||
[Required]
|
||||
public int PuissanceFiscale { get; set; }
|
||||
|
||||
/// <summary>Date à partir de laquelle ce véhicule devient le véhicule courant (<= date du jour).</summary>
|
||||
[Required]
|
||||
public DateOnly DateEffet { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public ICollection<Trajet> Trajets { get; set; } = new List<Trajet>();
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using FluentValidation;
|
||||
using FluentValidation.AspNetCore;
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrajetKmTracker.Api.Data;
|
||||
using TrajetKmTracker.Api.Validators;
|
||||
|
||||
using TrajetKmTracker.Api.Configuration;
|
||||
using TrajetKmTracker.Api.Repositories;
|
||||
using TrajetKmTracker.Api.Services;
|
||||
|
||||
// Licence QuestPDF (usage communautaire, gratuit) — requise avant toute génération de PDF.
|
||||
QuestPDF.Settings.License = QuestPDF.Infrastructure.LicenseType.Community;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// --- Options de configuration liées depuis appsettings.json ---
|
||||
builder.Services.Configure<BANOptions>(
|
||||
builder.Configuration.GetSection(BANOptions.SectionName));
|
||||
builder.Services.Configure<ORSOptions>(
|
||||
builder.Configuration.GetSection(ORSOptions.SectionName));
|
||||
|
||||
// --- Client typé BAN (Base Adresses Nationale) ---
|
||||
// BaseAddress et Timeout proviennent de BANOptions (appsettings.json).
|
||||
var banOptions = builder.Configuration
|
||||
.GetSection(BANOptions.SectionName)
|
||||
.Get<BANOptions>() ?? new BANOptions();
|
||||
|
||||
builder.Services.AddHttpClient<IBANClient, BANClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(banOptions.BaseUrl);
|
||||
client.Timeout = TimeSpan.FromSeconds(banOptions.TimeoutSeconds);
|
||||
});
|
||||
|
||||
// --- Client typé ORS (OpenRouteService) ---
|
||||
// BaseAddress, Timeout et en-tête de clé d'API proviennent de ORSOptions (appsettings.json).
|
||||
var orsOptions = builder.Configuration
|
||||
.GetSection(ORSOptions.SectionName)
|
||||
.Get<ORSOptions>() ?? new ORSOptions();
|
||||
|
||||
builder.Services.AddHttpClient<IORSClient, ORSClient>(client =>
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(orsOptions.BaseUrl))
|
||||
{
|
||||
// Le slash final garantit la résolution correcte des chemins relatifs.
|
||||
var baseUrl = orsOptions.BaseUrl.EndsWith('/')
|
||||
? orsOptions.BaseUrl
|
||||
: orsOptions.BaseUrl + "/";
|
||||
client.BaseAddress = new Uri(baseUrl);
|
||||
}
|
||||
|
||||
client.Timeout = TimeSpan.FromSeconds(orsOptions.TimeoutSeconds);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(orsOptions.ApiKey))
|
||||
{
|
||||
// ORS attend la clé brute dans l'en-tête Authorization (sans schéma « Bearer »).
|
||||
// Les clés ORS (JWT) contiennent des caractères refusés par la validation stricte
|
||||
// de DefaultRequestHeaders.Add ; on utilise TryAddWithoutValidation pour l'insérer
|
||||
// telle quelle et éviter une FormatException à la création du client.
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", orsOptions.ApiKey);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Calculateur de distance (orchestration BAN + ORS + fallback Haversine) ---
|
||||
builder.Services.AddScoped<IDistanceCalculator, DistanceCalculatorService>();
|
||||
|
||||
// --- Cache des adresses géocodées ---
|
||||
builder.Services.AddScoped<IAdresseCacheRepository, AdresseCacheRepository>();
|
||||
builder.Services.AddScoped<IAdresseCacheService, AdresseCacheService>();
|
||||
|
||||
// --- Services de base ---
|
||||
builder.Services.AddControllers();
|
||||
|
||||
// --- Health checks (endpoint GET /health, utilisé par Docker Compose) ---
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
// --- Validation des requêtes (FluentValidation) ---
|
||||
// Enregistre tous les validateurs de l'assembly et active la validation automatique côté MVC.
|
||||
builder.Services.AddValidatorsFromAssemblyContaining<CreateTrajetDtoValidator>();
|
||||
builder.Services.AddFluentValidationAutoValidation();
|
||||
|
||||
// --- Base de données (EF Core + SQLite) ---
|
||||
// La chaîne de connexion provient de la configuration ("ConnectionStrings:Default").
|
||||
var connectionString = builder.Configuration.GetConnectionString("Default")
|
||||
?? "Data Source=trajets.db";
|
||||
builder.Services.AddDbContext<AppDbContext>(options =>
|
||||
options.UseSqlite(connectionString));
|
||||
|
||||
// --- Repositories & services métier ---
|
||||
builder.Services.AddScoped<TrajetKmTracker.Api.Repositories.IVehiculeRepository,
|
||||
TrajetKmTracker.Api.Repositories.VehiculeRepository>();
|
||||
builder.Services.AddScoped<IVehiculeService, VehiculeService>();
|
||||
builder.Services.AddScoped<IProfilService, ProfilService>();
|
||||
builder.Services.AddScoped<IAdresseNommeeService, AdresseNommeeService>();
|
||||
builder.Services.AddScoped<IBaremeService, BaremeService>();
|
||||
builder.Services.AddScoped<IMontantRegleService, MontantRegleService>();
|
||||
builder.Services.AddScoped<IRemboursementService, RemboursementService>();
|
||||
builder.Services.AddScoped<ITotauxService, TotauxService>();
|
||||
builder.Services.AddScoped<ITrajetRepository, TrajetRepository>();
|
||||
builder.Services.AddScoped<ITrajetService, TrajetService>();
|
||||
builder.Services.AddScoped<IResumeService, ResumeService>();
|
||||
builder.Services.AddScoped<IPdfService, PdfService>();
|
||||
|
||||
// Exploration des endpoints + Swagger / OpenAPI
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(options =>
|
||||
{
|
||||
options.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo
|
||||
{
|
||||
Title = "SchotzIAD KM Tracker API",
|
||||
Version = "v1",
|
||||
Description = "API de saisie, calcul et consultation des trajets kilométriques."
|
||||
});
|
||||
});
|
||||
|
||||
// ProblemDetails : réponses d'erreur normalisées (RFC 7807)
|
||||
builder.Services.AddProblemDetails();
|
||||
|
||||
// CORS pour le frontend Angular.
|
||||
// Les origines autorisées sont configurables via appsettings ("Cors:AllowedOrigins").
|
||||
const string CorsPolicyName = "AngularFrontend";
|
||||
var allowedOrigins = builder.Configuration
|
||||
.GetSection("Cors:AllowedOrigins")
|
||||
.Get<string[]>() ?? new[] { "http://localhost:4200" };
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy(CorsPolicyName, policy =>
|
||||
{
|
||||
policy.WithOrigins(allowedOrigins)
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod();
|
||||
});
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// --- Pipeline HTTP ---
|
||||
|
||||
// Gestion centralisée des exceptions -> ProblemDetails
|
||||
app.UseExceptionHandler();
|
||||
app.UseStatusCodePages();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
app.UseCors(CorsPolicyName);
|
||||
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
// Endpoint de santé — utilisé par le healthcheck Docker Compose (depends_on: service_healthy).
|
||||
app.MapHealthChecks("/health");
|
||||
|
||||
// --- Application automatique des migrations EF Core au démarrage ---
|
||||
// Au premier lancement, la base (ex. /data/trajets.db sur le volume monté) est créée ;
|
||||
// aux lancements suivants, les migrations en attente sont appliquées sans intervention manuelle.
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await dbContext.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
// Exposé pour les tests d'intégration (WebApplicationFactory<Program>).
|
||||
public partial class Program { }
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:8200",
|
||||
"sslPort": 44379
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "http://localhost:5237",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "https://localhost:7085;http://localhost:5237",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrajetKmTracker.Api.Data;
|
||||
using TrajetKmTracker.Api.Models.Entities;
|
||||
|
||||
namespace TrajetKmTracker.Api.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Implémentation EF Core de <see cref="IAdresseCacheRepository"/> au-dessus d'<see cref="AppDbContext"/>.
|
||||
/// </summary>
|
||||
public class AdresseCacheRepository : IAdresseCacheRepository
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public AdresseCacheRepository(AppDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AdresseCache>> RechercherAsync(
|
||||
string fragmentNormalise,
|
||||
int limite,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// La colonne Adresse stocke déjà la forme normalisée : la comparaison se fait donc
|
||||
// directement sur celle-ci. EF.Functions.Like assure une comparaison indépendante
|
||||
// de la casse côté SQLite.
|
||||
var motif = $"%{fragmentNormalise}%";
|
||||
|
||||
return await _db.AdresseCache
|
||||
.Where(a => EF.Functions.Like(a.Adresse, motif))
|
||||
.OrderByDescending(a => a.DerniereUtilisation)
|
||||
.Take(limite)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<AdresseCache?> ObtenirParAdresseNormaliseeAsync(
|
||||
string adresseNormalisee,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _db.AdresseCache
|
||||
.FirstOrDefaultAsync(a => a.Adresse == adresseNormalisee, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AjouterAsync(AdresseCache entree, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _db.AdresseCache.AddAsync(entree, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<int> CompterAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _db.AdresseCache.CountAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AdresseCache>> ObtenirPlusAnciennesAsync(
|
||||
int nombre,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (nombre <= 0)
|
||||
{
|
||||
return Array.Empty<AdresseCache>();
|
||||
}
|
||||
|
||||
return await _db.AdresseCache
|
||||
.OrderBy(a => a.DerniereUtilisation)
|
||||
.Take(nombre)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public void Supprimer(IEnumerable<AdresseCache> entrees)
|
||||
{
|
||||
_db.AdresseCache.RemoveRange(entrees);
|
||||
}
|
||||
|
||||
public Task<int> SauvegarderAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using TrajetKmTracker.Api.Models.Entities;
|
||||
|
||||
namespace TrajetKmTracker.Api.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Accès aux données du Cache_Adresses (table <c>AdresseCache</c>).
|
||||
/// Toutes les correspondances d'adresse s'effectuent sur le texte normalisé
|
||||
/// (voir <see cref="AdresseCacheService"/>).
|
||||
/// </summary>
|
||||
public interface IAdresseCacheRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Retourne les entrées dont l'adresse normalisée contient le fragment normalisé fourni,
|
||||
/// triées de la plus récemment utilisée à la plus ancienne, limitées à <paramref name="limite"/>.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<AdresseCache>> RechercherAsync(
|
||||
string fragmentNormalise,
|
||||
int limite,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Retourne l'entrée correspondant exactement à l'adresse normalisée, ou <c>null</c>.</summary>
|
||||
Task<AdresseCache?> ObtenirParAdresseNormaliseeAsync(
|
||||
string adresseNormalisee,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Ajoute une nouvelle entrée au cache.</summary>
|
||||
Task AjouterAsync(AdresseCache entree, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Compte le nombre total d'entrées du cache.</summary>
|
||||
Task<int> CompterAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retourne les entrées les plus anciennes (par <c>DerniereUtilisation</c> croissante),
|
||||
/// limitées à <paramref name="nombre"/>. Utilisé pour l'éviction LRU.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<AdresseCache>> ObtenirPlusAnciennesAsync(
|
||||
int nombre,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Supprime les entrées fournies du cache.</summary>
|
||||
void Supprimer(IEnumerable<AdresseCache> entrees);
|
||||
|
||||
/// <summary>Persiste les modifications en attente.</summary>
|
||||
Task<int> SauvegarderAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using TrajetKmTracker.Api.Models.Entities;
|
||||
|
||||
namespace TrajetKmTracker.Api.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Accès aux données des trajets et gestion des positions au sein d'une journée.
|
||||
/// <para>
|
||||
/// La <see cref="Trajet.Position"/> est une valeur décimale (REAL) permettant l'insertion
|
||||
/// sans réordonnancement systématique : ajout en fin de journée → <c>max(Position) + 1.0</c>,
|
||||
/// insertion entre deux positions P₁ et P₂ → <c>(P₁ + P₂) / 2</c>. Lorsque l'écart devient
|
||||
/// trop faible (< <see cref="SeuilRenumerotation"/>), la journée est renumérotée par pas de
|
||||
/// 1.0 en transaction en préservant l'ordre relatif (Property 15, Exigences 11.7/11.8).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Ce repository fournit uniquement les primitives d'accès et de calcul de position ;
|
||||
/// la logique métier (validation, calcul de distance, mise en cache) relève de TrajetService.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface ITrajetRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Seuil de précision en deçà duquel une renumérotation de la journée est déclenchée.
|
||||
/// Conforme à la conception : écart < 0.001.
|
||||
/// </summary>
|
||||
const decimal SeuilRenumerotation = 0.001m;
|
||||
|
||||
/// <summary>Retourne le trajet par identifiant, ou <c>null</c> s'il n'existe pas.</summary>
|
||||
Task<Trajet?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Retourne les trajets d'une journée triés par <see cref="Trajet.Position"/> croissante.</summary>
|
||||
Task<List<Trajet>> GetByDateAsync(DateOnly date, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Résout le véhicule applicable à la date du trajet et copie sa <c>PuissanceFiscale</c>
|
||||
/// sur le trajet (dénormalisation, Exigence 10.14). Ne modifie ni <c>VehiculeId</c> ni
|
||||
/// <c>PuissanceFiscale</c> si aucun véhicule n'est applicable à cette date.
|
||||
/// Coordonné avec <see cref="Services.IVehiculeService.GetVehiculeADateAsync"/>.
|
||||
/// </summary>
|
||||
Task AppliquerVehiculeADateAsync(Trajet trajet, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Calcule la position d'un trajet ajouté en fin de journée : <c>max(Position) + 1.0</c>
|
||||
/// pour la date, ou <c>1.0</c> si la journée est vide.
|
||||
/// </summary>
|
||||
Task<decimal> CalculerPositionFinDeJourneeAsync(DateOnly date, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Calcule la position d'un trajet inséré entre les positions <paramref name="positionPrecedente"/>
|
||||
/// (P₁) et <paramref name="positionSuivante"/> (P₂) : <c>(P₁ + P₂) / 2</c>.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentException">Si P₁ n'est pas strictement inférieure à P₂.</exception>
|
||||
decimal CalculerPositionEntre(decimal positionPrecedente, decimal positionSuivante);
|
||||
|
||||
/// <summary>
|
||||
/// Indique si l'écart entre deux positions adjacentes est devenu trop faible
|
||||
/// (< <see cref="SeuilRenumerotation"/>) pour insérer une nouvelle valeur intermédiaire.
|
||||
/// </summary>
|
||||
bool NecessiteRenumerotation(decimal positionPrecedente, decimal positionSuivante);
|
||||
|
||||
/// <summary>Ajoute un trajet (sans persister). L'appelant gère la transaction et l'appel à <see cref="SauvegarderAsync"/>.</summary>
|
||||
Task AddAsync(Trajet trajet, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Marque un trajet existant comme modifié (sans persister).</summary>
|
||||
void Update(Trajet trajet);
|
||||
|
||||
/// <summary>Supprime un trajet (sans persister).</summary>
|
||||
void Delete(Trajet trajet);
|
||||
|
||||
/// <summary>Persiste les changements en attente.</summary>
|
||||
Task<int> SauvegarderAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Applique un réordonnancement manuel : met à jour uniquement la <see cref="Trajet.Position"/>
|
||||
/// des trajets fournis (par identifiant) sans altérer leurs autres champs, en transaction.
|
||||
/// Les identifiants inconnus sont ignorés.
|
||||
/// </summary>
|
||||
Task ReordonnerAsync(
|
||||
IReadOnlyList<(int Id, decimal Position)> positions,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Renumérote les trajets d'une journée par pas de 1.0 (1.0, 2.0, 3.0, …) en préservant
|
||||
/// l'ordre relatif courant (tri par Position puis Id), en transaction. Retourne la liste
|
||||
/// renumérotée triée par Position croissante (Property 15).
|
||||
/// </summary>
|
||||
Task<List<Trajet>> RenumeroterJourneeAsync(DateOnly date, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using TrajetKmTracker.Api.Models.Entities;
|
||||
|
||||
namespace TrajetKmTracker.Api.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Accès aux données des véhicules et aux trajets impactés par une réaffectation.
|
||||
/// L'historique des véhicules est conservé (aucune suppression) — Exigence 10.12 / Property 22.
|
||||
/// </summary>
|
||||
public interface IVehiculeRepository
|
||||
{
|
||||
/// <summary>Retourne l'historique complet des véhicules, trié par Date_Effet décroissante puis Id décroissant.</summary>
|
||||
Task<List<Vehicule>> GetHistoriqueAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Retourne le véhicule par identifiant, ou <c>null</c> s'il n'existe pas.</summary>
|
||||
Task<Vehicule?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Résout le véhicule courant à une date donnée : Date_Effet la plus récente parmi
|
||||
/// celles <= <paramref name="date"/> (ORDER BY DateEffet DESC, Id DESC LIMIT 1).
|
||||
/// Retourne <c>null</c> si aucun véhicule n'a de Date_Effet <= <paramref name="date"/>.
|
||||
/// </summary>
|
||||
Task<Vehicule?> GetVehiculeCourantAsync(DateOnly date, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Ajoute un nouveau véhicule (sans persister). L'appelant gère la transaction.</summary>
|
||||
Task AddAsync(Vehicule vehicule, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retourne les trajets dont la Date est >= <paramref name="dateEffet"/> et dont la
|
||||
/// PuissanceFiscale diffère de <paramref name="puissanceFiscale"/> (candidats à la réaffectation).
|
||||
/// </summary>
|
||||
Task<List<Trajet>> GetTrajetsAReaffecterAsync(
|
||||
DateOnly dateEffet,
|
||||
int puissanceFiscale,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrajetKmTracker.Api.Data;
|
||||
using TrajetKmTracker.Api.Models.Entities;
|
||||
using TrajetKmTracker.Api.Services;
|
||||
|
||||
namespace TrajetKmTracker.Api.Repositories;
|
||||
|
||||
/// <inheritdoc />
|
||||
public class TrajetRepository : ITrajetRepository
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
private readonly IVehiculeService _vehiculeService;
|
||||
|
||||
public TrajetRepository(AppDbContext context, IVehiculeService vehiculeService)
|
||||
{
|
||||
_context = context;
|
||||
_vehiculeService = vehiculeService;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Trajet?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Trajets
|
||||
.FirstOrDefaultAsync(t => t.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<Trajet>> GetByDateAsync(DateOnly date, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// SQLite ne sait pas trier (ORDER BY) sur une colonne decimal : on ramène les
|
||||
// trajets du jour puis on trie côté client (volume par journée très réduit).
|
||||
var trajets = await _context.Trajets
|
||||
.Where(t => t.Date == date)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return trajets
|
||||
.OrderBy(t => t.Position)
|
||||
.ThenBy(t => t.Id)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AppliquerVehiculeADateAsync(Trajet trajet, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Résolution du véhicule applicable à la date du trajet (Date_Effet la plus récente <= Date).
|
||||
var vehicule = await _vehiculeService.GetVehiculeADateAsync(trajet.Date, cancellationToken);
|
||||
if (vehicule is null)
|
||||
{
|
||||
// Aucun véhicule applicable à cette date : on laisse la logique métier (TrajetService) décider.
|
||||
return;
|
||||
}
|
||||
|
||||
// Dénormalisation : copie de la puissance fiscale du véhicule au moment de l'association (Exigence 10.14).
|
||||
trajet.VehiculeId = vehicule.Id;
|
||||
trajet.PuissanceFiscale = vehicule.PuissanceFiscale;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<decimal> CalculerPositionFinDeJourneeAsync(DateOnly date, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// SQLite ne sait pas appliquer MAX sur une colonne decimal : on ramène les positions
|
||||
// du jour et on calcule le maximum côté client (volume par journée très réduit).
|
||||
var positions = await _context.Trajets
|
||||
.Where(t => t.Date == date)
|
||||
.Select(t => t.Position)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (positions.Count == 0)
|
||||
{
|
||||
return 1.0m;
|
||||
}
|
||||
|
||||
return positions.Max() + 1.0m;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public decimal CalculerPositionEntre(decimal positionPrecedente, decimal positionSuivante)
|
||||
{
|
||||
if (positionPrecedente >= positionSuivante)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"La position précédente ({positionPrecedente}) doit être strictement inférieure à la position suivante ({positionSuivante}).",
|
||||
nameof(positionPrecedente));
|
||||
}
|
||||
|
||||
return (positionPrecedente + positionSuivante) / 2.0m;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool NecessiteRenumerotation(decimal positionPrecedente, decimal positionSuivante)
|
||||
{
|
||||
return positionSuivante - positionPrecedente < ITrajetRepository.SeuilRenumerotation;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddAsync(Trajet trajet, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.Trajets.Add(trajet);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Update(Trajet trajet)
|
||||
{
|
||||
_context.Trajets.Update(trajet);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Delete(Trajet trajet)
|
||||
{
|
||||
_context.Trajets.Remove(trajet);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> SauvegarderAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ReordonnerAsync(
|
||||
IReadOnlyList<(int Id, decimal Position)> positions,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (positions.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var ids = positions.Select(p => p.Id).ToList();
|
||||
var trajets = await _context.Trajets
|
||||
.Where(t => ids.Contains(t.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var positionParId = positions.ToDictionary(p => p.Id, p => p.Position);
|
||||
|
||||
foreach (var trajet in trajets)
|
||||
{
|
||||
if (positionParId.TryGetValue(trajet.Id, out var nouvellePosition))
|
||||
{
|
||||
// Seule la Position est modifiée ; aucun autre champ n'est altéré (Exigence 11.9).
|
||||
trajet.Position = nouvellePosition;
|
||||
trajet.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<Trajet>> RenumeroterJourneeAsync(DateOnly date, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
// Préservation de l'ordre relatif courant (Position croissante, Id en départage) — Property 15.
|
||||
var trajets = (await _context.Trajets
|
||||
.Where(t => t.Date == date)
|
||||
.ToListAsync(cancellationToken))
|
||||
.OrderBy(t => t.Position)
|
||||
.ThenBy(t => t.Id)
|
||||
.ToList();
|
||||
|
||||
decimal position = 1.0m;
|
||||
foreach (var trajet in trajets)
|
||||
{
|
||||
trajet.Position = position;
|
||||
trajet.UpdatedAt = DateTime.UtcNow;
|
||||
position += 1.0m;
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
return trajets;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrajetKmTracker.Api.Data;
|
||||
using TrajetKmTracker.Api.Models.Entities;
|
||||
|
||||
namespace TrajetKmTracker.Api.Repositories;
|
||||
|
||||
/// <inheritdoc />
|
||||
public class VehiculeRepository : IVehiculeRepository
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
|
||||
public VehiculeRepository(AppDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<Vehicule>> GetHistoriqueAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Vehicules
|
||||
.OrderByDescending(v => v.DateEffet)
|
||||
.ThenByDescending(v => v.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Vehicule?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Vehicules
|
||||
.FirstOrDefaultAsync(v => v.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Vehicule?> GetVehiculeCourantAsync(DateOnly date, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Design SQL : SELECT * FROM Vehicules WHERE DateEffet <= date ORDER BY DateEffet DESC, Id DESC LIMIT 1
|
||||
return await _context.Vehicules
|
||||
.Where(v => v.DateEffet <= date)
|
||||
.OrderByDescending(v => v.DateEffet)
|
||||
.ThenByDescending(v => v.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddAsync(Vehicule vehicule, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.Vehicules.Add(vehicule);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<Trajet>> GetTrajetsAReaffecterAsync(
|
||||
DateOnly dateEffet,
|
||||
int puissanceFiscale,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Trajets
|
||||
.Where(t => t.Date >= dateEffet && t.PuissanceFiscale != puissanceFiscale)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrajetKmTracker.Api.Data;
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
using TrajetKmTracker.Api.Models.Entities;
|
||||
using TrajetKmTracker.Api.Repositories;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implémentation du Cache_Adresses. Les adresses sont stockées et comparées sous forme
|
||||
/// normalisée (trim + minuscules + suppression des accents) afin d'assurer un matching robuste.
|
||||
/// Les écritures (upsert + éviction LRU) sont exécutées en transaction.
|
||||
/// </summary>
|
||||
public class AdresseCacheService : IAdresseCacheService
|
||||
{
|
||||
/// <summary>Longueur minimale d'une requête de recherche locale (Requirement 9.3).</summary>
|
||||
private const int LongueurMinimaleRecherche = 3;
|
||||
|
||||
/// <summary>Taille maximale du cache avant éviction LRU (Requirement 9.5).</summary>
|
||||
private const int TailleMaximaleCache = 1_000;
|
||||
|
||||
private readonly IAdresseCacheRepository _repository;
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public AdresseCacheService(IAdresseCacheRepository repository, AppDbContext db)
|
||||
{
|
||||
_repository = repository;
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AdresseCacheDto>> RechercherLocalAsync(
|
||||
string query,
|
||||
int limite = 5,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var fragment = Normaliser(query);
|
||||
if (fragment.Length < LongueurMinimaleRecherche)
|
||||
{
|
||||
return Array.Empty<AdresseCacheDto>();
|
||||
}
|
||||
|
||||
if (limite <= 0)
|
||||
{
|
||||
limite = 5;
|
||||
}
|
||||
|
||||
var entrees = await _repository.RechercherAsync(fragment, limite, cancellationToken);
|
||||
|
||||
return entrees
|
||||
.Select(e => new AdresseCacheDto(e.Adresse, e.Latitude, e.Longitude))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<AdresseCacheEntreeDto?> ObtenirParAdresseAsync(
|
||||
string adresse,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalisee = Normaliser(adresse);
|
||||
if (normalisee.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var entree = await _repository.ObtenirParAdresseNormaliseeAsync(normalisee, cancellationToken);
|
||||
if (entree is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Toute utilisation d'une adresse en cache rafraîchit sa date de dernière utilisation.
|
||||
// Si une transaction est déjà ouverte par l'appelant (ex. création d'un Trajet),
|
||||
// on y participe sans en ouvrir une nouvelle : SQLite interdit les transactions imbriquées.
|
||||
entree.DerniereUtilisation = DateTime.UtcNow;
|
||||
if (_db.Database.CurrentTransaction is null)
|
||||
{
|
||||
await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken);
|
||||
await _repository.SauvegarderAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _repository.SauvegarderAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return new AdresseCacheEntreeDto(
|
||||
entree.Adresse,
|
||||
entree.Latitude,
|
||||
entree.Longitude,
|
||||
entree.GeometrieJson);
|
||||
}
|
||||
|
||||
public async Task UpsertAdresseAsync(
|
||||
string adresse,
|
||||
double latitude,
|
||||
double longitude,
|
||||
string? geometrieJson = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalisee = Normaliser(adresse);
|
||||
if (normalisee.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Si une transaction est déjà ouverte par l'appelant (ex. TrajetService.CreateAsync),
|
||||
// on y participe sans en ouvrir une nouvelle (SQLite interdit les transactions imbriquées).
|
||||
var transactionAmbiante = _db.Database.CurrentTransaction is not null;
|
||||
var transaction = transactionAmbiante
|
||||
? null
|
||||
: await _db.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
var existante = await _repository.ObtenirParAdresseNormaliseeAsync(normalisee, cancellationToken);
|
||||
if (existante is not null)
|
||||
{
|
||||
// Adresse déjà présente : coordonnées inchangées, seule la date d'utilisation est
|
||||
// rafraîchie (Requirements 9.1, 9.6).
|
||||
existante.DerniereUtilisation = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Nouvelle adresse : insertion avec les coordonnées fournies.
|
||||
var nouvelle = new AdresseCache
|
||||
{
|
||||
Adresse = normalisee,
|
||||
Latitude = latitude,
|
||||
Longitude = longitude,
|
||||
GeometrieJson = geometrieJson,
|
||||
DerniereUtilisation = DateTime.UtcNow
|
||||
};
|
||||
await _repository.AjouterAsync(nouvelle, cancellationToken);
|
||||
}
|
||||
|
||||
await _repository.SauvegarderAsync(cancellationToken);
|
||||
|
||||
// Éviction LRU : conserver au plus les 1 000 entrées les plus récemment utilisées
|
||||
// et supprimer les plus anciennes (Requirement 9.5).
|
||||
var total = await _repository.CompterAsync(cancellationToken);
|
||||
if (total > TailleMaximaleCache)
|
||||
{
|
||||
var aSupprimer = await _repository.ObtenirPlusAnciennesAsync(
|
||||
total - TailleMaximaleCache, cancellationToken);
|
||||
|
||||
_repository.Supprimer(aSupprimer);
|
||||
await _repository.SauvegarderAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// On ne valide que la transaction que l'on a nous-mêmes ouverte ; sinon c'est
|
||||
// l'appelant qui validera la transaction ambiante.
|
||||
if (transaction is not null)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
await transaction.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalise un texte d'adresse : suppression des espaces de bord, passage en minuscules
|
||||
/// et suppression des accents (diacritiques). Cohérent avec la normalisation des Adresses_Nommées.
|
||||
/// </summary>
|
||||
private static string Normaliser(string? valeur)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(valeur))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var trimmee = valeur.Trim().ToLowerInvariant();
|
||||
|
||||
var decomposee = trimmee.Normalize(NormalizationForm.FormD);
|
||||
var builder = new StringBuilder(decomposee.Length);
|
||||
foreach (var caractere in decomposee)
|
||||
{
|
||||
var categorie = CharUnicodeInfo.GetUnicodeCategory(caractere);
|
||||
if (categorie != UnicodeCategory.NonSpacingMark)
|
||||
{
|
||||
builder.Append(caractere);
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString().Normalize(NormalizationForm.FormC);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Implémentation du Carnet_Adresses (Exigence 15). Gère le CRUD des Adresses_Nommées avec :
|
||||
/// <list type="bullet">
|
||||
/// <item>normalisation de la Dénomination (trim + minuscules + suppression des accents) pour
|
||||
/// garantir l'unicité (15.6/15.7) ;</item>
|
||||
/// <item>réutilisation du Cache_Adresses si l'adresse normalisée y est présente (15.10),
|
||||
/// sinon géocodage/re-géocodage via l'API BAN avec un délai de 5 s (15.11) ;</item>
|
||||
/// <item>persistance sans coordonnées + <c>GeocodageReussi = false</c> si BAN est
|
||||
/// indisponible ou dépasse le délai (15.12) ;</item>
|
||||
/// <item>mise en cache des coordonnées obtenues (15.9).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public class AdresseNommeeService : IAdresseNommeeService
|
||||
{
|
||||
private const int DenominationMaxLength = 100;
|
||||
private const int AdresseMaxLength = 255;
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IBANClient _banClient;
|
||||
private readonly IAdresseCacheService _cache;
|
||||
|
||||
public AdresseNommeeService(AppDbContext db, IBANClient banClient, IAdresseCacheService cache)
|
||||
{
|
||||
_db = db;
|
||||
_banClient = banClient;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<AdresseNommeeDto>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var adresses = await _db.AdressesNommees
|
||||
.AsNoTracking()
|
||||
.OrderBy(a => a.Denomination)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return adresses.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AdresseNommeeDto> CreateAsync(
|
||||
SaveAdresseNommeeDto dto,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValiderDenomination(dto.Denomination);
|
||||
ValiderAdresse(dto.Adresse);
|
||||
|
||||
var denominationNormalisee = Normaliser(dto.Denomination);
|
||||
|
||||
// Contrôle d'unicité avant persistance (15.7 / Property 28) ; l'index unique
|
||||
// UX_AdresseNommee_Denomination reste le dernier rempart au niveau SQL.
|
||||
var collision = await _db.AdressesNommees
|
||||
.AnyAsync(a => a.DenominationNormalisee == denominationNormalisee, cancellationToken);
|
||||
if (collision)
|
||||
{
|
||||
throw new DenominationDupliqueeException(
|
||||
$"La dénomination « {dto.Denomination.Trim()} » est déjà utilisée.");
|
||||
}
|
||||
|
||||
var (latitude, longitude, geocodageReussi) =
|
||||
await ResoudreCoordonneesAsync(dto.Adresse, dto.Latitude, dto.Longitude, cancellationToken);
|
||||
|
||||
var maintenant = DateTime.UtcNow;
|
||||
var entite = new AdresseNommee
|
||||
{
|
||||
Denomination = dto.Denomination.Trim(),
|
||||
DenominationNormalisee = denominationNormalisee,
|
||||
Adresse = dto.Adresse.Trim(),
|
||||
Latitude = latitude,
|
||||
Longitude = longitude,
|
||||
GeocodageReussi = geocodageReussi,
|
||||
CreatedAt = maintenant,
|
||||
UpdatedAt = maintenant
|
||||
};
|
||||
|
||||
await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
_db.AdressesNommees.Add(entite);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException ex)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
// Dernier rempart : violation de l'index unique (course concurrente).
|
||||
throw new DenominationDupliqueeException(
|
||||
$"La dénomination « {dto.Denomination.Trim()} » est déjà utilisée.", ex);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
|
||||
// Mise en cache des coordonnées obtenues si absentes du Cache_Adresses (15.9).
|
||||
if (geocodageReussi && latitude.HasValue && longitude.HasValue)
|
||||
{
|
||||
await _cache.UpsertAdresseAsync(entite.Adresse, latitude.Value, longitude.Value,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
return ToDto(entite);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AdresseNommeeDto> UpdateAsync(
|
||||
int id,
|
||||
SaveAdresseNommeeDto dto,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValiderDenomination(dto.Denomination);
|
||||
ValiderAdresse(dto.Adresse);
|
||||
|
||||
var entite = await _db.AdressesNommees.FirstOrDefaultAsync(a => a.Id == id, cancellationToken)
|
||||
?? throw new AdresseNommeeNotFoundException(
|
||||
$"L'adresse nommée d'identifiant {id} est introuvable.");
|
||||
|
||||
var denominationNormalisee = Normaliser(dto.Denomination);
|
||||
|
||||
// Unicité : une autre Adresse_Nommee ne doit pas porter la même Dénomination normalisée (15.7).
|
||||
var collision = await _db.AdressesNommees
|
||||
.AnyAsync(a => a.Id != id && a.DenominationNormalisee == denominationNormalisee, cancellationToken);
|
||||
if (collision)
|
||||
{
|
||||
throw new DenominationDupliqueeException(
|
||||
$"La dénomination « {dto.Denomination.Trim()} » est déjà utilisée.");
|
||||
}
|
||||
|
||||
var adresseTrim = dto.Adresse.Trim();
|
||||
var adresseAChange = !string.Equals(
|
||||
Normaliser(entite.Adresse), Normaliser(adresseTrim), StringComparison.Ordinal);
|
||||
|
||||
double? latitude = entite.Latitude;
|
||||
double? longitude = entite.Longitude;
|
||||
var geocodageReussi = entite.GeocodageReussi;
|
||||
|
||||
// Re-géocodage uniquement si le texte d'adresse a changé (15.11).
|
||||
if (adresseAChange)
|
||||
{
|
||||
(latitude, longitude, geocodageReussi) =
|
||||
await ResoudreCoordonneesAsync(adresseTrim, dto.Latitude, dto.Longitude, cancellationToken);
|
||||
}
|
||||
|
||||
entite.Denomination = dto.Denomination.Trim();
|
||||
entite.DenominationNormalisee = denominationNormalisee;
|
||||
entite.Adresse = adresseTrim;
|
||||
entite.Latitude = latitude;
|
||||
entite.Longitude = longitude;
|
||||
entite.GeocodageReussi = geocodageReussi;
|
||||
entite.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException ex)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw new DenominationDupliqueeException(
|
||||
$"La dénomination « {dto.Denomination.Trim()} » est déjà utilisée.", ex);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
|
||||
if (geocodageReussi && latitude.HasValue && longitude.HasValue)
|
||||
{
|
||||
await _cache.UpsertAdresseAsync(entite.Adresse, latitude.Value, longitude.Value,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
return ToDto(entite);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entite = await _db.AdressesNommees.FirstOrDefaultAsync(a => a.Id == id, cancellationToken)
|
||||
?? throw new AdresseNommeeNotFoundException(
|
||||
$"L'adresse nommée d'identifiant {id} est introuvable.");
|
||||
|
||||
// Suppression du seul enregistrement du Carnet_Adresses : les Trajets ne référencent que
|
||||
// le texte d'adresse et les coordonnées copiés, donc rien d'autre n'est modifié (15.14).
|
||||
await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
_db.AdressesNommees.Remove(entite);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Résout les coordonnées d'un texte d'adresse : réutilisation du Cache_Adresses si l'adresse
|
||||
/// normalisée y figure (15.10), sinon coordonnées fournies par le frontend (sélection BAN),
|
||||
/// sinon géocodage via l'API BAN (15.11). En cas d'indisponibilité/timeout de la BAN, aucune
|
||||
/// coordonnée n'est retournée et <c>GeocodageReussi</c> vaut <c>false</c> (15.12).
|
||||
/// </summary>
|
||||
private async Task<(double? Latitude, double? Longitude, bool GeocodageReussi)> ResoudreCoordonneesAsync(
|
||||
string adresse,
|
||||
double? latitudeFournie,
|
||||
double? longitudeFournie,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1) Réutilisation du Cache_Adresses sans appel BAN (15.10).
|
||||
var enCache = await _cache.ObtenirParAdresseAsync(adresse, cancellationToken);
|
||||
if (enCache is not null)
|
||||
{
|
||||
return (enCache.Latitude, enCache.Longitude, true);
|
||||
}
|
||||
|
||||
// 2) Coordonnées déjà résolues côté frontend via une sélection BAN : on les conserve
|
||||
// directement, ce qui évite un appel BAN redondant (15.5).
|
||||
if (latitudeFournie.HasValue && longitudeFournie.HasValue)
|
||||
{
|
||||
return (latitudeFournie, longitudeFournie, true);
|
||||
}
|
||||
|
||||
// 3) Géocodage via l'API BAN (timeout 5 s configuré sur le HttpClient) (15.11).
|
||||
try
|
||||
{
|
||||
var resultats = await _banClient.RechercherAsync(adresse, cancellationToken);
|
||||
var premier = resultats.FirstOrDefault();
|
||||
if (premier is not null)
|
||||
{
|
||||
return (premier.Latitude, premier.Longitude, true);
|
||||
}
|
||||
|
||||
// Aucune correspondance BAN : persistance sans coordonnées.
|
||||
return (null, null, false);
|
||||
}
|
||||
catch (BANIndisponibleException)
|
||||
{
|
||||
// BAN indisponible/timeout : persistance sans coordonnées (15.12).
|
||||
return (null, null, false);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValiderDenomination(string? denomination)
|
||||
{
|
||||
var trimmee = denomination?.Trim() ?? string.Empty;
|
||||
if (trimmee.Length == 0 || trimmee.Length > DenominationMaxLength)
|
||||
{
|
||||
throw new DenominationInvalideException(
|
||||
"La dénomination est absente ou invalide (1 à 100 caractères requis).");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValiderAdresse(string? adresse)
|
||||
{
|
||||
var trimmee = adresse?.Trim() ?? string.Empty;
|
||||
if (trimmee.Length == 0 || trimmee.Length > AdresseMaxLength)
|
||||
{
|
||||
throw new AdresseNommeeInvalideException(
|
||||
"L'adresse est absente ou invalide (1 à 255 caractères requis).");
|
||||
}
|
||||
}
|
||||
|
||||
private static AdresseNommeeDto ToDto(AdresseNommee a) =>
|
||||
new(a.Id, a.Denomination, a.Adresse, a.Latitude, a.Longitude);
|
||||
|
||||
/// <summary>
|
||||
/// Normalise un texte : suppression des espaces de bord, passage en minuscules et suppression
|
||||
/// des accents (diacritiques). Cohérent avec <see cref="AdresseCacheService"/> afin d'assurer
|
||||
/// un matching identique entre Carnet_Adresses et Cache_Adresses.
|
||||
/// </summary>
|
||||
private static string Normaliser(string? valeur)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(valeur))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var trimmee = valeur.Trim().ToLowerInvariant();
|
||||
|
||||
var decomposee = trimmee.Normalize(NormalizationForm.FormD);
|
||||
var builder = new StringBuilder(decomposee.Length);
|
||||
foreach (var caractere in decomposee)
|
||||
{
|
||||
var categorie = CharUnicodeInfo.GetUnicodeCategory(caractere);
|
||||
if (categorie != UnicodeCategory.NonSpacingMark)
|
||||
{
|
||||
builder.Append(caractere);
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString().Normalize(NormalizationForm.FormC);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
using TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client typé de l'API BAN. Appelle <c>GET /search/?q={query}&limit=5</c>,
|
||||
/// parse la réponse GeoJSON (FeatureCollection) et mappe les features en
|
||||
/// <see cref="BANAdresseDto"/>. Lève <see cref="BANIndisponibleException"/>
|
||||
/// en cas d'échec ou de timeout.
|
||||
/// </summary>
|
||||
public class BANClient : IBANClient
|
||||
{
|
||||
private const int MaxResultats = 5;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public BANClient(HttpClient httpClient)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<BANAdresseDto>> RechercherAsync(
|
||||
string query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return Array.Empty<BANAdresseDto>();
|
||||
}
|
||||
|
||||
var requestUri = $"/search/?q={Uri.EscapeDataString(query)}&limit={MaxResultats}";
|
||||
|
||||
BANFeatureCollection? collection;
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(requestUri, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
collection = await response.Content.ReadFromJsonAsync<BANFeatureCollection>(
|
||||
JsonOptions, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Le timeout configuré sur le HttpClient déclenche une annulation
|
||||
// non demandée par l'appelant.
|
||||
throw new BANIndisponibleException(
|
||||
"L'appel à l'API BAN a dépassé le délai imparti (timeout).");
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
throw new BANIndisponibleException(
|
||||
"L'API BAN est indisponible ou a renvoyé une réponse en erreur.", ex);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new BANIndisponibleException(
|
||||
"La réponse de l'API BAN n'a pas pu être interprétée.", ex);
|
||||
}
|
||||
|
||||
if (collection?.Features is null || collection.Features.Count == 0)
|
||||
{
|
||||
return Array.Empty<BANAdresseDto>();
|
||||
}
|
||||
|
||||
var resultats = new List<BANAdresseDto>(collection.Features.Count);
|
||||
foreach (var feature in collection.Features)
|
||||
{
|
||||
var coordinates = feature.Geometry?.Coordinates;
|
||||
if (coordinates is null || coordinates.Count < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// GeoJSON : coordinates = [longitude, latitude].
|
||||
var longitude = coordinates[0];
|
||||
var latitude = coordinates[1];
|
||||
|
||||
resultats.Add(new BANAdresseDto(
|
||||
Label: feature.Properties?.Label ?? string.Empty,
|
||||
Latitude: latitude,
|
||||
Longitude: longitude,
|
||||
Postcode: feature.Properties?.Postcode ?? string.Empty,
|
||||
City: feature.Properties?.City ?? string.Empty));
|
||||
}
|
||||
|
||||
return resultats;
|
||||
}
|
||||
|
||||
// --- Modèles internes de désérialisation de la réponse GeoJSON BAN ---
|
||||
|
||||
private sealed class BANFeatureCollection
|
||||
{
|
||||
[JsonPropertyName("features")]
|
||||
public List<BANFeature>? Features { get; set; }
|
||||
}
|
||||
|
||||
private sealed class BANFeature
|
||||
{
|
||||
[JsonPropertyName("properties")]
|
||||
public BANProperties? Properties { get; set; }
|
||||
|
||||
[JsonPropertyName("geometry")]
|
||||
public BANGeometry? Geometry { get; set; }
|
||||
}
|
||||
|
||||
private sealed class BANProperties
|
||||
{
|
||||
[JsonPropertyName("label")]
|
||||
public string? Label { get; set; }
|
||||
|
||||
[JsonPropertyName("postcode")]
|
||||
public string? Postcode { get; set; }
|
||||
|
||||
[JsonPropertyName("city")]
|
||||
public string? City { get; set; }
|
||||
}
|
||||
|
||||
private sealed class BANGeometry
|
||||
{
|
||||
// [longitude, latitude]
|
||||
[JsonPropertyName("coordinates")]
|
||||
public List<double>? Coordinates { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Résultat du calcul du montant barème pour un (année, puissance fiscale, total km) donné.
|
||||
/// Lorsque aucune tranche n'est configurée, <see cref="BaremeConfigure"/> vaut <c>false</c>,
|
||||
/// le <see cref="Montant"/> est nul et un <see cref="Avertissement"/> décrit la situation
|
||||
/// (Exigence 13.4) — le calcul n'est jamais interrompu par une exception bloquante.
|
||||
/// </summary>
|
||||
/// <param name="Montant">Montant calculé : total_km × Coefficient + Base de la tranche, ou 0 si non configuré.</param>
|
||||
/// <param name="BaremeConfigure">Vrai si une tranche a été trouvée et appliquée.</param>
|
||||
/// <param name="Coefficient">Coefficient €/km de la tranche appliquée (0 si non configuré).</param>
|
||||
/// <param name="Base">Base fixe € de la tranche appliquée (0 si non configuré).</param>
|
||||
/// <param name="Avertissement">Message d'avertissement lorsque le barème n'est pas configuré, sinon <c>null</c>.</param>
|
||||
public record BaremeCalculResult(
|
||||
decimal Montant,
|
||||
bool BaremeConfigure,
|
||||
decimal Coefficient,
|
||||
decimal Base,
|
||||
string? Avertissement);
|
||||
@@ -0,0 +1,248 @@
|
||||
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 BaremeService : IBaremeService
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
|
||||
public BaremeService(AppDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TrancheBaremeDto>> GetBaremeAsync(
|
||||
int annee, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// KmMin est un decimal : SQLite ne peut pas l'utiliser dans un ORDER BY, on trie côté client.
|
||||
var tranches = (await _context.TranchesBareme
|
||||
.AsNoTracking()
|
||||
.Where(t => t.Annee == annee)
|
||||
.ToListAsync(cancellationToken))
|
||||
.OrderBy(t => t.PuissanceFiscale)
|
||||
.ThenBy(t => t.KmMin)
|
||||
.ToList();
|
||||
|
||||
return tranches.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TrancheBaremeDto> AddTrancheAsync(
|
||||
SaveTrancheBaremeDto dto, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureNoOverlapAsync(dto.Annee, dto.PuissanceFiscale, dto.KmMin, dto.KmMax, excludeId: null, cancellationToken);
|
||||
|
||||
var tranche = new TrancheBareme
|
||||
{
|
||||
Annee = dto.Annee,
|
||||
PuissanceFiscale = dto.PuissanceFiscale,
|
||||
KmMin = dto.KmMin,
|
||||
KmMax = dto.KmMax,
|
||||
Coefficient = dto.Coefficient,
|
||||
Base = dto.Base,
|
||||
};
|
||||
|
||||
_context.TranchesBareme.Add(tranche);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return ToDto(tranche);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TrancheBaremeDto> UpdateTrancheAsync(
|
||||
int id, SaveTrancheBaremeDto dto, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tranche = await _context.TranchesBareme
|
||||
.FirstOrDefaultAsync(t => t.Id == id, cancellationToken)
|
||||
?? throw new TrancheBaremeNotFoundException($"La tranche de barème d'identifiant {id} est introuvable.");
|
||||
|
||||
await EnsureNoOverlapAsync(dto.Annee, dto.PuissanceFiscale, dto.KmMin, dto.KmMax, excludeId: id, cancellationToken);
|
||||
|
||||
tranche.Annee = dto.Annee;
|
||||
tranche.PuissanceFiscale = dto.PuissanceFiscale;
|
||||
tranche.KmMin = dto.KmMin;
|
||||
tranche.KmMax = dto.KmMax;
|
||||
tranche.Coefficient = dto.Coefficient;
|
||||
tranche.Base = dto.Base;
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return ToDto(tranche);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteTrancheAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tranche = await _context.TranchesBareme
|
||||
.FirstOrDefaultAsync(t => t.Id == id, cancellationToken)
|
||||
?? throw new TrancheBaremeNotFoundException($"La tranche de barème d'identifiant {id} est introuvable.");
|
||||
|
||||
_context.TranchesBareme.Remove(tranche);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BaremeCalculResult> CalculerMontantAsync(
|
||||
int annee, int puissanceFiscale, decimal totalKm, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tranches = await _context.TranchesBareme
|
||||
.AsNoTracking()
|
||||
.Where(t => t.Annee == annee && t.PuissanceFiscale == puissanceFiscale)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Aucune tranche configurée : montant zéro + signal (Exigence 13.4), sans erreur bloquante.
|
||||
if (tranches.Count == 0)
|
||||
{
|
||||
return new BaremeCalculResult(
|
||||
Montant: 0m,
|
||||
BaremeConfigure: false,
|
||||
Coefficient: 0m,
|
||||
Base: 0m,
|
||||
Avertissement: $"Aucun barème configuré pour la puissance fiscale {puissanceFiscale} CV et l'année {annee} ; le montant de remboursement est nul.");
|
||||
}
|
||||
|
||||
// Sélection de la tranche dont [KmMin, KmMax[ contient le total annuel (Property 24/27).
|
||||
// Borne max EXCLUSIVE ; KmMax == null = illimité. Les tranches ne se chevauchant pas,
|
||||
// au plus une est sélectionnable.
|
||||
var tranche = tranches.FirstOrDefault(t =>
|
||||
totalKm >= t.KmMin && (t.KmMax == null || totalKm < t.KmMax.Value));
|
||||
|
||||
if (tranche is null)
|
||||
{
|
||||
// Des tranches existent mais aucune ne couvre ce total : traité comme non couvert (montant nul).
|
||||
return new BaremeCalculResult(
|
||||
Montant: 0m,
|
||||
BaremeConfigure: false,
|
||||
Coefficient: 0m,
|
||||
Base: 0m,
|
||||
Avertissement: $"Aucune tranche du barème ne couvre un total de {totalKm} km pour la puissance fiscale {puissanceFiscale} CV et l'année {annee} ; le montant de remboursement est nul.");
|
||||
}
|
||||
|
||||
var montant = totalKm * tranche.Coefficient + tranche.Base;
|
||||
|
||||
return new BaremeCalculResult(
|
||||
Montant: montant,
|
||||
BaremeConfigure: true,
|
||||
Coefficient: tranche.Coefficient,
|
||||
Base: tranche.Base,
|
||||
Avertissement: null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> CopierDernierBaremeAsync(int anneeCible, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Année source = année la plus récente STRICTEMENT antérieure à l'année cible qui a un barème.
|
||||
var anneesAvecBareme = (await _context.TranchesBareme
|
||||
.AsNoTracking()
|
||||
.Select(t => t.Annee)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken))
|
||||
.Where(a => a < anneeCible)
|
||||
.OrderByDescending(a => a)
|
||||
.ToList();
|
||||
|
||||
if (anneesAvecBareme.Count == 0)
|
||||
{
|
||||
return 0; // Aucun barème antérieur : rien à recopier.
|
||||
}
|
||||
|
||||
var anneeSource = anneesAvecBareme.First();
|
||||
|
||||
var source = await _context.TranchesBareme
|
||||
.AsNoTracking()
|
||||
.Where(t => t.Annee == anneeSource)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
// Annule et remplace : on supprime d'abord les tranches existantes de l'année cible.
|
||||
var existantes = await _context.TranchesBareme
|
||||
.Where(t => t.Annee == anneeCible)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (existantes.Count > 0)
|
||||
{
|
||||
_context.TranchesBareme.RemoveRange(existantes);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// Copie des tranches de l'année source vers l'année cible.
|
||||
var copies = source.Select(t => new TrancheBareme
|
||||
{
|
||||
Annee = anneeCible,
|
||||
PuissanceFiscale = t.PuissanceFiscale,
|
||||
KmMin = t.KmMin,
|
||||
KmMax = t.KmMax,
|
||||
Coefficient = t.Coefficient,
|
||||
Base = t.Base,
|
||||
}).ToList();
|
||||
|
||||
_context.TranchesBareme.AddRange(copies);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return copies.Count;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vérifie qu'aucune tranche existante pour la même (année, puissance fiscale) ne chevauche
|
||||
/// l'intervalle [<paramref name="kmMin"/>, <paramref name="kmMax"/>]. La tranche d'identifiant
|
||||
/// <paramref name="excludeId"/> (en cours de mise à jour) est exclue de la comparaison.
|
||||
/// Deux intervalles [a1,a2] et [b1,b2] se chevauchent ssi a1 <= b2 ET b1 <= a2.
|
||||
/// </summary>
|
||||
private async Task EnsureNoOverlapAsync(
|
||||
int annee, int puissanceFiscale, decimal kmMin, decimal? kmMax, int? excludeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var existantes = await _context.TranchesBareme
|
||||
.AsNoTracking()
|
||||
.Where(t => t.Annee == annee
|
||||
&& t.PuissanceFiscale == puissanceFiscale
|
||||
&& (excludeId == null || t.Id != excludeId.Value))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Convention [KmMin, KmMax[ (borne max EXCLUSIVE). KmMax == null = illimité (+infini).
|
||||
// Deux intervalles [a1,a2[ et [b1,b2[ se chevauchent ssi a1 < b2 ET b1 < a2,
|
||||
// en traitant une borne max nulle comme +infini.
|
||||
var chevauche = existantes.Any(t =>
|
||||
{
|
||||
var aMaxInfini = kmMax == null;
|
||||
var bMaxInfini = t.KmMax == null;
|
||||
var condA = bMaxInfini || kmMin < t.KmMax!.Value; // a1 < b2
|
||||
var condB = aMaxInfini || t.KmMin < kmMax!.Value; // b1 < a2
|
||||
return condA && condB;
|
||||
});
|
||||
|
||||
if (chevauche)
|
||||
{
|
||||
throw new TranchesBaremeChevauchantesException(
|
||||
"Les tranches du barème pour cette puissance fiscale et cette année se chevauchent.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<int>> GetAnneesAvecBaremeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var annees = await _context.TranchesBareme
|
||||
.AsNoTracking()
|
||||
.Select(t => t.Annee)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
annees.Sort();
|
||||
return annees;
|
||||
}
|
||||
|
||||
private static TrancheBaremeDto ToDto(TrancheBareme t) =>
|
||||
new(t.Id, t.Annee, t.PuissanceFiscale, t.KmMin, t.KmMax, t.Coefficient, t.Base);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Résultat d'un calcul de distance orchestré par <see cref="DistanceCalculatorService"/>.
|
||||
/// Porte les coordonnées résolues des adresses de départ et d'arrivée, la distance
|
||||
/// éventuellement calculée (routière via ORS ou orthodromique via Haversine), la
|
||||
/// géométrie GeoJSON de l'itinéraire lorsqu'elle est disponible, et un éventuel
|
||||
/// avertissement lorsque la distance n'a pas pu être calculée.
|
||||
/// </summary>
|
||||
/// <param name="DepartLatitude">Latitude résolue du point de départ (null si non résoluble).</param>
|
||||
/// <param name="DepartLongitude">Longitude résolue du point de départ (null si non résoluble).</param>
|
||||
/// <param name="ArriveeLatitude">Latitude résolue du point d'arrivée (null si non résoluble).</param>
|
||||
/// <param name="ArriveeLongitude">Longitude résolue du point d'arrivée (null si non résoluble).</param>
|
||||
/// <param name="DistanceKm">
|
||||
/// Distance en kilomètres, arrondie à 2 décimales et bornée entre 0,01 et 99 999,99 km ;
|
||||
/// <c>null</c> lorsque la distance n'a pas pu être calculée (aucune coordonnée résoluble).
|
||||
/// </param>
|
||||
/// <param name="GeometrieJson">
|
||||
/// Géométrie GeoJSON de l'itinéraire routier (fournie par ORS) ; <c>null</c> en cas de
|
||||
/// fallback Haversine ou d'absence de calcul.
|
||||
/// </param>
|
||||
/// <param name="Avertissement">
|
||||
/// Vrai lorsque la distance n'a pas pu être calculée faute de coordonnées résolubles ;
|
||||
/// dans ce cas l'appelant stocke une distance nulle avec un avertissement.
|
||||
/// </param>
|
||||
/// <param name="Message">Message d'avertissement associé, ou <c>null</c>.</param>
|
||||
public record DistanceCalculationResult(
|
||||
double? DepartLatitude,
|
||||
double? DepartLongitude,
|
||||
double? ArriveeLatitude,
|
||||
double? ArriveeLongitude,
|
||||
decimal? DistanceKm,
|
||||
string? GeometrieJson,
|
||||
bool Avertissement,
|
||||
string? Message);
|
||||
@@ -0,0 +1,193 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implémentation de <see cref="IDistanceCalculator"/>.
|
||||
///
|
||||
/// Résout d'abord les coordonnées manquantes via BAN (priorité BAN sur ORS,
|
||||
/// Property 16), puis calcule la distance routière via ORS. En cas de timeout ou
|
||||
/// d'échec ORS, applique le fallback orthodromique (Haversine) sans erreur. Si les
|
||||
/// coordonnées ne peuvent pas être résolues (BAN sans résultat / indisponible), retourne
|
||||
/// une distance nulle assortie d'un avertissement, sans lever d'exception bloquante.
|
||||
///
|
||||
/// _Requirements: 2.1, 2.2, 2.3, 2.4, 2.6_
|
||||
/// </summary>
|
||||
public class DistanceCalculatorService : IDistanceCalculator
|
||||
{
|
||||
/// <summary>Distance minimale stockable (km).</summary>
|
||||
private const decimal DistanceMinKm = 0.01m;
|
||||
|
||||
/// <summary>Distance maximale stockable (km).</summary>
|
||||
private const decimal DistanceMaxKm = 99_999.99m;
|
||||
|
||||
private readonly IBANClient _banClient;
|
||||
private readonly IORSClient _orsClient;
|
||||
private readonly ILogger<DistanceCalculatorService> _logger;
|
||||
|
||||
public DistanceCalculatorService(
|
||||
IBANClient banClient,
|
||||
IORSClient orsClient,
|
||||
ILogger<DistanceCalculatorService> logger)
|
||||
{
|
||||
_banClient = banClient;
|
||||
_orsClient = orsClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DistanceCalculationResult> CalculerAsync(
|
||||
string adresseDepart,
|
||||
string adresseArrivee,
|
||||
double? departLatitude,
|
||||
double? departLongitude,
|
||||
double? arriveeLatitude,
|
||||
double? arriveeLongitude,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Coordonnées de départ connues seulement si latitude ET longitude sont présentes.
|
||||
double? depLat = departLatitude;
|
||||
double? depLon = departLongitude;
|
||||
double? arrLat = arriveeLatitude;
|
||||
double? arrLon = arriveeLongitude;
|
||||
|
||||
// Résolution BAN AVANT tout appel ORS pour les coordonnées manquantes (Property 16).
|
||||
if (!EstCoordonneeConnue(depLat, depLon))
|
||||
{
|
||||
var (lat, lon) = await GeocoderAsync(adresseDepart, cancellationToken);
|
||||
depLat = lat;
|
||||
depLon = lon;
|
||||
}
|
||||
|
||||
if (!EstCoordonneeConnue(arrLat, arrLon))
|
||||
{
|
||||
var (lat, lon) = await GeocoderAsync(adresseArrivee, cancellationToken);
|
||||
arrLat = lat;
|
||||
arrLon = lon;
|
||||
}
|
||||
|
||||
// Si une coordonnée reste absente après géocodage BAN → distance nulle + avertissement.
|
||||
if (!EstCoordonneeConnue(depLat, depLon) || !EstCoordonneeConnue(arrLat, arrLon))
|
||||
{
|
||||
return new DistanceCalculationResult(
|
||||
DepartLatitude: depLat,
|
||||
DepartLongitude: depLon,
|
||||
ArriveeLatitude: arrLat,
|
||||
ArriveeLongitude: arrLon,
|
||||
DistanceKm: null,
|
||||
GeometrieJson: null,
|
||||
Avertissement: true,
|
||||
Message: "Le trajet a été enregistré mais la distance n'a pas pu être "
|
||||
+ "calculée faute de coordonnées géographiques.");
|
||||
}
|
||||
|
||||
// À ce stade, les quatre coordonnées sont non-null : jamais d'appel ORS avec des coordonnées nulles.
|
||||
var lat1 = depLat!.Value;
|
||||
var lon1 = depLon!.Value;
|
||||
var lat2 = arrLat!.Value;
|
||||
var lon2 = arrLon!.Value;
|
||||
|
||||
decimal distanceKm;
|
||||
string? geometrieJson = null;
|
||||
var avertissement = false;
|
||||
string? message = null;
|
||||
|
||||
try
|
||||
{
|
||||
var route = await _orsClient.CalculateRouteAsync(lat1, lon1, lat2, lon2, cancellationToken);
|
||||
distanceKm = route.DistanceKm;
|
||||
geometrieJson = route.GeometrieJson;
|
||||
}
|
||||
catch (ORSTimeoutException ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Timeout ORS : application du fallback Haversine pour le calcul de distance.");
|
||||
distanceKm = HaversineKm(lat1, lon1, lat2, lon2);
|
||||
}
|
||||
catch (ORSException ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Échec ORS : application du fallback Haversine pour le calcul de distance.");
|
||||
distanceKm = HaversineKm(lat1, lon1, lat2, lon2);
|
||||
}
|
||||
|
||||
// Arrondi 2 décimales + bornage [0,01 ; 99 999,99] km.
|
||||
distanceKm = BornerDistance(Math.Round(distanceKm, 2, MidpointRounding.AwayFromZero));
|
||||
|
||||
return new DistanceCalculationResult(
|
||||
DepartLatitude: lat1,
|
||||
DepartLongitude: lon1,
|
||||
ArriveeLatitude: lat2,
|
||||
ArriveeLongitude: lon2,
|
||||
DistanceKm: distanceKm,
|
||||
GeometrieJson: geometrieJson,
|
||||
Avertissement: avertissement,
|
||||
Message: message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Géocode une adresse via BAN et retourne les coordonnées du meilleur (premier) résultat.
|
||||
/// Retourne <c>(null, null)</c> si aucune adresse n'est trouvée ou si BAN est indisponible,
|
||||
/// sans propager d'exception (échec BAN non bloquant).
|
||||
/// </summary>
|
||||
private async Task<(double? Latitude, double? Longitude)> GeocoderAsync(
|
||||
string adresse,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(adresse))
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var resultats = await _banClient.RechercherAsync(adresse, cancellationToken);
|
||||
if (resultats.Count == 0)
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
// Meilleur / premier résultat (la BAN retourne les résultats par pertinence décroissante).
|
||||
var meilleur = resultats[0];
|
||||
return (meilleur.Latitude, meilleur.Longitude);
|
||||
}
|
||||
catch (BANIndisponibleException ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"BAN indisponible lors du géocodage de l'adresse manquante ; distance non calculable.");
|
||||
return (null, null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Vrai si latitude ET longitude sont toutes deux renseignées.</summary>
|
||||
private static bool EstCoordonneeConnue(double? latitude, double? longitude)
|
||||
=> latitude.HasValue && longitude.HasValue;
|
||||
|
||||
/// <summary>Borne une distance dans l'intervalle stockable [0,01 ; 99 999,99] km.</summary>
|
||||
private static decimal BornerDistance(decimal distanceKm)
|
||||
{
|
||||
if (distanceKm < DistanceMinKm)
|
||||
{
|
||||
return DistanceMinKm;
|
||||
}
|
||||
|
||||
return distanceKm > DistanceMaxKm ? DistanceMaxKm : distanceKm;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calcule la distance orthodromique (formule de Haversine) entre deux points, en
|
||||
/// kilomètres, arrondie à 2 décimales. R = 6371 km (rayon moyen de la Terre).
|
||||
/// </summary>
|
||||
private static decimal HaversineKm(double lat1, double lon1, double lat2, double lon2)
|
||||
{
|
||||
const double R = 6371.0;
|
||||
var dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
var dLon = (lon2 - lon1) * Math.PI / 180;
|
||||
var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2)
|
||||
+ Math.Cos(lat1 * Math.PI / 180) * Math.Cos(lat2 * Math.PI / 180)
|
||||
* Math.Sin(dLon / 2) * Math.Sin(dLon / 2);
|
||||
var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
|
||||
return Math.Round((decimal)(R * c), 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsque le texte d'adresse d'une Adresse_Nommee est absent, vide ou dépasse
|
||||
/// 255 caractères (Exigence 15.8). Convertie en réponse HTTP 400 (Bad Request).
|
||||
/// </summary>
|
||||
public class AdresseNommeeInvalideException : Exception
|
||||
{
|
||||
public AdresseNommeeInvalideException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public AdresseNommeeInvalideException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsqu'aucune Adresse_Nommee ne correspond à l'identifiant demandé
|
||||
/// (modification, suppression). Convertie en réponse HTTP 404 (Not Found).
|
||||
/// </summary>
|
||||
public class AdresseNommeeNotFoundException : Exception
|
||||
{
|
||||
public AdresseNommeeNotFoundException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public AdresseNommeeNotFoundException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsque l'API BAN (Base Adresses Nationale) est indisponible :
|
||||
/// échec réseau, réponse HTTP non réussie ou dépassement du délai (timeout).
|
||||
/// </summary>
|
||||
public class BANIndisponibleException : Exception
|
||||
{
|
||||
public BANIndisponibleException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public BANIndisponibleException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Signale l'absence de barème configuré pour une (puissance fiscale, année) lors d'un calcul de montant.
|
||||
/// Ce n'est pas une erreur bloquante : le calcul retourne un montant zéro accompagné d'un avertissement
|
||||
/// (Exigence 13.4). Le service de calcul n'a donc pas à lever cette exception pour interrompre le calcul ;
|
||||
/// elle est fournie pour les cas où un appelant souhaite explicitement signaler la non-configuration.
|
||||
/// </summary>
|
||||
public class BaremeNonConfigureException : Exception
|
||||
{
|
||||
public BaremeNonConfigureException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public BaremeNonConfigureException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsqu'une Date_Effet fournie pour un véhicule est postérieure à la date du jour.
|
||||
/// Convertie en réponse HTTP 400 (Bad Request) — Exigence 10.11.
|
||||
/// </summary>
|
||||
public class DateEffetFutureException : Exception
|
||||
{
|
||||
public DateEffetFutureException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public DateEffetFutureException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsqu'une Dénomination d'Adresse_Nommee, après normalisation (trim + minuscules +
|
||||
/// suppression des accents), correspond à une Adresse_Nommee déjà enregistrée (Exigence 15.7).
|
||||
/// Convertie en réponse HTTP 400 (Bad Request).
|
||||
/// </summary>
|
||||
public class DenominationDupliqueeException : Exception
|
||||
{
|
||||
public DenominationDupliqueeException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public DenominationDupliqueeException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsqu'une Dénomination d'Adresse_Nommee est absente, vide ou dépasse 100 caractères
|
||||
/// (Exigence 15.6). Convertie en réponse HTTP 400 (Bad Request).
|
||||
/// </summary>
|
||||
public class DenominationInvalideException : Exception
|
||||
{
|
||||
public DenominationInvalideException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public DenominationInvalideException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsqu'un recalcul de la Distance échoue lors de la modification d'un Trajet.
|
||||
/// Le service annule (rollback) la modification et conserve les données existantes du Trajet
|
||||
/// sans modification, puis retourne cette erreur descriptive (Exigence 5.8).
|
||||
/// Convertie en réponse HTTP 502 (Bad Gateway) / erreur descriptive par le contrôleur.
|
||||
/// </summary>
|
||||
public class DistanceRecalculException : Exception
|
||||
{
|
||||
public DistanceRecalculException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public DistanceRecalculException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsqu'un Montant_Regle soumis est négatif. Convertie en réponse HTTP 400
|
||||
/// (Bad Request) — Exigence 14.4 : « Le montant réglé ne peut pas être négatif. »
|
||||
/// </summary>
|
||||
public class MontantRegleNegatifException : Exception
|
||||
{
|
||||
public MontantRegleNegatifException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public MontantRegleNegatifException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsque la génération d'un Export_PDF échoue : erreur de rendu QuestPDF
|
||||
/// ou dépassement du délai de 15 secondes (Exigence 7.5). Le controller la mappe
|
||||
/// vers une réponse <c>503 Service Unavailable</c>.
|
||||
/// </summary>
|
||||
public class PdfGenerationException : Exception
|
||||
{
|
||||
public PdfGenerationException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public PdfGenerationException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsqu'un paramètre de résumé (mois ou année) est hors des plages autorisées :
|
||||
/// mois ∈ [1, 12] et année ∈ [2000, 2100]. Convertie en réponse HTTP 400 (Bad Request)
|
||||
/// par le controller — Exigence 3.6.
|
||||
/// </summary>
|
||||
public class ResumeParametreInvalideException : Exception
|
||||
{
|
||||
public ResumeParametreInvalideException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public ResumeParametreInvalideException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsqu'une demande de modification ou de suppression cible un Trajet inexistant.
|
||||
/// Convertie en réponse HTTP 404 (Not Found) ; l'opération est bloquée et aucun Résumé_Mensuel
|
||||
/// n'est mis à jour (Exigences 5.6).
|
||||
/// </summary>
|
||||
public class TrajetNotFoundException : Exception
|
||||
{
|
||||
public TrajetNotFoundException(int id)
|
||||
: base($"Le trajet {id} n'existe pas.")
|
||||
{
|
||||
}
|
||||
|
||||
public TrajetNotFoundException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public TrajetNotFoundException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsqu'aucune tranche de barème ne correspond à l'identifiant demandé
|
||||
/// (mise à jour ou suppression). Convertie en réponse HTTP 404 (Not Found).
|
||||
/// </summary>
|
||||
public class TrancheBaremeNotFoundException : Exception
|
||||
{
|
||||
public TrancheBaremeNotFoundException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public TrancheBaremeNotFoundException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
namespace TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsqu'une opération sur le barème produirait des tranches dont les intervalles
|
||||
/// [KmMin, KmMax] se chevauchent pour une même (année, puissance fiscale).
|
||||
/// Convertie en réponse HTTP 400 (Bad Request) — Exigence 13.2.
|
||||
/// </summary>
|
||||
public class TranchesBaremeChevauchantesException : Exception
|
||||
{
|
||||
public TranchesBaremeChevauchantesException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public TranchesBaremeChevauchantesException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsqu'aucun véhicule ne correspond à l'identifiant demandé
|
||||
/// (résolution du véhicule courant, réaffectation, etc.).
|
||||
/// Convertie en réponse HTTP 404 (Not Found).
|
||||
/// </summary>
|
||||
public class VehiculeNotFoundException : Exception
|
||||
{
|
||||
public VehiculeNotFoundException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public VehiculeNotFoundException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Gestion du Cache_Adresses : recherche locale des adresses déjà connues, mise en cache
|
||||
/// (upsert) des adresses géocodées et politique d'éviction LRU.
|
||||
/// </summary>
|
||||
public interface IAdresseCacheService
|
||||
{
|
||||
/// <summary>
|
||||
/// Recherche locale des adresses en cache correspondant au fragment fourni.
|
||||
/// Retourne une liste vide si le fragment fait moins de 3 caractères (Requirement 9.3).
|
||||
/// Les suggestions sont ordonnées de la plus récemment utilisée à la plus ancienne.
|
||||
/// </summary>
|
||||
/// <param name="query">Fragment de recherche (min. 3 caractères).</param>
|
||||
/// <param name="limite">Nombre maximal de suggestions retournées (par défaut 5).</param>
|
||||
Task<IReadOnlyList<AdresseCacheDto>> RechercherLocalAsync(
|
||||
string query,
|
||||
int limite = 5,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Recherche l'entrée du cache correspondant exactement à l'adresse fournie (après normalisation).
|
||||
/// Utilisé lors de la création d'un Trajet pour éviter un appel BAN (Requirement 9.2).
|
||||
/// Met à jour la date de dernière utilisation de l'entrée trouvée.
|
||||
/// </summary>
|
||||
/// <returns>Les coordonnées et la géométrie si l'adresse est en cache, sinon <c>null</c>.</returns>
|
||||
Task<AdresseCacheEntreeDto?> ObtenirParAdresseAsync(
|
||||
string adresse,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Insère l'adresse dans le cache si elle est absente, sinon met uniquement à jour sa date de
|
||||
/// dernière utilisation sans modifier ses coordonnées (Requirements 9.1, 9.6). Applique ensuite
|
||||
/// la politique d'éviction LRU (Requirement 9.5). L'ensemble s'exécute en transaction.
|
||||
/// </summary>
|
||||
/// <param name="adresse">Libellé de l'adresse (sera normalisé pour le matching).</param>
|
||||
/// <param name="latitude">Latitude à utiliser si l'adresse est nouvelle.</param>
|
||||
/// <param name="longitude">Longitude à utiliser si l'adresse est nouvelle.</param>
|
||||
/// <param name="geometrieJson">Géométrie GeoJSON optionnelle si l'adresse est nouvelle.</param>
|
||||
Task UpsertAdresseAsync(
|
||||
string adresse,
|
||||
double latitude,
|
||||
double longitude,
|
||||
string? geometrieJson = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Gestion du Carnet_Adresses : CRUD des Adresses_Nommées avec normalisation de la Dénomination
|
||||
/// (unicité) et résolution des coordonnées via le Cache_Adresses ou l'API BAN (Exigence 15).
|
||||
/// </summary>
|
||||
public interface IAdresseNommeeService
|
||||
{
|
||||
/// <summary>
|
||||
/// Liste toutes les Adresses_Nommées du Carnet_Adresses, triées par Dénomination (Exigence 15.1).
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<AdresseNommeeDto>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Crée une Adresse_Nommee : vérifie l'unicité de la Dénomination normalisée (15.6/15.7),
|
||||
/// réutilise le Cache_Adresses si l'adresse y est déjà présente (15.10) ou géocode via BAN (15.11),
|
||||
/// persiste avec ou sans coordonnées (15.5/15.12) et met en cache les coordonnées obtenues (15.9).
|
||||
/// </summary>
|
||||
/// <exception cref="Exceptions.DenominationDupliqueeException">Dénomination normalisée déjà utilisée (400).</exception>
|
||||
Task<AdresseNommeeDto> CreateAsync(SaveAdresseNommeeDto dto, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Modifie une Adresse_Nommee existante. Si le texte d'adresse a changé et n'est pas en cache,
|
||||
/// re-géocode via BAN (15.11). Applique le contrôle d'unicité de la Dénomination (15.7).
|
||||
/// </summary>
|
||||
/// <exception cref="Exceptions.AdresseNommeeNotFoundException">Adresse_Nommee introuvable (404).</exception>
|
||||
/// <exception cref="Exceptions.DenominationDupliqueeException">Dénomination normalisée déjà utilisée (400).</exception>
|
||||
Task<AdresseNommeeDto> UpdateAsync(int id, SaveAdresseNommeeDto dto, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Supprime une Adresse_Nommee du Carnet_Adresses. Les Trajets existants ne sont pas modifiés
|
||||
/// (ils conservent leur texte d'adresse et leurs coordonnées) (Exigence 15.14).
|
||||
/// </summary>
|
||||
/// <exception cref="Exceptions.AdresseNommeeNotFoundException">Adresse_Nommee introuvable (404).</exception>
|
||||
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client de l'API BAN (Base Adresses Nationale) pour l'autocomplétion
|
||||
/// et le géocodage des adresses françaises.
|
||||
/// </summary>
|
||||
public interface IBANClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Recherche des adresses correspondant à <paramref name="query"/> via l'endpoint
|
||||
/// <c>/search/</c> de la BAN (limité à 5 résultats).
|
||||
/// </summary>
|
||||
/// <param name="query">Texte de recherche (adresse partielle ou complète).</param>
|
||||
/// <param name="cancellationToken">Jeton d'annulation.</param>
|
||||
/// <returns>La liste des adresses trouvées, mappées en <see cref="BANAdresseDto"/>.</returns>
|
||||
/// <exception cref="Exceptions.BANIndisponibleException">
|
||||
/// Levée en cas d'échec réseau, de réponse non réussie ou de dépassement du délai.
|
||||
/// </exception>
|
||||
Task<IReadOnlyList<BANAdresseDto>> RechercherAsync(string query, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Logique métier du barème kilométrique : CRUD des tranches, validation du non-chevauchement
|
||||
/// par (année, puissance fiscale), sélection de tranche et calcul de montant.
|
||||
/// </summary>
|
||||
public interface IBaremeService
|
||||
{
|
||||
/// <summary>Retourne toutes les tranches d'une année (toutes puissances fiscales confondues).</summary>
|
||||
Task<IReadOnlyList<TrancheBaremeDto>> GetBaremeAsync(int annee, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Ajoute une tranche. Rejette l'ajout s'il crée un chevauchement d'intervalle [KmMin, KmMax]
|
||||
/// pour la même (année, puissance fiscale) via <see cref="Exceptions.TranchesBaremeChevauchantesException"/>.
|
||||
/// </summary>
|
||||
Task<TrancheBaremeDto> AddTrancheAsync(SaveTrancheBaremeDto dto, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Met à jour une tranche existante. Rejette la mise à jour si elle crée un chevauchement
|
||||
/// (hors la tranche elle-même) via <see cref="Exceptions.TranchesBaremeChevauchantesException"/>.
|
||||
/// Lève <see cref="Exceptions.TrancheBaremeNotFoundException"/> si la tranche n'existe pas.
|
||||
/// </summary>
|
||||
Task<TrancheBaremeDto> UpdateTrancheAsync(int id, SaveTrancheBaremeDto dto, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Supprime une tranche. Lève <see cref="Exceptions.TrancheBaremeNotFoundException"/> si elle n'existe pas.
|
||||
/// </summary>
|
||||
Task DeleteTrancheAsync(int id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sélectionne la tranche dont [KmMin, KmMax] contient <paramref name="totalKm"/> pour la
|
||||
/// (année, puissance fiscale) donnée et calcule le montant `totalKm × Coefficient + Base`
|
||||
/// (Property 24/27). Si aucune tranche n'existe pour cette combinaison, retourne un montant nul
|
||||
/// et un signal « barème non configuré » sans lever d'erreur bloquante (Exigence 13.4).
|
||||
/// </summary>
|
||||
Task<BaremeCalculResult> CalculerMontantAsync(
|
||||
int annee, int puissanceFiscale, decimal totalKm, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Copie toutes les tranches de l'année la plus récente (strictement antérieure à
|
||||
/// <paramref name="anneeCible"/>) qui possède un barème, vers <paramref name="anneeCible"/>.
|
||||
/// Les tranches existantes de l'année cible sont d'abord supprimées (annule et remplace),
|
||||
/// en transaction. Retourne le nombre de tranches copiées. Retourne 0 si aucun barème
|
||||
/// antérieur n'existe (rien n'est modifié).
|
||||
/// </summary>
|
||||
Task<int> CopierDernierBaremeAsync(int anneeCible, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Retourne les années (triées croissantes) pour lesquelles au moins une tranche de barème existe.</summary>
|
||||
Task<IReadOnlyList<int>> GetAnneesAvecBaremeAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestre le calcul de la distance d'un trajet à partir des adresses de départ et
|
||||
/// d'arrivée et de leurs coordonnées éventuelles.
|
||||
///
|
||||
/// Logique :
|
||||
/// <list type="bullet">
|
||||
/// <item>Si les deux coordonnées sont connues → appel ORS ; en cas de timeout/échec ORS
|
||||
/// → fallback orthodromique (Haversine), sans erreur pour l'utilisateur.</item>
|
||||
/// <item>Si une coordonnée est absente → géocodage BAN de l'adresse manquante
|
||||
/// (meilleur résultat) AVANT tout appel ORS ; ORS n'est jamais appelé avec des
|
||||
/// coordonnées nulles (Property 16).</item>
|
||||
/// <item>Si BAN ne peut pas résoudre (aucun résultat ou indisponible) → distance nulle
|
||||
/// + avertissement, sans erreur bloquante, pour que l'appelant stocke la distance null.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public interface IDistanceCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Calcule la distance entre <paramref name="adresseDepart"/> et
|
||||
/// <paramref name="adresseArrivee"/>, en résolvant au besoin les coordonnées manquantes
|
||||
/// via BAN, puis en appelant ORS (avec fallback Haversine).
|
||||
/// </summary>
|
||||
/// <param name="adresseDepart">Texte de l'adresse de départ.</param>
|
||||
/// <param name="adresseArrivee">Texte de l'adresse d'arrivée.</param>
|
||||
/// <param name="departLatitude">Latitude de départ connue, ou <c>null</c>.</param>
|
||||
/// <param name="departLongitude">Longitude de départ connue, ou <c>null</c>.</param>
|
||||
/// <param name="arriveeLatitude">Latitude d'arrivée connue, ou <c>null</c>.</param>
|
||||
/// <param name="arriveeLongitude">Longitude d'arrivée connue, ou <c>null</c>.</param>
|
||||
/// <param name="cancellationToken">Jeton d'annulation.</param>
|
||||
/// <returns>Un <see cref="DistanceCalculationResult"/> (jamais d'exception bloquante).</returns>
|
||||
Task<DistanceCalculationResult> CalculerAsync(
|
||||
string adresseDepart,
|
||||
string adresseArrivee,
|
||||
double? departLatitude,
|
||||
double? departLongitude,
|
||||
double? arriveeLatitude,
|
||||
double? arriveeLongitude,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Logique métier des Montant_Regle (montants effectivement versés, saisis par l'utilisateur)
|
||||
/// et du Montant_Previsionnel (indication de versement à 80% du barème, purement informative).
|
||||
/// </summary>
|
||||
public interface IMontantRegleService
|
||||
{
|
||||
/// <summary>
|
||||
/// Enregistre ou met à jour (upsert) le Montant_Regle pour un couple (année, mois, véhicule).
|
||||
/// S'il existe déjà une ligne pour la même (Annee, Mois, VehiculeId), elle est mise à jour ;
|
||||
/// sinon une nouvelle ligne est insérée (Property 26, Exigences 14.1/14.2/14.3).
|
||||
/// Rejette un montant négatif via <see cref="Exceptions.MontantRegleNegatifException"/> (Exigence 14.4).
|
||||
/// </summary>
|
||||
Task<MontantRegleDto> UpsertMontantRegleAsync(
|
||||
MontantRegleDto dto, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retourne les Montant_Regle saisis pour une (année, véhicule), triés par mois croissant.
|
||||
/// Destiné au controller et au service de régularisation.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<MontantRegleDto>> GetMontantsReglesAsync(
|
||||
int annee, int vehiculeId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Calcule le Montant_Previsionnel indicatif pour un (année, mois, véhicule) :
|
||||
/// 80% du Bareme_Kilometrique plein appliqué au Total_Annuel_Partiel du véhicule
|
||||
/// (somme des distances de l'année pour ce véhicule jusqu'au mois indiqué inclus),
|
||||
/// arrondi à deux décimales (Property 23, Exigences 13.5/13.6).
|
||||
/// Cette valeur est purement informative et n'entre jamais dans la régularisation.
|
||||
/// </summary>
|
||||
Task<MontantPrevisionnelDto> GetMontantPrevisionnelAsync(
|
||||
int annee, int mois, int vehiculeId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client de l'API OpenRouteService (ORS) — calcul d'itinéraire routier uniquement.
|
||||
/// ORS n'est jamais appelé pour du géocodage ni avec des coordonnées nulles :
|
||||
/// les coordonnées de départ et d'arrivée doivent être connues (non-null).
|
||||
/// </summary>
|
||||
public interface IORSClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Calcule l'itinéraire routier entre deux points géographiques connus via
|
||||
/// <c>POST /v2/directions/driving-car</c> et retourne la distance (km) et la
|
||||
/// géométrie GeoJSON de l'itinéraire.
|
||||
/// </summary>
|
||||
/// <param name="lat1">Latitude du point de départ.</param>
|
||||
/// <param name="lon1">Longitude du point de départ.</param>
|
||||
/// <param name="lat2">Latitude du point d'arrivée.</param>
|
||||
/// <param name="lon2">Longitude du point d'arrivée.</param>
|
||||
/// <param name="cancellationToken">Jeton d'annulation.</param>
|
||||
/// <returns>Un <see cref="RouteResult"/> (distance en km + géométrie GeoJSON).</returns>
|
||||
/// <exception cref="ORSTimeoutException">
|
||||
/// Levée si l'appel dépasse le délai imparti (voir <c>ORSOptions.TimeoutSeconds</c>),
|
||||
/// afin que l'appelant applique le fallback orthodromique (Haversine).
|
||||
/// </exception>
|
||||
/// <exception cref="ORSException">
|
||||
/// Levée en cas d'échec général (erreur réseau, réponse invalide, corps illisible).
|
||||
/// </exception>
|
||||
Task<RouteResult> CalculateRouteAsync(
|
||||
double lat1,
|
||||
double lon1,
|
||||
double lat2,
|
||||
double lon2,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Génération des Export_PDF (QuestPDF). Compose les services métier existants
|
||||
/// (résumé, profil, véhicule, barème, montants réglés) pour produire les documents.
|
||||
/// La génération est enveloppée dans un délai de 15 secondes ; en cas de dépassement
|
||||
/// ou d'erreur de rendu, une <see cref="Exceptions.PdfGenerationException"/> est levée
|
||||
/// (Exigence 7.5) — le controller la mappe vers <c>503 Service Unavailable</c>.
|
||||
/// </summary>
|
||||
public interface IPdfService
|
||||
{
|
||||
/// <summary>
|
||||
/// Génère le rapport PDF mensuel d'un (année, mois) pour le véhicule indiqué (Exigences 7.1,
|
||||
/// 7.4, 7.6, 7.8, 7.10, 7.11). L'en-tête contient l'identité de l'utilisateur, le véhicule
|
||||
/// (plaque, puissance fiscale, coefficient et base de la tranche annuelle applicable), la
|
||||
/// période, l'adresse de départ du profil, le total kilométrique du mois, le total kilométrique
|
||||
/// annuel cumulé, le TOTAL IK du mois (km du mois × coefficient de la tranche annuelle) et le
|
||||
/// Montant_Regle du mois. Le corps liste les trajets (date, motif/commentaire, départ → arrivée, km).
|
||||
/// Le nom de fichier suggéré est <c>trajets_MM_AAAA.pdf</c>.
|
||||
/// </summary>
|
||||
/// <exception cref="Exceptions.PdfGenerationException">
|
||||
/// Levée en cas de dépassement du délai de 15 s ou d'échec de rendu (Exigence 7.5).
|
||||
/// </exception>
|
||||
Task<PdfResult> GenererRapportMensuelAsync(
|
||||
int annee, int mois, int vehiculeId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Génère la synthèse annuelle de régularisation d'une <paramref name="annee"/> pour le véhicule
|
||||
/// indiqué (Exigences 7.2, 7.4, 7.6, 7.7, 7.8, 7.12). La page de synthèse contient le nom et le
|
||||
/// prénom de l'utilisateur, l'année, l'adresse de domicile, le barème applicable de l'année (les
|
||||
/// trois tranches coefficient/base par puissance fiscale du véhicule), le véhicule et sa puissance
|
||||
/// fiscale, la tranche annuelle applicable ; puis pour chacun des douze mois : les kilomètres
|
||||
/// parcourus, le Montant_Regle (frais payé) et le Frais_Reel_Mensuel (frais réel). Les totaux
|
||||
/// (km annuel, total réglé, Montant_Reel, Regularisation) et la « formule réelle » sont indiqués.
|
||||
/// Le nom de fichier suggéré est <c>trajets_AAAA.pdf</c>.
|
||||
/// </summary>
|
||||
/// <exception cref="Exceptions.PdfGenerationException">
|
||||
/// Levée en cas de dépassement du délai de 15 s ou d'échec de rendu (Exigence 7.5).
|
||||
/// </exception>
|
||||
Task<PdfResult> GenererRapportAnnuelAsync(
|
||||
int annee, int vehiculeId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Détermine les véhicules ayant des Trajets pour l'<paramref name="annee"/> et génère un rapport
|
||||
/// annuel PDF distinct par véhicule (Exigence 7.9). Retourne un PDF par véhicule ayant des données,
|
||||
/// trié par identifiant de véhicule. Retourne une liste vide si aucun trajet n'existe pour l'année.
|
||||
/// </summary>
|
||||
/// <exception cref="Exceptions.PdfGenerationException">
|
||||
/// Levée en cas de dépassement du délai de 15 s ou d'échec de rendu (Exigence 7.5).
|
||||
/// </exception>
|
||||
Task<IReadOnlyList<PdfResult>> GenererRapportsAnnuelsParVehiculeAsync(
|
||||
int annee, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Détermine les véhicules ayant des Trajets pour le couple (<paramref name="annee"/>,
|
||||
/// <paramref name="mois"/>) et génère un rapport mensuel PDF distinct par véhicule (Exigence 7.9).
|
||||
/// Retourne un PDF par véhicule ayant des données, trié par identifiant de véhicule. Retourne une
|
||||
/// liste vide si aucun trajet n'existe pour la période.
|
||||
/// </summary>
|
||||
/// <exception cref="Exceptions.PdfGenerationException">
|
||||
/// Levée en cas de dépassement du délai de 15 s ou d'échec de rendu (Exigence 7.5).
|
||||
/// </exception>
|
||||
Task<IReadOnlyList<PdfResult>> GenererRapportsMensuelsParVehiculeAsync(
|
||||
int annee, int mois, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Logique métier du profil utilisateur (singleton Id = 1) : lecture, sauvegarde avec
|
||||
/// géocodage BAN de l'Adresse_Domicile et détermination de l'état de configuration (onboarding).
|
||||
/// </summary>
|
||||
public interface IProfilService
|
||||
{
|
||||
/// <summary>
|
||||
/// Retourne le profil (singleton Id = 1). Crée l'enregistrement à la volée s'il est absent
|
||||
/// afin de garantir qu'un profil vide soit toujours disponible à la lecture.
|
||||
/// </summary>
|
||||
Task<ProfilDto> GetProfilAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sauvegarde le profil. Géocode l'Adresse_Domicile via la BAN : en cas de succès, stocke les
|
||||
/// coordonnées et positionne <c>DomicileGeocode = true</c> (avec mise en cache de l'adresse) ;
|
||||
/// en cas d'échec BAN, persiste l'adresse textuelle sans coordonnées et <c>DomicileGeocode = false</c>
|
||||
/// sans lever d'exception (Requirements 10.2, 10.7, 10.8).
|
||||
/// </summary>
|
||||
Task<ProfilDto> SaveProfilAsync(SaveProfilDto dto, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Détermine l'état de configuration du profil (onboarding) :
|
||||
/// <c>EstConfigure = true</c> si et seulement si un Profil existe avec un Nom ET un Prénom non vides
|
||||
/// (après trim) ET qu'au moins un Véhicule existe (Property 31, Requirements 16.1, 16.2).
|
||||
/// </summary>
|
||||
Task<EtatProfilDto> GetEtatAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Logique métier de la régularisation annuelle (barème plein, 100%).
|
||||
///
|
||||
/// Contrairement au Montant_Previsionnel (80% indicatif, hors régularisation), la régularisation
|
||||
/// applique le Bareme_Kilometrique plein au total kilométrique annuel COMPLET du véhicule :
|
||||
/// <list type="bullet">
|
||||
/// <item>Montant_Reel = total_km_annuel × Coefficient + Base de la tranche déterminée par ce
|
||||
/// total annuel (Exigence 14.5, Property 25/27).</item>
|
||||
/// <item>Frais_Reel_Mensuel[m] = km_du_mois[m] × Coefficient de la tranche annuelle, de sorte que
|
||||
/// la somme des douze Frais_Reel_Mensuel + la Base de la tranche = Montant_Reel (Exigence 14.6).</item>
|
||||
/// <item>Regularisation = Montant_Reel − total des Montant_Regle de l'année (Exigence 14.7).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public interface IRemboursementService
|
||||
{
|
||||
/// <summary>
|
||||
/// Construit la synthèse annuelle de régularisation pour un couple (année, véhicule) :
|
||||
/// identité de la tranche annuelle applicable, 12 lignes mensuelles
|
||||
/// (<see cref="LigneSyntheseMoisDto"/>) et totaux (km annuel, réglé, Montant_Reel, Regularisation).
|
||||
/// </summary>
|
||||
Task<RegularisationAnnuelleDto> GetRegularisationAnnuelleAsync(
|
||||
int annee, int vehiculeId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Logique métier des résumés kilométriques mensuels et annuels.
|
||||
/// Les distances nulles (Trajet non calculé, Exigence 2.4) sont traitées comme 0 dans les totaux.
|
||||
/// </summary>
|
||||
public interface IResumeService
|
||||
{
|
||||
/// <summary>
|
||||
/// Construit le Résumé_Mensuel d'un mois : liste des Trajets triés par date croissante puis
|
||||
/// par Position croissante, chacun avec sa Distance, et total kilométrique (somme des distances,
|
||||
/// null → 0) arrondi à 2 décimales (Property 1 — Exigences 3.2, 3.3, 6.5).
|
||||
/// Filtre par véhicule lorsque <paramref name="vehiculeId"/> est fourni ; sinon inclut tous les véhicules.
|
||||
/// </summary>
|
||||
/// <exception cref="Exceptions.ResumeParametreInvalideException">
|
||||
/// Levée si <paramref name="mois"/> ∉ [1, 12] ou <paramref name="annee"/> ∉ [2000, 2100] (Exigence 3.6).
|
||||
/// </exception>
|
||||
Task<ResumeMensuelDto> GetResumeMensuelAsync(
|
||||
int annee, int mois, int? vehiculeId = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Construit le Résumé_Annuel d'une année : toujours exactement 12 Résumés_Mensuels
|
||||
/// (mois 1..12, les mois vides ayant un total de 0,00), et total annuel égal à la somme
|
||||
/// des 12 totaux mensuels (Property 2, Property 7 — Exigences 4.2, 4.3, 4.4, 4.7, 6.6).
|
||||
/// Filtre par véhicule lorsque <paramref name="vehiculeId"/> est fourni ; sinon inclut tous les véhicules.
|
||||
/// </summary>
|
||||
/// <exception cref="Exceptions.ResumeParametreInvalideException">
|
||||
/// Levée si <paramref name="annee"/> ∉ [2000, 2100] (Exigence 3.6).
|
||||
/// </exception>
|
||||
Task<ResumeAnnuelDto> GetResumeAnnuelAsync(
|
||||
int annee, int? vehiculeId = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Calcul des totaux du Panneau_Totaux (jour, mois, Total_Annuel_Partiel) filtrés par véhicule
|
||||
/// et fourniture des données de navigation temporelle (années, mois et jours disponibles).
|
||||
/// </summary>
|
||||
public interface ITotauxService
|
||||
{
|
||||
/// <summary>
|
||||
/// Calcule, pour la <paramref name="date"/> sélectionnée et le véhicule indiqué, les totaux
|
||||
/// kilométriques affichés dans le Panneau_Totaux (Exigences 12.7, 12.8, 12.19) :
|
||||
/// <list type="bullet">
|
||||
/// <item><description>TotalJour : somme des distances des Trajets de la journée.</description></item>
|
||||
/// <item><description>TotalMois : somme des distances des Trajets du mois de la date.</description></item>
|
||||
/// <item><description>TotalAnnuelPartiel : cumul de l'année jusqu'au mois de la date inclus,
|
||||
/// à l'exclusion des mois strictement postérieurs (Property 17).</description></item>
|
||||
/// </list>
|
||||
/// Les distances nulles sont comptées comme 0 et tous les totaux sont arrondis à 2 décimales.
|
||||
/// Seuls les Trajets du véhicule sont additionnés (Property 21).
|
||||
/// Si <paramref name="vehiculeId"/> vaut <c>null</c>, le Véhicule_Courant est utilisé
|
||||
/// (via <see cref="IVehiculeService.GetVehiculeCourantAsync"/>).
|
||||
/// </summary>
|
||||
Task<TotauxDto> GetTotauxAsync(DateOnly date, int? vehiculeId = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retourne la liste des années comportant au moins un Trajet, à laquelle est ajoutée
|
||||
/// l'année courante si elle n'y figure pas déjà (Exigence 12.15). Liste triée croissante.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<int>> GetAnneesDisponiblesAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retourne la liste des mois (1..12) de l'année <paramref name="annee"/> comportant au moins
|
||||
/// un Trajet (Exigence 12.16). Liste triée croissante.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<int>> GetMoisDisponiblesAsync(int annee, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retourne la liste des jours du mois <paramref name="mois"/> de l'année <paramref name="annee"/>
|
||||
/// comportant au moins un Trajet (Exigence 12.16). Liste triée croissante.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<int>> GetJoursDisponiblesAsync(int annee, int mois, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Logique métier de gestion des Trajets. Cette interface couvre pour l'instant la
|
||||
/// création (tâche 8.2) ; la modification, la suppression et le réordonnancement
|
||||
/// (tâche 8.5) l'étendront ultérieurement.
|
||||
/// </summary>
|
||||
public interface ITrajetService
|
||||
{
|
||||
/// <summary>
|
||||
/// Crée un Trajet à partir de <paramref name="dto"/> :
|
||||
/// <list type="bullet">
|
||||
/// <item>parse la date au format JJ/MM/AAAA (la validation de forme est faite par le validateur MVC) ;</item>
|
||||
/// <item>associe le véhicule : <c>VehiculeId</c> fourni sinon le véhicule courant à la date
|
||||
/// du trajet, en copiant sa <c>PuissanceFiscale</c> (dénormalisation, Exigence 10.14) ;</item>
|
||||
/// <item>calcule la distance via <see cref="IDistanceCalculator"/> (BAN puis ORS, fallback Haversine) ;</item>
|
||||
/// <item>met en cache les adresses résolues via <see cref="IAdresseCacheService"/> ;</item>
|
||||
/// <item>attribue la position en fin de journée (<c>max(Position)+1.0</c>) ;</item>
|
||||
/// <item>persiste le Trajet en transaction. Lorsque la distance n'a pas pu être calculée,
|
||||
/// le Trajet est tout de même persisté avec une Distance nulle et un avertissement (Exigence 2.4).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <param name="dto">Données de création transmises par le contrôleur (déjà validées côté MVC).</param>
|
||||
/// <param name="cancellationToken">Jeton d'annulation.</param>
|
||||
/// <returns>Le <see cref="TrajetCreationResult"/> (trajet créé + éventuel avertissement).</returns>
|
||||
/// <exception cref="Exceptions.VehiculeNotFoundException">
|
||||
/// Si aucun véhicule n'est applicable à la date du trajet et qu'aucun <c>VehiculeId</c> n'est fourni.
|
||||
/// </exception>
|
||||
Task<TrajetCreationResult> CreateAsync(CreateTrajetDto dto, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Modifie un Trajet existant à partir de <paramref name="dto"/> :
|
||||
/// <list type="bullet">
|
||||
/// <item>lève <see cref="Exceptions.TrajetNotFoundException"/> (404) si le Trajet est introuvable
|
||||
/// (Exigence 5.6) ;</item>
|
||||
/// <item>recalcule la Distance <b>uniquement</b> si au moins une adresse a changé (comparaison sur
|
||||
/// le texte d'adresse normalisé) ou si le Trajet avait une Distance nulle et que les nouvelles
|
||||
/// adresses deviennent résolubles (Exigences 5.2, 5.9, 5.10) ; sinon les autres champs (commentaire,
|
||||
/// véhicule, date) sont mis à jour <b>sans</b> recalcul de distance (Exigence 5.3) ;</item>
|
||||
/// <item>en cas d'échec du recalcul (coordonnées connues mais calcul impossible), annule la
|
||||
/// modification (rollback) et lève <see cref="Exceptions.DistanceRecalculException"/> en préservant
|
||||
/// les données existantes du Trajet (Exigence 5.8) ;</item>
|
||||
/// <item>met en cache les adresses résolues (Exigences 6.7, 6.8) ;</item>
|
||||
/// <item>persiste la modification en transaction.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <param name="id">Identifiant du Trajet à modifier.</param>
|
||||
/// <param name="dto">Données de modification (déjà validées côté MVC).</param>
|
||||
/// <param name="cancellationToken">Jeton d'annulation.</param>
|
||||
/// <returns>Le <see cref="TrajetUpdateResult"/> (trajet mis à jour + éventuel avertissement).</returns>
|
||||
/// <exception cref="Exceptions.TrajetNotFoundException">Si le Trajet n'existe pas.</exception>
|
||||
/// <exception cref="Exceptions.DistanceRecalculException">Si le recalcul de la distance échoue.</exception>
|
||||
/// <exception cref="Exceptions.VehiculeNotFoundException">
|
||||
/// Si le <c>VehiculeId</c> fourni est introuvable, ou qu'aucun véhicule n'est applicable à la date.
|
||||
/// </exception>
|
||||
Task<TrajetUpdateResult> UpdateAsync(int id, UpdateTrajetDto dto, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Supprime un Trajet existant de manière transactionnelle (Exigence 5.5).
|
||||
/// Lève <see cref="Exceptions.TrajetNotFoundException"/> (404) si le Trajet est introuvable (Exigence 5.6).
|
||||
/// </summary>
|
||||
/// <param name="id">Identifiant du Trajet à supprimer.</param>
|
||||
/// <param name="cancellationToken">Jeton d'annulation.</param>
|
||||
/// <exception cref="Exceptions.TrajetNotFoundException">Si le Trajet n'existe pas.</exception>
|
||||
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Applique un réordonnancement manuel des trajets d'une journée : met à jour uniquement la
|
||||
/// <c>Position</c> des trajets fournis via <see cref="ITrajetRepository.ReordonnerAsync"/>,
|
||||
/// sans altérer leurs autres champs (Exigence 11.9). Les identifiants inconnus sont ignorés.
|
||||
/// </summary>
|
||||
/// <param name="dto">Nouvelles positions des trajets à réordonner.</param>
|
||||
/// <param name="cancellationToken">Jeton d'annulation.</param>
|
||||
Task ReordonnerAsync(ReordonnerTrajetsDto dto, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Recalcule la Distance (et la géométrie d'itinéraire mise en cache) de TOUS les trajets
|
||||
/// via <see cref="IDistanceCalculator"/> (BAN + ORS, fallback Haversine), indépendamment de
|
||||
/// tout changement d'adresse. Utile pour « réparer » des distances calculées en mode dégradé
|
||||
/// (ex. clé ORS invalide → distances à vol d'oiseau) une fois ORS de nouveau opérationnel.
|
||||
/// Chaque trajet est traité de façon indépendante : un échec sur l'un n'interrompt pas les autres.
|
||||
/// </summary>
|
||||
/// <returns>Le nombre de trajets dont la distance a effectivement été recalculée.</returns>
|
||||
Task<int> RecalculerToutesLesDistancesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Logique métier de gestion des véhicules : historique, véhicule courant,
|
||||
/// création (avec détection de réaffectation) et réaffectation des trajets.
|
||||
/// </summary>
|
||||
public interface IVehiculeService
|
||||
{
|
||||
/// <summary>Retourne l'historique complet des véhicules (plaque, puissance fiscale, date d'effet), trié par Date_Effet décroissante.</summary>
|
||||
Task<IReadOnlyList<VehiculeDto>> GetHistoriqueAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retourne le Véhicule_Courant : Date_Effet la plus récente parmi celles <= aujourd'hui.
|
||||
/// Lève <see cref="Exceptions.VehiculeNotFoundException"/> si aucun véhicule n'est applicable.
|
||||
/// </summary>
|
||||
Task<VehiculeDto> GetVehiculeCourantAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Résout le véhicule applicable à une date donnée (pour l'association d'un trajet) :
|
||||
/// Date_Effet la plus récente <= <paramref name="date"/>.
|
||||
/// Retourne <c>null</c> si aucun véhicule n'est applicable à cette date.
|
||||
/// </summary>
|
||||
Task<VehiculeDto?> GetVehiculeADateAsync(DateOnly date, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Crée un véhicule, conserve l'historique et détecte les trajets à réaffecter
|
||||
/// (date >= Date_Effet et puissance fiscale différente).
|
||||
/// Lève <see cref="Exceptions.DateEffetFutureException"/> si la Date_Effet est postérieure à aujourd'hui.
|
||||
/// </summary>
|
||||
Task<VehiculeCreeDto> CreateAsync(CreateVehiculeDto dto, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Applique (ou non) la réaffectation des trajets au véhicule indiqué.
|
||||
/// Si <see cref="ReaffectationDto.Accepter"/> est vrai, met à jour VehiculeId + PuissanceFiscale
|
||||
/// de tous les trajets dont la Date >= Date_Effet du véhicule ; sinon ne change rien.
|
||||
/// Lève <see cref="Exceptions.VehiculeNotFoundException"/> si le véhicule n'existe pas.
|
||||
/// </summary>
|
||||
Task ReaffecterAsync(ReaffectationDto dto, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
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 MontantRegleService : IMontantRegleService
|
||||
{
|
||||
private const decimal TauxPrevisionnel = 0.80m;
|
||||
|
||||
private readonly AppDbContext _context;
|
||||
private readonly IBaremeService _baremeService;
|
||||
|
||||
public MontantRegleService(AppDbContext context, IBaremeService baremeService)
|
||||
{
|
||||
_context = context;
|
||||
_baremeService = baremeService;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MontantRegleDto> UpsertMontantRegleAsync(
|
||||
MontantRegleDto dto, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Défense en profondeur : le validateur FluentValidation impose déjà Montant >= 0,
|
||||
// mais le service refuse aussi les montants négatifs (Exigence 14.4).
|
||||
if (dto.Montant < 0m)
|
||||
{
|
||||
throw new MontantRegleNegatifException("Le montant réglé ne peut pas être négatif.");
|
||||
}
|
||||
|
||||
// Upsert par la clé métier (Annee, Mois, VehiculeId) — index unique
|
||||
// UX_MontantsRegles_Periode_Vehicule garantit l'unicité (Property 26, Exigence 14.3).
|
||||
var existant = await _context.MontantsRegles
|
||||
.FirstOrDefaultAsync(
|
||||
m => m.Annee == dto.Annee && m.Mois == dto.Mois && m.VehiculeId == dto.VehiculeId,
|
||||
cancellationToken);
|
||||
|
||||
if (existant is null)
|
||||
{
|
||||
var montantRegle = new MontantRegle
|
||||
{
|
||||
Annee = dto.Annee,
|
||||
Mois = dto.Mois,
|
||||
VehiculeId = dto.VehiculeId,
|
||||
Montant = dto.Montant,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
_context.MontantsRegles.Add(montantRegle);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return ToDto(montantRegle);
|
||||
}
|
||||
|
||||
existant.Montant = dto.Montant;
|
||||
existant.UpdatedAt = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return ToDto(existant);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MontantRegleDto>> GetMontantsReglesAsync(
|
||||
int annee, int vehiculeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var montants = await _context.MontantsRegles
|
||||
.AsNoTracking()
|
||||
.Where(m => m.Annee == annee && m.VehiculeId == vehiculeId)
|
||||
.OrderBy(m => m.Mois)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return montants.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MontantPrevisionnelDto> GetMontantPrevisionnelAsync(
|
||||
int annee, int mois, int vehiculeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Puissance fiscale du véhicule concerné : détermine la tranche du barème.
|
||||
var vehicule = await _context.Vehicules
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(v => v.Id == vehiculeId, cancellationToken)
|
||||
?? throw new VehiculeNotFoundException($"Le véhicule d'identifiant {vehiculeId} est introuvable.");
|
||||
|
||||
// Total_Annuel_Partiel : somme des distances de l'année pour ce véhicule,
|
||||
// jusqu'au mois sélectionné inclus (les mois postérieurs sont exclus).
|
||||
// SQLite ne sait pas appliquer SUM sur une colonne decimal : on ramène les distances
|
||||
// concernées et on somme côté client (null traité comme 0).
|
||||
var distancesPartielles = await _context.Trajets
|
||||
.AsNoTracking()
|
||||
.Where(t => t.VehiculeId == vehiculeId
|
||||
&& t.Date.Year == annee
|
||||
&& t.Date.Month <= mois)
|
||||
.Select(t => t.Distance)
|
||||
.ToListAsync(cancellationToken);
|
||||
var totalAnnuelPartiel = distancesPartielles.Sum(d => d ?? 0m);
|
||||
|
||||
// Barème plein appliqué au total partiel (sans minoration).
|
||||
var bareme = await _baremeService.CalculerMontantAsync(
|
||||
annee, vehicule.PuissanceFiscale, totalAnnuelPartiel, cancellationToken);
|
||||
|
||||
var montantBaremePlein = Math.Round(bareme.Montant, 2, MidpointRounding.AwayFromZero);
|
||||
|
||||
// Montant_Previsionnel = 80% du barème plein, arrondi à 2 décimales (indicatif — Exigences 13.5/13.6).
|
||||
var montantPrevisionnel = Math.Round(bareme.Montant * TauxPrevisionnel, 2, MidpointRounding.AwayFromZero);
|
||||
|
||||
return new MontantPrevisionnelDto(
|
||||
Annee: annee,
|
||||
Mois: mois,
|
||||
VehiculeId: vehiculeId,
|
||||
TotalAnnuelPartielKm: Math.Round(totalAnnuelPartiel, 2, MidpointRounding.AwayFromZero),
|
||||
MontantBaremePlein: montantBaremePlein,
|
||||
MontantPrevisionnel: montantPrevisionnel);
|
||||
}
|
||||
|
||||
private static MontantRegleDto ToDto(MontantRegle m) =>
|
||||
new(m.Annee, m.Mois, m.VehiculeId, m.Montant);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client typé (HttpClient) de l'API OpenRouteService (ORS) pour le calcul
|
||||
/// d'itinéraire routier. La <c>BaseAddress</c>, le <c>Timeout</c> et l'en-tête
|
||||
/// de clé d'API sont configurés lors de l'enregistrement du client typé dans
|
||||
/// <c>Program.cs</c> à partir de <c>ORSOptions</c>.
|
||||
/// </summary>
|
||||
public class ORSClient : IORSClient
|
||||
{
|
||||
// Point d'accès GeoJSON : retourne une FeatureCollection avec la géométrie
|
||||
// de l'itinéraire au format GeoJSON et le résumé (distance en mètres).
|
||||
private const string DirectionsGeoJsonPath = "v2/directions/driving-car/geojson";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public ORSClient(HttpClient httpClient)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RouteResult> CalculateRouteAsync(
|
||||
double lat1,
|
||||
double lon1,
|
||||
double lat2,
|
||||
double lon2,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// ORS attend les coordonnées au format [longitude, latitude].
|
||||
var body = new
|
||||
{
|
||||
coordinates = new[]
|
||||
{
|
||||
new[] { lon1, lat1 },
|
||||
new[] { lon2, lat2 }
|
||||
}
|
||||
};
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await _httpClient.PostAsJsonAsync(DirectionsGeoJsonPath, body, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Le HttpClient annule via son Timeout : OperationCanceledException est levée
|
||||
// sans que le jeton de l'appelant soit annulé -> il s'agit d'un dépassement de délai.
|
||||
throw new ORSTimeoutException(
|
||||
"L'appel à OpenRouteService a dépassé le délai imparti.", ex);
|
||||
}
|
||||
catch (TaskCanceledException ex)
|
||||
{
|
||||
throw new ORSTimeoutException(
|
||||
"L'appel à OpenRouteService a dépassé le délai imparti.", ex);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
throw new ORSException(
|
||||
"Échec de l'appel réseau à OpenRouteService.", ex);
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new ORSException(
|
||||
$"OpenRouteService a retourné un statut non conforme : {(int)response.StatusCode}.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
|
||||
return ParseRouteResult(document.RootElement);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new ORSException(
|
||||
"La réponse d'OpenRouteService n'a pas pu être analysée.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extrait la distance (mètres -> km) et la géométrie GeoJSON de la réponse ORS.
|
||||
/// La réponse GeoJSON est une FeatureCollection dont la première feature contient
|
||||
/// <c>properties.summary.distance</c> (mètres) et <c>geometry</c> (GeoJSON).
|
||||
/// </summary>
|
||||
private static RouteResult ParseRouteResult(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("features", out var features)
|
||||
|| features.ValueKind != JsonValueKind.Array
|
||||
|| features.GetArrayLength() == 0)
|
||||
{
|
||||
throw new ORSException(
|
||||
"La réponse d'OpenRouteService ne contient aucun itinéraire.");
|
||||
}
|
||||
|
||||
var feature = features[0];
|
||||
|
||||
double distanceMeters;
|
||||
if (feature.TryGetProperty("properties", out var properties)
|
||||
&& properties.TryGetProperty("summary", out var summary)
|
||||
&& summary.TryGetProperty("distance", out var distanceElement)
|
||||
&& distanceElement.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
distanceMeters = distanceElement.GetDouble();
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new ORSException(
|
||||
"La distance est absente de la réponse d'OpenRouteService.");
|
||||
}
|
||||
|
||||
// Conversion mètres -> km, arrondi à 2 décimales.
|
||||
var distanceKm = Math.Round((decimal)(distanceMeters / 1000.0), 2, MidpointRounding.AwayFromZero);
|
||||
|
||||
string? geometrieJson = null;
|
||||
if (feature.TryGetProperty("geometry", out var geometry)
|
||||
&& geometry.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
geometrieJson = geometry.GetRawText();
|
||||
}
|
||||
|
||||
return new RouteResult(distanceKm, geometrieJson);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Levée en cas d'échec général d'un appel à OpenRouteService (ORS) qui n'est pas
|
||||
/// un dépassement de délai : erreur réseau, réponse HTTP non conforme, corps de
|
||||
/// réponse illisible, etc. Distincte de <see cref="ORSTimeoutException"/> afin que
|
||||
/// l'appelant puisse différencier un timeout (fallback Haversine) d'une défaillance
|
||||
/// générale du service de calcul d'itinéraire.
|
||||
/// </summary>
|
||||
public class ORSException : Exception
|
||||
{
|
||||
public ORSException()
|
||||
: base("Erreur lors de l'appel à OpenRouteService.")
|
||||
{
|
||||
}
|
||||
|
||||
public ORSException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public ORSException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsqu'un appel à OpenRouteService (ORS) dépasse le délai imparti
|
||||
/// (voir <see cref="Configuration.ORSOptions.TimeoutSeconds"/>).
|
||||
/// Le service appelant (DistanceCalculatorService) intercepte cette exception
|
||||
/// pour appliquer le fallback orthodromique (Haversine) sans erreur bloquante
|
||||
/// pour l'utilisateur. Voir Requirement 2.6.
|
||||
/// </summary>
|
||||
public class ORSTimeoutException : Exception
|
||||
{
|
||||
public ORSTimeoutException()
|
||||
: base("L'appel à OpenRouteService a dépassé le délai imparti.")
|
||||
{
|
||||
}
|
||||
|
||||
public ORSTimeoutException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public ORSTimeoutException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Résultat de la génération d'un Export_PDF : les octets du document et le nom de fichier
|
||||
/// suggéré (format <c>trajets_MM_AAAA.pdf</c> pour le mensuel, <c>trajets_AAAA.pdf</c> pour l'annuel).
|
||||
/// Le câblage de l'en-tête HTTP <c>Content-Disposition</c> relève du controller (tâche 11.3).
|
||||
/// </summary>
|
||||
/// <param name="Contenu">Octets du fichier PDF (<c>application/pdf</c>).</param>
|
||||
/// <param name="NomFichier">Nom de fichier suggéré, ex. <c>trajets_01_2025.pdf</c>.</param>
|
||||
public record PdfResult(
|
||||
byte[] Contenu,
|
||||
string NomFichier);
|
||||
@@ -0,0 +1,745 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
using TrajetKmTracker.Api.Data;
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
using TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Génération des Export_PDF via QuestPDF. Ce service compose les services métier existants
|
||||
/// (résumé, profil, véhicule, barème, montants réglés) et n'accède pas directement à la base.
|
||||
/// Le rapport mensuel est produit par <see cref="GenererRapportMensuelAsync"/> ; la synthèse annuelle
|
||||
/// par <see cref="GenererRapportAnnuelAsync"/>. Les variantes « par véhicule » génèrent un PDF distinct
|
||||
/// pour chaque véhicule ayant des trajets sur la période (Exigence 7.9).
|
||||
/// </summary>
|
||||
public class PdfService : IPdfService
|
||||
{
|
||||
/// <summary>Délai maximal de génération d'un PDF (Exigence 7.5).</summary>
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(15);
|
||||
|
||||
private static readonly CultureInfo FrCulture = CultureInfo.GetCultureInfo("fr-FR");
|
||||
|
||||
private readonly IResumeService _resumeService;
|
||||
private readonly IProfilService _profilService;
|
||||
private readonly IVehiculeService _vehiculeService;
|
||||
private readonly IBaremeService _baremeService;
|
||||
private readonly IMontantRegleService _montantRegleService;
|
||||
private readonly IRemboursementService _remboursementService;
|
||||
private readonly AppDbContext _context;
|
||||
|
||||
public PdfService(
|
||||
IResumeService resumeService,
|
||||
IProfilService profilService,
|
||||
IVehiculeService vehiculeService,
|
||||
IBaremeService baremeService,
|
||||
IMontantRegleService montantRegleService,
|
||||
IRemboursementService remboursementService,
|
||||
AppDbContext context)
|
||||
{
|
||||
_resumeService = resumeService;
|
||||
_profilService = profilService;
|
||||
_vehiculeService = vehiculeService;
|
||||
_baremeService = baremeService;
|
||||
_montantRegleService = montantRegleService;
|
||||
_remboursementService = remboursementService;
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PdfResult> GenererRapportMensuelAsync(
|
||||
int annee, int mois, int vehiculeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Enveloppe la génération dans un délai de 15 s (Exigence 7.5) — le dépassement ou toute
|
||||
// erreur de rendu se traduit par une PdfGenerationException que le controller mappe en 503.
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(Timeout);
|
||||
var token = timeoutCts.Token;
|
||||
|
||||
return await ExecuterAvecTimeoutAsync(cancellationToken, async token =>
|
||||
{
|
||||
var modele = await AssemblerModeleMensuelAsync(annee, mois, vehiculeId, token);
|
||||
var octets = RendreMensuel(modele, token);
|
||||
|
||||
var nomFichier = $"trajets_{mois:D2}_{annee:D4}.pdf";
|
||||
return new PdfResult(octets, nomFichier);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PdfResult> GenererRapportAnnuelAsync(
|
||||
int annee, int vehiculeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await ExecuterAvecTimeoutAsync(cancellationToken, async token =>
|
||||
{
|
||||
var modele = await AssemblerModeleAnnuelAsync(annee, vehiculeId, token);
|
||||
var octets = RendreAnnuel(modele, token);
|
||||
|
||||
var nomFichier = $"trajets_{annee:D4}.pdf";
|
||||
return new PdfResult(octets, nomFichier);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PdfResult>> GenererRapportsAnnuelsParVehiculeAsync(
|
||||
int annee, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var vehiculeIds = await VehiculesAvecTrajetsAnneeAsync(annee, cancellationToken);
|
||||
|
||||
var resultats = new List<PdfResult>(vehiculeIds.Count);
|
||||
foreach (var vehiculeId in vehiculeIds)
|
||||
{
|
||||
resultats.Add(await GenererRapportAnnuelAsync(annee, vehiculeId, cancellationToken));
|
||||
}
|
||||
|
||||
return resultats;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PdfResult>> GenererRapportsMensuelsParVehiculeAsync(
|
||||
int annee, int mois, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var vehiculeIds = await VehiculesAvecTrajetsMoisAsync(annee, mois, cancellationToken);
|
||||
|
||||
var resultats = new List<PdfResult>(vehiculeIds.Count);
|
||||
foreach (var vehiculeId in vehiculeIds)
|
||||
{
|
||||
resultats.Add(await GenererRapportMensuelAsync(annee, mois, vehiculeId, cancellationToken));
|
||||
}
|
||||
|
||||
return resultats;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enveloppe une opération de génération PDF dans un délai de 15 s (Exigence 7.5). Tout
|
||||
/// dépassement du délai propre au PDF (et non une annulation demandée par l'appelant) ou toute
|
||||
/// erreur de rendu est traduit en <see cref="PdfGenerationException"/> — que le controller mappe
|
||||
/// vers <c>503 Service Unavailable</c>.
|
||||
/// </summary>
|
||||
private static async Task<PdfResult> ExecuterAvecTimeoutAsync(
|
||||
CancellationToken cancellationToken, Func<CancellationToken, Task<PdfResult>> operation)
|
||||
{
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(Timeout);
|
||||
var token = timeoutCts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
return await operation(token);
|
||||
}
|
||||
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Dépassement du délai propre à la génération PDF (et non annulation par l'appelant).
|
||||
throw new PdfGenerationException(
|
||||
"La génération du PDF a échoué : délai de 15 secondes dépassé.");
|
||||
}
|
||||
catch (PdfGenerationException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Annulation demandée par l'appelant : propagée telle quelle.
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new PdfGenerationException(
|
||||
"La génération du PDF a échoué.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retourne, triés par identifiant, les véhicules ayant au moins un Trajet sur l'année.
|
||||
/// </summary>
|
||||
private async Task<IReadOnlyList<int>> VehiculesAvecTrajetsAnneeAsync(
|
||||
int annee, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.Trajets
|
||||
.AsNoTracking()
|
||||
.Where(t => t.Date.Year == annee)
|
||||
.Select(t => t.VehiculeId)
|
||||
.Distinct()
|
||||
.OrderBy(id => id)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retourne, triés par identifiant, les véhicules ayant au moins un Trajet sur le mois donné.
|
||||
/// </summary>
|
||||
private async Task<IReadOnlyList<int>> VehiculesAvecTrajetsMoisAsync(
|
||||
int annee, int mois, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.Trajets
|
||||
.AsNoTracking()
|
||||
.Where(t => t.Date.Year == annee && t.Date.Month == mois)
|
||||
.Select(t => t.VehiculeId)
|
||||
.Distinct()
|
||||
.OrderBy(id => id)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Agrège, via les services métier existants, toutes les données nécessaires au rapport mensuel.
|
||||
/// </summary>
|
||||
private async Task<ModeleRapportMensuel> AssemblerModeleMensuelAsync(
|
||||
int annee, int mois, int vehiculeId, CancellationToken cancellationToken)
|
||||
{
|
||||
// Identité et adresse de départ (profil).
|
||||
var profil = await _profilService.GetProfilAsync(cancellationToken);
|
||||
|
||||
// Véhicule concerné (plaque, puissance fiscale).
|
||||
var vehicule = await ResoudreVehiculeAsync(vehiculeId, cancellationToken);
|
||||
|
||||
// Résumé mensuel filtré sur le véhicule (trajets triés + total km du mois).
|
||||
var resumeMensuel = await _resumeService.GetResumeMensuelAsync(annee, mois, vehiculeId, cancellationToken);
|
||||
|
||||
// Total kilométrique annuel cumulé (tous mois de l'année, même véhicule).
|
||||
var resumeAnnuel = await _resumeService.GetResumeAnnuelAsync(annee, vehiculeId, cancellationToken);
|
||||
var totalKmAnnuel = resumeAnnuel.TotalKm;
|
||||
|
||||
// Coefficient et base de la tranche ANNUELLE applicable (déterminée par le total annuel).
|
||||
var bareme = await _baremeService.CalculerMontantAsync(
|
||||
annee, vehicule.PuissanceFiscale, totalKmAnnuel, cancellationToken);
|
||||
|
||||
// TOTAL IK du mois = km du mois × coefficient de la tranche annuelle applicable.
|
||||
var totalIkMois = Math.Round(resumeMensuel.TotalKm * bareme.Coefficient, 2, MidpointRounding.AwayFromZero);
|
||||
|
||||
// Montant_Regle saisi pour ce (année, mois, véhicule) — 0 si absent.
|
||||
var montantsRegles = await _montantRegleService.GetMontantsReglesAsync(annee, vehiculeId, cancellationToken);
|
||||
var montantRegle = montantsRegles
|
||||
.Where(m => m.Mois == mois)
|
||||
.Select(m => m.Montant)
|
||||
.FirstOrDefault();
|
||||
|
||||
return new ModeleRapportMensuel(
|
||||
Annee: annee,
|
||||
Mois: mois,
|
||||
Profil: profil,
|
||||
Vehicule: vehicule,
|
||||
Trajets: resumeMensuel.Trajets,
|
||||
TotalKmMois: resumeMensuel.TotalKm,
|
||||
TotalKmAnnuel: totalKmAnnuel,
|
||||
Coefficient: bareme.Coefficient,
|
||||
Base: bareme.Base,
|
||||
TotalIkMois: totalIkMois,
|
||||
MontantRegle: montantRegle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Agrège, via les services métier existants, les données nécessaires à la synthèse annuelle :
|
||||
/// profil (identité + domicile), régularisation annuelle (12 lignes + totaux + tranche) et les
|
||||
/// trois tranches du barème de l'année pour la puissance fiscale du véhicule.
|
||||
/// </summary>
|
||||
private async Task<ModeleRapportAnnuel> AssemblerModeleAnnuelAsync(
|
||||
int annee, int vehiculeId, CancellationToken cancellationToken)
|
||||
{
|
||||
// Identité et adresse de domicile (profil).
|
||||
var profil = await _profilService.GetProfilAsync(cancellationToken);
|
||||
|
||||
// Synthèse annuelle de régularisation (barème plein, 12 lignes, totaux, tranche applicable).
|
||||
var regularisation = await _remboursementService.GetRegularisationAnnuelleAsync(
|
||||
annee, vehiculeId, cancellationToken);
|
||||
|
||||
// Barème de l'année filtré sur la puissance fiscale du véhicule (les trois tranches).
|
||||
var toutesTranches = await _baremeService.GetBaremeAsync(annee, cancellationToken);
|
||||
var tranchesVehicule = toutesTranches
|
||||
.Where(t => t.PuissanceFiscale == regularisation.PuissanceFiscale)
|
||||
.OrderBy(t => t.KmMin)
|
||||
.ToList();
|
||||
|
||||
return new ModeleRapportAnnuel(
|
||||
Annee: annee,
|
||||
Profil: profil,
|
||||
Regularisation: regularisation,
|
||||
TranchesBareme: tranchesVehicule);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Résout le véhicule ciblé par son identifiant à partir de l'historique.
|
||||
/// Lève <see cref="VehiculeNotFoundException"/> si aucun véhicule ne correspond.
|
||||
/// </summary>
|
||||
private async Task<VehiculeDto> ResoudreVehiculeAsync(int vehiculeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var historique = await _vehiculeService.GetHistoriqueAsync(cancellationToken);
|
||||
var vehicule = historique.FirstOrDefault(v => v.Id == vehiculeId);
|
||||
|
||||
return vehicule
|
||||
?? throw new VehiculeNotFoundException($"Le véhicule d'identifiant {vehiculeId} est introuvable.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rend le document mensuel en octets PDF selon la maquette « PDF Mensuel » du design.
|
||||
/// </summary>
|
||||
private static byte[] RendreMensuel(ModeleRapportMensuel modele, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var document = Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.A4);
|
||||
page.Margin(30);
|
||||
page.DefaultTextStyle(x => x.FontSize(9).FontColor(Colors.Black));
|
||||
|
||||
page.Header().Element(c => ComposerEntete(c, modele));
|
||||
page.Content().PaddingTop(10).Element(c => ComposerTableTrajets(c, modele));
|
||||
page.Footer().AlignCenter().Text(txt =>
|
||||
{
|
||||
txt.Span("Page ");
|
||||
txt.CurrentPageNumber();
|
||||
txt.Span(" / ");
|
||||
txt.TotalPages();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return document.GeneratePdf();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compose l'en-tête obligatoire (Exigences 7.6, 7.8, 7.10) : titre fixe, identité, véhicule,
|
||||
/// période, adresse de départ, totaux mois/année, TOTAL IK et Montant_Regle.
|
||||
/// </summary>
|
||||
private static void ComposerEntete(IContainer container, ModeleRapportMensuel modele)
|
||||
{
|
||||
var periode = $"{NomMois(modele.Mois).ToUpper(FrCulture)} {modele.Annee}";
|
||||
var identite = FormatIdentite(modele.Profil);
|
||||
var matricule = string.IsNullOrWhiteSpace(modele.Profil.Matricule)
|
||||
? "—"
|
||||
: modele.Profil.Matricule!;
|
||||
var adresseDepart = string.IsNullOrWhiteSpace(modele.Profil.AdresseDomicile)
|
||||
? "—"
|
||||
: modele.Profil.AdresseDomicile!;
|
||||
var vehiculeLibelle =
|
||||
$"{modele.Vehicule.Plaque} — {modele.Vehicule.PuissanceFiscale} CV " +
|
||||
$"(Coef. : {FormatDecimal(modele.Coefficient, 3)} / Base : {FormatDecimal(modele.Base, 0)})";
|
||||
|
||||
container.Column(col =>
|
||||
{
|
||||
col.Spacing(2);
|
||||
|
||||
// Titre fixe imposé par l'Exigence 7.6.
|
||||
col.Item().Text("Rapport de trajets").FontSize(16).Bold();
|
||||
col.Item().Text("Feuille d'indemnité kilométrique mensuelle").FontSize(11).SemiBold();
|
||||
|
||||
// Identité (nom, prénom, matricule) — Exigence 7.8.
|
||||
col.Item().PaddingTop(4).Text(txt =>
|
||||
{
|
||||
txt.Span("Utilisateur : ").SemiBold();
|
||||
txt.Span($"{identite} — Matricule : {matricule}");
|
||||
});
|
||||
|
||||
// Véhicule (plaque, puissance fiscale, coefficient/base de la tranche annuelle) — Exigences 7.8/7.10.
|
||||
col.Item().Text(txt =>
|
||||
{
|
||||
txt.Span("Véhicule : ").SemiBold();
|
||||
txt.Span(vehiculeLibelle);
|
||||
});
|
||||
|
||||
// Période et adresse de départ du profil — Exigences 7.6/7.10.
|
||||
col.Item().Text(txt =>
|
||||
{
|
||||
txt.Span("Période : ").SemiBold();
|
||||
txt.Span(periode);
|
||||
});
|
||||
col.Item().Text(txt =>
|
||||
{
|
||||
txt.Span("Adresse de départ : ").SemiBold();
|
||||
txt.Span(adresseDepart);
|
||||
});
|
||||
|
||||
// Totaux : mois, année cumulée, TOTAL IK du mois, Montant_Regle — Exigences 7.6/7.10.
|
||||
col.Item().PaddingTop(6).Row(row =>
|
||||
{
|
||||
row.RelativeItem().Column(c =>
|
||||
{
|
||||
c.Item().Text(txt =>
|
||||
{
|
||||
txt.Span("Total parcouru sur le mois : ").SemiBold();
|
||||
txt.Span($"{FormatKm(modele.TotalKmMois)} km");
|
||||
});
|
||||
c.Item().Text(txt =>
|
||||
{
|
||||
txt.Span("Total parcouru sur l'année : ").SemiBold();
|
||||
txt.Span($"{FormatKm(modele.TotalKmAnnuel)} km");
|
||||
});
|
||||
});
|
||||
row.RelativeItem().Column(c =>
|
||||
{
|
||||
c.Item().Text(txt =>
|
||||
{
|
||||
txt.Span("TOTAL IK du mois : ").SemiBold();
|
||||
txt.Span($"{FormatMontant(modele.TotalIkMois)} €");
|
||||
});
|
||||
c.Item().Text(txt =>
|
||||
{
|
||||
txt.Span("Réglé : ").SemiBold();
|
||||
txt.Span($"{FormatMontant(modele.MontantRegle)} €");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
col.Item().PaddingTop(6).LineHorizontal(1).LineColor(Colors.Grey.Medium);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compose la table des trajets du mois (Exigence 7.11) : date, motif (commentaire),
|
||||
/// adresse de départ → arrivée, kilomètres.
|
||||
/// </summary>
|
||||
private static void ComposerTableTrajets(IContainer container, ModeleRapportMensuel modele)
|
||||
{
|
||||
container.Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(columns =>
|
||||
{
|
||||
columns.ConstantColumn(70); // Date
|
||||
columns.RelativeColumn(2); // Motif (commentaire)
|
||||
columns.RelativeColumn(3); // Adresse départ → arrivée
|
||||
columns.ConstantColumn(55); // Kms
|
||||
});
|
||||
|
||||
// En-tête de table.
|
||||
table.Header(header =>
|
||||
{
|
||||
header.Cell().Element(CelluleEntete).Text("Date");
|
||||
header.Cell().Element(CelluleEntete).Text("Motifs");
|
||||
header.Cell().Element(CelluleEntete).Text("Départ → Arrivée");
|
||||
header.Cell().Element(CelluleEntete).AlignRight().Text("Kms");
|
||||
});
|
||||
|
||||
if (modele.Trajets.Count == 0)
|
||||
{
|
||||
table.Cell().ColumnSpan(4).Element(Cellule).Text("Aucun trajet pour ce mois.").Italic();
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var trajet in modele.Trajets)
|
||||
{
|
||||
var motif = string.IsNullOrWhiteSpace(trajet.Commentaire) ? "—" : trajet.Commentaire!;
|
||||
var itineraire = $"{trajet.AdresseDepart} → {trajet.AdresseArrivee}";
|
||||
var km = trajet.Distance ?? 0m;
|
||||
|
||||
table.Cell().Element(Cellule).Text(trajet.Date.ToString("dd/MM/yyyy", FrCulture));
|
||||
table.Cell().Element(Cellule).Text(motif);
|
||||
table.Cell().Element(Cellule).Text(itineraire);
|
||||
table.Cell().Element(Cellule).AlignRight().Text(FormatKm(km));
|
||||
}
|
||||
|
||||
// Ligne de total du mois.
|
||||
table.Cell().ColumnSpan(3).Element(CelluleTotal).AlignRight().Text("Total du mois :").SemiBold();
|
||||
table.Cell().Element(CelluleTotal).AlignRight().Text($"{FormatKm(modele.TotalKmMois)}").SemiBold();
|
||||
}
|
||||
});
|
||||
|
||||
static IContainer CelluleEntete(IContainer c) =>
|
||||
c.Background(Colors.Grey.Lighten2).BorderBottom(1).BorderColor(Colors.Grey.Medium)
|
||||
.PaddingVertical(4).PaddingHorizontal(3);
|
||||
|
||||
static IContainer Cellule(IContainer c) =>
|
||||
c.BorderBottom(1).BorderColor(Colors.Grey.Lighten2).PaddingVertical(3).PaddingHorizontal(3);
|
||||
|
||||
static IContainer CelluleTotal(IContainer c) =>
|
||||
c.BorderTop(1).BorderColor(Colors.Grey.Medium).PaddingVertical(4).PaddingHorizontal(3);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rend le document de synthèse annuelle en octets PDF selon la maquette « PDF Annuel » du design.
|
||||
/// </summary>
|
||||
private static byte[] RendreAnnuel(ModeleRapportAnnuel modele, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var document = Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.A4);
|
||||
page.Margin(30);
|
||||
page.DefaultTextStyle(x => x.FontSize(9).FontColor(Colors.Black));
|
||||
|
||||
page.Header().Element(c => ComposerEnteteAnnuel(c, modele));
|
||||
page.Content().PaddingTop(10).Column(col =>
|
||||
{
|
||||
col.Spacing(10);
|
||||
col.Item().Element(c => ComposerTableBaremeAnnuel(c, modele));
|
||||
col.Item().Element(c => ComposerTableSyntheseAnnuelle(c, modele));
|
||||
col.Item().Element(c => ComposerTotauxAnnuels(c, modele));
|
||||
});
|
||||
page.Footer().AlignCenter().Text(txt =>
|
||||
{
|
||||
txt.Span("Page ");
|
||||
txt.CurrentPageNumber();
|
||||
txt.Span(" / ");
|
||||
txt.TotalPages();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return document.GeneratePdf();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compose l'en-tête de la synthèse annuelle (Exigences 7.6, 7.7, 7.8, 7.12) : titre fixe,
|
||||
/// identité, année, domicile, véhicule (plaque, puissance fiscale) et tranche annuelle applicable.
|
||||
/// </summary>
|
||||
private static void ComposerEnteteAnnuel(IContainer container, ModeleRapportAnnuel modele)
|
||||
{
|
||||
var reg = modele.Regularisation;
|
||||
var identite = FormatIdentite(modele.Profil);
|
||||
var matricule = string.IsNullOrWhiteSpace(modele.Profil.Matricule)
|
||||
? "—"
|
||||
: modele.Profil.Matricule!;
|
||||
var domicile = string.IsNullOrWhiteSpace(modele.Profil.AdresseDomicile)
|
||||
? "—"
|
||||
: modele.Profil.AdresseDomicile!;
|
||||
|
||||
container.Column(col =>
|
||||
{
|
||||
col.Spacing(2);
|
||||
|
||||
// Titre fixe imposé par l'Exigence 7.6.
|
||||
col.Item().Text("Rapport de trajets").FontSize(16).Bold();
|
||||
col.Item().Text($"Synthèse {modele.Annee} d'indemnité kilométrique").FontSize(11).SemiBold();
|
||||
|
||||
// Identité (nom, prénom, matricule) — Exigence 7.8.
|
||||
col.Item().PaddingTop(4).Text(txt =>
|
||||
{
|
||||
txt.Span("Utilisateur : ").SemiBold();
|
||||
txt.Span($"{identite} — Matricule : {matricule}");
|
||||
});
|
||||
|
||||
// Année (période couverte) — Exigence 7.6.
|
||||
col.Item().Text(txt =>
|
||||
{
|
||||
txt.Span("Année : ").SemiBold();
|
||||
txt.Span(modele.Annee.ToString(CultureInfo.InvariantCulture));
|
||||
});
|
||||
|
||||
// Adresse de domicile — Exigence 7.12.
|
||||
col.Item().Text(txt =>
|
||||
{
|
||||
txt.Span("Domicile : ").SemiBold();
|
||||
txt.Span(domicile);
|
||||
});
|
||||
|
||||
// Véhicule (plaque, puissance fiscale) et tranche annuelle applicable — Exigences 7.7/7.8/7.12.
|
||||
col.Item().Text(txt =>
|
||||
{
|
||||
txt.Span("Véhicule : ").SemiBold();
|
||||
txt.Span($"{reg.Plaque} — {reg.PuissanceFiscale} CV");
|
||||
});
|
||||
col.Item().Text(txt =>
|
||||
{
|
||||
txt.Span("Barème applicable : ").SemiBold();
|
||||
txt.Span(reg.TrancheLibelle);
|
||||
});
|
||||
|
||||
// Total kilométrique de la période (Exigence 7.6).
|
||||
col.Item().Text(txt =>
|
||||
{
|
||||
txt.Span("Total kilométrique de l'année : ").SemiBold();
|
||||
txt.Span($"{FormatKm(reg.TotalKmAnnuel)} km");
|
||||
});
|
||||
|
||||
col.Item().PaddingTop(6).LineHorizontal(1).LineColor(Colors.Grey.Medium);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compose la table du barème applicable de l'année pour la puissance fiscale du véhicule
|
||||
/// (les trois tranches coefficient/base) — Exigence 7.12.
|
||||
/// </summary>
|
||||
private static void ComposerTableBaremeAnnuel(IContainer container, ModeleRapportAnnuel modele)
|
||||
{
|
||||
container.Column(col =>
|
||||
{
|
||||
col.Spacing(3);
|
||||
col.Item().Text(txt =>
|
||||
{
|
||||
txt.Span("Barème applicable pour l'année ").SemiBold();
|
||||
txt.Span($"({modele.Regularisation.PuissanceFiscale} CV)").SemiBold();
|
||||
});
|
||||
|
||||
if (modele.TranchesBareme.Count == 0)
|
||||
{
|
||||
col.Item().Text("Barème non configuré pour cette année et cette puissance fiscale.").Italic();
|
||||
return;
|
||||
}
|
||||
|
||||
col.Item().Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(columns =>
|
||||
{
|
||||
columns.RelativeColumn(3); // Tranche (bornes km)
|
||||
columns.RelativeColumn(2); // Coefficient
|
||||
columns.RelativeColumn(2); // Base
|
||||
});
|
||||
|
||||
table.Header(header =>
|
||||
{
|
||||
header.Cell().Element(CelluleEnteteBareme).Text("Tranche (km)");
|
||||
header.Cell().Element(CelluleEnteteBareme).AlignRight().Text("Coefficient");
|
||||
header.Cell().Element(CelluleEnteteBareme).AlignRight().Text("Base (€)");
|
||||
});
|
||||
|
||||
foreach (var tranche in modele.TranchesBareme)
|
||||
{
|
||||
var bornes = tranche.KmMax.HasValue
|
||||
? $"{FormatDecimal(tranche.KmMin, 0)} à {FormatDecimal(tranche.KmMax.Value, 0)}"
|
||||
: $"{FormatDecimal(tranche.KmMin, 0)} et plus";
|
||||
table.Cell().Element(CelluleBareme).Text(bornes);
|
||||
table.Cell().Element(CelluleBareme).AlignRight().Text(FormatDecimal(tranche.Coefficient, 3));
|
||||
table.Cell().Element(CelluleBareme).AlignRight().Text(FormatDecimal(tranche.Base, 0));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
static IContainer CelluleEnteteBareme(IContainer c) =>
|
||||
c.Background(Colors.Grey.Lighten2).BorderBottom(1).BorderColor(Colors.Grey.Medium)
|
||||
.PaddingVertical(4).PaddingHorizontal(3);
|
||||
|
||||
static IContainer CelluleBareme(IContainer c) =>
|
||||
c.BorderBottom(1).BorderColor(Colors.Grey.Lighten2).PaddingVertical(3).PaddingHorizontal(3);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compose la table des douze mois (kilomètres parcourus, frais payé, frais réel) — Exigence 7.12.
|
||||
/// Les données proviennent de <see cref="RegularisationAnnuelleDto.Mois"/>.
|
||||
/// </summary>
|
||||
private static void ComposerTableSyntheseAnnuelle(IContainer container, ModeleRapportAnnuel modele)
|
||||
{
|
||||
var reg = modele.Regularisation;
|
||||
|
||||
container.Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(columns =>
|
||||
{
|
||||
columns.RelativeColumn(2); // Mois
|
||||
columns.RelativeColumn(2); // Kms parcourus
|
||||
columns.RelativeColumn(2); // Frais payé (réglé)
|
||||
columns.RelativeColumn(2); // Frais réel
|
||||
});
|
||||
|
||||
table.Header(header =>
|
||||
{
|
||||
header.Cell().Element(CelluleEntete).Text("Mois");
|
||||
header.Cell().Element(CelluleEntete).AlignRight().Text("Kms parcourus");
|
||||
header.Cell().Element(CelluleEntete).AlignRight().Text("Frais payé");
|
||||
header.Cell().Element(CelluleEntete).AlignRight().Text("Frais réel");
|
||||
});
|
||||
|
||||
foreach (var ligne in reg.Mois)
|
||||
{
|
||||
table.Cell().Element(Cellule).Text(NomMois(ligne.Mois));
|
||||
table.Cell().Element(Cellule).AlignRight().Text($"{FormatKm(ligne.KmMois)}");
|
||||
table.Cell().Element(Cellule).AlignRight().Text($"{FormatMontant(ligne.FraisPaye)} €");
|
||||
table.Cell().Element(Cellule).AlignRight().Text($"{FormatMontant(ligne.FraisReelMensuel)} €");
|
||||
}
|
||||
|
||||
// Ligne de total annuel.
|
||||
table.Cell().Element(CelluleTotal).Text("TOTAL").SemiBold();
|
||||
table.Cell().Element(CelluleTotal).AlignRight().Text($"{FormatKm(reg.TotalKmAnnuel)}").SemiBold();
|
||||
table.Cell().Element(CelluleTotal).AlignRight().Text($"{FormatMontant(reg.TotalPaye)} €").SemiBold();
|
||||
table.Cell().Element(CelluleTotal).AlignRight().Text($"{FormatMontant(reg.MontantReel)} €").SemiBold();
|
||||
});
|
||||
|
||||
static IContainer CelluleEntete(IContainer c) =>
|
||||
c.Background(Colors.Grey.Lighten2).BorderBottom(1).BorderColor(Colors.Grey.Medium)
|
||||
.PaddingVertical(4).PaddingHorizontal(3);
|
||||
|
||||
static IContainer Cellule(IContainer c) =>
|
||||
c.BorderBottom(1).BorderColor(Colors.Grey.Lighten2).PaddingVertical(3).PaddingHorizontal(3);
|
||||
|
||||
static IContainer CelluleTotal(IContainer c) =>
|
||||
c.BorderTop(1).BorderColor(Colors.Grey.Medium).PaddingVertical(4).PaddingHorizontal(3);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compose le bloc « formule réelle » et la régularisation (réel − payé) — Exigence 7.12.
|
||||
/// </summary>
|
||||
private static void ComposerTotauxAnnuels(IContainer container, ModeleRapportAnnuel modele)
|
||||
{
|
||||
var reg = modele.Regularisation;
|
||||
|
||||
// Formule réelle : total_km × coefficient + base = Montant_Reel (tranche annuelle).
|
||||
var formule =
|
||||
$"{FormatKm(reg.TotalKmAnnuel)} km × {FormatDecimal(reg.CoefficientTranche, 3)} " +
|
||||
$"+ {FormatDecimal(reg.BaseTranche, 0)} = {FormatMontant(reg.MontantReel)} € " +
|
||||
$"(tranche {reg.TrancheLibelle}, {reg.PuissanceFiscale} CV)";
|
||||
|
||||
container.Column(col =>
|
||||
{
|
||||
col.Spacing(3);
|
||||
col.Item().Text(txt =>
|
||||
{
|
||||
txt.Span("Formule réelle : ").SemiBold();
|
||||
txt.Span(formule);
|
||||
});
|
||||
col.Item().Text(txt =>
|
||||
{
|
||||
txt.Span("Régularisation (réel − payé) : ").SemiBold();
|
||||
txt.Span($"{FormatMontant(reg.Regularisation)} €");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static string FormatIdentite(ProfilDto profil)
|
||||
{
|
||||
var prenom = (profil.Prenom ?? string.Empty).Trim();
|
||||
var nom = (profil.Nom ?? string.Empty).Trim();
|
||||
var identite = string.Join(" ", new[] { prenom, nom }.Where(s => s.Length > 0));
|
||||
return identite.Length > 0 ? identite : "—";
|
||||
}
|
||||
|
||||
/// <summary>Kilomètres : séparateur de milliers, 2 décimales.</summary>
|
||||
private static string FormatKm(decimal km) =>
|
||||
Math.Round(km, 2, MidpointRounding.AwayFromZero).ToString("N2", FrCulture);
|
||||
|
||||
/// <summary>Montant : séparateur de milliers, 2 décimales.</summary>
|
||||
private static string FormatMontant(decimal montant) =>
|
||||
Math.Round(montant, 2, MidpointRounding.AwayFromZero).ToString("N2", FrCulture);
|
||||
|
||||
private static string FormatDecimal(decimal valeur, int decimales) =>
|
||||
Math.Round(valeur, decimales, MidpointRounding.AwayFromZero)
|
||||
.ToString("N" + decimales, FrCulture);
|
||||
|
||||
private static string NomMois(int mois)
|
||||
{
|
||||
if (mois < 1 || mois > 12)
|
||||
{
|
||||
return mois.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
var nom = FrCulture.DateTimeFormat.GetMonthName(mois);
|
||||
return nom.Length > 0 ? char.ToUpper(nom[0], FrCulture) + nom[1..] : nom;
|
||||
}
|
||||
|
||||
/// <summary>Modèle de données agrégé pour le rendu du rapport mensuel.</summary>
|
||||
private sealed record ModeleRapportMensuel(
|
||||
int Annee,
|
||||
int Mois,
|
||||
ProfilDto Profil,
|
||||
VehiculeDto Vehicule,
|
||||
IReadOnlyList<TrajetDto> Trajets,
|
||||
decimal TotalKmMois,
|
||||
decimal TotalKmAnnuel,
|
||||
decimal Coefficient,
|
||||
decimal Base,
|
||||
decimal TotalIkMois,
|
||||
decimal MontantRegle);
|
||||
|
||||
/// <summary>Modèle de données agrégé pour le rendu de la synthèse annuelle.</summary>
|
||||
private sealed record ModeleRapportAnnuel(
|
||||
int Annee,
|
||||
ProfilDto Profil,
|
||||
RegularisationAnnuelleDto Regularisation,
|
||||
IReadOnlyList<TrancheBaremeDto> TranchesBareme);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrajetKmTracker.Api.Data;
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
using TrajetKmTracker.Api.Services.Exceptions;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <inheritdoc />
|
||||
public class RemboursementService : IRemboursementService
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
private readonly IBaremeService _baremeService;
|
||||
private readonly IMontantRegleService _montantRegleService;
|
||||
|
||||
public RemboursementService(
|
||||
AppDbContext context,
|
||||
IBaremeService baremeService,
|
||||
IMontantRegleService montantRegleService)
|
||||
{
|
||||
_context = context;
|
||||
_baremeService = baremeService;
|
||||
_montantRegleService = montantRegleService;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RegularisationAnnuelleDto> GetRegularisationAnnuelleAsync(
|
||||
int annee, int vehiculeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var vehicule = await _context.Vehicules
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(v => v.Id == vehiculeId, cancellationToken)
|
||||
?? throw new VehiculeNotFoundException($"Le véhicule d'identifiant {vehiculeId} est introuvable.");
|
||||
|
||||
// Kilomètres du véhicule par mois pour l'année (Distance nullable → 0 quand null).
|
||||
// Le GROUP BY + SUM sur decimal n'est pas traduisible par SQLite : on ramène les
|
||||
// trajets de l'année puis on agrège côté client.
|
||||
var trajetsAnnee = await _context.Trajets
|
||||
.AsNoTracking()
|
||||
.Where(t => t.VehiculeId == vehiculeId && t.Date.Year == annee)
|
||||
.Select(t => new { t.Date.Month, t.Distance })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var kmMoisMap = trajetsAnnee
|
||||
.GroupBy(t => t.Month)
|
||||
.ToDictionary(g => g.Key, g => g.Sum(t => t.Distance ?? 0m));
|
||||
|
||||
// Total kilométrique annuel COMPLET (détermine la tranche — Exigence 14.5, Property 27).
|
||||
var totalKmAnnuel = kmMoisMap.Values.Sum();
|
||||
|
||||
// Barème plein (100%) appliqué au total annuel : Montant_Reel = total_km × coef + base.
|
||||
var bareme = await _baremeService.CalculerMontantAsync(
|
||||
annee, vehicule.PuissanceFiscale, totalKmAnnuel, cancellationToken);
|
||||
|
||||
var coefficient = bareme.Coefficient;
|
||||
var baseTranche = bareme.Base;
|
||||
var montantReel = Math.Round(bareme.Montant, 2, MidpointRounding.AwayFromZero);
|
||||
|
||||
// Libellé de la tranche annuelle applicable (ex. « 5 001 à 20 000 km »).
|
||||
var trancheLibelle = await BuildTrancheLibelleAsync(
|
||||
annee, vehicule.PuissanceFiscale, totalKmAnnuel, bareme.BaremeConfigure, cancellationToken);
|
||||
|
||||
// Montant_Regle par mois pour ce véhicule/année.
|
||||
var montantsRegles = await _montantRegleService.GetMontantsReglesAsync(annee, vehiculeId, cancellationToken);
|
||||
var reglesMap = montantsRegles.ToDictionary(m => m.Mois, m => m.Montant);
|
||||
|
||||
// 12 lignes mensuelles :
|
||||
// FraisReelMensuel = km_du_mois × coefficient de la tranche annuelle (Exigence 14.6),
|
||||
// FraisPaye = Montant_Regle saisi du mois (0 si absent).
|
||||
var lignes = new List<LigneSyntheseMoisDto>(12);
|
||||
for (var mois = 1; mois <= 12; mois++)
|
||||
{
|
||||
var kmMois = kmMoisMap.TryGetValue(mois, out var km) ? km : 0m;
|
||||
var fraisPaye = reglesMap.TryGetValue(mois, out var paye) ? paye : 0m;
|
||||
var fraisReelMensuel = Math.Round(kmMois * coefficient, 2, MidpointRounding.AwayFromZero);
|
||||
|
||||
lignes.Add(new LigneSyntheseMoisDto(
|
||||
Mois: mois,
|
||||
KmMois: Math.Round(kmMois, 2, MidpointRounding.AwayFromZero),
|
||||
FraisPaye: fraisPaye,
|
||||
FraisReelMensuel: fraisReelMensuel));
|
||||
}
|
||||
|
||||
var totalPaye = Math.Round(montantsRegles.Sum(m => m.Montant), 2, MidpointRounding.AwayFromZero);
|
||||
|
||||
// Regularisation = Montant_Reel − total des Montant_Regle (Exigence 14.7).
|
||||
var regularisation = Math.Round(montantReel - totalPaye, 2, MidpointRounding.AwayFromZero);
|
||||
|
||||
return new RegularisationAnnuelleDto(
|
||||
Annee: annee,
|
||||
VehiculeId: vehiculeId,
|
||||
Plaque: vehicule.Plaque,
|
||||
PuissanceFiscale: vehicule.PuissanceFiscale,
|
||||
CoefficientTranche: coefficient,
|
||||
BaseTranche: baseTranche,
|
||||
TrancheLibelle: trancheLibelle,
|
||||
Mois: lignes,
|
||||
TotalKmAnnuel: Math.Round(totalKmAnnuel, 2, MidpointRounding.AwayFromZero),
|
||||
TotalPaye: totalPaye,
|
||||
MontantReel: montantReel,
|
||||
Regularisation: regularisation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Construit un libellé lisible de la tranche annuelle dont l'intervalle contient
|
||||
/// <paramref name="totalKmAnnuel"/>. Retourne un libellé « non configuré » si aucune tranche
|
||||
/// ne s'applique (barème absent ou total hors des intervalles définis).
|
||||
/// </summary>
|
||||
private async Task<string> BuildTrancheLibelleAsync(
|
||||
int annee, int puissanceFiscale, decimal totalKmAnnuel, bool baremeConfigure, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!baremeConfigure)
|
||||
{
|
||||
return "Barème non configuré";
|
||||
}
|
||||
|
||||
var tranches = await _context.TranchesBareme
|
||||
.AsNoTracking()
|
||||
.Where(t => t.Annee == annee && t.PuissanceFiscale == puissanceFiscale)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var tranche = tranches
|
||||
.FirstOrDefault(t => totalKmAnnuel >= t.KmMin && (t.KmMax == null || totalKmAnnuel < t.KmMax.Value));
|
||||
|
||||
if (tranche is null)
|
||||
{
|
||||
return "Barème non configuré";
|
||||
}
|
||||
|
||||
return tranche.KmMax.HasValue
|
||||
? $"{FormatKm(tranche.KmMin)} à {FormatKm(tranche.KmMax.Value)} km"
|
||||
: $"{FormatKm(tranche.KmMin)} km et plus";
|
||||
}
|
||||
|
||||
private static string FormatKm(decimal km)
|
||||
{
|
||||
// Espace comme séparateur de milliers, sans décimales (bornes de tranche entières).
|
||||
var fr = CultureInfo.GetCultureInfo("fr-FR");
|
||||
return Math.Round(km, 0, MidpointRounding.AwayFromZero).ToString("N0", fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
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 ResumeService : IResumeService
|
||||
{
|
||||
private const int MoisMin = 1;
|
||||
private const int MoisMax = 12;
|
||||
private const int AnneeMin = 2000;
|
||||
private const int AnneeMax = 2100;
|
||||
|
||||
private readonly AppDbContext _context;
|
||||
|
||||
public ResumeService(AppDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ResumeMensuelDto> GetResumeMensuelAsync(
|
||||
int annee, int mois, int? vehiculeId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValiderAnnee(annee);
|
||||
ValiderMois(mois);
|
||||
|
||||
var trajets = await ChargerTrajetsDuMoisAsync(annee, mois, vehiculeId, cancellationToken);
|
||||
|
||||
return ConstruireResumeMensuel(annee, mois, trajets);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ResumeAnnuelDto> GetResumeAnnuelAsync(
|
||||
int annee, int? vehiculeId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValiderAnnee(annee);
|
||||
|
||||
// Charge une seule fois tous les trajets de l'année, puis répartit par mois.
|
||||
var query = _context.Trajets
|
||||
.AsNoTracking()
|
||||
.Where(t => t.Date.Year == annee);
|
||||
|
||||
if (vehiculeId.HasValue)
|
||||
{
|
||||
query = query.Where(t => t.VehiculeId == vehiculeId.Value);
|
||||
}
|
||||
|
||||
var trajetsAnnee = await query.ToListAsync(cancellationToken);
|
||||
|
||||
var resumesMensuels = new List<ResumeMensuelDto>(MoisMax);
|
||||
for (var mois = MoisMin; mois <= MoisMax; mois++)
|
||||
{
|
||||
var trajetsDuMois = trajetsAnnee
|
||||
.Where(t => t.Date.Month == mois)
|
||||
.ToList();
|
||||
|
||||
resumesMensuels.Add(ConstruireResumeMensuel(annee, mois, trajetsDuMois));
|
||||
}
|
||||
|
||||
// Total annuel = somme des 12 totaux mensuels (Exigences 4.3, 4.7, 6.6).
|
||||
var totalAnnuel = Arrondir(resumesMensuels.Sum(r => r.TotalKm));
|
||||
|
||||
return new ResumeAnnuelDto(annee, resumesMensuels, totalAnnuel);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Charge les Trajets d'un mois donné, filtrés éventuellement par véhicule, triés par
|
||||
/// date croissante puis par Position croissante (Exigence 3.2).
|
||||
/// </summary>
|
||||
private async Task<List<Trajet>> ChargerTrajetsDuMoisAsync(
|
||||
int annee, int mois, int? vehiculeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = _context.Trajets
|
||||
.AsNoTracking()
|
||||
.Where(t => t.Date.Year == annee && t.Date.Month == mois);
|
||||
|
||||
if (vehiculeId.HasValue)
|
||||
{
|
||||
query = query.Where(t => t.VehiculeId == vehiculeId.Value);
|
||||
}
|
||||
|
||||
// Tri par Position (decimal) non traduisible par SQLite : ordonnancement côté client.
|
||||
var trajets = await query.ToListAsync(cancellationToken);
|
||||
return trajets
|
||||
.OrderBy(t => t.Date)
|
||||
.ThenBy(t => t.Position)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Construit un <see cref="ResumeMensuelDto"/> à partir d'une liste de Trajets déjà triée.
|
||||
/// Le total est la somme des distances (null → 0) arrondie à 2 décimales (Property 1).
|
||||
/// </summary>
|
||||
private static ResumeMensuelDto ConstruireResumeMensuel(int annee, int mois, IReadOnlyList<Trajet> trajets)
|
||||
{
|
||||
var trajetsDto = trajets.Select(ToDto).ToList();
|
||||
var totalKm = Arrondir(trajets.Sum(t => t.Distance ?? 0m));
|
||||
|
||||
return new ResumeMensuelDto(annee, mois, trajetsDto, totalKm);
|
||||
}
|
||||
|
||||
private void ValiderMois(int mois)
|
||||
{
|
||||
if (mois < MoisMin || mois > MoisMax)
|
||||
{
|
||||
throw new ResumeParametreInvalideException(
|
||||
$"Le mois doit être compris entre {MoisMin} et {MoisMax} inclus ; valeur reçue : {mois}.");
|
||||
}
|
||||
}
|
||||
|
||||
private void ValiderAnnee(int annee)
|
||||
{
|
||||
if (annee < AnneeMin || annee > AnneeMax)
|
||||
{
|
||||
throw new ResumeParametreInvalideException(
|
||||
$"L'année doit être comprise entre {AnneeMin} et {AnneeMax} inclus ; valeur reçue : {annee}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static decimal Arrondir(decimal valeur) =>
|
||||
Math.Round(valeur, 2, MidpointRounding.AwayFromZero);
|
||||
|
||||
private static TrajetDto ToDto(Trajet t) =>
|
||||
new(
|
||||
t.Id,
|
||||
t.Date,
|
||||
t.AdresseDepart,
|
||||
t.AdresseArrivee,
|
||||
t.Distance,
|
||||
t.Commentaire,
|
||||
t.Position,
|
||||
t.VehiculeId,
|
||||
t.PuissanceFiscale);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Résultat d'un calcul d'itinéraire routier via OpenRouteService (ORS).
|
||||
/// </summary>
|
||||
/// <param name="DistanceKm">
|
||||
/// Distance routière en kilomètres (ORS retourne des mètres, convertis ici en km).
|
||||
/// </param>
|
||||
/// <param name="GeometrieJson">
|
||||
/// Géométrie de l'itinéraire au format GeoJSON (chaîne JSON), ou <c>null</c>
|
||||
/// si ORS ne l'a pas fournie.
|
||||
/// </param>
|
||||
public record RouteResult(decimal DistanceKm, string? GeometrieJson);
|
||||
@@ -0,0 +1,110 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrajetKmTracker.Api.Data;
|
||||
using TrajetKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace TrajetKmTracker.Api.Services;
|
||||
|
||||
/// <inheritdoc />
|
||||
public class TotauxService : ITotauxService
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
private readonly IVehiculeService _vehiculeService;
|
||||
|
||||
public TotauxService(AppDbContext context, IVehiculeService vehiculeService)
|
||||
{
|
||||
_context = context;
|
||||
_vehiculeService = vehiculeService;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TotauxDto> GetTotauxAsync(
|
||||
DateOnly date, int? vehiculeId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Véhicule cible : celui fourni, sinon le Véhicule_Courant (Exigence 12.19).
|
||||
var vehiculeCible = vehiculeId
|
||||
?? (await _vehiculeService.GetVehiculeCourantAsync(cancellationToken)).Id;
|
||||
|
||||
// Trajets du véhicule pour l'année sélectionnée (Property 21 : filtrage par véhicule).
|
||||
// Distance nullable → 0 lors des sommes (traitée côté client après projection).
|
||||
var trajetsAnnee = await _context.Trajets
|
||||
.AsNoTracking()
|
||||
.Where(t => t.VehiculeId == vehiculeCible && t.Date.Year == date.Year)
|
||||
.Select(t => new { t.Date, Distance = (decimal?)t.Distance ?? 0m })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// TotalJour : somme des distances de la journée sélectionnée (Exigence 12.7).
|
||||
var totalJour = trajetsAnnee
|
||||
.Where(t => t.Date == date)
|
||||
.Sum(t => t.Distance);
|
||||
|
||||
// TotalMois : somme des distances du mois de la date sélectionnée (Exigence 12.7).
|
||||
var totalMois = trajetsAnnee
|
||||
.Where(t => t.Date.Month == date.Month)
|
||||
.Sum(t => t.Distance);
|
||||
|
||||
// Total_Annuel_Partiel : cumul de l'année jusqu'au mois sélectionné INCLUS,
|
||||
// exclusion des mois strictement postérieurs (Exigence 12.8, Property 17).
|
||||
var totalAnnuelPartiel = trajetsAnnee
|
||||
.Where(t => t.Date.Month <= date.Month)
|
||||
.Sum(t => t.Distance);
|
||||
|
||||
return new TotauxDto(
|
||||
Date: date,
|
||||
VehiculeId: vehiculeCible,
|
||||
TotalJour: Math.Round(totalJour, 2, MidpointRounding.AwayFromZero),
|
||||
TotalMois: Math.Round(totalMois, 2, MidpointRounding.AwayFromZero),
|
||||
TotalAnnuelPartiel: Math.Round(totalAnnuelPartiel, 2, MidpointRounding.AwayFromZero));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<int>> GetAnneesDisponiblesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Années comportant au moins un Trajet (Exigence 12.15).
|
||||
var annees = await _context.Trajets
|
||||
.AsNoTracking()
|
||||
.Select(t => t.Date.Year)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Ajout de l'année courante si absente.
|
||||
var anneeCourante = DateOnly.FromDateTime(DateTime.Today).Year;
|
||||
if (!annees.Contains(anneeCourante))
|
||||
{
|
||||
annees.Add(anneeCourante);
|
||||
}
|
||||
|
||||
annees.Sort();
|
||||
return annees;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<int>> GetMoisDisponiblesAsync(int annee, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Mois de l'année comportant au moins un Trajet (Exigence 12.16).
|
||||
var mois = await _context.Trajets
|
||||
.AsNoTracking()
|
||||
.Where(t => t.Date.Year == annee)
|
||||
.Select(t => t.Date.Month)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
mois.Sort();
|
||||
return mois;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<int>> GetJoursDisponiblesAsync(
|
||||
int annee, int mois, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Jours du mois comportant au moins un Trajet (Exigence 12.16).
|
||||
var jours = await _context.Trajets
|
||||
.AsNoTracking()
|
||||
.Where(t => t.Date.Year == annee && t.Date.Month == mois)
|
||||
.Select(t => t.Date.Day)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
jours.Sort();
|
||||
return jours;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user