feat(rtt) : custom contract deficit counts as signed recovery (1h=1h, no bands)

This commit is contained in:
2026-06-09 10:06:23 +02:00
parent f344eb536c
commit fa3861b122
2 changed files with 106 additions and 22 deletions
@@ -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<string, int> $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;
@@ -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);