resolveFirstComputationYear($employee, $ruleCode, $targetYear); if ($targetYear < $firstYear) { return [0.0, 0.0]; } $previousRemainingDays = 0.0; $previousRemainingSaturdays = 0.0; for ($year = $firstYear; $year <= $targetYear; ++$year) { [$from, $to] = $this->resolvePeriodBounds($ruleCode, $year); $carryDays = 0.0; $carrySaturdays = 0.0; if ($year > $firstYear) { [$previousFrom, $previousTo] = $this->resolvePeriodBounds($ruleCode, $year - 1); $hasSettlementOnPreviousYear = $this->periodRepository->hasPaidLeaveSettledClosureBetween($employee, $previousFrom, $previousTo); if (!$hasSettlementOnPreviousYear) { $carryDays = $previousRemainingDays; $carrySaturdays = LeaveRuleCode::CDI_CDD_NON_FORFAIT === $ruleCode ? $previousRemainingSaturdays : 0.0; } } $effectiveFrom = $this->resolveEffectivePeriodStart($employee, $from, $to); if ($effectiveFrom > $from) { $carryDays = 0.0; $carrySaturdays = 0.0; } $fractionedDays = $this->resolveFractionedDays($employee, $ruleCode, $year); if (LeaveRuleCode::FORFAIT_218 === $ruleCode) { $totalBusinessDays = $this->countBusinessDays($from, $to); $baseAcquiredDays = (float) max(0, $totalBusinessDays - self::FORFAIT_TARGET_WORKED_DAYS); $acquiredDays = $carryDays + $baseAcquiredDays + $fractionedDays; $absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to); [$takenDays] = $this->computeTakenAbsences($absences, $effectiveFrom, $to, false, false); $previousRemainingDays = max(0.0, $acquiredDays - $takenDays); $previousRemainingSaturdays = 0.0; continue; } $suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace( $this->resolveSuspensionsForEmployeePeriod($employee, $from, $to) ); $generatedDays = $this->computeAccruedDays( $this->resolveAnnualDays($employee), $this->resolveDaysAccrualPerMonth($employee), $effectiveFrom, $to, $suspensions ); $generatedSaturdays = $this->computeAccruedDays( $this->resolveAnnualSaturdays($employee), $this->resolveSaturdayAccrualPerMonth($employee), $effectiveFrom, $to, $suspensions ); $absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to); [$takenDays, $takenSaturdays] = $this->computeTakenAbsences($absences, $effectiveFrom, $to, true, true); $acquiredWithFractioned = $carryDays + $fractionedDays; $takenFromAcquired = min(max(0.0, $acquiredWithFractioned), $takenDays); $remainingAcquired = $acquiredWithFractioned - $takenFromAcquired; $remainingToImpute = max(0.0, $takenDays - $takenFromAcquired); $remainingGenerated = $generatedDays - $remainingToImpute; $takenFromAcquiredSaturdays = min(max(0.0, $carrySaturdays), $takenSaturdays); $remainingAcquiredSaturdays = $carrySaturdays - $takenFromAcquiredSaturdays; $remainingSaturdaysToImpute = max(0.0, $takenSaturdays - $takenFromAcquiredSaturdays); $remainingGeneratedSaturdays = $generatedSaturdays - $remainingSaturdaysToImpute; $previousRemainingDays = $remainingAcquired + $remainingGenerated; $previousRemainingSaturdays = $remainingAcquiredSaturdays + $remainingGeneratedSaturdays; } return [$previousRemainingDays, $previousRemainingSaturdays]; } /** * @return array{DateTimeImmutable, DateTimeImmutable} */ public function resolvePeriodBounds(LeaveRuleCode $ruleCode, int $year): array { if (LeaveRuleCode::FORFAIT_218 === $ruleCode) { return [ new DateTimeImmutable(sprintf('%d-01-01 00:00:00', $year)), new DateTimeImmutable(sprintf('%d-12-31 00:00:00', $year)), ]; } return [ new DateTimeImmutable(sprintf('%d-06-01 00:00:00', $year - 1)), new DateTimeImmutable(sprintf('%d-05-31 00:00:00', $year)), ]; } public function hasPaidLeaveSettledClosureBetween( Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to ): bool { return $this->periodRepository->hasPaidLeaveSettledClosureBetween($employee, $from, $to); } private function resolveFirstComputationYear(Employee $employee, LeaveRuleCode $ruleCode, int $fallbackYear): int { $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) { return $fallbackYear; } if (LeaveRuleCode::FORFAIT_218 === $ruleCode) { return (int) $oldestStartDate->format('Y'); } $startYear = (int) $oldestStartDate->format('Y'); $startMonth = (int) $oldestStartDate->format('n'); return $startMonth >= 6 ? $startYear + 1 : $startYear; } 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) { 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 resolveFractionedDays(Employee $employee, LeaveRuleCode $ruleCode, int $year): float { $balance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $year); return null !== $balance ? $balance->getFractionedDays() : 0.0; } private function resolveAnnualDays(Employee $employee): float { return 4 === $employee->getContract()?->getWeeklyHours() ? self::FOUR_HOUR_ANNUAL_DAYS : self::STANDARD_ANNUAL_DAYS; } private function resolveAnnualSaturdays(Employee $employee): float { return 4 === $employee->getContract()?->getWeeklyHours() ? 0.0 : self::STANDARD_ANNUAL_SATURDAYS; } private function resolveDaysAccrualPerMonth(Employee $employee): float { return 4 === $employee->getContract()?->getWeeklyHours() ? self::FOUR_HOUR_ACCRUAL_PER_MONTH : self::STANDARD_ACCRUAL_PER_MONTH; } private function resolveSaturdayAccrualPerMonth(Employee $employee): float { return 4 === $employee->getContract()?->getWeeklyHours() ? 0.0 : self::STANDARD_SATURDAY_ACCRUAL_PER_MONTH; } private function computeAccruedDays( float $annualCap, float $accrualPerMonth, DateTimeImmutable $periodStart, DateTimeImmutable $periodEnd, array $suspensions = [] ): float { if ($accrualPerMonth <= 0.0 || $periodEnd < $periodStart) { return 0.0; } $periodStart = $this->normalizeDate($periodStart); $periodEnd = $this->normalizeDate($periodEnd); $coveredMonths = 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; } $coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1; if ([] !== $suspensions) { $suspendedDays = $this->suspensionDaysCalculator->countSuspendedDaysInMonth($monthStart, $monthEnd, $suspensions); $coveredDays = max(0, $coveredDays - $suspendedDays); } $daysInMonth = (int) $cursor->format('t'); $coveredMonths += $coveredDays / $daysInMonth; $cursor = $cursor->modify('first day of next month'); } return min($annualCap, $coveredMonths * $accrualPerMonth); } 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); } 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; } /** * @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; 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) { if (7 === $dayOfWeek) { continue; } } else { 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 resolveSuspensionsForEmployeePeriod(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 { $startYmd = DateTimeImmutable::createFromInterface($absence->getStartDate())->format('Y-m-d'); $endYmd = DateTimeImmutable::createFromInterface($absence->getEndDate())->format('Y-m-d'); $startHalf = $absence->getStartHalf()->value; $endHalf = $absence->getEndHalf()->value; $isSingleDay = $startYmd === $endYmd; $isStartDay = $date === $startYmd; $isEndDay = $date === $endYmd; if ($isSingleDay) { return ['AM' === $startHalf, 'PM' === $endHalf]; } if ($isStartDay) { return ['AM' === $startHalf, true]; } if ($isEndDay) { return [true, 'PM' === $endHalf]; } return [true, true]; } }