Precision configurable des distances (arrondi superieur 0/1/2 decimales)

- Nouveau parametre global Precision_Distance (sous-menu Application de la
  Page_Parametrage, entre Compte et Vehicules), persiste via une entite
  singleton ParametresApplication, expose par GET/PUT /api/parametres-application
- Arrondi superieur (ceiling) applique a l'affichage et aux agregats : trajet
  simple, aller/retour (arrondi puis doublement), totaux jour/mois/annee,
  carte, export PDF et bareme kilometrique - jamais a la distance brute stockee
- Refonte du Montant_Previsionnel : le montant mensuel (80%) porte desormais
  sur le seul mois affiche (tranche determinee par le cumul annuel) ; nouvelle
  ligne Montant_Previsionnel_Annuel (100%, sans minoration, sur le cumul)
- Backend : PrecisionDistanceService (ArrondirDistance), integration dans
  ResumeService/TotauxService/RemboursementService/MontantRegleService,
  migration EF Core ; 7 tests de propriete (FsCheck) + tests existants, 62/62 verts
- Frontend : ParametresApplicationService (signal partage), composant de
  parametrage, fonction miroir arrondirDistance, adaptation de la liste des
  trajets, du panneau des totaux, de la carte et des montants regles
- Specs : nouvelle spec precision-distances-km (requirements/design/tasks) ;
  mise a jour de requirements.md, design.md et design-requirements.md de
  schotziad-km-tracker (Requirement 22, renvois croises, Montant_Previsionnel)
- Suppression du fichier chromium .deb suivi par erreur dans le depot
This commit is contained in:
2026-09-25 12:02:52 +02:00
parent 702f5b6e9c
commit 7e80427e09
48 changed files with 3478 additions and 91 deletions
+8
View File
@@ -4,6 +4,7 @@ import { toSignal } from '@angular/core/rxjs-interop';
import { catchError, filter, map, of, startWith } from 'rxjs';
import { AuthService } from './core/services/auth.service';
import { ParametresApplicationService } from './core/services/parametres-application.service';
import { ProfilService } from './core/services/profil.service';
/**
@@ -28,6 +29,7 @@ export class AppComponent {
private readonly authService = inject(AuthService);
private readonly profilService = inject(ProfilService);
private readonly parametresService = inject(ParametresApplicationService);
private readonly router = inject(Router);
/** Chemin de la route active, mis à jour à chaque navigation terminée. */
@@ -89,6 +91,12 @@ export class AppComponent {
this.prenom.set(profil?.prenom ?? null);
this.nom.set(profil?.nom ?? null);
});
// Charge la précision de distance globale ; en cas d'échec, le signal
// `precisionDistance` reste à sa valeur par défaut (2) sans bloquer l'application.
this.parametresService
.charger()
.pipe(catchError(() => of(null)))
.subscribe();
} else {
this.prenom.set(null);
this.nom.set(null);
+9 -6
View File
@@ -13,6 +13,7 @@ import { MontantsReglesComponent } from './features/montants-regles/montants-reg
import { MaintenanceComponent } from './features/maintenance/maintenance.component';
import { OnboardingComponent } from './features/onboarding/onboarding.component';
import { ParametrageComponent } from './features/parametrage/parametrage.component';
import { ParametresApplicationComponent } from './features/parametres-application/parametres-application.component';
import { ProfilComponent } from './features/profil/profil.component';
import { ResumeAnnuelComponent } from './features/resume-annuel/resume-annuel.component';
import { ResumeMensuelComponent } from './features/resume-mensuel/resume-mensuel.component';
@@ -29,10 +30,11 @@ import { VehiculesComponent } from './features/vehicules/vehicules.component';
* et du premier véhicule — comportement inchangé).
* `authGuard` est vérifiée en premier ; on n'y applique pas `profilConfigureGuard`
* pour ne pas piéger un utilisateur partiellement configuré.
* - `parametrage` (menu latéral + routes enfants `compte` / `vehicules` / `bareme`,
* accessible depuis le sous-menu de l'avatar utilisateur) : `parametrage` lui-même
* et ses enfants `compte` / `vehicules` ne portent que `authGuard` (mêmes raisons
* que ci-dessus) ; seul l'enfant `bareme` ajoute `profilConfigureGuard`.
* - `parametrage` (menu latéral + routes enfants `compte` / `application` / `vehicules` /
* `bareme`, accessible depuis le sous-menu de l'avatar utilisateur) : `parametrage`
* lui-même et ses enfants `compte` / `application` / `vehicules` ne portent que
* `authGuard` (mêmes raisons que ci-dessus) ; seul l'enfant `bareme` ajoute
* `profilConfigureGuard`.
* - Routes de données PROTEGEES par `[authGuard, profilConfigureGuard]` :
* `consultation`, `carnet-adresses`, `resume-mensuel`, `resume-annuel`,
* `montants-regles`. L'authentification est contrôlée d'abord, puis la
@@ -64,8 +66,8 @@ export const routes: Routes = [
canActivate: [authGuard]
},
// Paramétrage : menu latéral (Compte, Véhicules, Barème) + contenu en routes enfants.
// - 'compte' et 'vehicules' : authGuard SEUL (pas profilConfigureGuard, pour ne pas
// Paramétrage : menu latéral (Compte, Application, Véhicules, Barème) + contenu en routes enfants.
// - 'compte', 'application' et 'vehicules' : authGuard SEUL (pas profilConfigureGuard, pour ne pas
// piéger un utilisateur dont le profil n'est pas encore configuré).
// - 'bareme' : authGuard + profilConfigureGuard (comportement inchangé).
{
@@ -75,6 +77,7 @@ export const routes: Routes = [
children: [
{ path: '', redirectTo: 'compte', pathMatch: 'full' },
{ path: 'compte', component: ProfilComponent },
{ path: 'application', component: ParametresApplicationComponent },
{ path: 'vehicules', component: VehiculesComponent },
{ path: 'bareme', component: BaremeComponent, canActivate: [profilConfigureGuard] }
]
+3 -2
View File
@@ -34,9 +34,10 @@ export interface MontantPrevisionnel {
annee: number;
mois: number;
vehiculeId: number;
totalMoisKm: number;
totalAnnuelPartielKm: number;
montantBaremePlein: number; // barème plein sur le total partiel
montantPrevisionnel: number; // = 80% (indicatif uniquement)
montantPrevisionnelMensuel: number;
montantPrevisionnelAnnuel: number;
}
/** Ligne mensuelle de la synthèse annuelle — miroir de LigneSyntheseMoisDto. */
+1
View File
@@ -9,3 +9,4 @@ export * from './bareme.model';
export * from './adresse.model';
export * from './auth.model';
export * from './spec.model';
export * from './parametres-application.model';
@@ -0,0 +1,6 @@
// Modèle ParametresApplication — miroir de ParametresApplicationDtos.cs.
/** Paramètres globaux de l'application — miroir de ParametresApplicationDto. */
export interface ParametresApplication {
precisionDistanceKm: number;
}
+1
View File
@@ -9,3 +9,4 @@ export * from './profil.service';
export * from './bareme.service';
export * from './remboursement.service';
export * from './spec.service';
export * from './parametres-application.service';
@@ -0,0 +1,39 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject, signal } from '@angular/core';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { environment } from '../../../environments/environment';
import { ParametresApplication } from '../models';
/**
* Service HTTP des paramètres globaux de l'application.
*
* Porte le signal partagé `precisionDistance`, chargé une fois puis mis à jour après
* modification — mécanisme central de propagation de la précision d'arrondi des distances
* à toute l'application (liste des trajets, totaux, carte, PDF, barème).
*/
@Injectable({ providedIn: 'root' })
export class ParametresApplicationService {
private readonly http = inject(HttpClient);
private readonly baseUrl = environment.apiUrl;
/** Précision courante (0, 1 ou 2), partagée par toute l'application. Défaut 2 avant premier chargement. */
readonly precisionDistance = signal<0 | 1 | 2>(2);
/** Charge la précision courante depuis le backend et met à jour le signal partagé (GET /api/parametres-application). */
charger(): Observable<ParametresApplication> {
return this.http.get<ParametresApplication>(`${this.baseUrl}/parametres-application`).pipe(
tap((p) => this.precisionDistance.set(p.precisionDistanceKm as 0 | 1 | 2))
);
}
/** Modifie la précision et met à jour le signal partagé en cas de succès (PUT /api/parametres-application). */
definirPrecisionDistance(valeur: 0 | 1 | 2): Observable<ParametresApplication> {
return this.http
.put<ParametresApplication>(`${this.baseUrl}/parametres-application`, {
precisionDistanceKm: valeur
})
.pipe(tap((p) => this.precisionDistance.set(p.precisionDistanceKm as 0 | 1 | 2)));
}
}
@@ -17,6 +17,7 @@ import { Subscription } from 'rxjs';
import { TrajetCarte } from '../../core/models';
import { TrajetService } from '../../core/services';
import { ParametresApplicationService } from '../../core/services/parametres-application.service';
/**
* Palette de couleurs distinctes attribuée aux tracés successifs d'une journée.
@@ -72,6 +73,7 @@ type EtatCarte = 'chargement' | 'ok' | 'vide' | 'erreur';
export class CarteTrajetsComponent implements AfterViewInit, OnChanges, OnDestroy {
private readonly trajetService = inject(TrajetService);
private readonly cdr = inject(ChangeDetectorRef);
private readonly parametresService = inject(ParametresApplicationService);
/** Date de la journée à visualiser, au format ISO (yyyy-MM-dd). */
@Input() date: string | null = null;
@@ -419,7 +421,9 @@ export class CarteTrajetsComponent implements AfterViewInit, OnChanges, OnDestro
*/
private contenuInfobulle(trajet: TrajetCarte): string {
const distanceBase =
trajet.distance != null ? `${trajet.distance.toFixed(2)} km` : 'distance non calculée';
trajet.distance != null
? `${trajet.distance.toFixed(this.parametresService.precisionDistance())} km`
: 'distance non calculée';
// Précise l'aller/retour après la distance (la distance affichée est déjà la
// distance effective doublée côté API).
const distance = trajet.allerRetour ? `${distanceBase} (A/R)` : distanceBase;
@@ -156,7 +156,7 @@ export class MontantsReglesComponent implements OnInit {
}
}
previsionnels.forEach((p, index) => {
lignes[index].previsionnel = p ? p.montantPrevisionnel : null;
lignes[index].previsionnel = p ? p.montantPrevisionnelMensuel : null;
});
this.lignes.set(lignes);
},
@@ -8,15 +8,15 @@
<dl class="panneau-totaux__liste">
<div class="panneau-totaux__ligne">
<dt>Total du jour</dt>
<dd>{{ (totaux()?.totalJour ?? 0) | number: '1.2-2' }} km</dd>
<dd>{{ (totaux()?.totalJour ?? 0) | number: formatKm() }} km</dd>
</div>
<div class="panneau-totaux__ligne">
<dt>Total du mois</dt>
<dd>{{ (totaux()?.totalMois ?? 0) | number: '1.2-2' }} km</dd>
<dd>{{ (totaux()?.totalMois ?? 0) | number: formatKm() }} km</dd>
</div>
<div class="panneau-totaux__ligne panneau-totaux__ligne--fort">
<dt>Total annuel partiel</dt>
<dd>{{ (totaux()?.totalAnnuelPartiel ?? 0) | number: '1.2-2' }} km</dd>
<dd>{{ (totaux()?.totalAnnuelPartiel ?? 0) | number: formatKm() }} km</dd>
</div>
</dl>
@@ -27,13 +27,28 @@
Indication de versement (80%)
</span>
<span class="panneau-totaux__previsionnel-montant">
{{ montantPrevisionnel.montantPrevisionnel | number: '1.2-2' }} €
{{ montantPrevisionnel.montantPrevisionnelMensuel | number: '1.2-2' }} €
</span>
</div>
<p class="panneau-totaux__previsionnel-note">
Simple indication de versement, calculée à 80% du barème sur le total
annuel partiel. Le montant réellement versé est le montant réglé que
vous saisissez.
Simple indication de versement, calculée à 80% du barème sur le mois
affiché. Le montant réellement versé est le montant réglé que vous
saisissez.
</p>
</div>
<div class="panneau-totaux__previsionnel">
<div class="panneau-totaux__ligne panneau-totaux__ligne--fort">
<span class="panneau-totaux__previsionnel-libelle">
Montant prévisionnel annuel
</span>
<span class="panneau-totaux__previsionnel-montant">
{{ montantPrevisionnel.montantPrevisionnelAnnuel | number: '1.2-2' }} €
</span>
</div>
<p class="panneau-totaux__previsionnel-note">
Projection à 100% du barème sur le total annuel partiel, sans
minoration. Cette projection ne se substitue pas au montant réel
calculé en fin d'année.
</p>
</div>
}
@@ -14,6 +14,7 @@ import { Subscription } from 'rxjs';
import { MontantPrevisionnel, Totaux } from '../../core/models';
import { BaremeService } from '../../core/services/bareme.service';
import { ParametresApplicationService } from '../../core/services/parametres-application.service';
import { TrajetService } from '../../core/services/trajet.service';
/**
@@ -22,18 +23,21 @@ import { TrajetService } from '../../core/services/trajet.service';
*
* Reçoit en entrée une `date` (ISO `yyyy-MM-dd`) et un `vehiculeId`, puis affiche :
* - le total du jour, le total du mois et le Total_Annuel_Partiel (cumul de
* l'année jusqu'au mois sélectionné inclus), tous à deux décimales
* (Exigences 12.7, 12.8) ;
* - le Montant_Previsionnel minoré à 80%, calculé sur le Total_Annuel_Partiel du
* véhicule sélectionné, présenté explicitement comme une simple indication de
* l'année jusqu'au mois sélectionné inclus), formatés dynamiquement selon la
* précision courante des distances (0, 1 ou 2 décimales — Exigences 5.4, 5.5) ;
* - le Montant_Previsionnel_Mensuel minoré à 80%, calculé sur le seul mois affiché
* du véhicule sélectionné, présenté explicitement comme une simple indication de
* versement — le montant réellement versé étant le Montant_Regle saisi par
* l'utilisateur (Exigences 13.5, 13.6). Les montants sont exprimés en euros à
* deux décimales (Exigence 13.7).
* l'utilisateur (Exigences 13.5, 13.6) ;
* - le Montant_Previsionnel_Annuel, projection à 100% (sans minoration) sur le
* Total_Annuel_Partiel (Exigences 8.1, 8.3, 8.4).
* Les montants sont exprimés en euros à deux décimales, indépendamment de la
* précision des distances (Exigence 13.7, 8.4).
*
* Les totaux et le montant prévisionnel sont recalculés à chaque changement de
* `date` ou de `vehiculeId` (recalcul au changement, sans rechargement complet).
*
* _Requirements: 12.7, 12.8, 13.5, 13.6, 13.7, 13.8_
* _Requirements: 5.4, 5.5, 8.1, 8.3, 8.4, 12.7, 12.8, 13.5, 13.6, 13.7, 13.8_
*/
@Component({
selector: 'app-panneau-totaux',
@@ -47,6 +51,7 @@ export class PanneauTotauxComponent implements OnChanges, OnDestroy {
private readonly trajetService = inject(TrajetService);
private readonly baremeService = inject(BaremeService);
private readonly cdr = inject(ChangeDetectorRef);
readonly parametresService = inject(ParametresApplicationService);
/** Date sélectionnée au format ISO (`yyyy-MM-dd`). */
@Input() date: string | null = null;
@@ -140,6 +145,15 @@ export class PanneauTotauxComponent implements OnChanges, OnDestroy {
});
}
/**
* Chaîne de format `number` (`1.{p}-{p}`) basée sur la précision courante des
* distances (0, 1 ou 2 décimales), pour un affichage dynamique des totaux en km.
*/
formatKm(): string {
const precision = this.parametresService.precisionDistance();
return `1.${precision}-${precision}`;
}
/** Année (nombre) extraite d'une date ISO `yyyy-MM-dd`. */
private anneeDe(dateIso: string): number {
return Number(dateIso.slice(0, 4));
@@ -10,6 +10,13 @@
>
Compte
</a>
<a
class="parametrage__lien"
routerLink="/parametrage/application"
routerLinkActive="parametrage__lien--actif"
>
Application
</a>
<a
class="parametrage__lien"
routerLink="/parametrage/vehicules"
@@ -0,0 +1,29 @@
<section class="parametres-application">
<h1>Application</h1>
<h2>Nombre de décimales des distances (km)</h2>
<div class="choix-precision" role="group" aria-label="Nombre de décimales des distances (km)">
@for (valeur of precisionsDisponibles; track valeur) {
<button
type="button"
class="btn-precision"
[class.btn-precision--actif]="precisionSelectionnee() === valeur"
[disabled]="enregistrement()"
[attr.aria-pressed]="precisionSelectionnee() === valeur"
(click)="choisir(valeur)"
>
{{ valeur }}
</button>
}
</div>
@if (enregistrement()) {
<p class="enregistrement-en-cours">Enregistrement en cours…</p>
}
<p class="aide">
L'arrondi appliqué aux distances est toujours supérieur (jamais vers le bas), quelle que
soit la précision choisie.
</p>
</section>
@@ -0,0 +1,65 @@
.parametres-application {
padding: 1.5rem;
max-width: 720px;
margin: 0 auto;
}
h1 {
font-size: 1.4rem;
margin-bottom: 1rem;
}
h2 {
font-size: 1.1rem;
margin: 1.5rem 0 0.75rem;
}
.choix-precision {
display: flex;
gap: 0.75rem;
}
.btn-precision {
padding: 0.55rem 1.25rem;
background: #fff;
color: var(--couleur-bleu);
border: 1px solid var(--couleur-bleu);
border-radius: 6px;
cursor: pointer;
font-size: 0.95rem;
min-width: 3rem;
&:hover:not(:disabled) {
border-color: var(--couleur-orange);
color: var(--couleur-orange);
}
}
.btn-precision--actif {
background: var(--couleur-bleu);
color: #fff;
border-color: var(--couleur-bleu);
&:hover:not(:disabled) {
background: var(--couleur-orange);
border-color: var(--couleur-orange);
color: #fff;
}
}
.btn-precision:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.enregistrement-en-cours {
margin-top: 0.75rem;
color: #777;
font-style: italic;
}
.aide {
margin-top: 1rem;
color: #555;
font-size: 0.9rem;
}
@@ -0,0 +1,63 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';
import { NotificationService } from '../../core/services/notification.service';
import { ParametresApplicationService } from '../../core/services/parametres-application.service';
/**
* Paramétrage global de l'application : précision d'arrondi des distances (km).
*
* - Présélectionne la valeur courante au chargement (Exigence 2.3).
* - Applique le choix immédiatement (optimiste) puis confirme ou restaure/notifie
* en cas d'échec (Exigences 2.4, 2.5).
*/
@Component({
selector: 'app-parametres-application',
standalone: true,
imports: [CommonModule],
templateUrl: './parametres-application.component.html',
styleUrl: './parametres-application.component.scss'
})
export class ParametresApplicationComponent implements OnInit {
private readonly parametresService = inject(ParametresApplicationService);
private readonly notification = inject(NotificationService);
/** Valeur sélectionnée (0, 1 ou 2 décimales). */
readonly precisionSelectionnee = signal<0 | 1 | 2>(2);
/** true pendant l'appel d'enregistrement. */
readonly enregistrement = signal(false);
/** Valeurs de précision proposées (0, 1 ou 2 décimales). */
readonly precisionsDisponibles: readonly (0 | 1 | 2)[] = [0, 1, 2];
ngOnInit(): void {
this.precisionSelectionnee.set(this.parametresService.precisionDistance());
}
/**
* Sélectionne une nouvelle précision et l'enregistre côté serveur.
* Mise à jour optimiste du signal local, restaurée en cas d'échec (Exigence 2.5).
*/
choisir(valeur: 0 | 1 | 2): void {
if (valeur === this.precisionSelectionnee() || this.enregistrement()) {
return;
}
const precedente = this.precisionSelectionnee();
this.precisionSelectionnee.set(valeur);
this.enregistrement.set(true);
this.parametresService.definirPrecisionDistance(valeur).subscribe({
next: () => {
this.enregistrement.set(false);
this.notification.success('Précision des distances enregistrée.');
},
error: () => {
this.enregistrement.set(false);
this.precisionSelectionnee.set(precedente);
this.notification.error("La modification n'a pas pu être enregistrée.");
}
});
}
}
@@ -16,6 +16,7 @@ import {
import { ReordonnerTrajets, Trajet, TrajetPosition } from '../../core/models';
import { NotificationService } from '../../core/services/notification.service';
import { ParametresApplicationService } from '../../core/services/parametres-application.service';
import { TrajetService } from '../../core/services/trajet.service';
import { ServeurIndisponibleError } from '../../core/interceptors/timeout.interceptor';
import { ConfirmDialogComponent } from '../../shared/components/confirm-dialog/confirm-dialog.component';
@@ -87,6 +88,7 @@ export interface GroupeJour {
export class TrajetListComponent {
private readonly trajetService = inject(TrajetService);
private readonly notifications = inject(NotificationService);
private readonly parametresService = inject(ParametresApplicationService);
/**
* Trajets du mois. Ils sont regroupés par journée (`groupes`), chaque groupe
@@ -295,12 +297,12 @@ export class TrajetListComponent {
return index === groupe.trajets.length - 1;
}
/** Distance formatée à 2 décimales, ou « — » si non calculée (Exigence 2.4). */
/** Distance formatée selon la précision courante, ou « — » si non calculée (Exigence 2.4). */
distanceAffichee(trajet: Trajet): string {
if (trajet.distance == null) {
return '—';
}
return `${trajet.distance.toFixed(2)} km`;
return `${trajet.distance.toFixed(this.parametresService.precisionDistance())} km`;
}
/** Identité stable pour le suivi de la liste (drag & drop). */
@@ -0,0 +1,21 @@
/**
* Arrondit une distance brute (km) au supérieur (ceiling) à la précision donnée.
* Miroir exact de PrecisionDistanceService.ArrondirDistance côté backend.
*/
export function arrondirDistance(distanceBrute: number, precision: 0 | 1 | 2): number {
const facteur = Math.pow(10, precision);
return Math.ceil(distanceBrute * facteur) / facteur;
}
/** Distance_Effective_Arrondie : arrondi de la distance simple PUIS doublement si aller/retour. */
export function distanceEffectiveArrondie(
distanceBrute: number | null,
allerRetour: boolean,
precision: 0 | 1 | 2
): number | null {
if (distanceBrute == null) {
return null;
}
const arrondie = arrondirDistance(distanceBrute, precision);
return allerRetour ? arrondie * 2 : arrondie;
}