addOption( 'force', null, InputOption::VALUE_NONE, 'Run rollover regardless of business date (manual recovery mode).' ); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $today = new DateTimeImmutable('today'); $force = (bool) $input->getOption('force'); $this->logger->info('app:leave:rollover started.', ['date' => $today->format('Y-m-d'), 'force' => $force]); if (!$force && !$this->isBusinessRolloverDate($today)) { $message = 'No rollover today: business date is neither 01/01 nor 01/06.'; $this->logger->info($message, ['date' => $today->format('Y-m-d')]); $io->success($message); return Command::SUCCESS; } $created = 0; $skipped = 0; foreach ($this->employeeRepository->findAll() as $employee) { if (!$employee instanceof Employee) { continue; } $ruleCode = $this->resolveRuleCode($employee); if (null === $ruleCode) { $this->logger->info('Employee skipped: no eligible rule.', ['employeeId' => $employee->getId()]); ++$skipped; continue; } if (!$force && !$this->shouldProcessRuleToday($ruleCode, $today)) { ++$skipped; continue; } $targetYear = $this->resolveTargetYear($ruleCode, $today); $existing = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $targetYear); if (null !== $existing) { $this->logger->info('Employee skipped: balance already exists.', ['employeeId' => $employee->getId(), 'year' => $targetYear, 'rule' => $ruleCode->value]); ++$skipped; continue; } try { [$carryDays, $carrySaturdays] = $this->resolveCarry($employee, $ruleCode, $targetYear); } catch (Throwable $e) { $this->logger->error('Error computing carry for employee.', ['employeeId' => $employee->getId(), 'error' => $e->getMessage()]); ++$skipped; continue; } $balance = new EmployeeLeaveBalance() ->setEmployee($employee) ->setRuleCode($ruleCode) ->setYear($targetYear) ->setOpeningDays($carryDays) ->setOpeningSaturdays($carrySaturdays) ->setAccruedDays(0.0) ->setAccruedSaturdays(0.0) ->setTakenDays(0.0) ->setTakenSaturdays(0.0) ->setClosingDays($carryDays) ->setClosingSaturdays($carrySaturdays) ->setIsLocked(false) ; $this->entityManager->persist($balance); $this->logger->info('Balance created.', ['employeeId' => $employee->getId(), 'year' => $targetYear, 'rule' => $ruleCode->value, 'carryDays' => $carryDays, 'carrySaturdays' => $carrySaturdays]); ++$created; } try { $this->entityManager->flush(); } catch (Throwable $e) { $this->logger->error('Error flushing leave balances.', ['error' => $e->getMessage()]); $io->error('Leave rollover failed: '.$e->getMessage()); return Command::FAILURE; } $message = sprintf('Leave rollover done: %d created, %d skipped.', $created, $skipped); $this->logger->info($message); $io->success($message); return Command::SUCCESS; } private function resolveRuleCode(Employee $employee): ?LeaveRuleCode { $type = $employee->getContract()?->getType(); if (null === $type || ContractType::INTERIM === $type) { return null; } if (ContractType::FORFAIT === $type) { return LeaveRuleCode::FORFAIT_218; } return LeaveRuleCode::CDI_CDD_NON_FORFAIT; } private function resolveTargetYear(LeaveRuleCode $ruleCode, DateTimeImmutable $today): int { if (LeaveRuleCode::FORFAIT_218 === $ruleCode) { return (int) $today->format('Y'); } $year = (int) $today->format('Y'); $month = (int) $today->format('n'); return $month >= 6 ? $year + 1 : $year; } private function isBusinessRolloverDate(DateTimeImmutable $today): bool { return in_array($today->format('m-d'), ['01-01', '06-01'], true); } private function shouldProcessRuleToday(LeaveRuleCode $ruleCode, DateTimeImmutable $today): bool { $day = $today->format('m-d'); if (LeaveRuleCode::FORFAIT_218 === $ruleCode) { return '01-01' === $day; } return '06-01' === $day; } /** * @return array{float, float} */ private function resolveCarry(Employee $employee, LeaveRuleCode $ruleCode, int $targetYear): array { $previousYear = $targetYear - 1; $previous = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $previousYear); if (null !== $previous) { $carryDays = $previous->getClosingDays() + $previous->getFractionedDays(); $carrySaturdays = LeaveRuleCode::CDI_CDD_NON_FORFAIT === $ruleCode ? $previous->getClosingSaturdays() : 0.0; } else { [$carryDays, $carrySaturdays] = $this->leaveBalanceComputationService ->computeDynamicClosingForYear($employee, $ruleCode, $previousYear) ; } [$from, $to] = $this->leaveBalanceComputationService->resolvePeriodBounds($ruleCode, $previousYear); $hasSettlement = $this->leaveBalanceComputationService ->hasPaidLeaveSettledClosureBetween($employee, $from, $to) ; if ($hasSettlement) { return [0.0, 0.0]; } return [$carryDays, $carrySaturdays]; } }