security->getUser(); if (!$user instanceof User) { throw new AccessDeniedHttpException('Authentication required.'); } $employeeId = (int) ($uriVariables['id'] ?? 0); if ($employeeId <= 0) { throw new UnprocessableEntityHttpException('id must be a positive integer.'); } $employee = $this->employeeRepository->find($employeeId); if (!$employee instanceof Employee) { throw new NotFoundHttpException('Employee not found.'); } if (!$this->employeeScopeService->canAccessEmployee($user, $employee)) { throw new AccessDeniedHttpException('Employee outside your scope.'); } $year = $this->resolveYear($employee); $summary = new EmployeeLeaveSummary(); $summary->year = $year; $summary->ruleCode = LeaveRuleCode::UNSUPPORTED->value; $yearSummary = $this->computeYearSummary($employee, $year); if (null === $yearSummary) { return $summary; } $fractionedDays = $this->resolveFractionedDays($employee, $yearSummary['ruleCode'], $year); $summary->isSupported = true; $summary->ruleCode = $yearSummary['ruleCode']; $summary->acquiredDays = $yearSummary['acquiredDays'] + $fractionedDays; $summary->acquiredSaturdays = $yearSummary['acquiredSaturdays']; $summary->fractionedDays = $fractionedDays; $summary->accruingDays = $yearSummary['accruingDays']; $summary->takenDays = $yearSummary['takenDays']; $summary->takenSaturdays = $yearSummary['takenSaturdays']; $summary->remainingDays = $yearSummary['remainingDays'] + $fractionedDays; $summary->remainingSaturdays = $yearSummary['remainingSaturdays']; return $summary; } /** * @return null|array{ * ruleCode: string, * acquiredDays: float, * acquiredSaturdays: float, * accruingDays: float, * takenDays: float, * takenSaturdays: float, * remainingDays: float, * remainingSaturdays: float * } */ private function computeYearSummary(Employee $employee, int $targetYear): ?array { $firstYear = $this->resolveFirstComputationYear($employee); if ($targetYear < $firstYear) { $targetYear = $firstYear; } $previousRemainingDays = 0.0; $previousRemainingSaturdays = 0.0; $targetSummary = null; for ($year = $firstYear; $year <= $targetYear; ++$year) { [$from, $to] = $this->resolvePeriodBounds($employee, $year); $leavePolicy = $this->resolveLeavePolicy($employee, $from, $to); if (null === $leavePolicy) { if ($year === $targetYear) { return null; } continue; } $carryDays = 0.0; $carrySaturdays = 0.0; $openingBalance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear( $employee, $leavePolicy['ruleCode'], $year ); if (null !== $openingBalance) { $carryDays = $openingBalance->getOpeningDays(); $carrySaturdays = $leavePolicy['splitSaturdays'] ? $openingBalance->getOpeningSaturdays() : 0.0; } elseif ($year > $firstYear) { $ruleCode = LeaveRuleCode::from($leavePolicy['ruleCode']); [$carryDays, $carrySaturdays] = $this->leaveBalanceComputationService ->computeDynamicClosingForYear($employee, $ruleCode, $year - 1) ; [$previousFrom, $previousTo] = $this->leaveBalanceComputationService->resolvePeriodBounds($ruleCode, $year - 1); $hasSettlement = $this->leaveBalanceComputationService ->hasPaidLeaveSettledClosureBetween($employee, $previousFrom, $previousTo) ; if ($hasSettlement) { $carryDays = 0.0; $carrySaturdays = 0.0; } elseif (!$leavePolicy['splitSaturdays']) { $carrySaturdays = 0.0; } } $effectiveFrom = $this->resolveEffectivePeriodStart($employee, $from, $to); $hasShiftedStart = $effectiveFrom > $from; if ($hasShiftedStart && null === $openingBalance) { $carryDays = 0.0; $carrySaturdays = 0.0; } $calculationEnd = $this->resolveCalculationEndDate($to, $employee); $generatedDays = $leavePolicy['accrualPerMonth'] > 0.0 ? $this->computeAccruedDaysFromStart( $leavePolicy['acquiredDays'], $leavePolicy['accrualPerMonth'], $effectiveFrom, $calculationEnd ) : 0.0; $generatedSaturdays = $leavePolicy['saturdayAccrualPerMonth'] > 0.0 ? $this->computeAccruedDaysFromStart( $leavePolicy['acquiredSaturdays'], $leavePolicy['saturdayAccrualPerMonth'], $effectiveFrom, $calculationEnd ) : 0.0; $absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to); [$takenDays, $takenSaturdays] = $this->computeTakenAbsences( $absences, $effectiveFrom, $calculationEnd, $leavePolicy['countOnlyCp'], $leavePolicy['splitSaturdays'] ); // Bootstrap support: if the opening balance has pre-filled taken days // (e.g. manual data entry for production bootstrap), add them as an offset. if (null !== $openingBalance) { $takenDays += $openingBalance->getTakenDays(); $takenSaturdays += $openingBalance->getTakenSaturdays(); } if (LeaveRuleCode::CDI_CDD_NON_FORFAIT->value === $leavePolicy['ruleCode']) { $availableAcquired = max(0.0, $carryDays); $takenFromAcquired = min($availableAcquired, $takenDays); $remainingAcquired = $carryDays - $takenFromAcquired; $remainingToImpute = max(0.0, $takenDays - $takenFromAcquired); $remainingGenerated = $generatedDays - $remainingToImpute; $availableAcquiredSaturdays = max(0.0, $carrySaturdays); $takenFromAcquiredSaturdays = min($availableAcquiredSaturdays, $takenSaturdays); $remainingAcquiredSaturdays = $carrySaturdays - $takenFromAcquiredSaturdays; $remainingSaturdaysToImpute = max(0.0, $takenSaturdays - $takenFromAcquiredSaturdays); $remainingGeneratedSaturdays = $generatedSaturdays - $remainingSaturdaysToImpute; $acquiredDays = $carryDays; $accruingDays = $remainingGenerated + $remainingGeneratedSaturdays; $remainingDays = $remainingAcquired; $acquiredSaturdays = $carrySaturdays; $remainingSaturdays = max(0.0, $remainingAcquiredSaturdays); $previousRemainingDays = $remainingAcquired + $remainingGenerated; $previousRemainingSaturdays = $remainingAcquiredSaturdays + $remainingGeneratedSaturdays; } else { // Forfait: no "en cours d'acquisition" counter, all rights are in acquired. $acquiredDays = $carryDays + $leavePolicy['acquiredDays']; $accruingDays = 0.0; $remainingDays = max(0.0, $acquiredDays - $takenDays); $acquiredSaturdays = 0.0; $remainingSaturdays = 0.0; $previousRemainingDays = $remainingDays; $previousRemainingSaturdays = 0.0; } if ($year === $targetYear) { $targetSummary = [ 'ruleCode' => $leavePolicy['ruleCode'], 'acquiredDays' => $acquiredDays, 'acquiredSaturdays' => $acquiredSaturdays, 'accruingDays' => $accruingDays, 'takenDays' => $takenDays, 'takenSaturdays' => $takenSaturdays, 'remainingDays' => $remainingDays, 'remainingSaturdays' => $remainingSaturdays, ]; } } return $targetSummary; } private function resolveEffectivePeriodStart( Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to ): DateTimeImmutable { $latestSettledClosure = $this->periodRepository->findLatestPaidLeaveSettledClosureDateBetween($employee, $from, $to); $start = $from; if (null !== $latestSettledClosure) { $nextDay = $latestSettledClosure->modify('+1 day'); if ($nextDay > $start) { $start = $nextDay; } } $earliestContractStart = $this->resolveEarliestContractStartWithinRange($employee, $from, $to); if (null !== $earliestContractStart && $earliestContractStart > $start) { $start = $earliestContractStart; } return $start; } private function resolveEarliestContractStartWithinRange( Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to ): ?DateTimeImmutable { $earliest = null; foreach ($employee->getContractHistory() as $period) { $start = DateTimeImmutable::createFromFormat('Y-m-d', $period->startDate); if (!$start instanceof DateTimeImmutable) { continue; } $end = null; if (null !== $period->endDate && '' !== trim($period->endDate)) { $end = DateTimeImmutable::createFromFormat('Y-m-d', $period->endDate); } if ($start > $to) { continue; } if ($end instanceof DateTimeImmutable && $end < $from) { continue; } $candidate = $start < $from ? $from : $start; if (null === $earliest || $candidate < $earliest) { $earliest = $candidate; } } return $earliest; } private function resolveYear(Employee $employee): int { $raw = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? ''); if ('' === $raw) { $today = new DateTimeImmutable('today'); if (ContractType::FORFAIT === $employee->getContract()?->getType()) { return (int) $today->format('Y'); } return $this->resolveCurrentLeaveYear($today); } if (!preg_match('/^\d{4}$/', $raw)) { throw new UnprocessableEntityHttpException('year must use YYYY format.'); } $year = (int) $raw; if ($year < 2000 || $year > 2100) { throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.'); } return $year; } private function computeAccruedDaysFromStart( float $acquiredDays, float $accrualPerMonth, DateTimeImmutable $periodStart, ?DateTimeImmutable $periodEnd ): float { if ($accrualPerMonth <= 0.0) { return $acquiredDays; } if (!$periodEnd instanceof DateTimeImmutable || $periodEnd < $periodStart) { return 0.0; } $monthsElapsed = ((int) $periodEnd->format('Y') - (int) $periodStart->format('Y')) * 12 + ((int) $periodEnd->format('n') - (int) $periodStart->format('n')) + 1; if ($monthsElapsed < 0) { return 0.0; } return min($acquiredDays, $monthsElapsed * $accrualPerMonth); } private function resolveCalculationEndDate( DateTimeImmutable $periodEnd, Employee $employee ): ?DateTimeImmutable { $end = $periodEnd; // Cap at contract end date if the employee has left. $contractEndRaw = $employee->getCurrentContractEndDate(); if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) { $contractEnd = DateTimeImmutable::createFromFormat('Y-m-d', $contractEndRaw); if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) { $end = $contractEnd; } } return $end; } /** * @return null|array{ * ruleCode: string, * acquiredDays: float, * acquiredSaturdays: float, * accrualPerMonth: float, * saturdayAccrualPerMonth: float, * countOnlyCp: bool, * splitSaturdays: bool * } */ private function resolveLeavePolicy(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): ?array { $type = $employee->getContract()?->getType(); if (ContractType::FORFAIT === $type) { $businessDaysInPeriod = $this->countBusinessDays($from, $to); return [ 'ruleCode' => LeaveRuleCode::FORFAIT_218->value, 'acquiredDays' => (float) max(0, $businessDaysInPeriod - self::FORFAIT_TARGET_WORKED_DAYS), 'acquiredSaturdays' => 0.0, 'accrualPerMonth' => 0.0, 'saturdayAccrualPerMonth' => 0.0, 'countOnlyCp' => false, 'splitSaturdays' => false, ]; } $nature = ContractNature::tryFrom($employee->getCurrentContractNature()); if (ContractNature::CDI !== $nature && ContractNature::CDD !== $nature) { return null; } $weeklyHours = $employee->getContract()?->getWeeklyHours(); if (4 === $weeklyHours) { return [ 'ruleCode' => LeaveRuleCode::CDI_CDD_NON_FORFAIT->value, 'acquiredDays' => self::CDI_NON_FORFAIT_4H_ACQUIRED_DAYS, 'acquiredSaturdays' => self::CDI_NON_FORFAIT_4H_ACQUIRED_SATURDAYS, 'accrualPerMonth' => self::CDI_NON_FORFAIT_4H_ACCRUAL_PER_MONTH, 'saturdayAccrualPerMonth' => self::CDI_NON_FORFAIT_4H_SATURDAY_ACCRUAL_PER_MONTH, 'countOnlyCp' => true, 'splitSaturdays' => true, ]; } return [ 'ruleCode' => LeaveRuleCode::CDI_CDD_NON_FORFAIT->value, 'acquiredDays' => self::CDI_NON_FORFAIT_STANDARD_ACQUIRED_DAYS, 'acquiredSaturdays' => self::CDI_NON_FORFAIT_STANDARD_ACQUIRED_SATURDAYS, 'accrualPerMonth' => self::CDI_NON_FORFAIT_STANDARD_ACCRUAL_PER_MONTH, 'saturdayAccrualPerMonth' => self::CDI_NON_FORFAIT_STANDARD_SATURDAY_ACCRUAL_PER_MONTH, 'countOnlyCp' => true, 'splitSaturdays' => true, ]; } private function countBusinessDays(DateTimeImmutable $from, DateTimeImmutable $to): int { $publicHolidays = $this->buildPublicHolidayMap($from, $to); $count = 0; for ($cursor = $from; $cursor <= $to; $cursor = $cursor->modify('+1 day')) { $weekDay = (int) $cursor->format('N'); $dayKey = $cursor->format('Y-m-d'); if ($weekDay <= 5 && !isset($publicHolidays[$dayKey])) { ++$count; } } return $count; } /** * @return array */ private function buildPublicHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array { $map = []; $startYear = (int) $from->format('Y'); $endYear = (int) $to->format('Y'); try { for ($year = $startYear; $year <= $endYear; ++$year) { $holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', (string) $year); foreach ($holidays as $date => $label) { $map[(string) $date] = (string) $label; } } } catch (Throwable) { return []; } return $map; } /** * @return array{DateTimeImmutable, DateTimeImmutable} */ private function resolvePeriodBounds(Employee $employee, int $year): array { if (ContractType::FORFAIT === $employee->getContract()?->getType()) { return $this->resolveForfaitYearBounds($employee, $year); } return $this->resolveLeavePeriodBounds($year); } /** * @return array{DateTimeImmutable, DateTimeImmutable} */ private function resolveLeavePeriodBounds(int $leaveYear): array { // Exercice CP "2026" = du 1er juin 2025 au 31 mai 2026. $from = new DateTimeImmutable(sprintf('%d-06-01', $leaveYear - 1)); $to = new DateTimeImmutable(sprintf('%d-05-31', $leaveYear)); return [$from, $to]; } /** * @return array{DateTimeImmutable, DateTimeImmutable} */ private function resolveForfaitYearBounds(Employee $employee, int $year): array { $from = new DateTimeImmutable(sprintf('%d-01-01', $year)); $to = new DateTimeImmutable(sprintf('%d-12-31', $year)); $contractStartRaw = $employee->getCurrentContractStartDate(); if (null !== $contractStartRaw && '' !== trim($contractStartRaw)) { $contractStart = DateTimeImmutable::createFromFormat('Y-m-d', $contractStartRaw); if ($contractStart instanceof DateTimeImmutable && $contractStart > $from) { $from = $contractStart; } } $contractEndRaw = $employee->getCurrentContractEndDate(); if (null !== $contractEndRaw && '' !== trim($contractEndRaw)) { $contractEnd = DateTimeImmutable::createFromFormat('Y-m-d', $contractEndRaw); if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $to) { $to = $contractEnd; } } return [$from, $to]; } private function resolveFractionedDays(Employee $employee, string $ruleCode, int $year): float { $balance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $year); return null !== $balance ? $balance->getFractionedDays() : 0.0; } private function resolveCurrentLeaveYear(DateTimeImmutable $today): int { $year = (int) $today->format('Y'); $month = (int) $today->format('n'); return $month >= 6 ? $year + 1 : $year; } private function resolveFirstComputationYear(Employee $employee): int { $isForfait = ContractType::FORFAIT === $employee->getContract()?->getType(); $fallbackYear = $isForfait ? (int) new DateTimeImmutable('today')->format('Y') : $this->resolveCurrentLeaveYear(new DateTimeImmutable('today')); $history = $employee->getContractHistory(); if ([] === $history) { return $fallbackYear; } $oldestStartDate = null; foreach ($history as $item) { $start = DateTimeImmutable::createFromFormat('Y-m-d', $item->startDate); if (!$start) { continue; } if (null === $oldestStartDate || $start < $oldestStartDate) { $oldestStartDate = $start; } } if (null === $oldestStartDate) { $oldestBalanceYear = $this->leaveBalanceRepository->findEarliestYearForEmployee($employee); return null === $oldestBalanceYear ? $fallbackYear : min($fallbackYear, $oldestBalanceYear); } $firstYear = $isForfait ? (int) $oldestStartDate->format('Y') : ((int) $oldestStartDate->format('n') >= 6 ? (int) $oldestStartDate->format('Y') + 1 : (int) $oldestStartDate->format('Y')); $oldestBalanceYear = $this->leaveBalanceRepository->findEarliestYearForEmployee($employee); if (null !== $oldestBalanceYear && $oldestBalanceYear < $firstYear) { return $oldestBalanceYear; } return $firstYear; } /** * @param list $absences * * @return array{float, float} */ private function computeTakenAbsences( array $absences, DateTimeImmutable $from, ?DateTimeImmutable $to, bool $countOnlyCp, bool $splitSaturdays ): array { $takenDays = 0.0; $takenSaturdays = 0.0; if (!$to instanceof DateTimeImmutable || $to < $from) { return [$takenDays, $takenSaturdays]; } foreach ($absences as $absence) { if ($countOnlyCp) { $typeCode = strtoupper((string) $absence->getType()?->getCode()); if ('C' !== $typeCode) { continue; } } if (null === $absence->getType()) { continue; } $start = DateTimeImmutable::createFromInterface($absence->getStartDate()); $end = DateTimeImmutable::createFromInterface($absence->getEndDate()); $rangeStart = $start < $from ? $from : $start; $rangeEnd = $end > $to ? $to : $end; if ($rangeEnd < $rangeStart) { continue; } for ($cursor = $rangeStart; $cursor <= $rangeEnd; $cursor = $cursor->modify('+1 day')) { $dayOfWeek = (int) $cursor->format('N'); if ($splitSaturdays) { // Mode CDI/CDD : dimanche ignoré, samedi compté séparément. if (7 === $dayOfWeek) { continue; } } else { // Mode forfait : seuls les jours ouvrés (lun-ven) comptent. if ($dayOfWeek >= 6) { continue; } } [$am, $pm] = $this->resolveSegmentsForDate($absence, $cursor->format('Y-m-d')); $dayAmount = ($am ? 0.5 : 0.0) + ($pm ? 0.5 : 0.0); if ($dayAmount <= 0.0) { continue; } if ($splitSaturdays && 6 === $dayOfWeek) { $takenSaturdays += $dayAmount; } else { $takenDays += $dayAmount; } } } return [$takenDays, $takenSaturdays]; } /** * @return array{bool, bool} */ private function resolveSegmentsForDate(Absence $absence, string $date): array { $startDate = $absence->getStartDate()->format('Y-m-d'); $endDate = $absence->getEndDate()->format('Y-m-d'); $startHalf = $absence->getStartHalf()->value; $endHalf = $absence->getEndHalf()->value; $isStart = $date === $startDate; $isEnd = $date === $endDate; $isSingleDay = $startDate === $endDate; if ($isSingleDay) { return ['AM' === $startHalf, 'PM' === $endHalf]; } if ($isStart) { return ['AM' === $startHalf, true]; } if ($isEnd) { return [true, 'PM' === $endHalf]; } return [true, true]; } }