*/ public function buildDays(DateTimeImmutable $from, DateTimeImmutable $to): array { $days = []; $current = $from; while ($current <= $to) { $days[] = $current->format('Y-m-d'); $current = $current->add(new DateInterval('P1D')); } return $days; } /** * @param list $employees * * @return list}> */ public function buildForEmployees(array $employees, DateTimeImmutable $from, DateTimeImmutable $to): array { $days = $this->buildDays($from, $to); $workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, $employees); $absences = $this->absenceRepository->findForPrint($from, $to, $employees); $contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days); $driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days); $workDaysMap = $this->contractResolver->resolveWorkDaysMinutesForEmployeesAndDays($employees, $days); $holidayMap = $this->buildHolidayMap($from, $to); $workHourMap = $this->buildWorkHourMap($workHours); $absenceMap = $this->buildAbsenceMap($absences, $days); $results = []; foreach ($employees as $employee) { $employeeId = $employee->getId(); $absenceData = $this->resolveAbsenceDataForEmployee($absenceMap[$employeeId] ?? [], $days, $employee); $segments = $this->buildSegments( $days, $contractMap[$employeeId] ?? [], $driverMap[$employeeId] ?? [], $workHourMap[$employeeId] ?? [], $absenceData, $workDaysMap[$employeeId] ?? [], $holidayMap, ); if ([] === $segments) { continue; } $results[] = [ 'employeeName' => trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? '')), 'contractLabel' => $this->buildContractLabel($employee), 'segments' => $segments, ]; } return $results; } /** * @return list}> */ public function buildForEmployee(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array { return $this->buildForEmployees([$employee], $from, $to); } public function buildContractLabel(Employee $employee): ?string { $contract = $employee->getContract(); if (null === $contract) { return null; } $natureRaw = $employee->getCurrentContractNature(); $nature = ContractNature::tryFrom($natureRaw) ?? ContractNature::CDI; $natureLabel = match ($nature) { ContractNature::CDI => 'CDI', ContractNature::CDD => 'CDD', ContractNature::INTERIM => 'Intérim', }; $contractType = $contract->getType(); if (ContractType::FORFAIT === $contractType) { return $natureLabel.' Forfait'; } $weeklyHours = $contract->getWeeklyHours(); if (null !== $weeklyHours && $weeklyHours > 0) { return sprintf('%s %d heures', $natureLabel, $weeklyHours); } $name = $contract->getName(); return null !== $name && '' !== $name ? $natureLabel.' '.$name : $natureLabel; } /** * @return array> */ private function buildWorkHourMap(array $workHours): array { $map = []; foreach ($workHours as $wh) { $employeeId = $wh->getEmployee()?->getId(); if (!$employeeId) { continue; } $date = $wh->getWorkDate()->format('Y-m-d'); $map[$employeeId][$date] = $wh; } return $map; } /** * @return array> */ private function buildAbsenceMap(array $absences, array $days): array { $map = []; foreach ($absences as $absence) { $employeeId = $absence->getEmployee()?->getId(); if (!$employeeId) { continue; } $map[$employeeId][] = $absence; } return $map; } /** * @return array{credited: array, labels: array, absentMorning: array, absentAfternoon: array, hasDayAbsence: array} */ private function resolveAbsenceDataForEmployee(array $absences, array $days, Employee $employee): array { $credited = []; $labels = []; $absentMorning = []; $absentAfternoon = []; $hasDayAbsence = []; foreach ($absences as $absence) { $start = $absence->getStartDate()->format('Y-m-d'); $end = $absence->getEndDate()->format('Y-m-d'); foreach ($days as $date) { if ($date < $start || $date > $end) { continue; } [$isMorning, $isAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date); if ($isMorning || $isAfternoon) { $hasDayAbsence[$date] = true; $absentMorning[$date] = ($absentMorning[$date] ?? false) || $isMorning; $absentAfternoon[$date] = ($absentAfternoon[$date] ?? false) || $isAfternoon; if (!isset($labels[$date])) { $labels[$date] = $absence->getType()?->getLabel() ?? ''; } } $credited[$date] = ($credited[$date] ?? 0) + $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $isMorning, $isAfternoon); } } return [ 'credited' => $credited, 'labels' => $labels, 'absentMorning' => $absentMorning, 'absentAfternoon' => $absentAfternoon, 'hasDayAbsence' => $hasDayAbsence, ]; } /** * @param array> $workDaysMinutesByDate * @param array $holidayMap * * @return list}> */ private function buildSegments( array $days, array $contractsByDate, array $driverByDate, array $workHoursByDate, array $absenceData, array $workDaysMinutesByDate, array $holidayMap, ): array { $segments = []; $currentMode = null; $currentRows = []; $currentName = null; $firstDataDate = null; foreach ($days as $date) { $hasRow = null !== ($workHoursByDate[$date] ?? null) || ($absenceData['hasDayAbsence'][$date] ?? false) || isset($holidayMap[$date]); if ($hasRow) { $firstDataDate = $date; break; } } if (null === $firstDataDate) { return []; } $todayYmd = new DateTimeImmutable('today')->format('Y-m-d'); foreach ($days as $date) { if ($date < $firstDataDate || $date > $todayYmd) { continue; } $contract = $contractsByDate[$date] ?? null; $isDriver = $driverByDate[$date] ?? false; $wh = $workHoursByDate[$date] ?? null; $hasData = null !== $wh || ($absenceData['hasDayAbsence'][$date] ?? false); $holidayLabel = $holidayMap[$date] ?? null; $isHoliday = null !== $holidayLabel; $isoDay = (int) new DateTimeImmutable($date)->format('N'); $isWeekend = $isoDay >= 6; // Tous les jours contractés sont affichés, même vides ou non saisis (lignes // « manquantes » signalées par la RH). Seuls les jours hors contrat (avant // embauche, après départ, suspension) sont omis. if (!$hasData && null === $contract) { continue; } $trackingMode = $contract?->getTrackingMode() ?? TrackingMode::TIME->value; $mode = $this->resolveSegmentMode($trackingMode, $isDriver); $contractName = $contract?->getName(); if ($mode !== $currentMode) { if (null !== $currentMode && [] !== $currentRows) { $segments[] = [ 'mode' => $currentMode, 'contractName' => $currentName, 'rows' => $currentRows, ]; } $currentMode = $mode; $currentRows = []; $currentName = $contractName; } $creditedMinutes = $absenceData['credited'][$date] ?? 0; $absenceLabel = $absenceData['labels'][$date] ?? null; $hasAbsence = $absenceData['hasDayAbsence'][$date] ?? false; $virtualMinutes = $this->holidayVirtualHoursResolver->resolveVirtualCredit( $contract, new DateTimeImmutable($date), $hasAbsence, $workDaysMinutesByDate[$date] ?? null, ); $row = [ 'date' => new DateTimeImmutable($date)->format('d/m/Y'), 'absenceLabel' => $absenceLabel, 'holidayLabel' => $holidayLabel, 'isWeekend' => $isWeekend, ]; if ('presence' === $mode) { $absentMorning = $absenceData['absentMorning'][$date] ?? false; $absentAfternoon = $absenceData['absentAfternoon'][$date] ?? false; $morning = (($wh?->getIsPresentMorning() ?? false) && !$absentMorning) ? 0.5 : 0.0; $afternoon = (($wh?->getIsPresentAfternoon() ?? false) && !$absentAfternoon) ? 0.5 : 0.0; $total = $morning + $afternoon; $row['presentMorning'] = $morning > 0; $row['presentAfternoon'] = $afternoon > 0; $row['total'] = $total > 0 ? (string) $total : ''; } elseif ('driver' === $mode) { $dayMin = $wh?->getDayHoursMinutes() ?? 0; $nightMin = $wh?->getNightHoursMinutes() ?? 0; $workshopMin = $wh?->getWorkshopHoursMinutes() ?? 0; $totalMin = $dayMin + $nightMin + $workshopMin + $creditedMinutes; if ($virtualMinutes > $totalMin) { $totalMin = $virtualMinutes; } $row['dayHours'] = $this->formatMinutes($dayMin); $row['nightHours'] = $this->formatMinutes($nightMin); $row['workshopHours'] = $this->formatMinutes($workshopMin); $row['total'] = $this->formatMinutes($totalMin); } else { $metrics = null !== $wh ? $this->computeMetrics($wh) : new WorkMetrics(); $metrics->addCreditedMinutes($creditedMinutes); $totalMin = $metrics->totalMinutes; if ($virtualMinutes > $totalMin) { $totalMin = $virtualMinutes; } $row['morningFrom'] = $wh?->getMorningFrom() ?? ''; $row['morningTo'] = $wh?->getMorningTo() ?? ''; $row['afternoonFrom'] = $wh?->getAfternoonFrom() ?? ''; $row['afternoonTo'] = $wh?->getAfternoonTo() ?? ''; $row['eveningFrom'] = $wh?->getEveningFrom() ?? ''; $row['eveningTo'] = $wh?->getEveningTo() ?? ''; $row['total'] = $this->formatMinutes($totalMin); } $currentRows[] = $row; } if (null !== $currentMode && [] !== $currentRows) { $segments[] = [ 'mode' => $currentMode, 'contractName' => $currentName, 'rows' => $currentRows, ]; } return $segments; } /** * @return array Y-m-d => label */ private function buildHolidayMap(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; } private function resolveSegmentMode(string $trackingMode, bool $isDriver): string { if ($isDriver) { return 'driver'; } if (TrackingMode::PRESENCE->value === $trackingMode) { return 'presence'; } return 'time'; } private function computeMetrics(WorkHour $workHour): WorkMetrics { $ranges = [ [$workHour->getMorningFrom(), $workHour->getMorningTo()], [$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()], [$workHour->getEveningFrom(), $workHour->getEveningTo()], ]; $totalMinutes = 0; $nightMinutes = 0; foreach ($ranges as [$from, $to]) { $totalMinutes += $this->intervalMinutes($from, $to); $nightMinutes += $this->nightIntervalMinutes($from, $to); } $dayMinutes = max(0, $totalMinutes - $nightMinutes); return new WorkMetrics( dayMinutes: $dayMinutes, nightMinutes: $nightMinutes, totalMinutes: $totalMinutes, ); } /** * @return null|array{int, int} */ private function resolveInterval(?string $from, ?string $to): ?array { $fromMinutes = $this->toMinutes($from); $toMinutes = $this->toMinutes($to); if (null === $fromMinutes || null === $toMinutes) { return null; } $end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes; return [$fromMinutes, $end]; } private function toMinutes(?string $time): ?int { if (null === $time || '' === $time) { return null; } [$hours, $minutes] = array_map('intval', explode(':', $time)); return ($hours * 60) + $minutes; } private function intervalMinutes(?string $from, ?string $to): int { $interval = $this->resolveInterval($from, $to); if (null === $interval) { return 0; } [$start, $end] = $interval; return max(0, $end - $start); } private function nightIntervalMinutes(?string $from, ?string $to): int { $interval = $this->resolveInterval($from, $to); if (null === $interval) { return 0; } [$start, $end] = $interval; $windows = [[0, 360], [1260, 1440]]; $total = 0; for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) { $shift = $dayOffset * 1440; foreach ($windows as [$windowStart, $windowEnd]) { $total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift); } } return $total; } private function overlap(int $startA, int $endA, int $startB, int $endB): int { $start = max($startA, $startB); $end = min($endA, $endB); return max(0, $end - $start); } private function formatMinutes(int $minutes): string { if (0 === $minutes) { return ''; } $h = intdiv($minutes, 60); $m = $minutes % 60; return 0 === $m ? "{$h}h" : "{$h}h{$m}m"; } }