diff --git a/src/Service/Rtt/RttRecoveryComputationService.php b/src/Service/Rtt/RttRecoveryComputationService.php index 257b4f0..c40044a 100644 --- a/src/Service/Rtt/RttRecoveryComputationService.php +++ b/src/Service/Rtt/RttRecoveryComputationService.php @@ -33,6 +33,7 @@ final readonly class RttRecoveryComputationService private EmployeeContractResolver $contractResolver, private DailyReferenceMinutesResolver $dailyReferenceResolver, private HolidayVirtualHoursResolver $holidayVirtualHoursResolver, + private SolidarityDayResolver $solidarityDayResolver, string $rttStartDate = '', ) { $this->rttStartDate = '' !== $rttStartDate ? new DateTimeImmutable($rttStartDate) : null; @@ -162,7 +163,8 @@ final readonly class RttRecoveryComputationService } } - $results = []; + $results = []; + $solidarityDates = $this->resolveSolidarityDatesInRange($periodFrom, $periodTo); foreach ($weeks as $week) { $weekStart = $week['start']; $weekEnd = $week['end']; @@ -244,6 +246,30 @@ final readonly class RttRecoveryComputationService ? 0 : $weeklyTotalMinutes - $overtimeReferenceMinutes; + foreach ($solidarityDates as $solidarityDate) { + // isset ⇒ le jour de solidarité fait partie du sommage de CETTE semaine + // (donc ≤ limitDate et ≥ rttStartDate). Sinon : jour futur ou hors service → pas de déficit. + if (!isset($dailyWorkedMinutes[$solidarityDate])) { + continue; + } + + $contractAtSolidarity = $employeeContractsByDate[$solidarityDate] ?? null; + $solidarityIsoDay = (int) new DateTimeImmutable($solidarityDate)->format('N'); + // Attendu RÉEL du jour (planning workDaysHours), pas la répartition uniforme : + // c'est ce qui rend la neutralisation correcte (cf. spec). + $solidarityExpected = $this->dailyReferenceResolver->resolve( + $contractAtSolidarity?->getWeeklyHours(), + $solidarityIsoDay, + $workDaysByDate[$employeeId][$solidarityDate] ?? null, + ); + + $weeklyOvertimeTotalMinutes += $this->computeSolidarityDeficitAdjustment( + $contractAtSolidarity, + $solidarityExpected, + $dailyWorkedMinutes[$solidarityDate], + ); + } + [$rawBase25, $rawBase50] = $this->computeOvertimeBaseMinutes($weeklyTotalMinutes, $overtime25StartMinutes, $overtime50StartMinutes); $results[$weekKey] = $this->buildWeekRecoveryDetail( @@ -451,6 +477,59 @@ final readonly class RttRecoveryComputationService return $weekDays[0]; } + /** + * Lundi(s) de Pentecôte (jour de solidarité) inclus dans [from, to]. Un exercice + * Juin N-1 → Mai N couvre les années civiles N-1 et N ; on retient les dates dans la fenêtre. + * + * @return list dates au format 'Y-m-d' + */ + private function resolveSolidarityDatesInRange(DateTimeImmutable $from, DateTimeImmutable $to): array + { + $dates = []; + $firstYear = (int) $from->format('Y'); + $lastYear = (int) $to->format('Y'); + + for ($year = $firstYear; $year <= $lastYear; ++$year) { + $candidate = $this->solidarityDayResolver->pentecostMonday($year); + if ($candidate >= $from && $candidate <= $to) { + $dates[] = $candidate->format('Y-m-d'); + } + } + + return $dates; + } + + /** + * Déficit forfaitaire du jour de solidarité pour les contrats CUSTOM < 35h. + * + * Le jour est neutralisé puis chargé du prorata légal : on remplace la valeur réelle + * du jour ($workedMinutes : RTT posé, heures saisies, vide, ou crédit férié virtuel) + * par l'attendu contractuel du jour ($expectedMinutes = workDaysHours), puis on + * retranche le prorata = 7h/35h × heuresHebdo = 12 min par heure hebdo. Sur une + * semaine par ailleurs normale, le net vaut exactement −prorata. Renvoie le delta à + * ajouter à weeklyOvertimeTotalMinutes (0 hors périmètre : non-CUSTOM ou ≥ 35h). + */ + private function computeSolidarityDeficitAdjustment( + ?Contract $contractAtSolidarity, + int $expectedMinutes, + int $workedMinutes, + ): int { + $weeklyHours = $contractAtSolidarity?->getWeeklyHours(); + $type = ContractType::resolve( + $contractAtSolidarity?->getName(), + $contractAtSolidarity?->getTrackingMode(), + $weeklyHours, + ); + + if (ContractType::CUSTOM !== $type || null === $weeklyHours || $weeklyHours >= 35) { + return 0; + } + + $prorata = (int) round($weeklyHours * 12); + + return ($expectedMinutes - $workedMinutes) - $prorata; + } + /** * @param list $days * @param array $contractsByDate diff --git a/tests/Service/Rtt/RttRecoveryComputationServiceTest.php b/tests/Service/Rtt/RttRecoveryComputationServiceTest.php index c34e228..09232b9 100644 --- a/tests/Service/Rtt/RttRecoveryComputationServiceTest.php +++ b/tests/Service/Rtt/RttRecoveryComputationServiceTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Tests\Service\Rtt; use App\Entity\Contract; +use App\Enum\TrackingMode; use App\Service\Rtt\RttRecoveryComputationService; use PHPUnit\Framework\TestCase; use ReflectionClass; @@ -161,6 +162,109 @@ final class RttRecoveryComputationServiceTest extends TestCase self::assertSame(300 + 60 + 30, $detail->totalMinutes); } + /** + * CUSTOM 4h, jour de solidarité non travaillé (RTT posé ou vide) : delta = (attendu − 0) − prorata. + * attendu lundi = workDaysHours = 120 ; prorata = round(4×12) = 48 ; delta = 120 − 48 = 72. + * (Combiné au naturel −120 de la semaine, donne −48 min.). + */ + public function testSolidarityAdjustmentCustomNotWorkedNeutralisesToProrata(): void + { + $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); + + $delta = $this->invokePrivate( + $service, + 'computeSolidarityDeficitAdjustment', + self::customContract(4), + 120, // expectedMinutes (workDaysHours du lundi) + 0, // workedMinutes (RTT posé / vide) + ); + + self::assertSame(72, $delta); + } + + /** + * CUSTOM 4h, jour de solidarité travaillé normalement (120) : delta = (120 − 120) − 48 = −48. + */ + public function testSolidarityAdjustmentCustomWorkedNormallyChargesProrata(): void + { + $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); + + $delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', self::customContract(4), 120, 120); + + self::assertSame(-48, $delta); + } + + /** + * CUSTOM 4h, jour de solidarité travaillé en plus (240) : delta = (120 − 240) − 48 = −168. + * Le surplus du jour de solidarité n'est PAS crédité (jour neutralisé, net forcé à −prorata). + */ + public function testSolidarityAdjustmentCustomWorkedExtraStillNetsProrata(): void + { + $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); + + $delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', self::customContract(4), 120, 240); + + self::assertSame(-168, $delta); + } + + /** + * CUSTOM 28h : prorata = round(28×12) = 336 (5h36). worked 0, expected 336 → delta 0. + */ + public function testSolidarityAdjustmentCustom28hUsesProrata(): void + { + $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); + + $delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', self::customContract(28), 336, 0); + + self::assertSame(0, $delta); + } + + /** + * CUSTOM ≥ 35h (36h) : hors périmètre → delta 0. + */ + public function testSolidarityAdjustmentCustom36hOutOfScope(): void + { + $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); + + $delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', self::customContract(36), 999, 0); + + self::assertSame(0, $delta); + } + + /** + * 35h : type H35 (pas CUSTOM) → delta 0 (comportement inchangé, RTT posé fait foi). + */ + public function testSolidarityAdjustment35hOutOfScope(): void + { + $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); + $contract = new Contract()->setName('35h')->setTrackingMode(TrackingMode::TIME)->setWeeklyHours(35); + + $delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', $contract, 420, 0); + + self::assertSame(0, $delta); + } + + /** + * Aucun contrat ce jour-là (salarié parti / pas encore embauché) → delta 0. + */ + public function testSolidarityAdjustmentNoContractIsZero(): void + { + $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); + + $delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', null, 0, 0); + + self::assertSame(0, $delta); + } + + private static function customContract(int $weeklyHours): Contract + { + return new Contract() + ->setName('Temps partiel') + ->setTrackingMode(TrackingMode::TIME) + ->setWeeklyHours($weeklyHours) + ; + } + private function invokePrivate(object $obj, string $method, mixed ...$args): mixed { return new ReflectionClass($obj::class)->getMethod($method)->invoke($obj, ...$args);