diff --git a/CLAUDE.md b/CLAUDE.md index 9f711e3..a79e130 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,6 +90,14 @@ - Sur un exercice passé, le bouton **+ Payer les RTT** est désactivé (pas de paiement rétroactif). - Doc : `doc/rtt-tab.md`. +## Rollover RTT (cron `app:rtt:rollover`) +- Bascule le **1er juin** (idempotente) : crée la ligne `employee_rtt_balances` du nouvel exercice (`targetYear`) pour chaque employé éligible (ni INTERIM, ni PRESENCE). +- **Report = solde de clôture de l'exercice N-1**, pas seulement l'acquis : `report_ouverture(N-1) + acquis(N-1) − RTT payés(N-1)`. C'est exactement le **disponible** affiché par `EmployeeRttSummaryProvider` (`carry + currentYearRecovery − totalPaid`). Le report stocké pour N reprend donc le disponible de fin N-1 ; le report déjà présent en début d'année n'est jamais perdu, et les heures payées ne sont pas re-créditées. +- 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`. +- Doc : `doc/rtt-rollover.md`. + ## 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-rollover.md b/doc/rtt-rollover.md index 3beaa64..fa6d8c1 100644 --- a/doc/rtt-rollover.md +++ b/doc/rtt-rollover.md @@ -79,15 +79,24 @@ Commande quotidienne (cron) idempotente. - le `01/06`: calcule et persiste le report pour chaque employe eligible - les autres jours: sortie sans action - option manuelle: `--force` pour executer hors date metier (reprise/correction) +- option manuelle: `--recompute` pour recalculer et **ecraser** les lignes existantes au lieu de les sauter (reprise apres correction). Les lignes verrouillees (`is_locked = true`, validees RH) ne sont jamais ecrasees. Date d'effet: - au `1er juin` (meme date que le rollover conges non forfait) Traitement par employe: 1. verifier l'eligibilite (ni INTERIM, ni suivi PRESENCE) -2. verifier qu'aucune ligne n'existe deja pour `(employee, targetYear)` (idempotence) -3. calculer la somme des minutes de recuperation de l'exercice N-1 -4. creer la ligne du nouvel exercice avec ce total en `opening_minutes` +2. en mode normal: si une ligne existe deja pour `(employee, targetYear)`, la sauter (idempotence). En mode `--recompute`: la recalculer, sauf si elle est verrouillee. +3. calculer le **solde de cloture** de l'exercice N-1 (= disponible affiche en fin d'exercice) : + `report d'ouverture N-1 + acquis N-1 − RTT payes N-1` + - le **report d'ouverture N-1** vient de la ligne `employee_rtt_balances` de l'exercice N-1 (import go-live ou rollover precedent) ; a defaut, calcul dynamique des acquis de N-2. + - l'**acquis N-1** = somme des minutes de recuperation hebdomadaires de l'exercice N-1. + - les **RTT payes N-1** (`employee_rtt_payments`) sont deduits. +4. creer (ou mettre a jour) la ligne du nouvel exercice avec ce solde, reparti sur les 4 tranches `opening_base25/bonus25/base50/bonus50`. + +> Regle clef : le report d'un exercice a l'autre reprend exactement le **disponible** affiche sur l'onglet RTT (cf. `EmployeeRttSummaryProvider`). Le report deja present au debut de l'exercice precedent n'est jamais perdu, et les heures deja payees ne sont pas re-creditees. Service mutualise : `App\Service\Rtt\RttClosingBalanceService`. + +> Bug historique corrige : la version initiale ne reportait que `acquis N-1` (ni report d'ouverture, ni deduction des paiements), ce qui faisait disparaitre le solde de depart. Pour corriger des lignes deja creees a tort, relancer avec `--force --recompute`. ## 7) Donnees a fournir au go-live diff --git a/frontend/data/documentation-content.ts b/frontend/data/documentation-content.ts index abcd00d..574ac68 100644 --- a/frontend/data/documentation-content.ts +++ b/frontend/data/documentation-content.ts @@ -523,6 +523,7 @@ export const documentationSections: DocSection[] = [ blocks: [ { type: 'list', content: 'Report N-1 : solde de l\'exercice précédent\nAcquis : cumul des heures supplémentaires de l\'exercice en cours\nDisponible : report + acquis − payé\nPayé : RTT convertis en salaire (soustraits du disponible)' }, { type: 'note', content: 'Les contrats INTERIM et le mode PRESENCE n\'accumulent pas de RTT (affiché à 0).' }, + { type: 'note', content: 'Au passage à l\'exercice suivant (1er juin), le « Report N-1 » du nouvel exercice reprend exactement le « Disponible » de fin d\'exercice précédent, c\'est-à-dire report précédent + acquis − RTT payés. Le report déjà présent en début d\'année n\'est donc jamais perdu.' }, { type: 'paragraph', content: 'La colonne "Cumul" affiche le solde RTT à la fin de chaque semaine : Report N-1 + somme des heures hebdomadaires jusqu\'à la semaine concernée − paiements RTT des mois précédents. Un paiement enregistré sur le mois M n\'est déduit qu\'à partir des semaines du mois M+1. Permet la comparaison ligne à ligne avec un suivi RH externe (Excel).' }, ], }, diff --git a/src/Command/RttRolloverCommand.php b/src/Command/RttRolloverCommand.php index 6090bc8..254df0c 100644 --- a/src/Command/RttRolloverCommand.php +++ b/src/Command/RttRolloverCommand.php @@ -10,7 +10,7 @@ use App\Enum\ContractType; use App\Enum\TrackingMode; use App\Repository\EmployeeRepository; use App\Repository\EmployeeRttBalanceRepository; -use App\Service\Rtt\RttRecoveryComputationService; +use App\Service\Rtt\RttClosingBalanceService; use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; @@ -32,7 +32,7 @@ final class RttRolloverCommand extends Command public function __construct( private readonly EmployeeRepository $employeeRepository, private readonly EmployeeRttBalanceRepository $rttBalanceRepository, - private readonly RttRecoveryComputationService $rttRecoveryService, + private readonly RttClosingBalanceService $rttClosingService, private readonly EntityManagerInterface $entityManager, #[Autowire(service: 'monolog.logger.cron')] private readonly LoggerInterface $logger, @@ -48,15 +48,22 @@ final class RttRolloverCommand extends Command InputOption::VALUE_NONE, 'Run rollover regardless of business date (manual recovery mode).' ); + $this->addOption( + 'recompute', + null, + InputOption::VALUE_NONE, + 'Recompute and overwrite existing (non-locked) balances instead of skipping them.' + ); } protected function execute(InputInterface $input, OutputInterface $output): int { - $io = new SymfonyStyle($input, $output); - $today = new DateTimeImmutable('today'); - $force = (bool) $input->getOption('force'); + $io = new SymfonyStyle($input, $output); + $today = new DateTimeImmutable('today'); + $force = (bool) $input->getOption('force'); + $recompute = (bool) $input->getOption('recompute'); - $this->logger->info('app:rtt:rollover started.', ['date' => $today->format('Y-m-d'), 'force' => $force]); + $this->logger->info('app:rtt:rollover started.', ['date' => $today->format('Y-m-d'), 'force' => $force, 'recompute' => $recompute]); if (!$force && '06-01' !== $today->format('m-d')) { $message = 'No RTT rollover today: business date is not 01/06.'; @@ -68,6 +75,7 @@ final class RttRolloverCommand extends Command $targetYear = $this->resolveTargetYear($today); $created = 0; + $updated = 0; $skipped = 0; foreach ($this->employeeRepository->findAll() as $employee) { @@ -83,36 +91,53 @@ final class RttRolloverCommand extends Command } $existing = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $targetYear); - if (null !== $existing) { + if (null !== $existing && !$recompute) { $this->logger->info('Employee skipped: balance already exists.', ['employeeId' => $employee->getId(), 'year' => $targetYear]); ++$skipped; continue; } + if (null !== $existing && $existing->isLocked()) { + // Never overwrite a balance an RH user has validated/frozen. + $this->logger->info('Employee skipped: balance is locked.', ['employeeId' => $employee->getId(), 'year' => $targetYear]); + ++$skipped; + + continue; + } try { $previousYear = $targetYear - 1; - $carry = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $previousYear); + // Closing of the previous exercise = opening report + earned − paid. + $closing = $this->rttClosingService->computeClosingBalance($employee, $previousYear); } catch (Throwable $e) { - $this->logger->error('Error computing carry for employee.', ['employeeId' => $employee->getId(), 'error' => $e->getMessage()]); + $this->logger->error('Error computing closing balance for employee.', ['employeeId' => $employee->getId(), 'error' => $e->getMessage()]); ++$skipped; continue; } - $balance = new EmployeeRttBalance() + $balance = $existing ?? new EmployeeRttBalance() ->setEmployee($employee) ->setYear($targetYear) - ->setOpeningBase25Minutes($carry->base25Minutes) - ->setOpeningBonus25Minutes($carry->bonus25Minutes) - ->setOpeningBase50Minutes($carry->base50Minutes) - ->setOpeningBonus50Minutes($carry->bonus50Minutes) ->setIsLocked(false) ; - $this->entityManager->persist($balance); - $this->logger->info('Balance created.', ['employeeId' => $employee->getId(), 'year' => $targetYear, 'carryMinutes' => $carry->totalMinutes]); - ++$created; + $balance + ->setOpeningBase25Minutes($closing->base25Minutes) + ->setOpeningBonus25Minutes($closing->bonus25Minutes) + ->setOpeningBase50Minutes($closing->base50Minutes) + ->setOpeningBonus50Minutes($closing->bonus50Minutes) + ; + + if (null === $existing) { + $this->entityManager->persist($balance); + $this->logger->info('Balance created.', ['employeeId' => $employee->getId(), 'year' => $targetYear, 'carryMinutes' => $closing->totalMinutes]); + ++$created; + } else { + $balance->touch(); + $this->logger->info('Balance recomputed.', ['employeeId' => $employee->getId(), 'year' => $targetYear, 'carryMinutes' => $closing->totalMinutes]); + ++$updated; + } } try { @@ -124,7 +149,7 @@ final class RttRolloverCommand extends Command return Command::FAILURE; } - $message = sprintf('RTT rollover done: %d created, %d skipped.', $created, $skipped); + $message = sprintf('RTT rollover done: %d created, %d recomputed, %d skipped.', $created, $updated, $skipped); $this->logger->info($message); $io->success($message); diff --git a/src/Service/Rtt/RttClosingBalanceService.php b/src/Service/Rtt/RttClosingBalanceService.php new file mode 100644 index 0000000..ecd8460 --- /dev/null +++ b/src/Service/Rtt/RttClosingBalanceService.php @@ -0,0 +1,164 @@ +recoveryService->resolveExerciseBounds($exerciseYear); + $weeks = $this->recoveryService->buildWeeksForExercise($from, $to); + $weekRanges = array_map( + static fn (array $week): array => [ + 'weekNumber' => (int) $week['weekNumber'], + 'start' => $week['start'], + 'end' => $week['end'], + ], + $weeks + ); + + // The exercise is fully closed at rollover time, so count every week up to its end. + $byWeek = $this->recoveryService->computeRecoveryByWeek($employee, $weekRanges, $from, $to, $to); + + $orderedDetails = []; + foreach ($weekRanges as $week) { + $key = $week['start']->format('Y-m-d'); + $orderedDetails[] = $byWeek[$key] ?? new WeekRecoveryDetail(); + } + + $opening = $this->resolveOpeningReport($employee, $exerciseYear); + $payments = $this->sumPayments($employee, $exerciseYear); + + return $this->fold($opening, $orderedDetails, $payments); + } + + /** + * Pure accumulation of the closing balance per bucket. + * + * Guarantees `sum(buckets) === opening.total + Σ week.total − payments.total`, + * i.e. the carried report matches the displayed disponible regardless of how the + * deficit cascade or the custom-recovery remainder is distributed across buckets. + * + * @param list $weeks chronological order + */ + public function fold(WeekRecoveryDetail $opening, array $weeks, WeekRecoveryDetail $payments): WeekRecoveryDetail + { + $b25 = $opening->base25Minutes; + $bo25 = $opening->bonus25Minutes; + $b50 = $opening->base50Minutes; + $bo50 = $opening->bonus50Minutes; + + foreach ($weeks as $week) { + if ($week->totalMinutes >= 0) { + $b25 += $week->base25Minutes; + $bo25 += $week->bonus25Minutes; + $b50 += $week->base50Minutes; + $bo50 += $week->bonus50Minutes; + + // Recovery not attributed to any 25/50 bucket (CUSTOM 1h=1h, rounding): + // park it in the plain 25%-base bucket so the bucket sum keeps the total. + $remainder = $week->totalMinutes + - ($week->base25Minutes + $week->bonus25Minutes + $week->base50Minutes + $week->bonus50Minutes); + $b25 += $remainder; + + continue; + } + + // Deficit week: drain the 50%-tier before the 25%-tier (mirrors + // EmployeeRttSummaryProvider's cumulative cascade). + $deficit = -$week->totalMinutes; + [$b50, $deficit] = $this->consume($b50, $deficit); + [$bo50, $deficit] = $this->consume($bo50, $deficit); + [$b25, $deficit] = $this->consume($b25, $deficit); + $bo25 -= $deficit; // leftover may push the balance negative, as on screen + } + + $b25 -= $payments->base25Minutes; + $bo25 -= $payments->bonus25Minutes; + $b50 -= $payments->base50Minutes; + $bo50 -= $payments->bonus50Minutes; + + return new WeekRecoveryDetail( + base25Minutes: $b25, + bonus25Minutes: $bo25, + base50Minutes: $b50, + bonus50Minutes: $bo50, + totalMinutes: $b25 + $bo25 + $b50 + $bo50, + ); + } + + /** + * The opening report of $year: the stored balance row when present, else the + * dynamic fallback (earned in $year-1). Same resolution as + * EmployeeRttSummaryProvider::resolveCarry. + */ + private function resolveOpeningReport(Employee $employee, int $year): WeekRecoveryDetail + { + $balance = $this->balanceRepository->findOneByEmployeeAndYear($employee, $year); + if (null !== $balance) { + return new WeekRecoveryDetail( + base25Minutes: $balance->getOpeningBase25Minutes(), + bonus25Minutes: $balance->getOpeningBonus25Minutes(), + base50Minutes: $balance->getOpeningBase50Minutes(), + bonus50Minutes: $balance->getOpeningBonus50Minutes(), + totalMinutes: $balance->getTotalOpeningMinutes(), + ); + } + + return $this->recoveryService->computeTotalRecoveryForExercise($employee, $year - 1); + } + + private function sumPayments(Employee $employee, int $year): WeekRecoveryDetail + { + $b25 = $bo25 = $b50 = $bo50 = 0; + foreach ($this->paymentRepository->findByEmployeeAndYear($employee, $year) as $payment) { + $b25 += $payment->getBase25Minutes(); + $bo25 += $payment->getBonus25Minutes(); + $b50 += $payment->getBase50Minutes(); + $bo50 += $payment->getBonus50Minutes(); + } + + return new WeekRecoveryDetail( + base25Minutes: $b25, + bonus25Minutes: $bo25, + base50Minutes: $b50, + bonus50Minutes: $bo50, + totalMinutes: $b25 + $bo25 + $b50 + $bo50, + ); + } + + /** + * @return array{int, int} [remaining bucket, remaining deficit] + */ + private function consume(int $bucket, int $deficit): array + { + $take = min($deficit, max(0, $bucket)); + + return [$bucket - $take, $deficit - $take]; + } +} diff --git a/tests/Service/Rtt/RttClosingBalanceServiceTest.php b/tests/Service/Rtt/RttClosingBalanceServiceTest.php new file mode 100644 index 0000000..173f904 --- /dev/null +++ b/tests/Service/Rtt/RttClosingBalanceServiceTest.php @@ -0,0 +1,115 @@ +service()->fold($opening, [$week], $this->payments()); + + // 10h report + 5h earned = 15h carried (NOT 5h). + self::assertSame(900, $closing->totalMinutes); + self::assertSame(600 + 240, $closing->base25Minutes); + self::assertSame(60, $closing->bonus25Minutes); + } + + public function testPaymentsAreDeductedFromClosing(): void + { + $opening = new WeekRecoveryDetail(base25Minutes: 600, totalMinutes: 600); + $week = new WeekRecoveryDetail(base25Minutes: 240, bonus25Minutes: 60, totalMinutes: 300); + + // 7h paid out of the 25% base bucket. + $closing = $this->service()->fold($opening, [$week], $this->payments(b25: 420)); + + self::assertSame(900 - 420, $closing->totalMinutes); + self::assertSame(600 + 240 - 420, $closing->base25Minutes); + } + + public function testDeficitWeekConsumesFiftyTierBeforeTwentyFiveTier(): void + { + // Opening: 60min in 50%-base, 120min in 25%-base. + $opening = new WeekRecoveryDetail(base25Minutes: 120, base50Minutes: 60, totalMinutes: 180); + // Deficit week of 100min (worked less than reference): buckets 0, negative total. + $deficit = new WeekRecoveryDetail(totalMinutes: -100); + + $closing = $this->service()->fold($opening, [$deficit], $this->payments()); + + // 50%-base absorbs 60 first, the remaining 40 hits the 25%-base. + self::assertSame(0, $closing->base50Minutes); + self::assertSame(80, $closing->base25Minutes); + self::assertSame(80, $closing->totalMinutes); + } + + public function testCustomRecoveryWithoutBucketsStillCountsInTotal(): void + { + // CUSTOM contract: positive total recovery (1h=1h) but every 25/50 bucket is 0. + $custom = new WeekRecoveryDetail(totalMinutes: 180); // 3h plain recovery + + $closing = $this->service()->fold(new WeekRecoveryDetail(), [$custom], $this->payments()); + + // The 3h must survive into the carried report (sum of buckets == total). + self::assertSame(180, $closing->totalMinutes); + self::assertSame( + 180, + $closing->base25Minutes + $closing->bonus25Minutes + $closing->base50Minutes + $closing->bonus50Minutes, + ); + } + + public function testBucketSumAlwaysEqualsTotalInvariant(): void + { + $opening = new WeekRecoveryDetail(base25Minutes: 200, bonus25Minutes: 50, base50Minutes: 100, bonus50Minutes: 50, totalMinutes: 400); + $weeks = [ + new WeekRecoveryDetail(base25Minutes: 240, bonus25Minutes: 60, totalMinutes: 300), + new WeekRecoveryDetail(totalMinutes: -500), // deeper deficit than tiers hold + new WeekRecoveryDetail(totalMinutes: 90), // custom-style recovery + ]; + + $closing = $this->service()->fold($opening, $weeks, $this->payments(b25: 120, b50: 30)); + + $bucketSum = $closing->base25Minutes + $closing->bonus25Minutes + $closing->base50Minutes + $closing->bonus50Minutes; + self::assertSame($closing->totalMinutes, $bucketSum); + // opening 400 + earned (300 - 500 + 90 = -110) - paid 150 = 140 + self::assertSame(140, $closing->totalMinutes); + } + + private function service(): RttClosingBalanceService + { + return new ReflectionClass(RttClosingBalanceService::class)->newInstanceWithoutConstructor(); + } + + private function payments(int $b25 = 0, int $bo25 = 0, int $b50 = 0, int $bo50 = 0): WeekRecoveryDetail + { + return new WeekRecoveryDetail( + base25Minutes: $b25, + bonus25Minutes: $bo25, + base50Minutes: $b50, + bonus50Minutes: $bo50, + totalMinutes: $b25 + $bo25 + $b50 + $bo50, + ); + } +} diff --git a/tests/State/EmployeeRttSummaryProviderTest.php b/tests/State/EmployeeRttSummaryProviderTest.php index 43a4609..3e4ba0b 100644 --- a/tests/State/EmployeeRttSummaryProviderTest.php +++ b/tests/State/EmployeeRttSummaryProviderTest.php @@ -156,8 +156,11 @@ final class EmployeeRttSummaryProviderTest extends TestCase $provider = $this->buildProvider([]); $year = $this->invokePrivate($provider, 'resolveYear', $currentPhase); - // Today is 2026-05-19 → current RTT exercise (Juin N-1 → Mai N) = 2026. - self::assertSame(2026, $year); + // No params → current RTT exercise (Juin N-1 → Mai N). Derive the expectation + // from today so the test is not pinned to a single calendar date. + $today = new DateTimeImmutable('today'); + $expected = (int) $today->format('n') >= 6 ? (int) $today->format('Y') + 1 : (int) $today->format('Y'); + self::assertSame($expected, $year); } public function testInvalidYearFormatReturns422(): void