diff --git a/src/State/EmployeeLeaveSummaryProvider.php b/src/State/EmployeeLeaveSummaryProvider.php index 43d26fc..dc0a53d 100644 --- a/src/State/EmployeeLeaveSummaryProvider.php +++ b/src/State/EmployeeLeaveSummaryProvider.php @@ -7,6 +7,7 @@ namespace App\State; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProviderInterface; use App\ApiResource\EmployeeLeaveSummary; +use App\Dto\Contracts\ContractPhase; use App\Entity\Absence; use App\Entity\ContractSuspension; use App\Entity\Employee; @@ -20,6 +21,7 @@ use App\Repository\EmployeeLeaveBalanceRepository; use App\Repository\EmployeeRepository; use App\Repository\WorkHourRepository; use App\Security\EmployeeScopeService; +use App\Service\Contracts\EmployeeContractPhaseResolver; use App\Service\Leave\LeaveBalanceComputationService; use App\Service\Leave\LongMaladieService; use App\Service\Leave\SuspensionDaysCalculator; @@ -60,6 +62,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface private PublicHolidayServiceInterface $publicHolidayService, private SuspensionDaysCalculator $suspensionDaysCalculator, private WorkHourRepository $workHourRepository, + private EmployeeContractPhaseResolver $phaseResolver, string $dataStartDate = '', ) { $this->dataStartDate = '' !== $dataStartDate ? $dataStartDate : null; @@ -86,14 +89,15 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface throw new AccessDeniedHttpException('Employee outside your scope.'); } - $year = $this->resolveYear($employee); + $phase = $this->resolveTargetPhase($employee); + $year = $this->resolveYear($employee, $phase); $summary = new EmployeeLeaveSummary(); $summary->year = $year; $summary->ruleCode = LeaveRuleCode::UNSUPPORTED->value; $summary->dataStartDate = $this->dataStartDate; - $yearSummary = $this->computeYearSummary($employee, $year); + $yearSummary = $this->computeYearSummary($employee, $year, 0.0, null, $phase); if (null === $yearSummary) { return $summary; } @@ -104,7 +108,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface // For forfait contracts, paid days reduce N-1 stock before taken-day attribution. // Recompute with paidLeaveDays so taken days shift from N-1 to N when N-1 is consumed by payment. if ($paidLeaveDays > 0.0) { - $yearSummary = $this->computeYearSummary($employee, $year, $paidLeaveDays); + $yearSummary = $this->computeYearSummary($employee, $year, $paidLeaveDays, null, $phase); if (null === $yearSummary) { return $summary; } @@ -125,7 +129,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface $summary->previousYearRemainingDays = $yearSummary['previousYearRemainingDays']; $summary->previousYearPaidDays = $paidLeaveDays; - [$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year); + [$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year, $phase); // Forfait-only: leaves taken from N-1 stock do NOT decrement presence days. // For non-forfait, previousYearTakenDays is always 0, so the budget has no effect. $n1AbsencesBudget = $yearSummary['previousYearTakenDays']; @@ -167,9 +171,14 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface * previousYearRemainingDays: float * } */ - public function computeYearSummary(Employee $employee, int $targetYear, float $paidLeaveDays = 0.0, ?DateTimeImmutable $asOfDate = null): ?array + public function computeYearSummary(Employee $employee, int $targetYear, float $paidLeaveDays = 0.0, ?DateTimeImmutable $asOfDate = null, ?ContractPhase $phase = null): ?array { - $firstYear = max($this->resolveFirstComputationYear($employee), $targetYear - 1); + $phase ??= $this->resolveCurrentPhase($employee); + if (null === $phase) { + return null; + } + + $firstYear = max($this->resolveFirstComputationYear($employee, $phase), $targetYear - 1); if ($targetYear < $firstYear) { $targetYear = $firstYear; } @@ -179,8 +188,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface $targetSummary = null; for ($year = $firstYear; $year <= $targetYear; ++$year) { - [$from, $to] = $this->resolvePeriodBounds($employee, $year); - $leavePolicy = $this->resolveLeavePolicy($employee, $from, $to); + [$from, $to] = $this->resolvePeriodBounds($employee, $year, $phase); + $leavePolicy = $this->resolveLeavePolicy($employee, $phase, $from, $to); if (null === $leavePolicy) { if ($year === $targetYear) { return null; @@ -224,8 +233,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface } $effectiveAsOfDate = ($year === $targetYear) ? $asOfDate : null; - $accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee, $effectiveAsOfDate); - $takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee, $effectiveAsOfDate); + $accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee, $phase, $effectiveAsOfDate); + $takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee, $phase, $effectiveAsOfDate); $suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace( $this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to) ); @@ -233,7 +242,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface $longMaladiePeriods = []; $longMaladieReductionFactor = 1.0; if (LeaveRuleCode::CDI_CDD_NON_FORFAIT->value === $leavePolicy['ruleCode'] - && 4 !== $employee->getContract()?->getWeeklyHours() + && 4 !== $phase->weeklyHours && null !== $accrualCalculationEnd ) { $longMaladiePeriods = $this->longMaladieService->findReducedRatePeriods($employee, $effectiveFrom, $accrualCalculationEnd); @@ -420,16 +429,29 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface return $earliest; } - private function resolveYear(Employee $employee): int + private function resolveYear(Employee $employee, ContractPhase $phase): int { - $raw = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? ''); + $isForfait = ContractType::FORFAIT === $phase->contractType; + $raw = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? ''); + $phaseIdRaw = $this->requestStack->getCurrentRequest()?->query->get('phaseId'); + $phaseIdProvided = null !== $phaseIdRaw && '' !== (string) $phaseIdRaw; + if ('' === $raw) { - $today = new DateTimeImmutable('today'); - if (ContractType::FORFAIT === $employee->getContract()?->getType()) { - return (int) $today->format('Y'); + // When a phaseId is explicitly provided, default to the 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 $isForfait + ? (int) $reference->format('Y') + : $this->resolveCurrentLeaveYear($reference); } - return $this->resolveCurrentLeaveYear($today); + $today = new DateTimeImmutable('today'); + + return $isForfait + ? (int) $today->format('Y') + : $this->resolveCurrentLeaveYear($today); } if (!preg_match('/^\d{4}$/', $raw)) { @@ -441,9 +463,90 @@ final readonly class EmployeeLeaveSummaryProvider 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, $isForfait); + } + return $year; } + private function clampYearToPhase(int $year, ContractPhase $phase, bool $isForfait): int + { + $firstYear = $isForfait + ? (int) $phase->startDate->format('Y') + : ((int) $phase->startDate->format('n') >= 6 + ? (int) $phase->startDate->format('Y') + 1 + : (int) $phase->startDate->format('Y')); + + $endDate = $phase->endDate; + $lastYear = null; + if ($endDate instanceof DateTimeImmutable) { + $lastYear = $isForfait + ? (int) $endDate->format('Y') + : ((int) $endDate->format('n') >= 6 + ? (int) $endDate->format('Y') + 1 + : (int) $endDate->format('Y')); + } + + if ($year < $firstYear) { + return $firstYear; + } + if (null !== $lastYear && $year > $lastYear) { + return $lastYear; + } + + return $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 resolveCurrentPhase(Employee $employee): ?ContractPhase + { + $phases = $this->phaseResolver->resolvePhases($employee); + if ([] === $phases) { + return null; + } + foreach ($phases as $phase) { + if ($phase->isCurrent) { + return $phase; + } + } + + return $phases[0]; + } + /** * @param list $suspensions * @param list $longMaladiePeriods @@ -518,6 +621,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface int $year, DateTimeImmutable $periodEnd, Employee $employee, + ContractPhase $phase, ?DateTimeImmutable $asOfDate = null ): ?DateTimeImmutable { $reference = $asOfDate ?? new DateTimeImmutable('today'); @@ -525,7 +629,11 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface ? (int) $reference->format('Y') : $this->resolveCurrentLeaveYear($reference); - if ($year < $currentYear) { + // When viewing a closed phase, treat its end date as the reference cutoff: + // accrual is bounded to the phase end, never running to "today". + if (!$phase->isCurrent && null !== $phase->endDate) { + $end = $phase->endDate < $periodEnd ? $phase->endDate : $periodEnd; + } elseif ($year < $currentYear) { $end = $periodEnd; } elseif ($year > $currentYear) { $end = null; @@ -538,12 +646,15 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface $end = $lastDayPreviousMonth < $periodEnd ? $lastDayPreviousMonth : $periodEnd; } - // Cap at contract end date if the employee has left. - $contractEndRaw = $employee->getCurrentContractEndDate(); - if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) { - $contractEnd = $this->parseYmdDate($contractEndRaw); - if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) { - $end = $contractEnd; + // Cap at contract end date if the employee has left (only meaningful when + // viewing the current phase; closed phases are already capped above). + if ($phase->isCurrent) { + $contractEndRaw = $employee->getCurrentContractEndDate(); + if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) { + $contractEnd = $this->parseYmdDate($contractEndRaw); + if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) { + $end = $contractEnd; + } } } @@ -553,6 +664,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface private function resolveTakenCalculationEndDate( DateTimeImmutable $periodEnd, Employee $employee, + ContractPhase $phase, ?DateTimeImmutable $asOfDate = null ): ?DateTimeImmutable { $end = $periodEnd; @@ -561,12 +673,18 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface $end = $asOfDate; } - // Cap at contract end date if the employee has left. - $contractEndRaw = $employee->getCurrentContractEndDate(); - if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) { - $contractEnd = $this->parseYmdDate($contractEndRaw); - if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) { - $end = $contractEnd; + // Closed phase: cap taken-absence accounting at the phase end. + if (!$phase->isCurrent && null !== $phase->endDate && $phase->endDate < $end) { + $end = $phase->endDate; + } + + if ($phase->isCurrent) { + $contractEndRaw = $employee->getCurrentContractEndDate(); + if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) { + $contractEnd = $this->parseYmdDate($contractEndRaw); + if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) { + $end = $contractEnd; + } } } @@ -584,9 +702,9 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface * splitSaturdays: bool * } */ - private function resolveLeavePolicy(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): ?array + private function resolveLeavePolicy(Employee $employee, ContractPhase $phase, DateTimeImmutable $from, DateTimeImmutable $to): ?array { - $type = $employee->getContract()?->getType(); + $type = $phase->contractType; if (ContractType::FORFAIT === $type) { // Business days for forfait must use the RAW holiday list (excluded holidays like // "Lundi de Pentecôte" / journée de solidarité still count as non-working days for @@ -615,12 +733,13 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface ]; } - $nature = ContractNature::tryFrom($employee->getCurrentContractNature()); + // Resolve nature from the period defining the phase (use the phase's first period). + $nature = $this->resolveNatureForPhase($employee, $phase); if (ContractNature::CDI !== $nature && ContractNature::CDD !== $nature) { return null; } - $weeklyHours = $employee->getContract()?->getWeeklyHours(); + $weeklyHours = $phase->weeklyHours; if (4 === $weeklyHours) { return [ 'ruleCode' => LeaveRuleCode::CDI_CDD_NON_FORFAIT->value, @@ -815,19 +934,29 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface /** * @return array{DateTimeImmutable, DateTimeImmutable} */ - private function resolvePeriodBounds(Employee $employee, int $year): array + private function resolvePeriodBounds(Employee $employee, int $year, ContractPhase $phase): array { - if (ContractType::FORFAIT === $employee->getContract()?->getType()) { - return $this->resolveForfaitYearBounds($employee, $year); + if (ContractType::FORFAIT === $phase->contractType) { + [$from, $to] = $this->resolveForfaitYearBounds($employee, $year, $phase); + } else { + [$from, $to] = $this->resolveLeavePeriodBounds($year, $phase); } - return $this->resolveLeavePeriodBounds($year); + // Cap to the phase boundaries (applies to both modes). + if ($phase->startDate > $from) { + $from = $phase->startDate; + } + if (null !== $phase->endDate && $phase->endDate < $to) { + $to = $phase->endDate; + } + + return [$from, $to]; } /** * @return array{DateTimeImmutable, DateTimeImmutable} */ - private function resolveLeavePeriodBounds(int $leaveYear): array + private function resolveLeavePeriodBounds(int $leaveYear, ContractPhase $phase): array { // Exercice CP "2026" = du 1er juin 2025 au 31 mai 2026. $from = new DateTimeImmutable(sprintf('%d-06-01 00:00:00', $leaveYear - 1)); @@ -839,24 +968,29 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface /** * @return array{DateTimeImmutable, DateTimeImmutable} */ - private function resolveForfaitYearBounds(Employee $employee, int $year): array + private function resolveForfaitYearBounds(Employee $employee, int $year, ContractPhase $phase): array { $from = new DateTimeImmutable(sprintf('%d-01-01 00:00:00', $year)); $to = new DateTimeImmutable(sprintf('%d-12-31 00:00:00', $year)); - $contractStartRaw = $employee->getCurrentContractStartDate(); - if (null !== $contractStartRaw && '' !== trim($contractStartRaw)) { - $contractStart = $this->parseYmdDate($contractStartRaw); - if ($contractStart instanceof DateTimeImmutable && $contractStart > $from) { - $from = $contractStart; + // When viewing the current phase, prefer the live "current contract" dates + // for backward compat with existing tests/usage. Closed phases rely on the + // generic cap applied in resolvePeriodBounds(). + if ($phase->isCurrent) { + $contractStartRaw = $employee->getCurrentContractStartDate(); + if (null !== $contractStartRaw && '' !== trim($contractStartRaw)) { + $contractStart = $this->parseYmdDate($contractStartRaw); + if ($contractStart instanceof DateTimeImmutable && $contractStart > $from) { + $from = $contractStart; + } } - } - $contractEndRaw = $employee->getCurrentContractEndDate(); - if (null !== $contractEndRaw && '' !== trim($contractEndRaw)) { - $contractEnd = $this->parseYmdDate($contractEndRaw); - if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $to) { - $to = $contractEnd; + $contractEndRaw = $employee->getCurrentContractEndDate(); + if (null !== $contractEndRaw && '' !== trim($contractEndRaw)) { + $contractEnd = $this->parseYmdDate($contractEndRaw); + if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $to) { + $to = $contractEnd; + } } } @@ -878,16 +1012,23 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface return $month >= 6 ? $year + 1 : $year; } - private function resolveFirstComputationYear(Employee $employee): int + private function resolveFirstComputationYear(Employee $employee, ContractPhase $phase): int { - $isForfait = ContractType::FORFAIT === $employee->getContract()?->getType(); + $isForfait = ContractType::FORFAIT === $phase->contractType; $fallbackYear = $isForfait ? (int) new DateTimeImmutable('today')->format('Y') : $this->resolveCurrentLeaveYear(new DateTimeImmutable('today')); + // Do not go before the exercice containing $phase->startDate. + $phaseFirstYear = $isForfait + ? (int) $phase->startDate->format('Y') + : ((int) $phase->startDate->format('n') >= 6 + ? (int) $phase->startDate->format('Y') + 1 + : (int) $phase->startDate->format('Y')); + $history = $employee->getContractHistory(); if ([] === $history) { - return $fallbackYear; + return max($phaseFirstYear, $fallbackYear); } $oldestStartDate = null; @@ -903,8 +1044,9 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface if (null === $oldestStartDate) { $oldestBalanceYear = $this->leaveBalanceRepository->findEarliestYearForEmployee($employee); + $candidate = null === $oldestBalanceYear ? $fallbackYear : min($fallbackYear, $oldestBalanceYear); - return null === $oldestBalanceYear ? $fallbackYear : min($fallbackYear, $oldestBalanceYear); + return max($phaseFirstYear, $candidate); } $firstYear = $isForfait @@ -915,10 +1057,23 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface $oldestBalanceYear = $this->leaveBalanceRepository->findEarliestYearForEmployee($employee); if (null !== $oldestBalanceYear && $oldestBalanceYear < $firstYear) { - return $oldestBalanceYear; + $firstYear = $oldestBalanceYear; } - return $firstYear; + return max($phaseFirstYear, $firstYear); + } + + private function resolveNatureForPhase(Employee $employee, ContractPhase $phase): ?ContractNature + { + // Find the period at the start of the phase to determine its nature. + foreach ($employee->getContractPeriods() as $period) { + if ((int) $period->getId() === $phase->id) { + return $period->getContractNatureEnum(); + } + } + + // Fallback: nature of the current period (legacy behavior). + return ContractNature::tryFrom($employee->getCurrentContractNature()); } private function parseYmdDate(string $value): ?DateTimeImmutable diff --git a/tests/State/EmployeeLeaveSummaryProviderTest.php b/tests/State/EmployeeLeaveSummaryProviderTest.php index 34fc8d7..f26583b 100644 --- a/tests/State/EmployeeLeaveSummaryProviderTest.php +++ b/tests/State/EmployeeLeaveSummaryProviderTest.php @@ -4,16 +4,32 @@ declare(strict_types=1); namespace App\Tests\State; +use App\Dto\Contracts\ContractPhase; +use App\Entity\Contract; +use App\Entity\Employee; +use App\Entity\EmployeeContractPeriod; +use App\Enum\ContractNature; +use App\Enum\ContractType; +use App\Enum\TrackingMode; +use App\Service\Contracts\EmployeeContractPhaseResolver; use App\State\EmployeeLeaveSummaryProvider; use DateTimeImmutable; use PHPUnit\Framework\TestCase; use ReflectionClass; +use ReflectionProperty; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; /** * @internal */ final class EmployeeLeaveSummaryProviderTest extends TestCase { + // ----------------------------------------------------------------------- + // Existing tests (unchanged) — verify accrual prorating arithmetic. + // ----------------------------------------------------------------------- + public function testComputeAccruedDaysFromStartProratesPartialFirstMonth(): void { $provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor(); @@ -68,4 +84,253 @@ final class EmployeeLeaveSummaryProviderTest extends TestCase self::assertEqualsWithDelta(25.0 / 12.0, $result, 0.0001); } + + // ----------------------------------------------------------------------- + // Phase resolution tests (Task 3 — phaseId support). + // The repository / service dependencies are typed against final classes + // which PHPUnit cannot double, so phase resolution is exercised via + // reflection on private methods to avoid instantiating the full DI graph. + // ----------------------------------------------------------------------- + + public function testResolveTargetPhasePicksH39PhaseFromPhaseId(): void + { + $employee = $this->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::assertSame(ContractType::FORFAIT, $resolved->contractType); + self::assertTrue($resolved->isCurrent); + } + + public function testPastH39PhaseAppliesNonForfaitRuleCodeEvenWhenCurrentIsForfait(): void + { + // Verifies resolveLeavePolicy uses the phase's contractType (not the current contract). + $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]); + $from = new DateTimeImmutable('2025-06-01'); + $to = new DateTimeImmutable('2026-04-30'); + $leavePolicy = $this->invokePrivate($provider, 'resolveLeavePolicy', $employee, $h39Phase, $from, $to); + + self::assertNotNull($leavePolicy); + self::assertSame('CDI_CDD_NON_FORFAIT', $leavePolicy['ruleCode']); + self::assertSame(25.0, $leavePolicy['acquiredDays']); + self::assertEqualsWithDelta(25.0 / 12.0, $leavePolicy['accrualPerMonth'], 0.0001); + } + + public function testResolvePeriodBoundsCapsAtPhaseEndDate(): void + { + // 39h phase (June 2020 → April 30 2026). Exercise 2026 spans June 2025 → May 31 2026. + // The phase cap should clip the upper bound to April 30 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' => '2026']); + [$from, $to] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2026, $h39Phase); + + self::assertSame('2025-06-01', $from->format('Y-m-d')); + self::assertSame('2026-04-30', $to->format('Y-m-d')); + } + + public function testTransitionExerciseOnH39PhaseAccruesAround22Point9Days(): void + { + // 11 full months of accrual at 25/12 ≈ 22.917 days. + $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']); + $method = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->getMethod('computeAccruedDaysFromStart'); + + // Period bounds for exercise 2026 on H39 phase = June 1 2025 → April 30 2026. + [$from, $to] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2026, $h39Phase); + $acquired = $method->invoke($provider, 25.0, 25.0 / 12.0, $from, $to); + + self::assertEqualsWithDelta(22.92, $acquired, 0.1); + } + + 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', $employee, $h39Phase); + + 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 (non-forfait) = 2021 (since month >=6 = year+1). + $provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2010']); + $year = $this->invokePrivate($provider, 'resolveYear', $employee, $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 → non-forfait 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', $employee, $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', $employee, $currentPhase); + + // Today is 2026-05-19, FORFAIT phase → year is the current calendar year (2026). + self::assertSame(2026, $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, LeaveBalanceComputationService, 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`. Tests targeting heavier code paths exercise + * private methods directly (resolveTargetPhase, resolvePeriodBounds, etc.). + * + * @param array $request query parameters (year, phaseId, ...) + */ + private function buildProvider(array $request = []): EmployeeLeaveSummaryProvider + { + $requestStack = new RequestStack(); + $requestStack->push(new Request(query: $request)); + + $reflection = new ReflectionClass(EmployeeLeaveSummaryProvider::class); + $provider = $reflection->newInstanceWithoutConstructor(); + + $this->setReadonlyProperty($provider, 'requestStack', $requestStack); + $this->setReadonlyProperty($provider, 'phaseResolver', new EmployeeContractPhaseResolver()); + $this->setReadonlyProperty($provider, 'dataStartDate', 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); + } }