requestStack->getCurrentRequest(); if (!$request) { return new Response('Missing request.', Response::HTTP_BAD_REQUEST); } $month = $request->query->get('month'); if (!$month || !preg_match('/^\d{4}-\d{2}$/', $month)) { return new Response('Missing or invalid month query param (expected YYYY-MM).', Response::HTTP_BAD_REQUEST); } $from = DateTimeImmutable::createFromFormat('Y-m-d', $month.'-01'); $to = $from->modify('last day of this month'); $employees = $this->employeeRepository->findForPrintBySiteIds([]); $workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, $employees); $absences = $this->absenceRepository->findForPrint($from, $to, $employees); $year = (int) $from->format('Y'); $monthNumber = (int) $from->format('n'); $rttPayments = $this->rttPaymentRepository->findByYearAndMonth($year, $monthNumber); $bonuses = $this->bonusRepository->findByMonth($from, $to); $mileages = $this->mileageAllowanceRepository->findByMonth($from, $to); $observations = $this->observationRepository->findByMonth($from, $to); $days = $this->buildDays($from, $to); $contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days); $driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days); $holidayMap = $this->buildHolidayMap($from, $to); $workHourMap = $this->buildWorkHourMap($workHours); $absenceMap = $this->buildAbsenceMap($absences); $rttPaymentMap = $this->buildRttPaymentMap($rttPayments); $bonusMap = $this->buildBonusMap($bonuses); $mileageMap = $this->buildMileageMap($mileages); $observationMap = $this->buildObservationMap($observations); $siteGroups = $this->aggregateBySite($employees, $days, $contractMap, $driverMap, $workHourMap, $absenceMap, $rttPaymentMap, $bonusMap, $mileageMap, $observationMap, $holidayMap); $options = new Options(); $options->set('isRemoteEnabled', true); $dompdf = new Dompdf($options); $html = $this->twig->render('salary-recap/print.html.twig', [ 'from' => $from, 'to' => $to, 'siteGroups' => $siteGroups, ]); $dompdf->loadHtml($html); $dompdf->setPaper('A4', 'landscape'); $dompdf->render(); $filename = sprintf( 'recap_salaire_%s.pdf', $from->format('Y-m') ); return new Response($dompdf->output(), Response::HTTP_OK, [ 'Content-Type' => 'application/pdf', 'Content-Disposition' => 'inline; filename="'.$filename.'"', ]); } /** * @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> */ private function buildAbsenceMap(array $absences): array { $map = []; foreach ($absences as $absence) { $employeeId = $absence->getEmployee()?->getId(); if (!$employeeId) { continue; } $map[$employeeId][] = $absence; } return $map; } /** * @return array */ private function buildRttPaymentMap(array $rttPayments): array { $map = []; foreach ($rttPayments as $payment) { $employeeId = $payment->getEmployee()?->getId(); if (!$employeeId) { continue; } $map[$employeeId] = ($map[$employeeId] ?? 0) + $payment->getBase25Minutes() + $payment->getBase50Minutes(); } return $map; } /** * @return array */ private function buildBonusMap(array $bonuses): array { $map = []; foreach ($bonuses as $bonus) { $employeeId = $bonus->getEmployee()?->getId(); if (!$employeeId) { continue; } $map[$employeeId] = ($map[$employeeId] ?? 0.0) + $bonus->getAmount(); } return $map; } /** * @return array */ private function buildMileageMap(array $mileages): array { $map = []; foreach ($mileages as $mileage) { $employeeId = $mileage->getEmployee()?->getId(); if (!$employeeId) { continue; } $map[$employeeId] = ($map[$employeeId] ?? 0.0) + $mileage->getKilometers(); } return $map; } /** * @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; } /** * @return array */ private function buildObservationMap(array $observations): array { $map = []; foreach ($observations as $observation) { $employeeId = $observation->getEmployee()?->getId(); if (!$employeeId) { continue; } $map[$employeeId] = $observation->getContent(); } return $map; } private function aggregateBySite( array $employees, array $days, array $contractMap, array $driverMap, array $workHourMap, array $absenceMap, array $rttPaymentMap, array $bonusMap, array $mileageMap, array $observationMap, array $holidayMap, ): array { $siteGroups = []; foreach ($employees as $employee) { $employeeId = $employee->getId(); $site = $employee->getSite(); $siteName = $site ? $site->getName() : 'Sans site'; $siteId = $site ? $site->getId() : 0; $row = $this->buildEmployeeRow( $employee, $employeeId, $days, $contractMap[$employeeId] ?? [], $driverMap[$employeeId] ?? [], $workHourMap[$employeeId] ?? [], $absenceMap[$employeeId] ?? [], $rttPaymentMap[$employeeId] ?? 0, $bonusMap[$employeeId] ?? 0.0, $mileageMap[$employeeId] ?? 0.0, $observationMap[$employeeId] ?? '', $holidayMap, ); if (!isset($siteGroups[$siteId])) { $siteGroups[$siteId] = [ 'name' => $siteName, 'color' => $site?->getColor() ?? '#ffd7d7', 'employees' => [], ]; } $siteGroups[$siteId]['employees'][] = $row; } return $siteGroups; } private function buildEmployeeRow( Employee $employee, int $employeeId, array $days, array $contractsByDate, array $driverByDate, array $workHoursByDate, array $absences, int $rttPaidMinutes, float $bonusAmount, float $mileageKm, string $observation, array $holidayMap, ): array { $contractName = null; $presenceDays = 0.0; $nightMinutesTotal = 0; $nightBasketCount = 0; $sundayMinutesTotal = 0; $holidayMinutesTotal = 0; $isDriverAnyDay = false; $driverBreakfast = 0; $driverMeals = 0; $driverOvernight = 0; $driverSaturdays = 0; $isForfait = false; foreach ($days as $date) { $contract = $contractsByDate[$date] ?? null; $isDriver = $driverByDate[$date] ?? false; $wh = $workHoursByDate[$date] ?? null; if ($contract && null === $contractName) { $contractName = $contract->getName(); $isForfait = TrackingMode::PRESENCE === $contract->getTrackingModeEnum(); } if ($isDriver) { $isDriverAnyDay = true; } if (!$wh) { continue; } $dayOfWeek = (int) new DateTimeImmutable($date)->format('N'); $isHoliday = isset($holidayMap[$date]); if ($isDriver) { $nightMinutesTotal += $wh->getNightHoursMinutes() ?? 0; $dayMin = $wh->getDayHoursMinutes() ?? 0; $nightMin = $wh->getNightHoursMinutes() ?? 0; $workshopMin = $wh->getWorkshopHoursMinutes() ?? 0; if (($nightMin > $dayMin && $nightMin > 0) || $nightMin >= 240) { ++$nightBasketCount; } if ($wh->getHasBreakfast()) { ++$driverBreakfast; } if ($wh->getHasLunch() || $wh->getHasDinner()) { ++$driverMeals; } if ($wh->getHasOvernight()) { ++$driverOvernight; } if (6 === $dayOfWeek && ($dayMin > 0 || $nightMin > 0 || $workshopMin > 0)) { ++$driverSaturdays; } if (7 === $dayOfWeek) { $sundayMinutesTotal += $dayMin + $nightMin + $workshopMin; } if ($isHoliday) { $holidayMinutesTotal += $dayMin + $nightMin + $workshopMin; } } else { $metrics = $this->computeNightMinutes($wh); $nightMinutesTotal += $metrics['nightMinutes']; if (($metrics['nightMinutes'] > $metrics['dayMinutes'] && $metrics['nightMinutes'] > 0) || $metrics['nightMinutes'] >= 240) { ++$nightBasketCount; } if (7 === $dayOfWeek) { $sundayMinutesTotal += $metrics['dayMinutes'] + $metrics['nightMinutes']; } // Samedi : les minutes après minuit débordent sur le dimanche if (6 === $dayOfWeek) { $sundayMinutesTotal += $this->computeOverflowAfterMidnight($wh); } if ($isHoliday) { $holidayMinutesTotal += $metrics['dayMinutes'] + $metrics['nightMinutes']; } if ($isForfait) { if ($wh->getIsPresentMorning()) { $presenceDays += 0.5; } if ($wh->getIsPresentAfternoon()) { $presenceDays += 0.5; } } } } $conges = $this->countAbsencesByCode($absences, ['C']); $maladie = $this->countAbsencesByCode($absences, ['M', 'AT']); $nightHours = round($nightMinutesTotal / 60, 2); $paidHours = round($rttPaidMinutes / 60, 2); $sundayHours = round($sundayMinutesTotal / 60, 2); $holidayHours = round($holidayMinutesTotal / 60, 2); return [ 'lastName' => mb_strimwidth($employee->getLastName() ?? '', 0, 15, '...'), 'firstName' => mb_strimwidth($employee->getFirstName() ?? '', 0, 15, '...'), 'contractName' => $contractName, 'presenceDays' => $presenceDays, 'mileageKm' => $mileageKm, 'nightHours' => $nightHours, 'nightBasketCount' => $nightBasketCount, 'paidHours' => $paidHours, 'sundayHours' => $sundayHours, 'holidayHours' => $holidayHours, 'bonusAmount' => $bonusAmount, 'congesCount' => $conges['count'], 'congesDates' => $conges['dates'], 'maladieCount' => $maladie['count'], 'maladieDates' => $maladie['dates'], 'isDriver' => $isDriverAnyDay, 'driverBreakfast' => $driverBreakfast, 'driverMeals' => $driverMeals, 'driverOvernight' => $driverOvernight, 'driverSaturdays' => $driverSaturdays, 'observation' => $observation, ]; } /** * @return array{nightMinutes: int, dayMinutes: int} */ private function computeNightMinutes(WorkHour $workHour): array { $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 [ 'nightMinutes' => $nightMinutes, 'dayMinutes' => $dayMinutes, ]; } /** * @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; } /** * Calcule les minutes qui débordent après minuit (> 1440) pour les créneaux d'un WorkHour. * Ex: créneau soir 21:00-05:00 → interval [1260, 1740] → overflow = 1740-1440 = 300 min (5h). */ private function computeOverflowAfterMidnight(WorkHour $workHour): int { $ranges = [ [$workHour->getMorningFrom(), $workHour->getMorningTo()], [$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()], [$workHour->getEveningFrom(), $workHour->getEveningTo()], ]; $overflow = 0; foreach ($ranges as [$from, $to]) { $interval = $this->resolveInterval($from, $to); if (null === $interval) { continue; } [$start, $end] = $interval; // Si le créneau dépasse minuit (1440), la partie au-delà est sur le jour suivant if ($end > 1440) { $overflow += $end - max($start, 1440); } } return $overflow; } 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); } /** * @param list $absences * @param list $codes * * @return array{count: float, dates: string} */ private function countAbsencesByCode(array $absences, array $codes): array { $count = 0.0; $dayKeys = []; foreach ($absences as $absence) { $type = $absence->getType(); if (!$type || !in_array($type->getCode(), $codes, true)) { continue; } $startHalf = $absence->getStartHalf(); $endHalf = $absence->getEndHalf(); if ($startHalf === $endHalf) { $count += 0.5; } else { $count += 1.0; } $dayKeys[] = $absence->getStartDate()->format('Y-m-d'); } sort($dayKeys); $dayKeys = array_unique($dayKeys); $periods = $this->mergeDaysIntoPeriods($dayKeys); return [ 'count' => $count, 'dates' => implode(', ', $periods), ]; } /** * @param list $sortedDates Y-m-d sorted * * @return list */ private function mergeDaysIntoPeriods(array $sortedDates): array { if ([] === $sortedDates) { return []; } $periods = []; $rangeStart = $sortedDates[0]; $rangeEnd = $sortedDates[0]; for ($i = 1, $len = count($sortedDates); $i < $len; ++$i) { $prev = new DateTimeImmutable($rangeEnd); $current = new DateTimeImmutable($sortedDates[$i]); if (1 === $current->diff($prev)->days) { $rangeEnd = $sortedDates[$i]; } else { $periods[] = $this->formatPeriod($rangeStart, $rangeEnd); $rangeStart = $sortedDates[$i]; $rangeEnd = $sortedDates[$i]; } } $periods[] = $this->formatPeriod($rangeStart, $rangeEnd); return $periods; } private function formatPeriod(string $start, string $end): string { $s = new DateTimeImmutable($start)->format('d/m'); if ($start === $end) { return $s; } return 'Du '.$s.' au '.new DateTimeImmutable($end)->format('d/m'); } }