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); $paidLeaveDays = $this->resolvePaidLeaveDays($employee, $yearSummary['ruleCode'], $year); // 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); if (null === $yearSummary) { return $summary; } } $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']; $summary->previousYearAcquiredDays = $yearSummary['previousYearAcquiredDays']; $summary->previousYearTakenDays = $yearSummary['previousYearTakenDays']; $summary->previousYearRemainingDays = $yearSummary['previousYearRemainingDays']; $summary->previousYearPaidDays = $paidLeaveDays; [$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year); $summary->presenceDaysByMonth = $this->computePresenceDaysByMonth($employee, $periodFrom, $periodTo); return $summary; } /** * @return null|array{ * ruleCode: string, * acquiredDays: float, * acquiredSaturdays: float, * accruingDays: float, * takenDays: float, * takenSaturdays: float, * remainingDays: float, * remainingSaturdays: float, * previousYearAcquiredDays: float, * previousYearTakenDays: float, * previousYearRemainingDays: float * } */ public function computeYearSummary(Employee $employee, int $targetYear, float $paidLeaveDays = 0.0): ?array { $firstYear = max($this->resolveFirstComputationYear($employee), $targetYear - 1); 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; } $accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee); $takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee); $suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace( $this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to) ); $longMaladiePeriods = []; $longMaladieReductionFactor = 1.0; if (LeaveRuleCode::CDI_CDD_NON_FORFAIT->value === $leavePolicy['ruleCode'] && 4 !== $employee->getContract()?->getWeeklyHours() && null !== $accrualCalculationEnd ) { $longMaladiePeriods = $this->longMaladieService->findReducedRatePeriods($employee, $effectiveFrom, $accrualCalculationEnd); if ([] !== $longMaladiePeriods) { $totalNormalAccrual = $leavePolicy['accrualPerMonth'] + $leavePolicy['saturdayAccrualPerMonth']; $longMaladieReductionFactor = self::LONG_MALADIE_MONTHLY_ACCRUAL / $totalNormalAccrual; } } $generatedDays = $leavePolicy['accrualPerMonth'] > 0.0 ? $this->computeAccruedDaysFromStart( $leavePolicy['acquiredDays'], $leavePolicy['accrualPerMonth'], $effectiveFrom, $accrualCalculationEnd, $suspensions, $longMaladiePeriods, $longMaladieReductionFactor ) : 0.0; $generatedSaturdays = $leavePolicy['saturdayAccrualPerMonth'] > 0.0 ? $this->computeAccruedDaysFromStart( $leavePolicy['acquiredSaturdays'], $leavePolicy['saturdayAccrualPerMonth'], $effectiveFrom, $accrualCalculationEnd, $suspensions, $longMaladiePeriods, $longMaladieReductionFactor ) : 0.0; $absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to); [$takenDays, $takenSaturdays] = $this->computeTakenAbsences( $absences, $effectiveFrom, $takenCalculationEnd, $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(); } $previousYearAcquired = 0.0; $previousYearTaken = 0.0; $previousYearRemaining = 0.0; 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. // Suspensions do not impact forfait 218 leave calculation. // Paid days reduce N-1 stock first, then taken days are attributed to what remains in N-1. $previousYearAcquired = $carryDays; $effectivePaidDays = ($year === $targetYear) ? $paidLeaveDays : 0.0; $availableAfterPayment = max(0.0, $previousYearAcquired - $effectivePaidDays); $takenFromPrevious = min($availableAfterPayment, $takenDays); $previousYearTaken = $takenFromPrevious; $takenFromCurrent = $takenDays - $takenFromPrevious; $previousYearRemaining = max(0.0, $availableAfterPayment - $takenFromPrevious); $acquiredDays = $leavePolicy['acquiredDays']; $accruingDays = 0.0; $remainingDays = max(0.0, $acquiredDays - $takenFromCurrent); $acquiredSaturdays = 0.0; $remainingSaturdays = 0.0; $previousRemainingDays = $previousYearRemaining + $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, 'previousYearAcquiredDays' => $previousYearAcquired, 'previousYearTakenDays' => $previousYearTaken, 'previousYearRemainingDays' => $previousYearRemaining, ]; } } return $targetSummary; } public function resolveLeaveYearForToday(Employee $employee): int { $today = new DateTimeImmutable('today'); if (ContractType::FORFAIT === $employee->getContract()?->getType()) { return (int) $today->format('Y'); } return $this->resolveCurrentLeaveYear($today); } 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 = $this->parseYmdDate($period->startDate); if (!$start instanceof DateTimeImmutable) { continue; } $end = null; if (null !== $period->endDate && '' !== trim($period->endDate)) { $end = $this->parseYmdDate($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; } /** * @param list $suspensions * @param list $longMaladiePeriods */ private function computeAccruedDaysFromStart( float $acquiredDays, float $accrualPerMonth, DateTimeImmutable $periodStart, ?DateTimeImmutable $periodEnd, array $suspensions = [], array $longMaladiePeriods = [], float $longMaladieReductionFactor = 1.0 ): float { if ($accrualPerMonth <= 0.0) { return $acquiredDays; } if (!$periodEnd instanceof DateTimeImmutable || $periodEnd < $periodStart) { return 0.0; } $periodStart = $this->normalizeDate($periodStart); $periodEnd = $this->normalizeDate($periodEnd); $publicHolidays = [] !== $suspensions ? $this->buildPublicHolidayMap($periodStart, $periodEnd) : []; $normalMonths = 0.0; $reducedMonths = 0.0; $cursor = $periodStart->modify('first day of this month')->setTime(0, 0); while ($cursor <= $periodEnd) { $monthStart = $cursor > $periodStart ? $cursor : $periodStart; $monthEnd = $cursor->modify('last day of this month')->setTime(0, 0); if ($monthEnd > $periodEnd) { $monthEnd = $periodEnd; } if ([] !== $suspensions) { $suspendedDays = $this->suspensionDaysCalculator->countSuspendedDaysInMonth($monthStart, $monthEnd, $suspensions); if ($suspendedDays > 0) { $businessDays = $this->countBusinessDays($monthStart, $monthEnd, $publicHolidays); $suspendedBusinessDays = $this->suspensionDaysCalculator->countSuspendedBusinessDays($monthStart, $monthEnd, $suspensions, $publicHolidays); $normalMonths += max(0, $businessDays - $suspendedBusinessDays) / 22.0; $cursor = $cursor->modify('first day of next month'); continue; } } $coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1; $daysInMonth = (int) $cursor->format('t'); if ([] !== $longMaladiePeriods) { $reducedDays = $this->longMaladieService->countReducedDaysInMonth($monthStart, $monthEnd, $longMaladiePeriods); if ($reducedDays > 0) { $normalDays = max(0, $coveredDays - $reducedDays); $normalMonths += $normalDays / $daysInMonth; $reducedMonths += min($coveredDays, $reducedDays) / $daysInMonth; $cursor = $cursor->modify('first day of next month'); continue; } } $normalMonths += $coveredDays / $daysInMonth; $cursor = $cursor->modify('first day of next month'); } return min($acquiredDays, ($normalMonths + $reducedMonths * $longMaladieReductionFactor) * $accrualPerMonth); } private function resolveAccrualCalculationEndDate( string $ruleCode, int $year, DateTimeImmutable $periodEnd, Employee $employee ): ?DateTimeImmutable { $today = new DateTimeImmutable('today'); $currentYear = LeaveRuleCode::FORFAIT_218->value === $ruleCode ? (int) $today->format('Y') : $this->resolveCurrentLeaveYear($today); if ($year < $currentYear) { $end = $periodEnd; } elseif ($year > $currentYear) { $end = null; } else { $lastDayPreviousMonth = $today ->modify('first day of this month') ->modify('-1 day') ->setTime(0, 0) ; $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; } } return $end; } private function resolveTakenCalculationEndDate( 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 = $this->parseYmdDate($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); $publicHolidays = $this->buildPublicHolidayMap($from, $to); $weekdayHolidays = array_filter( array_keys($publicHolidays), static fn (string $date): bool => (int) new DateTimeImmutable($date)->format('N') <= 5 ); $bonusDays = $this->workHourRepository->countWeekendAndHolidayWorkedDays( $employee, $from, $to, array_values($weekdayHolidays) ); return [ 'ruleCode' => LeaveRuleCode::FORFAIT_218->value, 'acquiredDays' => (float) max(0, $businessDaysInPeriod - self::FORFAIT_TARGET_WORKED_DAYS) + $bonusDays, '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, ]; } /** * @param null|array $publicHolidays pre-built map (built if null) */ private function countBusinessDays(DateTimeImmutable $from, DateTimeImmutable $to, ?array $publicHolidays = null): 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; } /** * Presence days = business days (Mon-Fri) - public holidays + weekend worked days - absence days. * * @return array YYYY-MM => presence day count */ private function computePresenceDaysByMonth(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array { $publicHolidays = $this->buildPublicHolidayMap($from, $to); $weekendWorkedDays = $this->workHourRepository->countWeekendWorkedDaysByMonth($employee, $from, $to); $absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to); // Find which public holidays were actually worked (should count as presence). $workedHolidays = [] !== $publicHolidays ? $this->workHourRepository->findWorkedDatesAmong($employee, array_keys($publicHolidays)) : []; // Count absence days per month, iterating day by day to handle multi-day absences // and properly distribute across months. $absenceDaysByMonth = []; foreach ($absences as $absence) { $start = DateTimeImmutable::createFromInterface($absence->getStartDate())->setTime(0, 0); $end = DateTimeImmutable::createFromInterface($absence->getEndDate())->setTime(0, 0); for ($day = $start; $day <= $end; $day = $day->modify('+1 day')) { $weekDay = (int) $day->format('N'); // Skip weekends if ($weekDay >= 6) { continue; } $monthKey = $day->format('Y-m'); [$am, $pm] = $this->resolveSegmentsForDate($absence, $day->format('Y-m-d')); $dayAmount = ($am ? 0.5 : 0.0) + ($pm ? 0.5 : 0.0); if ($dayAmount <= 0.0) { continue; } $absenceDaysByMonth[$monthKey] = ($absenceDaysByMonth[$monthKey] ?? 0.0) + $dayAmount; } } // Count business days and public holidays per month. $result = []; $cursor = $from->modify('first day of this month')->setTime(0, 0); while ($cursor <= $to) { $monthKey = $cursor->format('Y-m'); $monthStart = $cursor < $from ? $from : $cursor; $monthEnd = $cursor->modify('last day of this month')->setTime(0, 0); if ($monthEnd > $to) { $monthEnd = $to; } $businessDays = 0; for ( $day = $monthStart; $day <= $monthEnd; $day = $day->modify('+1 day') ) { $weekDay = (int) $day->format('N'); $dayKey = $day->format('Y-m-d'); if ($weekDay <= 5 && (!isset($publicHolidays[$dayKey]) || isset($workedHolidays[$dayKey]))) { ++$businessDays; } } $weekend = $weekendWorkedDays[$monthKey] ?? 0.0; $absenced = $absenceDaysByMonth[$monthKey] ?? 0.0; $presence = max(0.0, (float) $businessDays + $weekend - $absenced); if ($presence > 0.0) { $result[$monthKey] = $presence; } $cursor = $cursor->modify('first day of next month'); } return $result; } /** * @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 00:00:00', $leaveYear - 1)); $to = new DateTimeImmutable(sprintf('%d-05-31 00:00:00', $leaveYear)); return [$from, $to]; } /** * @return array{DateTimeImmutable, DateTimeImmutable} */ private function resolveForfaitYearBounds(Employee $employee, int $year): 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; } } $contractEndRaw = $employee->getCurrentContractEndDate(); if (null !== $contractEndRaw && '' !== trim($contractEndRaw)) { $contractEnd = $this->parseYmdDate($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 resolvePaidLeaveDays(Employee $employee, string $ruleCode, int $year): float { $balance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $year); return null !== $balance ? $balance->getPaidLeaveDays() : 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 = $this->parseYmdDate($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; } private function parseYmdDate(string $value): ?DateTimeImmutable { $date = DateTimeImmutable::createFromFormat('!Y-m-d', trim($value)); return $date instanceof DateTimeImmutable ? $date : null; } private function normalizeDate(DateTimeImmutable $date): DateTimeImmutable { return $date->setTime(0, 0); } /** * @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 list */ private function resolveSuspensionsForPeriod(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array { $suspensions = []; foreach ($employee->getContractPeriods() as $period) { $periodStart = $period->getStartDate(); $periodEnd = $period->getEndDate(); if ($periodStart > $to) { continue; } if ($periodEnd instanceof DateTimeImmutable && $periodEnd < $from) { continue; } foreach ($period->getSuspensions() as $suspension) { $suspensions[] = $suspension; } } return $suspensions; } /** * @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]; } }