From cf492f40a47676dab1673e05d4e8285568d38e7f Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 8 Jun 2026 13:39:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(rtt)=20:=20autoriser=20le=20paiement=20RTT?= =?UTF-8?q?=20r=C3=A9troactif=20sur=20l'exercice=20pr=C3=A9c=C3=A9dent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La RH peut désormais saisir un paiement RTT sur l'exercice immédiatement précédent (ex. RTT de mai réglés après la bascule du 1er juin), sans casser le report. - gate back (assertYearAllowedForPayment) : accepte courant, N-1, ou dernier exercice d'une phase clôturée - après saisie sur N-1, recalcul automatique du report d'ouverture de l'exercice courant (computeClosingBalance) dans une transaction → pas de double comptage - refus si le report de l'exercice courant est verrouillé (assertReportNotLocked) - fallback EmployeeRttSummaryProvider::resolveCarry passe sur computeClosingBalance : disponible correct même sans ligne stockée - front : bouton + Payer les RTT actif sur l'exercice précédent - docs : CLAUDE.md, doc/rtt-tab.md, documentation-content.ts Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 7 +++ doc/rtt-tab.md | 13 +++- frontend/components/employees/RttTab.vue | 10 ++- frontend/data/documentation-content.ts | 2 + src/State/EmployeeRttPaymentProcessor.php | 61 ++++++++++++++++--- src/State/EmployeeRttSummaryProvider.php | 8 ++- .../State/EmployeeRttPaymentProcessorTest.php | 49 +++++++++++++++ 7 files changed, 139 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a79e130..9af7a88 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,8 +96,15 @@ - Service mutualisé : `App\Service\Rtt\RttClosingBalanceService` (méthode `computeClosingBalance` + `fold` pur testable). `fold` garantit `somme(tranches) = report + acquis − payés` ; la cascade des semaines déficitaires draine la tranche 50% avant la 25%, et la récup non bucketisée (CUSTOM 1h=1h, arrondis) atterrit en `base25` pour que la somme égale le total. - Options : `--force` (hors 01/06) ; `--recompute` (recalcule/écrase les lignes existantes au lieu de les sauter ; **ne touche jamais** une ligne verrouillée `is_locked`). Reprise d'une bascule erronée : `app:rtt:rollover --force --recompute`. - ⚠️ Bug historique : la 1ʳᵉ version ne reportait que `acquis(N-1)` (report d'ouverture perdu, paiements non déduits). Corrigé via `RttClosingBalanceService`. +- **Fallback provider** : quand aucune ligne `employee_rtt_balances` n'existe pour l'exercice affiché (avant la bascule), `EmployeeRttSummaryProvider::resolveCarry` calcule le report en direct via `RttClosingBalanceService::computeClosingBalance($year-1)` (et non plus `computeTotalRecoveryForExercise`) — le disponible reste donc correct (report d'ouverture + acquis − payés) même sans ligne stockée. - Doc : `doc/rtt-rollover.md`. +## Paiement RTT rétroactif (exercice précédent) — Option B +- Le paiement RTT est autorisé sur : l'**exercice courant**, l'**exercice immédiatement précédent** (N-1), ou le dernier exercice d'une phase clôturée. Garde back : `EmployeeRttPaymentProcessor::assertYearAllowedForPayment`. Garde front : `RttTab.vue` `isPayDisabled` (bouton actif sur `selectedYear === currentYear - 1`). +- **Cohérence du report** : un paiement sur N-1 modifie la clôture de N-1 = ouverture de N. Le processor **recalcule automatiquement** la ligne `employee_rtt_balances` de l'exercice courant (`computeClosingBalance(N-1)`) dans une **transaction** (le `flush` du paiement le rend visible au recalcul). Pas de double comptage. +- **Verrou** : si le report de l'exercice courant est `is_locked`, le paiement rétroactif est **refusé** (`assertReportNotLocked`) — la RH doit déverrouiller d'abord. +- Portée limitée à N-1 (chaîne de recalcul = 1 étape). Si la ligne courante n'existe pas encore, le fallback provider couvre l'affichage (cf. ci-dessus). + ## Vue contrat (sélecteur de phase) - Picker `Vue contrat` en haut de la fiche employé (`pages/employees/[id].vue`). Caché si l'employé n'a qu'une phase. - Phase = groupe d'`EmployeeContractPeriod` consécutifs partageant la signature `(contract.type, weeklyHours, isDriver)`. Résolu par `App\Service\Contracts\EmployeeContractPhaseResolver`. diff --git a/doc/rtt-tab.md b/doc/rtt-tab.md index 4eb9908..36ed0f8 100644 --- a/doc/rtt-tab.md +++ b/doc/rtt-tab.md @@ -34,9 +34,18 @@ Comportement : ## Verrouillage des éditions sur exercices passés -Quand `selectedYear !== currentYear` (consultation d'un exercice antérieur), le bouton **+ Payer les RTT** est désactivé. Justification : un paiement rétroactif sur un exercice clos décalerait les soldes courants et le report N-1 calculé. +Quand `selectedYear !== currentYear` (consultation d'un exercice antérieur), le bouton **+ Payer les RTT** est désactivé — **sauf sur l'exercice immédiatement précédent** (`selectedYear === currentYear - 1`), où le paiement rétroactif est autorisé (Option B). -La consultation reste possible, l'édition non. +La consultation des exercices plus anciens reste possible, l'édition non. + +### Paiement rétroactif sur l'exercice précédent (Option B) + +Un paiement enregistré sur l'exercice N-1 modifie sa clôture, donc le **report d'ouverture de l'exercice courant N**. Pour éviter tout décalage / double comptage : + +- garde back `EmployeeRttPaymentProcessor::assertYearAllowedForPayment` : accepte courant, **N-1**, ou dernier exercice d'une phase clôturée ; +- après enregistrement, le processor **recalcule automatiquement** la ligne `employee_rtt_balances` de l'exercice courant via `RttClosingBalanceService::computeClosingBalance(N-1)`, dans une **transaction** (le `flush` du paiement le rend visible au recalcul) ; +- si le report de l'exercice courant est **verrouillé** (`is_locked`), le paiement est **refusé** (`assertReportNotLocked`) : la RH doit déverrouiller d'abord ; +- portée volontairement limitée à N-1 (chaîne de recalcul = 1 étape). Si la ligne courante n'existe pas encore, l'affichage reste correct grâce au fallback de `EmployeeRttSummaryProvider::resolveCarry` (calcul dynamique de la clôture N-1). ## Sélecteur de phase de contrat diff --git a/frontend/components/employees/RttTab.vue b/frontend/components/employees/RttTab.vue index 4b28686..8c5f86f 100644 --- a/frontend/components/employees/RttTab.vue +++ b/frontend/components/employees/RttTab.vue @@ -313,8 +313,16 @@ const isLastExerciseOfPhase = computed(() => { return props.selectedYear === endYear }) +// Retroactive payment is allowed on the immediately previous exercise (Option B): +// the backend recomputes the next exercise's report so the carry stays correct. +const isPreviousExercise = computed(() => + props.selectedYear !== null + && props.currentYear !== null + && props.selectedYear === props.currentYear - 1 +) + const isPayDisabled = computed(() => - isHistoricalYear.value && !isLastExerciseOfPhase.value + isHistoricalYear.value && !isLastExerciseOfPhase.value && !isPreviousExercise.value ) const handleYearChange = (event: Event) => { diff --git a/frontend/data/documentation-content.ts b/frontend/data/documentation-content.ts index 574ac68..8b16866 100644 --- a/frontend/data/documentation-content.ts +++ b/frontend/data/documentation-content.ts @@ -534,6 +534,8 @@ export const documentationSections: DocSection[] = [ blocks: [ { type: 'paragraph', content: 'L\'administrateur RH peut enregistrer un paiement RTT depuis l\'onglet RTT de la fiche employé.' }, { type: 'list', content: 'Saisie : mois, nombre de minutes, taux (25% ou 50%)\nPlusieurs paiements possibles par mois\nLes heures payées sont soustraites du solde disponible' }, + { type: 'paragraph', content: 'Le paiement est possible sur l\'exercice courant et sur l\'exercice immédiatement précédent (paiement rétroactif, ex. des RTT de mai réglés après la bascule du 1er juin).' }, + { type: 'note', content: 'Un paiement saisi sur l\'exercice précédent recalcule automatiquement le « Report N-1 » de l\'exercice courant : aucun double comptage. Si ce report a déjà été verrouillé (validé), le paiement rétroactif est refusé — déverrouillez-le d\'abord.' }, ], }, { diff --git a/src/State/EmployeeRttPaymentProcessor.php b/src/State/EmployeeRttPaymentProcessor.php index c161460..5793507 100644 --- a/src/State/EmployeeRttPaymentProcessor.php +++ b/src/State/EmployeeRttPaymentProcessor.php @@ -8,12 +8,15 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use App\ApiResource\EmployeeRttPaymentInput; use App\Entity\Employee; +use App\Entity\EmployeeRttBalance; use App\Entity\EmployeeRttPayment; use App\Repository\EmployeeRepository; +use App\Repository\EmployeeRttBalanceRepository; use App\Repository\EmployeeRttPaymentRepository; use App\Service\AuditLogger; use App\Service\Contracts\EmployeeContractPhaseResolver; use App\Service\Exercise\ExerciseYearResolver; +use App\Service\Rtt\RttClosingBalanceService; use Doctrine\ORM\EntityManagerInterface; use Psr\Clock\ClockInterface; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -24,11 +27,13 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface public function __construct( private EmployeeRepository $employeeRepository, private EmployeeRttPaymentRepository $rttPaymentRepository, + private EmployeeRttBalanceRepository $rttBalanceRepository, private EntityManagerInterface $entityManager, private AuditLogger $auditLogger, private EmployeeContractPhaseResolver $phaseResolver, private ClockInterface $clock, private ExerciseYearResolver $exerciseYearResolver, + private RttClosingBalanceService $rttClosingService, ) {} public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EmployeeRttPaymentInput @@ -51,10 +56,20 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface throw new UnprocessableEntityHttpException('month must be between 1 and 12.'); } - $year = $data->year ?? $this->resolveCurrentExerciseYear(); + $year = $data->year ?? $this->resolveCurrentExerciseYear(); + $currentExerciseYear = $this->resolveCurrentExerciseYear(); $this->assertYearAllowedForPayment($employee, $year); + // Option B — retroactive payment on the previous exercise: the next exercise's + // opening report (a frozen snapshot) must be recomputed so the carry stays correct. + // Refuse upfront if that report has been locked (validated) by RH. + $downstreamBalance = null; + if ($year === $currentExerciseYear - 1) { + $downstreamBalance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $currentExerciseYear); + $this->assertReportNotLocked($downstreamBalance); + } + $payment = $this->rttPaymentRepository->findOneByEmployeeYearMonth($employee, $year, $data->month); if (null === $payment) { @@ -81,7 +96,24 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface ['new' => ['month' => $data->month, 'year' => $year, 'base25' => $data->base25Minutes, 'bonus25' => $data->bonus25Minutes, 'base50' => $data->base50Minutes, 'bonus50' => $data->bonus50Minutes]], ); - $this->entityManager->flush(); + // Persist the payment and, atomically, refresh the next exercise's opening report. + // The flush inside the transaction makes the new payment visible to the closing + // recomputation (same DB connection), so the carry reflects it. + $this->entityManager->wrapInTransaction(function () use ($employee, $year, $downstreamBalance): void { + $this->entityManager->flush(); + + if (null !== $downstreamBalance) { + $closing = $this->rttClosingService->computeClosingBalance($employee, $year); + $downstreamBalance + ->setOpeningBase25Minutes($closing->base25Minutes) + ->setOpeningBonus25Minutes($closing->bonus25Minutes) + ->setOpeningBase50Minutes($closing->base50Minutes) + ->setOpeningBonus50Minutes($closing->bonus50Minutes) + ->touch() + ; + $this->entityManager->flush(); + } + }); $data->year = $year; @@ -94,14 +126,15 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface } /** - * Allow payment when the requested exercise is either the current one - * or the last exercise of a closed contract phase (the one containing - * the phase end date). Reject any other exercise (past or future). + * Allow payment when the requested exercise is the current one, the + * immediately previous one (retroactive payment — Option B), or the last + * exercise of a closed contract phase (the one containing the phase end + * date). Reject any other exercise (older past or future). */ private function assertYearAllowedForPayment(Employee $employee, int $year): void { $currentExerciseYear = $this->resolveCurrentExerciseYear(); - if ($year === $currentExerciseYear) { + if ($year === $currentExerciseYear || $year === $currentExerciseYear - 1) { return; } @@ -116,7 +149,21 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface } throw new UnprocessableEntityHttpException( - 'RTT payment is only allowed on the current exercise or the last exercise of a closed contract phase.' + 'RTT payment is only allowed on the current exercise, the previous one, or the last exercise of a closed contract phase.' ); } + + /** + * Refuse a retroactive payment when the next exercise's opening report has + * been locked (validated) by RH: recomputing it would either be impossible + * or silently desync the carry. A missing report (null) never blocks. + */ + private function assertReportNotLocked(?EmployeeRttBalance $downstreamBalance): void + { + if (null !== $downstreamBalance && $downstreamBalance->isLocked()) { + throw new UnprocessableEntityHttpException( + 'Impossible : le report RTT de l\'exercice suivant est verrouillé. Déverrouillez-le pour saisir un paiement rétroactif.' + ); + } + } } diff --git a/src/State/EmployeeRttSummaryProvider.php b/src/State/EmployeeRttSummaryProvider.php index 41bdfeb..33dc033 100644 --- a/src/State/EmployeeRttSummaryProvider.php +++ b/src/State/EmployeeRttSummaryProvider.php @@ -20,6 +20,7 @@ use App\Repository\WorkHourRepository; use App\Security\EmployeeScopeService; use App\Service\Contracts\EmployeeContractPhaseResolver; use App\Service\Exercise\ExerciseYearResolver; +use App\Service\Rtt\RttClosingBalanceService; use App\Service\Rtt\RttRecoveryComputationService; use DateTimeImmutable; use Symfony\Bundle\SecurityBundle\Security; @@ -43,6 +44,7 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface private WorkHourRepository $workHourRepository, private EmployeeContractPhaseResolver $phaseResolver, private ExerciseYearResolver $exerciseYearResolver, + private RttClosingBalanceService $rttClosingService, string $rttStartDate = '', ) { $this->rttStartDate = '' !== $rttStartDate ? $rttStartDate : null; @@ -231,8 +233,12 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface ]; } + // No stored report row yet (before the 1st-June rollover materialises it): + // compute the previous exercise's full closing (opening + earned − paid) so the + // carry already reflects retroactive payments and the incoming report — matching + // what the rollover would persist. Falling back to earned-only would drop both. return [ - $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $year - 1), + $this->rttClosingService->computeClosingBalance($employee, $year - 1), 5, ]; } diff --git a/tests/State/EmployeeRttPaymentProcessorTest.php b/tests/State/EmployeeRttPaymentProcessorTest.php index 8ea17ad..3cc4ce1 100644 --- a/tests/State/EmployeeRttPaymentProcessorTest.php +++ b/tests/State/EmployeeRttPaymentProcessorTest.php @@ -7,6 +7,7 @@ namespace App\Tests\State; use App\Entity\Contract; use App\Entity\Employee; use App\Entity\EmployeeContractPeriod; +use App\Entity\EmployeeRttBalance; use App\Enum\ContractNature; use App\Enum\TrackingMode; use App\Service\Contracts\EmployeeContractPhaseResolver; @@ -74,6 +75,54 @@ final class EmployeeRttPaymentProcessorTest extends TestCase $this->invokePrivate($processor, 'assertYearAllowedForPayment', $employee, 2030); } + public function testPaymentAllowedOnPreviousExercise(): void + { + // Today = 2026-05-19 → current exercise = 2026. Retroactive payment on the + // immediately previous exercise (2025) is now allowed (Option B). + $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); + $processor = $this->buildProcessorWithClock(new DateTimeImmutable('2026-05-19')); + + $this->invokePrivate($processor, 'assertYearAllowedForPayment', $employee, 2025); + + // No exception → previous exercise accepted. + self::assertTrue(true); + } + + public function testPaymentStillRejectedTwoExercisesBack(): void + { + // 2024 is two exercises before current (2026) and not a closed-phase end → still rejected. + $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); + $processor = $this->buildProcessorWithClock(new DateTimeImmutable('2026-05-19')); + + $this->expectException(UnprocessableEntityHttpException::class); + $this->invokePrivate($processor, 'assertYearAllowedForPayment', $employee, 2024); + } + + public function testRetroactivePaymentRefusedWhenDownstreamReportLocked(): void + { + $processor = $this->buildProcessorWithClock(new DateTimeImmutable('2026-05-19')); + + $locked = new EmployeeRttBalance(); + $locked->setIsLocked(true); + + $this->expectException(UnprocessableEntityHttpException::class); + $this->invokePrivate($processor, 'assertReportNotLocked', $locked); + } + + public function testRetroactivePaymentAllowedWhenDownstreamReportMissingOrUnlocked(): void + { + $processor = $this->buildProcessorWithClock(new DateTimeImmutable('2026-05-19')); + + $unlocked = new EmployeeRttBalance(); + $unlocked->setIsLocked(false); + + // Neither a missing (null) nor an unlocked downstream report must block payment. + $this->invokePrivate($processor, 'assertReportNotLocked', null); + $this->invokePrivate($processor, 'assertReportNotLocked', $unlocked); + + self::assertTrue(true); + } + // ----------------------------------------------------------------------- // Test harness helpers. // -----------------------------------------------------------------------