feat(rtt) : autoriser le paiement RTT rétroactif sur l'exercice précédent

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 13:39:11 +02:00
parent c01e1f89a7
commit cf492f40a4
7 changed files with 139 additions and 11 deletions
+7
View File
@@ -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`.
+11 -2
View File
@@ -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
+9 -1
View File
@@ -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) => {
+2
View File
@@ -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.' },
],
},
{
+54 -7
View File
@@ -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.'
);
}
}
}
+7 -1
View File
@@ -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,
];
}
@@ -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.
// -----------------------------------------------------------------------