feat(rtt) : custom contract deficit counts as signed recovery (1h=1h, no bands)
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user