From fa3861b1224b1e3542e4a8cec4b0c8aff050d3eb Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 9 Jun 2026 10:06:23 +0200 Subject: [PATCH] feat(rtt) : custom contract deficit counts as signed recovery (1h=1h, no bands) --- .../Rtt/RttRecoveryComputationService.php | 80 ++++++++++++++----- .../Rtt/RttRecoveryComputationServiceTest.php | 48 +++++++++++ 2 files changed, 106 insertions(+), 22 deletions(-) diff --git a/src/Service/Rtt/RttRecoveryComputationService.php b/src/Service/Rtt/RttRecoveryComputationService.php index e8d4bc7..257b4f0 100644 --- a/src/Service/Rtt/RttRecoveryComputationService.php +++ b/src/Service/Rtt/RttRecoveryComputationService.php @@ -235,7 +235,7 @@ final readonly class RttRecoveryComputationService $overtimeReferenceMinutes = $isCustomContract ? $this->computeWeeklyCustomReferenceMinutes($weekDays, $employeeContractsByDate) : $this->computeWeeklyOvertimeReferenceMinutes($weekDays, $employeeContractsByDate); - $overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($weekDays, $employeeContractsByDate); + $overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($weekDays, $employeeContractsByDate); // Plafond séparant 25 %/50 % : seuil de départ proraté + largeur de la bande +25 % // (4h pour un 39h, 8h pour un 35h). Il se décale ainsi avec une embauche en milieu // de semaine au lieu de rester bloqué à 43h, ce qui ouvre la tranche 50 %. @@ -246,33 +246,69 @@ final readonly class RttRecoveryComputationService [$rawBase25, $rawBase50] = $this->computeOvertimeBaseMinutes($weeklyTotalMinutes, $overtime25StartMinutes, $overtime50StartMinutes); - $base25 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : $rawBase25; - $bonus25 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : (int) round($base25 * 0.25); - $base50 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : $rawBase50; - $bonus50 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : (int) round($base50 * 0.5); - - if ($isWeekPresenceTracking || $disableOvertimeBonuses) { - $totalMinutes = 0; - } elseif ($isCustomContract) { - $totalMinutes = max(0, $weeklyOvertimeTotalMinutes); - } else { - $totalMinutes = $weeklyOvertimeTotalMinutes + $bonus25 + $bonus50; - } - - $results[$weekKey] = new WeekRecoveryDetail( - overtimeMinutes: $weeklyOvertimeTotalMinutes, - base25Minutes: $base25, - bonus25Minutes: $bonus25, - base50Minutes: $base50, - bonus50Minutes: $bonus50, - totalMinutes: $totalMinutes, - dailyMinutes: $dailyWorkedMinutes, + $results[$weekKey] = $this->buildWeekRecoveryDetail( + $isWeekPresenceTracking, + $disableOvertimeBonuses, + $isCustomContract, + $weeklyOvertimeTotalMinutes, + $rawBase25, + $rawBase50, + $dailyWorkedMinutes, ); } return $results; } + /** + * Assemble le détail de récupération d'une semaine à partir des drapeaux résolus et + * des bandes d'heures sup brutes. + * + * - PRESENCE / INTERIM (bonus désactivés) : aucune récupération. + * - CUSTOM : récupération plate 1h = 1h, sans tranches 25/50 ; l'heure sup signée EST + * le total, donc une semaine travaillée sous les heures contractuelles produit un + * total négatif (déficit qui réduit le solde). Marquée isFlatRecovery pour que le + * provider ne draine pas les tranches 25/50. + * - Standard 35h/39h : heures sup + bonus 25 %/50 %. + * + * @param array $dailyMinutes + */ + private function buildWeekRecoveryDetail( + bool $isPresence, + bool $disableBonuses, + bool $isCustom, + int $overtimeTotalMinutes, + int $rawBase25, + int $rawBase50, + array $dailyMinutes, + ): WeekRecoveryDetail { + $noBands = $isPresence || $disableBonuses || $isCustom; + + $base25 = $noBands ? 0 : $rawBase25; + $bonus25 = $noBands ? 0 : (int) round($base25 * 0.25); + $base50 = $noBands ? 0 : $rawBase50; + $bonus50 = $noBands ? 0 : (int) round($base50 * 0.5); + + if ($isPresence || $disableBonuses) { + $totalMinutes = 0; + } elseif ($isCustom) { + $totalMinutes = $overtimeTotalMinutes; // signé : le déficit réduit le solde + } else { + $totalMinutes = $overtimeTotalMinutes + $bonus25 + $bonus50; + } + + return new WeekRecoveryDetail( + overtimeMinutes: $overtimeTotalMinutes, + base25Minutes: $base25, + bonus25Minutes: $bonus25, + base50Minutes: $base50, + bonus50Minutes: $bonus50, + totalMinutes: $totalMinutes, + dailyMinutes: $dailyMinutes, + isFlatRecovery: $isCustom, + ); + } + private function computeMetrics(WorkHour $workHour): WorkMetrics { $driverDay = $workHour->getDayHoursMinutes() ?? 0; diff --git a/tests/Service/Rtt/RttRecoveryComputationServiceTest.php b/tests/Service/Rtt/RttRecoveryComputationServiceTest.php index b0c9cf5..c34e228 100644 --- a/tests/Service/Rtt/RttRecoveryComputationServiceTest.php +++ b/tests/Service/Rtt/RttRecoveryComputationServiceTest.php @@ -113,6 +113,54 @@ final class RttRecoveryComputationServiceTest extends TestCase self::assertSame(3 * 60, $base50); } + public function testBuildWeekDetailCustomDeficitKeepsSignedTotalAndFlatFlag(): void + { + $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); + + // CUSTOM, semaine sous les heures : overtime -120 (worked 2h sur réf 4h). + $detail = $this->invokePrivate( + $service, + 'buildWeekRecoveryDetail', + false, // isPresence + false, // disableBonuses + true, // isCustom + -120, // overtimeTotalMinutes + 0, // rawBase25 + 0, // rawBase50 + [], // dailyMinutes + ); + + self::assertSame(-120, $detail->totalMinutes); + self::assertTrue($detail->isFlatRecovery); + self::assertSame(0, $detail->base25Minutes); + self::assertSame(0, $detail->base50Minutes); + } + + public function testBuildWeekDetailCustomPositiveIsFlatOneToOne(): void + { + $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); + + $detail = $this->invokePrivate($service, 'buildWeekRecoveryDetail', false, false, true, 180, 0, 0, []); + + self::assertSame(180, $detail->totalMinutes); // 1h = 1h + self::assertTrue($detail->isFlatRecovery); + } + + public function testBuildWeekDetailStandardKeepsBucketsAndBonuses(): void + { + $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); + + // 39h : overtime 300, base25 240, base50 60. + $detail = $this->invokePrivate($service, 'buildWeekRecoveryDetail', false, false, false, 300, 240, 60, []); + + self::assertFalse($detail->isFlatRecovery); + self::assertSame(240, $detail->base25Minutes); + self::assertSame(60, $detail->bonus25Minutes); // round(240 * 0.25) + self::assertSame(60, $detail->base50Minutes); + self::assertSame(30, $detail->bonus50Minutes); // round(60 * 0.5) + self::assertSame(300 + 60 + 30, $detail->totalMinutes); + } + private function invokePrivate(object $obj, string $method, mixed ...$args): mixed { return new ReflectionClass($obj::class)->getMethod($method)->invoke($obj, ...$args);