feat(rtt) : paiement RTT rétroactif sur l'exercice précédent (#23)
Auto Tag Develop / tag (push) Successful in 7s
Auto Tag Develop / tag (push) Successful in 7s
## Besoin RH Pouvoir saisir un paiement RTT sur l'exercice précédent (ex. RTT de mai réglés après la bascule du 1er juin). ## Implémentation (Option B) - Paiement autorisé sur l'exercice courant + l'exercice immédiatement précédent (N-1). - Après saisie sur N-1, le report d'ouverture de l'exercice courant est recalculé automatiquement (computeClosingBalance) dans une transaction → aucun double comptage. - Refus si ce report est verrouillé (is_locked) : la RH le déverrouille d'abord. - Fallback EmployeeRttSummaryProvider::resolveCarry aligné 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. ## Vérification - ✅ 172 tests OK, cs-fixer OK, conteneur compile. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #23 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #23.
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user