requestStack->getCurrentRequest(); if (!$request) { return new Response('Missing request.', Response::HTTP_BAD_REQUEST); } $employeeId = (int) $request->query->get('employeeId', '0'); if ($employeeId <= 0) { throw new UnprocessableEntityHttpException('employeeId must be a positive integer.'); } $employee = $this->employeeRepository->find($employeeId); if (!$employee instanceof Employee) { throw new NotFoundHttpException('Employee not found.'); } $yearRaw = (string) $request->query->get('year'); if (!preg_match('/^\d{4}$/', $yearRaw)) { throw new UnprocessableEntityHttpException('year must use YYYY format.'); } $year = (int) $yearRaw; $monthRaw = (string) $request->query->get('month', ''); $month = null; if ('' !== $monthRaw) { if (!preg_match('/^(?:0?[1-9]|1[0-2])$/', $monthRaw)) { throw new UnprocessableEntityHttpException('month must be between 1 and 12.'); } $month = (int) $monthRaw; } if (null !== $month) { $from = new DateTimeImmutable(sprintf('%d-%02d-01', $year, $month)); $to = $from->modify('last day of this month'); } else { $from = new DateTimeImmutable("{$year}-01-01"); $to = new DateTimeImmutable("{$year}-12-31"); } $days = $this->buildDays($from, $to); $workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, [$employee]); $absences = $this->absenceRepository->findForPrint($from, $to, [$employee]); $contractMap = $this->contractResolver->resolveForEmployeesAndDays([$employee], $days); $driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays([$employee], $days); $workHourMap = $this->buildWorkHourMap($workHours); $absenceData = $this->buildAbsenceData($absences, $days, $employee); $segments = $this->buildSegments( $employee, $days, $contractMap[$employee->getId()] ?? [], $driverMap[$employee->getId()] ?? [], $workHourMap[$employee->getId()] ?? [], $absenceData, ); $employeeName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? '')); $contractLabel = $this->buildContractLabel($employee); $options = new Options(); $options->set('isRemoteEnabled', true); $dompdf = new Dompdf($options); $html = $this->twig->render('employee-yearly-hours/print.html.twig', [ 'employeeName' => $employeeName, 'contractLabel' => $contractLabel, 'year' => $year, 'month' => $month, 'segments' => $segments, ]); $dompdf->loadHtml($html); $dompdf->setPaper('A4', 'portrait'); $dompdf->render(); $filename = null !== $month ? sprintf( '%s_%s_%d-%02d.pdf', $this->sanitizeFilename($employee->getLastName() ?? ''), $this->sanitizeFilename($employee->getFirstName() ?? ''), $year, $month, ) : sprintf( '%s_%s_%d.pdf', $this->sanitizeFilename($employee->getLastName() ?? ''), $this->sanitizeFilename($employee->getFirstName() ?? ''), $year, ); return new Response($dompdf->output(), Response::HTTP_OK, [ 'Content-Type' => 'application/pdf', 'Content-Disposition' => 'inline; filename="'.$filename.'"', ]); } private 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 list */ private 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; } /** * @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{credited: array, labels: array, absentMorning: array, absentAfternoon: array, hasDayAbsence: array} */ private function buildAbsenceData(array $absences, array $days, Employee $employee): array { $credited = []; $labels = []; $absentMorning = []; $absentAfternoon = []; $hasDayAbsence = []; foreach ($absences as $absence) { $absEmployeeId = $absence->getEmployee()?->getId(); if ($absEmployeeId !== $employee->getId()) { continue; } $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, ]; } /** * @return list}> */ private function buildSegments( Employee $employee, array $days, array $contractsByDate, array $driverByDate, array $workHoursByDate, array $absenceData, ): array { $segments = []; $currentMode = null; $currentRows = []; $currentName = null; // Crop the output window to [first data day, today] to avoid padding the // export with empty rows (notably weekends before the first saisie or after today). $firstDataDate = null; foreach ($days as $date) { $hasRow = null !== ($workHoursByDate[$date] ?? null) || ($absenceData['hasDayAbsence'][$date] ?? false); 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); $isoDay = (int) new DateTimeImmutable($date)->format('N'); $isWeekend = $isoDay >= 6; // Keep weekend rows even when empty so the reader can distinguish // worked vs non-worked Saturdays/Sundays at a glance. if (!$hasData && !$isWeekend) { continue; } 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; $row = [ 'date' => new DateTimeImmutable($date)->format('d/m/Y'), 'absenceLabel' => $absenceLabel, '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; $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); $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($metrics->totalMinutes); } $currentRows[] = $row; } if (null !== $currentMode && [] !== $currentRows) { $segments[] = [ 'mode' => $currentMode, 'contractName' => $currentName, 'rows' => $currentRows, ]; } return $segments; } 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"; } private function sanitizeFilename(string $name): string { $name = str_replace(' ', '_', $name); return preg_replace('/[^a-zA-Z0-9_\-]/', '', $name) ?? $name; } }