dataStartDate = '' !== $dataStartDate ? $dataStartDate : null; } public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmployeeLeaveSummary { $user = $this->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.'); } $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, 0.0, null, $phase); 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, null, $phase); 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, $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']; $summary->presenceDaysByMonth = $this->computePresenceDaysByMonth( $employee, $periodFrom, $periodTo, $n1AbsencesBudget ); // Same logic as presenceDaysByMonth but bounded at today: number of presence days // accumulated from leave year start up to today (inclusive). $today = new DateTimeImmutable('today'); $cappedTo = $today < $periodTo ? $today : $periodTo; $summary->presenceDaysToToday = $today < $periodFrom ? 0.0 : array_sum($this->computePresenceDaysByMonth( $employee, $periodFrom, $cappedTo, $n1AbsencesBudget )); 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, ?DateTimeImmutable $asOfDate = null, ?ContractPhase $phase = null): ?array { // Track whether a phase was provided explicitly. When the caller supplies $phase, // we apply the phase-end cap on period bounds. When we fall back to resolveCurrentPhase // (legacy callers without phase awareness, e.g. LeaveRecapRowBuilder), we preserve // the pre-phase-cap behavior to avoid changing observable results for terminated // employees (the resolved fallback phase would otherwise unduly cap `to`). $applyPhaseEndCap = null !== $phase; $phase ??= $this->resolveCurrentPhase($employee); if (null === $phase) { return null; } $firstYear = max($this->resolveFirstComputationYear($employee, $phase), $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, $phase, $applyPhaseEndCap); $leavePolicy = $this->resolveLeavePolicy($employee, $phase, $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; } $effectiveAsOfDate = ($year === $targetYear) ? $asOfDate : null; $accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee, $phase, $effectiveAsOfDate, $applyPhaseEndCap); $takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee, $phase, $effectiveAsOfDate, $applyPhaseEndCap); $suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace( $this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to) ); $longMaladiePeriods = []; $longMaladieReductionFactor = 1.0; if (LeaveRuleCode::CDI_CDD_NON_FORFAIT->value === $leavePolicy['ruleCode'] && 4 !== $phase->weeklyHours && 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); } public 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 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, ContractPhase $phase): int { $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) { // 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); } $today = new DateTimeImmutable('today'); return $isForfait ? (int) $today->format('Y') : $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.'); } // 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 = $this->exerciseYearResolver->forDate($phase->startDate, $isForfait); $lastYear = $phase->endDate instanceof DateTimeImmutable ? $this->exerciseYearResolver->forDate($phase->endDate, $isForfait) : null; 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]; } /** * Phase dont la date de début est la plus proche en deçà de celle de $phase * (la phase qui précède immédiatement). Null si $phase est la première. */ private function resolvePhaseImmediatelyBefore(Employee $employee, ContractPhase $phase): ?ContractPhase { $prior = null; foreach ($this->phaseResolver->resolvePhases($employee) as $candidate) { if ($candidate->startDate >= $phase->startDate) { continue; } if (null === $prior || $candidate->startDate > $prior->startDate) { $prior = $candidate; } } return $prior; } /** * CP nets encore disponibles (jours + samedis) hérités de la phase non-forfait * précédant immédiatement une entrée en FORFAIT. 0 si aucune phase précédente * ou si la précédente est elle-même un FORFAIT (nouvel embauché → cas 2). * * Le total disponible = remainingDays (acquis restant) + accruingDays (généré * restant, samedis générés inclus) + remainingSaturdays (samedis acquis restant). * Les congés déjà posés sous la phase précédente sont déjà déduits par * computeYearSummary, donc on récupère bien le NET (ex. Grégory : 12 acquis − 5 pris ≈ 7). * * Les jours fractionnés (fractionedDays, ajustement manuel ajouté par provide() à * l'affichage) sont volontairement EXCLUS : on ne reporte que le solde CP acquis/généré * de la phase précédente, pas les bonus de fractionnement. */ private function resolveCarriedCpFromPriorPhase(Employee $employee, ContractPhase $forfaitPhase): float { $prior = $this->resolvePhaseImmediatelyBefore($employee, $forfaitPhase); if (null === $prior || ContractType::FORFAIT === $prior->contractType) { return 0.0; } $reference = $prior->endDate ?? new DateTimeImmutable('today'); $priorYear = $this->exerciseYearResolver->forDate($reference, false); $summary = $this->computeYearSummary($employee, $priorYear, 0.0, null, $prior); if (null === $summary) { return 0.0; } return $summary['remainingDays'] + $summary['accruingDays'] + $summary['remainingSaturdays']; } /** * @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, ContractPhase $phase, ?DateTimeImmutable $asOfDate = null, bool $applyPhaseEndCap = true ): ?DateTimeImmutable { $reference = $asOfDate ?? new DateTimeImmutable('today'); $currentYear = LeaveRuleCode::FORFAIT_218->value === $ruleCode ? (int) $reference->format('Y') : $this->resolveCurrentLeaveYear($reference); // When viewing a closed phase explicitly, treat its end date as the reference cutoff: // accrual is bounded to the phase end, never running to "today". // Legacy callers (no explicit phase) skip this cap to preserve pre-phase behavior. if ($applyPhaseEndCap && !$phase->isCurrent && null !== $phase->endDate) { $end = $phase->endDate < $periodEnd ? $phase->endDate : $periodEnd; } elseif ($year < $currentYear) { $end = $periodEnd; } elseif ($year > $currentYear) { $end = null; } else { $lastDayPreviousMonth = $reference ->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 (only meaningful when // viewing the current phase; closed phases are already capped above). // Legacy callers (no explicit phase) always evaluate this branch to mimic // the pre-phase behavior, which relied on getCurrentContractEndDate(). if (!$applyPhaseEndCap || $phase->isCurrent) { $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, ContractPhase $phase, ?DateTimeImmutable $asOfDate = null, bool $applyPhaseEndCap = true ): ?DateTimeImmutable { $end = $periodEnd; if ($asOfDate instanceof DateTimeImmutable && $asOfDate < $end) { $end = $asOfDate; } // Closed phase: cap taken-absence accounting at the phase end. // Skip for legacy callers (no explicit phase) to preserve pre-phase behavior. if ($applyPhaseEndCap && !$phase->isCurrent && null !== $phase->endDate && $phase->endDate < $end) { $end = $phase->endDate; } // Legacy callers (no explicit phase) always use the live contract end date, // mirroring the pre-phase implementation. if (!$applyPhaseEndCap || $phase->isCurrent) { $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, ContractPhase $phase, DateTimeImmutable $from, DateTimeImmutable $to): ?array { $type = $phase->contractType; if (ContractType::FORFAIT === $type) { $year = (int) $from->format('Y'); // période forfait = année civile // Entrée en FORFAIT en cours d'année : repos proratisés + CP nets reportés de // la phase précédente, au lieu de max(0, businessDays − 218) qui donnerait 0. if ($this->isForfaitEntryYear($phase, $year)) { $yearStart = new DateTimeImmutable(sprintf('%d-01-01 00:00:00', $year)); $yearEnd = new DateTimeImmutable(sprintf('%d-12-31 00:00:00', $year)); $rawYearHolidays = $this->buildRawPublicHolidayMap($yearStart, $yearEnd); $businessDaysYear = $this->countBusinessDays($yearStart, $yearEnd, $rawYearHolidays); $businessDaysPeriod = $this->countBusinessDays($from, $to, $rawYearHolidays); $repoDays = $this->computeProratedForfaitRepoDays($businessDaysYear, $businessDaysPeriod); $carriedCp = $this->resolveCarriedCpFromPriorPhase($employee, $phase); // NB : le bonus week-end/férié travaillé (bonusDays du chemin année pleine) // n'est volontairement PAS ajouté ici. L'acquis de l'année d'entrée = repos // proratisés + CP reportés (règle comptable). À revoir si la RH veut créditer // le travail week-end/férié posé pendant la période forfait partielle. return [ 'ruleCode' => LeaveRuleCode::FORFAIT_218->value, 'acquiredDays' => $repoDays + $carriedCp, 'acquiredSaturdays' => 0.0, 'accrualPerMonth' => 0.0, 'saturdayAccrualPerMonth' => 0.0, 'countOnlyCp' => false, 'splitSaturdays' => false, ]; } // Année pleine : calcul 218 existant (INCHANGÉ). // 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 // the 218-day legal target). $businessDaysInPeriod = $this->countBusinessDays($from, $to, $this->buildRawPublicHolidayMap($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, ]; } // Resolve nature directly from the phase DTO (populated by EmployeeContractPhaseResolver). $nature = $phase->contractNature; if (ContractNature::CDI !== $nature && ContractNature::CDD !== $nature) { return null; } $weeklyHours = $phase->weeklyHours; 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, ]; } /** * Jours de repos forfait proratisés sur la fraction de jours ouvrés couverte. * * Repos année pleine = jours_ouvrés_année − 218 (cible travaillée) − 25 (CP standard). * Pour 2026 : 252 − 218 − 25 = 9, proratisés au ratio jours_ouvrés_période / jours_ouvrés_année. */ private function computeProratedForfaitRepoDays(int $businessDaysYear, int $businessDaysPeriod): float { if ($businessDaysYear <= 0) { return 0.0; } $repoDaysYear = max(0, $businessDaysYear - self::FORFAIT_TARGET_WORKED_DAYS - self::FORFAIT_STANDARD_CP_DAYS); return $repoDaysYear * $businessDaysPeriod / $businessDaysYear; } /** * Vrai si la phase FORFAIT démarre en cours de l'année civile consultée * (donc avec une période partielle), faux pour une année pleine ou un démarrage le 1er janvier. */ private function isForfaitEntryYear(ContractPhase $phase, int $year): bool { if (ContractType::FORFAIT !== $phase->contractType) { return false; } return (int) $phase->startDate->format('Y') === $year && '01-01' !== $phase->startDate->format('m-d'); } /** * @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; } /** * @return array */ private function buildRawPublicHolidayMap(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->getRawHolidaysDayByYears('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, float $n1AbsencesBudget = 0.0 ): 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)) : []; // Sort absences chronologically so N-1 budget (forfait only) is consumed in date order: // earliest absences attribute to N-1 first, later ones overflow to N and reduce presence. $sortedAbsences = $absences; usort( $sortedAbsences, static fn ($a, $b): int => $a->getStartDate() <=> $b->getStartDate() ); $remainingN1Budget = $n1AbsencesBudget; // Count absence days per month, iterating day by day to handle multi-day absences // and properly distribute across months. $absenceDaysByMonth = []; foreach ($sortedAbsences 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; } // Forfait: leaves taken from N-1 stock do NOT decrement presence days. // We chronologically consume the N-1 budget before counting any absence. if ($remainingN1Budget > 0.0) { $consumed = min($remainingN1Budget, $dayAmount); $remainingN1Budget -= $consumed; $dayAmount -= $consumed; 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, ContractPhase $phase, bool $applyPhaseEndCap = true): array { if (ContractType::FORFAIT === $phase->contractType) { [$from, $to] = $this->resolveForfaitYearBounds($employee, $year, $phase); // For FORFAIT, cap from at phase.startDate: the 218-day FORFAIT accrual // is calendar-year scoped and only counts the FORFAIT portion of the year. if ($phase->startDate > $from) { $from = $phase->startDate; } } else { [$from, $to] = $this->resolveLeavePeriodBounds($year); // For non-forfait, do NOT cap from at phase.startDate: CP accrual is // annual (Juin→Mai) and continuous across signature changes within the // same leave rule (e.g. 35h → 39h, driver flag flip, weeklyHours bump). // The contract-entry-date cap is handled by resolveEffectivePeriodStart(). } // End cap applies to both modes. Skipped when the phase was not explicitly // provided (legacy callers) to preserve pre-phase-cap behavior for // terminated employees. if ($applyPhaseEndCap && null !== $phase->endDate && $phase->endDate < $to) { $to = $phase->endDate; } return [$from, $to]; } /** * @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, 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)); // 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; } } } 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, ContractPhase $phase): int { $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 = $this->exerciseYearResolver->forDate($phase->startDate, $isForfait); $history = $employee->getContractHistory(); if ([] === $history) { return max($phaseFirstYear, $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); $candidate = null === $oldestBalanceYear ? $fallbackYear : min($fallbackYear, $oldestBalanceYear); return max($phaseFirstYear, $candidate); } $firstYear = $this->exerciseYearResolver->forDate($oldestStartDate, $isForfait); $oldestBalanceYear = $this->leaveBalanceRepository->findEarliestYearForEmployee($employee); if (null !== $oldestBalanceYear && $oldestBalanceYear < $firstYear) { $firstYear = $oldestBalanceYear; } return max($phaseFirstYear, $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]; } }