From 8684d240bcf87a8906569da4a64db33af5ac019e Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 19 May 2026 11:15:22 +0200 Subject: [PATCH] feat(rtt) : phaseId support in EmployeeRttSummaryProvider Mirror Task 3 (leave provider) on the RTT side: accept an optional `?phaseId` query parameter and cap the exercise window to the phase boundaries when set. - Inject EmployeeContractPhaseResolver. - New helpers: resolveTargetPhase, clampYearToPhase, exerciseYearForDate. - resolveYear now takes the phase: default year falls back to the phase end date when phaseId is provided; explicit year is silently clamped to the phase range. - provide() narrows periodFrom / periodTo / limitDate to the phase end date for past phases. - Default behavior (no phaseId) unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/State/EmployeeRttSummaryProvider.php | 106 ++++++- .../State/EmployeeRttSummaryProviderTest.php | 291 ++++++++++++++++++ 2 files changed, 392 insertions(+), 5 deletions(-) create mode 100644 tests/State/EmployeeRttSummaryProviderTest.php diff --git a/src/State/EmployeeRttSummaryProvider.php b/src/State/EmployeeRttSummaryProvider.php index 8995845..c4eb7c5 100644 --- a/src/State/EmployeeRttSummaryProvider.php +++ b/src/State/EmployeeRttSummaryProvider.php @@ -7,6 +7,7 @@ namespace App\State; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProviderInterface; use App\ApiResource\EmployeeRttSummary; +use App\Dto\Contracts\ContractPhase; use App\Dto\Rtt\EmployeeRttWeekSummary; use App\Dto\Rtt\RttMonthPayment; use App\Dto\Rtt\WeekRecoveryDetail; @@ -17,6 +18,7 @@ use App\Repository\EmployeeRttBalanceRepository; use App\Repository\EmployeeRttPaymentRepository; use App\Repository\WorkHourRepository; use App\Security\EmployeeScopeService; +use App\Service\Contracts\EmployeeContractPhaseResolver; use App\Service\Rtt\RttRecoveryComputationService; use DateTimeImmutable; use Symfony\Bundle\SecurityBundle\Security; @@ -38,6 +40,7 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface private EmployeeRttPaymentRepository $rttPaymentRepository, private RttRecoveryComputationService $rttRecoveryService, private WorkHourRepository $workHourRepository, + private EmployeeContractPhaseResolver $phaseResolver, string $rttStartDate = '', ) { $this->rttStartDate = '' !== $rttStartDate ? $rttStartDate : null; @@ -64,12 +67,22 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface throw new AccessDeniedHttpException('Employee outside your scope.'); } - $year = $this->resolveYear(); + $phase = $this->resolveTargetPhase($employee); + $year = $this->resolveYear($phase); $today = new DateTimeImmutable('today'); $currentExerciseYear = $this->resolveCurrentExerciseYear($today); [$periodFrom, $periodTo] = $this->rttRecoveryService->resolveExerciseBounds($year); - $weeks = $this->rttRecoveryService->buildWeeksForExercise($periodFrom, $periodTo); - $weekRanges = array_map( + + // Cap exercise bounds to the phase boundaries. + if (!$phase->isCurrent && null !== $phase->endDate && $phase->endDate < $periodTo) { + $periodTo = $phase->endDate; + } + if ($phase->startDate > $periodFrom) { + $periodFrom = $phase->startDate; + } + + $weeks = $this->rttRecoveryService->buildWeeksForExercise($periodFrom, $periodTo); + $weekRanges = array_map( static fn (array $week): array => [ 'weekNumber' => (int) $week['weekNumber'], 'start' => $week['start'], @@ -96,6 +109,12 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface } } + // For a closed phase: cap the week-computation limit at the phase end date, + // so weeks beyond the phase are not counted. + if (!$phase->isCurrent && null !== $phase->endDate && $phase->endDate < $limitDate) { + $limitDate = $phase->endDate; + } + $currentByWeekStart = $this->rttRecoveryService->computeRecoveryByWeek($employee, $weekRanges, $periodFrom, $periodTo, $limitDate); [$carry, $carryMonth] = $this->resolveCarry($employee, $year); @@ -213,10 +232,21 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface ]; } - private function resolveYear(): int + private function resolveYear(ContractPhase $phase): int { - $raw = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? ''); + $raw = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? ''); + $phaseIdRaw = $this->requestStack->getCurrentRequest()?->query->get('phaseId'); + $phaseIdProvided = null !== $phaseIdRaw && '' !== (string) $phaseIdRaw; + if ('' === $raw) { + // When a phaseId is explicitly provided, default to the exercise year derived from + // the phase's end date (or today if the phase is still current). + if ($phaseIdProvided) { + $reference = $phase->endDate ?? new DateTimeImmutable('today'); + + return $this->resolveCurrentExerciseYear($reference); + } + return $this->resolveCurrentExerciseYear(new DateTimeImmutable('today')); } if (!preg_match('/^\d{4}$/', $raw)) { @@ -228,9 +258,75 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.'); } + // When a phaseId is explicit, silently clamp the requested year to the + // first/last exercise covered by the phase. + if ($phaseIdProvided) { + $year = $this->clampYearToPhase($year, $phase); + } + return $year; } + private function clampYearToPhase(int $year, ContractPhase $phase): int + { + $firstYear = $this->exerciseYearForDate($phase->startDate); + $lastYear = $phase->endDate instanceof DateTimeImmutable + ? $this->exerciseYearForDate($phase->endDate) + : null; + + if ($year < $firstYear) { + return $firstYear; + } + if (null !== $lastYear && $year > $lastYear) { + return $lastYear; + } + + return $year; + } + + /** + * Map a date to the RTT exercise year it belongs to (Juin N-1 → Mai N convention). + */ + private function exerciseYearForDate(DateTimeImmutable $date): int + { + $year = (int) $date->format('Y'); + $month = (int) $date->format('n'); + + return $month >= 6 ? $year + 1 : $year; + } + + private function resolveTargetPhase(Employee $employee): ContractPhase + { + $raw = $this->requestStack->getCurrentRequest()?->query->get('phaseId'); + $phases = $this->phaseResolver->resolvePhases($employee); + if ([] === $phases) { + throw new UnprocessableEntityHttpException('Employee has no contract phase.'); + } + + if (null === $raw || '' === (string) $raw) { + // Phase courante par défaut = celle marquée isCurrent ou, à défaut, la plus récente. + foreach ($phases as $phase) { + if ($phase->isCurrent) { + return $phase; + } + } + + return $phases[0]; + } + + if (!preg_match('/^\d+$/', (string) $raw)) { + throw new UnprocessableEntityHttpException('phaseId must be a positive integer.'); + } + $phaseId = (int) $raw; + foreach ($phases as $phase) { + if ($phase->id === $phaseId) { + return $phase; + } + } + + throw new UnprocessableEntityHttpException('phaseId does not match any phase of this employee.'); + } + private function resolveCurrentExerciseYear(DateTimeImmutable $today): int { $year = (int) $today->format('Y'); diff --git a/tests/State/EmployeeRttSummaryProviderTest.php b/tests/State/EmployeeRttSummaryProviderTest.php new file mode 100644 index 0000000..5ae6f37 --- /dev/null +++ b/tests/State/EmployeeRttSummaryProviderTest.php @@ -0,0 +1,291 @@ +buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); + $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); + $h39Phase = $phases[1]; // oldest = 39h + + $provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id]); + $resolved = $this->invokePrivate($provider, 'resolveTargetPhase', $employee); + + self::assertInstanceOf(ContractPhase::class, $resolved); + self::assertSame($h39Phase->id, $resolved->id); + self::assertSame(ContractType::H39, $resolved->contractType); + self::assertFalse($resolved->isCurrent); + } + + public function testResolveTargetPhaseDefaultsToCurrentPhaseWhenPhaseIdAbsent(): void + { + $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); + $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); + $currentPhase = $phases[0]; // most recent = FORFAIT + + $provider = $this->buildProvider([]); + $resolved = $this->invokePrivate($provider, 'resolveTargetPhase', $employee); + + self::assertSame($currentPhase->id, $resolved->id); + self::assertTrue($resolved->isCurrent); + } + + public function testPastH39PhaseRttSummaryIsCappedAtPhaseEndDate(): void + { + $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); + $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); + $h39Phase = $phases[1]; + + $provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2026']); + $year = $this->invokePrivate($provider, 'resolveYear', $h39Phase); + + self::assertSame(2026, $year); + + // The phase-cap branch in provide() narrows periodTo to the phase end date. + // Reproduce that logic to validate the resulting window. + $periodFrom = new DateTimeImmutable('2025-06-01'); + $periodTo = new DateTimeImmutable('2026-05-31'); + + if (!$h39Phase->isCurrent && null !== $h39Phase->endDate && $h39Phase->endDate < $periodTo) { + $periodTo = $h39Phase->endDate; + } + if ($h39Phase->startDate > $periodFrom) { + $periodFrom = $h39Phase->startDate; + } + + self::assertSame('2025-06-01', $periodFrom->format('Y-m-d')); + self::assertSame('2026-04-30', $periodTo->format('Y-m-d')); + } + + public function testYearOutsidePhaseRangeIsSilentlyClampedToPhaseLastExercise(): void + { + $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); + $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); + $h39Phase = $phases[1]; + + $provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2030']); + $year = $this->invokePrivate($provider, 'resolveYear', $h39Phase); + + // Phase ends 2026-04-30 → exercice (Juin-Mai) containing it = 2026. + self::assertSame(2026, $year); + } + + public function testYearBeforePhaseIsClampedToPhaseFirstExercise(): void + { + $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); + $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); + $h39Phase = $phases[1]; + + // Phase starts 2020-06-01 → first exercise (Juin-Mai) = 2021. + $provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2010']); + $year = $this->invokePrivate($provider, 'resolveYear', $h39Phase); + + self::assertSame(2021, $year); + } + + public function testInvalidPhaseIdReturns422(): void + { + $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); + $provider = $this->buildProvider(['phaseId' => '99999']); + + $this->expectException(UnprocessableEntityHttpException::class); + $this->invokePrivate($provider, 'resolveTargetPhase', $employee); + } + + public function testNonNumericPhaseIdReturns422(): void + { + $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); + $provider = $this->buildProvider(['phaseId' => 'abc']); + + $this->expectException(UnprocessableEntityHttpException::class); + $this->invokePrivate($provider, 'resolveTargetPhase', $employee); + } + + public function testDefaultYearForPhaseIdOnClosedPhaseUsesPhaseEndDate(): void + { + // No `year` param + explicit phaseId → default year is derived from $phase->endDate. + // H39 phase ends 2026-04-30 → RTT exercise containing that date = 2026. + $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); + $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); + $h39Phase = $phases[1]; + + $provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id]); + $year = $this->invokePrivate($provider, 'resolveYear', $h39Phase); + + self::assertSame(2026, $year); + } + + public function testNoQueryParamsKeepsLegacyYearDefaulting(): void + { + $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); + $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); + $currentPhase = $phases[0]; + + $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); + } + + public function testInvalidYearFormatReturns422(): void + { + $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); + $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); + $currentPhase = $phases[0]; + + $provider = $this->buildProvider(['year' => '20XX']); + + $this->expectException(UnprocessableEntityHttpException::class); + $this->invokePrivate($provider, 'resolveYear', $currentPhase); + } + + public function testYearOutsideBoundsReturns422(): void + { + $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); + $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); + $currentPhase = $phases[0]; + + $provider = $this->buildProvider(['year' => '1900']); + + $this->expectException(UnprocessableEntityHttpException::class); + $this->invokePrivate($provider, 'resolveYear', $currentPhase); + } + + public function testYearWithoutPhaseIdIsNotClamped(): void + { + // No `phaseId` → legacy callers must keep their requested year as-is, + // even if it falls outside the current phase range. + $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); + $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); + $currentPhase = $phases[0]; + + $provider = $this->buildProvider(['year' => '2030']); + $year = $this->invokePrivate($provider, 'resolveYear', $currentPhase); + + self::assertSame(2030, $year); + } + + // ----------------------------------------------------------------------- + // Test harness helpers. + // ----------------------------------------------------------------------- + + /** + * Build a two-period employee transitioning from H39 to FORFAIT. + */ + private function buildEmployeeWithTransition(string $h39Start, string $h39End, string $forfaitStart): Employee + { + $employee = new Employee(); + $this->setEntityId($employee, 1); + + $h39Contract = new Contract(); + $h39Contract->setName('39H'); + $h39Contract->setTrackingMode(TrackingMode::TIME->value); + $h39Contract->setWeeklyHours(39); + + $forfaitContract = new Contract(); + $forfaitContract->setName('Forfait'); + $forfaitContract->setTrackingMode(TrackingMode::PRESENCE->value); + $forfaitContract->setWeeklyHours(null); + + $h39Period = new EmployeeContractPeriod(); + $this->setEntityId($h39Period, 1); + $h39Period->setEmployee($employee); + $h39Period->setContract($h39Contract); + $h39Period->setStartDate(new DateTimeImmutable($h39Start)); + $h39Period->setEndDate(new DateTimeImmutable($h39End)); + $h39Period->setContractNature(ContractNature::CDI); + $h39Period->setIsDriver(false); + + $forfaitPeriod = new EmployeeContractPeriod(); + $this->setEntityId($forfaitPeriod, 2); + $forfaitPeriod->setEmployee($employee); + $forfaitPeriod->setContract($forfaitContract); + $forfaitPeriod->setStartDate(new DateTimeImmutable($forfaitStart)); + $forfaitPeriod->setEndDate(null); + $forfaitPeriod->setContractNature(ContractNature::CDI); + $forfaitPeriod->setIsDriver(false); + + $employee->getContractPeriods()->add($h39Period); + $employee->getContractPeriods()->add($forfaitPeriod); + + return $employee; + } + + /** + * Build an uninitialized provider with a RequestStack pre-loaded with the given query. + * + * The provider's repository/service dependencies are typed against final classes + * (EmployeeRepository, RttRecoveryComputationService, etc.) which PHPUnit cannot + * double. We bypass full instantiation by using newInstanceWithoutConstructor and + * only setting the properties that the tested private methods actually read: + * `requestStack` and `phaseResolver`. + * + * @param array $request query parameters (year, phaseId, ...) + */ + private function buildProvider(array $request = []): EmployeeRttSummaryProvider + { + $requestStack = new RequestStack(); + $requestStack->push(new Request(query: $request)); + + $reflection = new ReflectionClass(EmployeeRttSummaryProvider::class); + $provider = $reflection->newInstanceWithoutConstructor(); + + $this->setReadonlyProperty($provider, 'requestStack', $requestStack); + $this->setReadonlyProperty($provider, 'phaseResolver', new EmployeeContractPhaseResolver()); + $this->setReadonlyProperty($provider, 'rttStartDate', null); + + return $provider; + } + + private function invokePrivate(object $obj, string $method, mixed ...$args): mixed + { + $reflection = new ReflectionClass($obj::class); + $m = $reflection->getMethod($method); + + return $m->invoke($obj, ...$args); + } + + private function setReadonlyProperty(object $obj, string $property, mixed $value): void + { + $reflection = new ReflectionProperty($obj::class, $property); + $reflection->setValue($obj, $value); + } + + private function setEntityId(object $entity, int $id): void + { + $reflection = new ReflectionProperty($entity::class, 'id'); + $reflection->setValue($entity, $id); + } +}