'Janvier', 2 => 'Février', 3 => 'Mars', 4 => 'Avril', 5 => 'Mai', 6 => 'Juin', 7 => 'Juillet', 8 => 'Août', 9 => 'Septembre', 10 => 'Octobre', 11 => 'Novembre', 12 => 'Décembre', ]; public function __construct( private readonly EmployeeRepository $employeeRepository, private readonly EmployeeContractPeriodRepository $contractPeriodRepository, private readonly EmployeeLeaveSummaryProvider $leaveSummaryProvider, private readonly LeaveRecapRowBuilder $leaveRecapRowBuilder, private readonly RttRecoveryComputationService $rttRecoveryService, private readonly EmployeeRttBalanceRepository $rttBalanceRepository, private readonly EmployeeRttPaymentRepository $rttPaymentRepository, private readonly WorkHourRepository $workHourRepository, private readonly AbsenceRepository $absenceRepository, #[Autowire('%kernel.project_dir%')] private readonly string $projectDir, ) { parent::__construct(); } protected function configure(): void { $this ->addArgument( 'employee_ids', InputArgument::IS_ARRAY | InputArgument::REQUIRED, 'Employee IDs to snapshot (space-separated).' ) ->addOption( 'output-dir', null, InputOption::VALUE_OPTIONAL, 'Output directory (relative to project root, or absolute).', 'docs/verifications' ) ->addOption( 'rtt-year', null, InputOption::VALUE_OPTIONAL, 'RTT exercise year (ending year, e.g. 2026 = June 2025 → May 2026). Defaults to current exercise.' ) ; } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $ids = array_map('intval', $input->getArgument('employee_ids')); $outputDirOpt = (string) $input->getOption('output-dir'); $outputDir = str_starts_with($outputDirOpt, '/') ? $outputDirOpt : $this->projectDir.'/'.$outputDirOpt; if (!is_dir($outputDir) && !mkdir($outputDir, 0o755, true) && !is_dir($outputDir)) { $io->error('Could not create output directory: '.$outputDir); return Command::FAILURE; } $today = new DateTimeImmutable('today'); $rttYearOpt = $input->getOption('rtt-year'); $rttYear = null !== $rttYearOpt && '' !== (string) $rttYearOpt ? (int) $rttYearOpt : $this->resolveCurrentRttExerciseYear($today); foreach ($ids as $id) { $employee = $this->employeeRepository->find($id); if (!$employee instanceof Employee) { $io->warning(sprintf('Employee id=%d not found — skipped.', $id)); continue; } $markdown = $this->buildEmployeeDoc($employee, $rttYear, $today); $slug = $this->slugify($employee->getFirstName().'-'.$employee->getLastName()); $filename = sprintf('%s/verification-rtt-conges-%s.md', $outputDir, $slug); file_put_contents($filename, $markdown); $io->success(sprintf('Wrote %s', $filename)); } return Command::SUCCESS; } private function buildEmployeeDoc(Employee $employee, int $rttYear, DateTimeImmutable $today): string { $parts = []; $parts[] = $this->buildHeader($employee, $rttYear, $today); $parts[] = $this->buildProfileSection($employee); $parts[] = $this->buildLeaveSection($employee, $today); $parts[] = $this->buildRecapRowSection($employee, $today); $parts[] = $this->buildRttSection($employee, $rttYear, $today); return implode("\n\n", $parts)."\n"; } private function buildHeader(Employee $employee, int $rttYear, DateTimeImmutable $today): string { $rttFrom = sprintf('01/06/%d', $rttYear - 1); $rttTo = sprintf('31/05/%d', $rttYear); return sprintf( "# Vérification RTT & Congés — %s %s (id=%d)\n\n" ."Généré le %s. \n" ."Exercice RTT de référence : **%d** (%s → %s). \n" ."Pour les contrats Forfait, l'exercice de congés est l'année civile.", $employee->getFirstName(), $employee->getLastName(), (int) $employee->getId(), $today->format('Y-m-d'), $rttYear, $rttFrom, $rttTo ); } private function buildProfileSection(Employee $employee): string { $contract = $employee->getContract(); $contractName = $contract?->getName() ?? '—'; $tracking = $contract?->getTrackingMode() ?? '—'; $weekly = $contract?->getWeeklyHours(); $weeklyLabel = null === $weekly ? '—' : ($weekly.'h'); $nature = $employee->getCurrentContractNature(); $lines = []; $lines[] = '## 1. Profil'; $lines[] = ''; $lines[] = sprintf('- **ID** : %d', (int) $employee->getId()); $lines[] = sprintf('- **Nom / Prénom** : %s %s', $employee->getLastName(), $employee->getFirstName()); $lines[] = sprintf('- **Contrat actif** : %s — tracking `%s` — %s', $contractName, $tracking, $weeklyLabel); $lines[] = sprintf('- **Nature** : %s', $nature); $lines[] = ''; $lines[] = '### Périodes de contrat'; $lines[] = ''; $lines[] = '| Début | Fin | Contrat | Nature | Conducteur | Solde CP soldé | Commentaire |'; $lines[] = '|-------|-----|---------|--------|------------|----------------|-------------|'; $periods = $this->contractPeriodRepository->findBy(['employee' => $employee], ['startDate' => 'ASC']); foreach ($periods as $period) { $lines[] = sprintf( '| %s | %s | %s | %s | %s | %s | %s |', $period->getStartDate()->format('Y-m-d'), null !== $period->getEndDate() ? $period->getEndDate()->format('Y-m-d') : '—', $period->getContract()?->getName() ?? '—', $period->getContractNature(), $period->getIsDriver() ? 'oui' : 'non', $period->isPaidLeaveSettled() ? 'oui' : 'non', str_replace("\n", ' ', (string) ($period->getComment() ?? '')) ); } return implode("\n", $lines); } private function buildLeaveSection(Employee $employee, DateTimeImmutable $today): string { $lines = []; $lines[] = '## 2. Congés'; $lines[] = ''; $leaveYear = $this->leaveSummaryProvider->resolveLeaveYearForToday($employee); $isForfait = ContractType::FORFAIT === $employee->getContract()?->getType(); $yearSummary = $this->leaveSummaryProvider->computeYearSummary($employee, $leaveYear); if (null === $yearSummary) { $lines[] = '_Aucun résumé congés disponible (contrat non supporté : INTERIM ou autre)._'; return implode("\n", $lines); } // Forfait: recompute with paid leave days if any. $paidLeaveDays = $this->leaveSummaryProvider->resolvePaidLeaveDays($employee, $yearSummary['ruleCode'], $leaveYear); if ($paidLeaveDays > 0.0) { $recomputed = $this->leaveSummaryProvider->computeYearSummary($employee, $leaveYear, $paidLeaveDays); if (null !== $recomputed) { $yearSummary = $recomputed; } } [$from, $to] = $isForfait ? [ new DateTimeImmutable(sprintf('%d-01-01', $leaveYear)), new DateTimeImmutable(sprintf('%d-12-31', $leaveYear)), ] : [ new DateTimeImmutable(sprintf('%d-06-01', $leaveYear - 1)), new DateTimeImmutable(sprintf('%d-05-31', $leaveYear)), ]; $lines[] = sprintf('**Règle applicable** : `%s`', $yearSummary['ruleCode']); $lines[] = sprintf('**Période** : %s → %s', $from->format('Y-m-d'), $to->format('Y-m-d')); $lines[] = ''; $lines[] = '### 2.1 Soldes (tels que calculés aujourd\'hui)'; $lines[] = ''; $lines[] = '| Indicateur | Valeur |'; $lines[] = '|------------|--------|'; $lines[] = sprintf('| Acquis (report N-1) | %s j |', $this->fmtDays($yearSummary['acquiredDays'])); $lines[] = sprintf('| Acquis samedis | %s j |', $this->fmtDays($yearSummary['acquiredSaturdays'])); $lines[] = sprintf('| En cours d\'acquisition | %s j |', $this->fmtDays($yearSummary['accruingDays'])); $lines[] = sprintf('| Pris | %s j |', $this->fmtDays($yearSummary['takenDays'])); $lines[] = sprintf('| Pris samedis | %s j |', $this->fmtDays($yearSummary['takenSaturdays'])); $lines[] = sprintf('| Restant (report N-1) | %s j |', $this->fmtDays($yearSummary['remainingDays'])); $lines[] = sprintf('| Restant samedis | %s j |', $this->fmtDays($yearSummary['remainingSaturdays'])); if ($isForfait) { $lines[] = sprintf('| N-1 acquis | %s j |', $this->fmtDays($yearSummary['previousYearAcquiredDays'])); $lines[] = sprintf('| N-1 pris | %s j |', $this->fmtDays($yearSummary['previousYearTakenDays'])); $lines[] = sprintf('| N-1 restant | %s j |', $this->fmtDays($yearSummary['previousYearRemainingDays'])); $lines[] = sprintf('| N-1 payés | %s j |', $this->fmtDays($paidLeaveDays)); } $lines[] = ''; $lines[] = '### 2.2 Absences de la période'; $lines[] = ''; $absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to); if ([] === $absences) { $lines[] = '_Aucune absence sur la période._'; } else { $lines[] = '| Début | Fin | Demi-début | Demi-fin | Type | Commentaire |'; $lines[] = '|-------|-----|------------|----------|------|-------------|'; foreach ($absences as $absence) { $lines[] = sprintf( '| %s | %s | %s | %s | %s (%s) | %s |', $absence->getStartDate()->format('Y-m-d'), $absence->getEndDate()->format('Y-m-d'), $absence->getStartHalf()->value, $absence->getEndHalf()->value, $absence->getType()?->getCode() ?? '—', $absence->getType()?->getLabel() ?? '—', str_replace("\n", ' ', (string) ($absence->getComment() ?? '')) ); } } $lines[] = ''; $lines[] = '### 2.3 Jours de présence par mois (calcul provider)'; $lines[] = ''; $presenceDaysByMonth = $this->computePresenceDaysByMonth($employee, $leaveYear); if ([] === $presenceDaysByMonth) { $lines[] = '_Aucun jour de présence sur la période._'; } else { $lines[] = '| Mois | Jours de présence |'; $lines[] = '|------|-------------------|'; ksort($presenceDaysByMonth); foreach ($presenceDaysByMonth as $monthKey => $days) { $lines[] = sprintf('| %s | %s |', $monthKey, $this->fmtDays($days)); } } return implode("\n", $lines); } /** * @return array */ private function computePresenceDaysByMonth(Employee $employee, int $leaveYear): array { // The provider method is private; we re-invoke `provide()` via its public path by // calling computeYearSummary then reading $summary->presenceDaysByMonth. // But computeYearSummary doesn't populate that. So we call the provider publicly // through LeaveRecapRowBuilder? No — we just call the summary API resource directly // via a small helper below. // // Workaround: reuse the provider's provide() would require security; instead we // rebuild the map from WorkHour/absences here, mirroring the provider logic. $isForfait = ContractType::FORFAIT === $employee->getContract()?->getType(); [$from, $to] = $isForfait ? [ new DateTimeImmutable(sprintf('%d-01-01', $leaveYear)), new DateTimeImmutable(sprintf('%d-12-31', $leaveYear)), ] : [ new DateTimeImmutable(sprintf('%d-06-01', $leaveYear - 1)), new DateTimeImmutable(sprintf('%d-05-31', $leaveYear)), ]; // Leave this aggregated figure available only for forfait (this is where the UI // shows it). For non-forfait we skip — the UI doesn't show presence per month. if (!$isForfait) { return []; } $weekendWorkedDays = $this->workHourRepository->countWeekendWorkedDaysByMonth($employee, $from, $to); $absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to); $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')) { if ((int) $day->format('N') >= 6) { continue; } $startDate = $absence->getStartDate()->format('Y-m-d'); $endDate = $absence->getEndDate()->format('Y-m-d'); $startHalf = $absence->getStartHalf()->value; $endHalf = $absence->getEndHalf()->value; $dateStr = $day->format('Y-m-d'); $isStart = $dateStr === $startDate; $isEnd = $dateStr === $endDate; if ($startDate === $endDate) { $am = 'AM' === $startHalf; $pm = 'PM' === $endHalf; } elseif ($isStart) { $am = 'AM' === $startHalf; $pm = true; } elseif ($isEnd) { $am = true; $pm = 'PM' === $endHalf; } else { $am = true; $pm = true; } $dayAmount = ($am ? 0.5 : 0.0) + ($pm ? 0.5 : 0.0); if ($dayAmount <= 0.0) { continue; } $mk = $day->format('Y-m'); $absenceDaysByMonth[$mk] = ($absenceDaysByMonth[$mk] ?? 0.0) + $dayAmount; } } $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')) { if ((int) $day->format('N') <= 5) { ++$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; } private function buildRecapRowSection(Employee $employee, DateTimeImmutable $today): string { $row = $this->leaveRecapRowBuilder->build($employee); $lines = []; $lines[] = '## 3. Ligne écran « Récap. congés » (live, as of today)'; $lines[] = ''; $lines[] = '| CP N-1 restant | CP N | Samedis | RTT |'; $lines[] = '|----------------|------|---------|-----|'; $lines[] = sprintf( '| %s | %s | %s | %s |', (string) $row['cpN1Remaining'], $row['cpN'], $row['acquiredSaturdays'], $row['rtt'] ); return implode("\n", $lines); } private function buildRttSection(Employee $employee, int $rttYear, DateTimeImmutable $today): string { $lines = []; $lines[] = '## 4. RTT — Onglet par mois'; $lines[] = ''; $contract = $employee->getContract(); $trackingMode = $contract?->getTrackingMode(); if (TrackingMode::PRESENCE->value === $trackingMode) { $lines[] = '_Contrat en mode `PRESENCE` (Forfait) : aucun calcul RTT (heures supplémentaires)._'; $lines[] = '_Sur l\'UI, l\'onglet RTT ne contient aucune donnée exploitable._'; $lines[] = ''; $lines[] = '> Voir toutefois la section Congés pour les bonus week-end / jours fériés travaillés intégrés au stock Forfait (acquisDays).'; return implode("\n", $lines); } [$periodFrom, $periodTo] = $this->rttRecoveryService->resolveExerciseBounds($rttYear); $weeks = $this->rttRecoveryService->buildWeeksForExercise($periodFrom, $periodTo); $weekRanges = array_map( static fn (array $w): array => ['weekNumber' => (int) $w['weekNumber'], 'start' => $w['start'], 'end' => $w['end']], $weeks ); $currentExerciseYear = $this->resolveCurrentRttExerciseYear($today); if ($rttYear > $currentExerciseYear) { $limitDate = $periodFrom->modify('-1 day'); } else { $isoDay = (int) $today->format('N'); $limitDate = 7 === $isoDay ? $today : $today->modify('last sunday'); if (7 !== $isoDay) { $currentWeekStart = $today->modify('monday this week'); $currentWeekEnd = $currentWeekStart->modify('+6 days'); $checkEnd = $this->resolveWeekEndForEmployee($employee, $currentWeekStart, $currentWeekEnd, $today); if ($this->workHourRepository->isWeekFullyValidated($employee, $currentWeekStart, $checkEnd)) { $limitDate = $currentWeekEnd; } } } $recoveryByWeek = $this->rttRecoveryService->computeRecoveryByWeek($employee, $weekRanges, $periodFrom, $periodTo, $limitDate); [$carry, $carryMonth] = $this->resolveCarry($employee, $rttYear); $weekSummaries = $this->buildWeekSummaries($weekRanges, $recoveryByWeek, $periodFrom, $periodTo); $weekSummaries = $this->distributeDeficits($weekSummaries, $carry); // Aggregate payments per month. $paymentsByMonth = []; foreach ($this->rttPaymentRepository->findByEmployeeAndYear($employee, $rttYear) as $payment) { $m = $payment->getMonth(); if (!isset($paymentsByMonth[$m])) { $paymentsByMonth[$m] = ['base25' => 0, 'bonus25' => 0, 'base50' => 0, 'bonus50' => 0]; } $paymentsByMonth[$m]['base25'] += $payment->getBase25Minutes(); $paymentsByMonth[$m]['bonus25'] += $payment->getBonus25Minutes(); $paymentsByMonth[$m]['base50'] += $payment->getBase50Minutes(); $paymentsByMonth[$m]['bonus50'] += $payment->getBonus50Minutes(); } $lines[] = sprintf('**Limite des semaines prises en compte** : %s (exclut la semaine en cours incomplète)', $limitDate->format('Y-m-d')); $lines[] = sprintf('**Report N-1 (carry)** : `Base 25%%=%s` / `+25%%=%s` / `Base 50%%=%s` / `+50%%=%s` — **Total %s**', $this->fmtMin($carry->base25Minutes), $this->fmtMin($carry->bonus25Minutes), $this->fmtMin($carry->base50Minutes), $this->fmtMin($carry->bonus50Minutes), $this->fmtMin($carry->totalMinutes)); $lines[] = ''; // Iterate the 12 exercise months (June → May). $cumulativeCarry = [ 'base25' => $carry->base25Minutes, 'bonus25' => $carry->bonus25Minutes, 'base50' => $carry->base50Minutes, 'bonus50' => $carry->bonus50Minutes, ]; $monthsInExercise = [6, 7, 8, 9, 10, 11, 12, 1, 2, 3, 4, 5]; foreach ($monthsInExercise as $i => $month) { $calYear = $month >= 6 ? $rttYear - 1 : $rttYear; $label = self::MONTH_LABELS[$month].' '.$calYear; $lines[] = '### '.$label; $lines[] = ''; $lines[] = '| Ligne | Heure | Base 25% | +25% | Total 25% | Base 50% | +50% | Total 50% | Total |'; $lines[] = '|-------|-------|----------|------|-----------|----------|------|-----------|-------|'; // Report line only on the first month (June). if (6 === $month) { $lines[] = sprintf( '| Report N-1 | | %s | %s | %s | %s | %s | %s | %s |', $this->fmtMin($carry->base25Minutes), $this->fmtMin($carry->bonus25Minutes), $this->fmtMin($carry->base25Minutes + $carry->bonus25Minutes), $this->fmtMin($carry->base50Minutes), $this->fmtMin($carry->bonus50Minutes), $this->fmtMin($carry->base50Minutes + $carry->bonus50Minutes), $this->fmtMin($carry->totalMinutes), ); } $monthWeeks = array_values(array_filter($weekSummaries, static fn (EmployeeRttWeekSummary $w): bool => $w->month === $month)); $totals = ['over' => 0, 'b25' => 0, 's25' => 0, 'b50' => 0, 's50' => 0, 'total' => 0]; foreach ($monthWeeks as $w) { $lines[] = sprintf( '| Semaine %d (%s → %s) | %s | %s | %s | %s | %s | %s | %s | %s |', $w->weekNumber, $w->weekStart, $w->weekEnd, $this->fmtMin($w->overtimeMinutes), $this->fmtMin($w->base25Minutes), $this->fmtMin($w->bonus25Minutes), $this->fmtMin($w->base25Minutes + $w->bonus25Minutes), $this->fmtMin($w->base50Minutes), $this->fmtMin($w->bonus50Minutes), $this->fmtMin($w->base50Minutes + $w->bonus50Minutes), $this->fmtMin($w->totalMinutes), ); $totals['over'] += $w->overtimeMinutes; $totals['b25'] += $w->base25Minutes; $totals['s25'] += $w->bonus25Minutes; $totals['b50'] += $w->base50Minutes; $totals['s50'] += $w->bonus50Minutes; $totals['total'] += $w->totalMinutes; } if ([] === $monthWeeks && 6 !== $month) { $lines[] = '| _aucune semaine_ | | | | | | | | |'; } $lines[] = sprintf( '| **Total** | %s | %s | %s | %s | %s | %s | %s | **%s** |', $this->fmtMin($totals['over']), $this->fmtMin($totals['b25']), $this->fmtMin($totals['s25']), $this->fmtMin($totals['b25'] + $totals['s25']), $this->fmtMin($totals['b50']), $this->fmtMin($totals['s50']), $this->fmtMin($totals['b50'] + $totals['s50']), $this->fmtMin($totals['total']), ); $p = $paymentsByMonth[$month] ?? null; $hasPayment = null !== $p; if ($hasPayment) { $lines[] = sprintf( '| Payé | | -%s | -%s | -%s | -%s | -%s | -%s | -%s |', $this->fmtMin($p['base25']), $this->fmtMin($p['bonus25']), $this->fmtMin($p['base25'] + $p['bonus25']), $this->fmtMin($p['base50']), $this->fmtMin($p['bonus50']), $this->fmtMin($p['base50'] + $p['bonus50']), $this->fmtMin($p['base25'] + $p['bonus25'] + $p['base50'] + $p['bonus50']), ); } else { $lines[] = '| Payé | | 0h | 0h | 0h | 0h | 0h | 0h | 0h |'; } // Cumulative carry update — add month totals, subtract payments. $cumulativeCarry['base25'] += $totals['b25'] - ($p['base25'] ?? 0); $cumulativeCarry['bonus25'] += $totals['s25'] - ($p['bonus25'] ?? 0); $cumulativeCarry['base50'] += $totals['b50'] - ($p['base50'] ?? 0); $cumulativeCarry['bonus50'] += $totals['s50'] - ($p['bonus50'] ?? 0); $cb25 = $cumulativeCarry['base25']; $cs25 = $cumulativeCarry['bonus25']; $cb50 = $cumulativeCarry['base50']; $cs50 = $cumulativeCarry['bonus50']; $cTotal = $cb25 + $cs25 + $cb50 + $cs50; $lines[] = sprintf( '| **Reste (cumul)** | | %s | %s | %s | %s | %s | %s | **%s** |', $this->fmtMin($cb25), $this->fmtMin($cs25), $this->fmtMin($cb25 + $cs25), $this->fmtMin($cb50), $this->fmtMin($cs50), $this->fmtMin($cb50 + $cs50), $this->fmtMin($cTotal), ); $lines[] = ''; } // Final summary. $currentYearRecovery = array_sum(array_map(static fn (EmployeeRttWeekSummary $w): int => $w->totalMinutes, $weekSummaries)); $totalPaid = 0; foreach ($paymentsByMonth as $p) { $totalPaid += $p['base25'] + $p['bonus25'] + $p['base50'] + $p['bonus50']; } $available = $carry->totalMinutes + $currentYearRecovery - $totalPaid; $lines[] = '### Solde RTT total (fin de période calculée)'; $lines[] = ''; $lines[] = sprintf('- Report N-1 (opening) : **%s**', $this->fmtMin($carry->totalMinutes)); $lines[] = sprintf('- Cumul récupération exercice : **%s**', $this->fmtMin($currentYearRecovery)); $lines[] = sprintf('- Total payé : **%s**', $this->fmtMin($totalPaid)); $lines[] = sprintf('- **Disponible** : **%s**', $this->fmtMin($available)); return implode("\n", $lines); } /** * Mirrors EmployeeRttSummaryProvider::buildWeekSummaries(). * * @param list $weekRanges * @param array $recoveryByWeek * * @return list */ private function buildWeekSummaries(array $weekRanges, array $recoveryByWeek, DateTimeImmutable $periodFrom, DateTimeImmutable $periodTo): array { $result = []; foreach ($weekRanges as $week) { $weekStart = $week['start']; $weekEnd = $week['end']; $weekKey = $weekStart->format('Y-m-d'); $detail = $recoveryByWeek[$weekKey] ?? new WeekRecoveryDetail(); $effectiveStart = $weekStart < $periodFrom ? $periodFrom : $weekStart; $effectiveEnd = $weekEnd > $periodTo ? $periodTo : $weekEnd; $startMonth = (int) $effectiveStart->format('n'); $endMonth = (int) $effectiveEnd->format('n'); if ($startMonth === $endMonth) { $result[] = new EmployeeRttWeekSummary( month: $startMonth, weekNumber: (int) $week['weekNumber'], weekStart: $weekStart->format('Y-m-d'), weekEnd: $weekEnd->format('Y-m-d'), overtimeMinutes: $detail->overtimeMinutes, base25Minutes: $detail->base25Minutes, bonus25Minutes: $detail->bonus25Minutes, base50Minutes: $detail->base50Minutes, bonus50Minutes: $detail->bonus50Minutes, totalMinutes: $detail->totalMinutes, ); continue; } $monthMinutes = []; $monthWeekdays = []; foreach ($detail->dailyMinutes as $date => $mins) { $m = (int) new DateTimeImmutable($date)->format('n'); $monthMinutes[$m] = ($monthMinutes[$m] ?? 0) + $mins; if ((int) new DateTimeImmutable($date)->format('N') < 6) { $monthWeekdays[$m] = ($monthWeekdays[$m] ?? 0) + 1; } } $totalWorked = array_sum($monthMinutes); $totalWeekdays = array_sum($monthWeekdays); foreach ([$startMonth, $endMonth] as $m) { if ($totalWorked > 0) { $ratio = ($monthMinutes[$m] ?? 0) / $totalWorked; } elseif ($totalWeekdays > 0) { $ratio = ($monthWeekdays[$m] ?? 0) / $totalWeekdays; } else { $ratio = 0.0; } $result[] = new EmployeeRttWeekSummary( month: $m, weekNumber: (int) $week['weekNumber'], weekStart: $weekStart->format('Y-m-d'), weekEnd: $weekEnd->format('Y-m-d'), overtimeMinutes: (int) round($detail->overtimeMinutes * $ratio), base25Minutes: (int) round($detail->base25Minutes * $ratio), bonus25Minutes: (int) round($detail->bonus25Minutes * $ratio), base50Minutes: (int) round($detail->base50Minutes * $ratio), bonus50Minutes: (int) round($detail->bonus50Minutes * $ratio), totalMinutes: (int) round($detail->totalMinutes * $ratio), ); } } return $result; } /** * Mirrors the deficit-distribution step in EmployeeRttSummaryProvider::provide(). * * @param list $weeks * * @return list */ private function distributeDeficits(array $weeks, WeekRecoveryDetail $carry): array { $cumulative50 = $carry->base50Minutes + $carry->bonus50Minutes; $cumulative25 = $carry->base25Minutes + $carry->bonus25Minutes; foreach ($weeks as $i => $week) { if ($week->totalMinutes >= 0) { $cumulative50 += $week->base50Minutes + $week->bonus50Minutes; $cumulative25 += $week->base25Minutes + $week->bonus25Minutes; continue; } $deficit = -$week->totalMinutes; $from50 = min($deficit, max(0, $cumulative50)); $from25 = $deficit - $from50; $cumulative50 -= $from50; $cumulative25 -= $from25; $weeks[$i] = new EmployeeRttWeekSummary( month: $week->month, weekNumber: $week->weekNumber, weekStart: $week->weekStart, weekEnd: $week->weekEnd, overtimeMinutes: $week->overtimeMinutes, base25Minutes: $from25 > 0 ? -$from25 : 0, bonus25Minutes: 0, base50Minutes: $from50 > 0 ? -$from50 : 0, bonus50Minutes: 0, totalMinutes: $week->totalMinutes, ); } return $weeks; } /** * @return array{WeekRecoveryDetail, int} */ private function resolveCarry(Employee $employee, int $year): array { $balance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $year); if (null !== $balance) { return [ new WeekRecoveryDetail( base25Minutes: $balance->getOpeningBase25Minutes(), bonus25Minutes: $balance->getOpeningBonus25Minutes(), base50Minutes: $balance->getOpeningBase50Minutes(), bonus50Minutes: $balance->getOpeningBonus50Minutes(), totalMinutes: $balance->getTotalOpeningMinutes(), ), $balance->getMonth(), ]; } return [$this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $year - 1), 5]; } private function resolveWeekEndForEmployee(Employee $employee, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd, DateTimeImmutable $today): DateTimeImmutable { foreach ($employee->getContractPeriods() as $period) { if ($period->getStartDate() > $today) { continue; } $endDate = $period->getEndDate(); if (null === $endDate) { continue; } if ($endDate >= $weekStart && $endDate <= $weekEnd) { return $endDate; } } return $weekEnd; } private function resolveCurrentRttExerciseYear(DateTimeImmutable $today): int { $y = (int) $today->format('Y'); $m = (int) $today->format('n'); return $m >= 6 ? $y + 1 : $y; } private function fmtMin(int $minutes): string { if (0 === $minutes) { return '0h'; } $sign = $minutes < 0 ? '-' : ''; $abs = abs($minutes); $h = intdiv($abs, 60); $m = $abs % 60; return 0 === $m ? sprintf('%s%dh', $sign, $h) : sprintf('%s%dh%02d', $sign, $h, $m); } private function fmtDays(float $value): string { if (abs($value - round($value)) < 0.001) { return (string) (int) round($value); } return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.'); } private function slugify(string $value): string { $value = trim($value); $ascii = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value); if (false === $ascii) { $ascii = $value; } $ascii = strtolower($ascii); $ascii = preg_replace('/[^a-z0-9]+/', '-', $ascii) ?? $ascii; return trim($ascii, '-'); } }