security->getUser(); // Endpoint protégé: résumé hebdo réservé aux utilisateurs authentifiés. if (!$user instanceof User) { throw new AccessDeniedHttpException('Authentication required.'); } $anchorDate = $this->resolveAnchorDate(); [$weekStart, $weekEnd, $days] = $this->resolveWeek($anchorDate); $employees = $this->employeeRepository->findScoped($user); $workHours = $this->workHourRepository->findByDateRangeAndEmployees($weekStart, $weekEnd, $employees); $absences = $this->absenceRepository->findForPrint($weekStart, $weekEnd, $employees); $summary = new WorkHourWeeklySummary(); $summary->weekStart = $weekStart->format('Y-m-d'); $summary->weekEnd = $weekEnd->format('Y-m-d'); $summary->days = $days; $summary->rows = $this->buildRows($employees, $workHours, $absences, $days, $anchorDate->format('Y-m-d')); return $summary; } private function resolveAnchorDate(): DateTimeImmutable { $query = $this->requestStack->getCurrentRequest()?->query; $raw = (string) ($query?->get('weekStart') ?? ''); // Sans paramètre, on ancre la semaine sur aujourd'hui. if ('' === $raw) { return new DateTimeImmutable('today'); } $date = DateTimeImmutable::createFromFormat('Y-m-d', $raw); // Validation stricte du format attendu. if (!$date || $date->format('Y-m-d') !== $raw) { throw new UnprocessableEntityHttpException('weekStart must use Y-m-d format.'); } return $date; } /** * @return array{DateTimeImmutable, DateTimeImmutable, list} */ private function resolveWeek(DateTimeImmutable $anchorDate): array { // Convention ISO: semaine de lundi (1) à dimanche (7). $dayOfWeek = (int) $anchorDate->format('N'); $weekStart = $anchorDate->modify(sprintf('-%d days', $dayOfWeek - 1)); $weekEnd = $weekStart->modify('+6 days'); $days = []; for ($i = 0; $i < 7; ++$i) { $days[] = $weekStart->modify(sprintf('+%d days', $i))->format('Y-m-d'); } return [$weekStart, $weekEnd, $days]; } /** * @param list $employees * @param list $workHours * @param list $absences * @param list $days * * @return list */ private function buildRows(array $employees, array $workHours, array $absences, array $days, string $anchorDateYmd): array { $contractsByEmployeeDate = $this->contractResolver->resolveForEmployeesAndDays($employees, $days); $contractNaturesByEmployeeDate = $this->contractResolver->resolveNaturesForEmployeesAndDays($employees, $days); $isDriverByEmployeeDate = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days); $metricsByEmployeeDate = []; foreach ($workHours as $workHour) { $employeeId = $workHour->getEmployee()?->getId(); if (!$employeeId) { continue; } // Pré-calcul des métriques par salarié/date pour simplifier l'agrégation finale. $dateKey = $workHour->getWorkDate()->format('Y-m-d'); $metricsByEmployeeDate[$employeeId][$dateKey] = [ 'metrics' => $this->computeMetrics($workHour), 'isPresentMorning' => $workHour->getIsPresentMorning(), 'isPresentAfternoon' => $workHour->getIsPresentAfternoon(), 'dayHoursMinutes' => $workHour->getDayHoursMinutes(), 'nightHoursMinutes' => $workHour->getNightHoursMinutes(), 'workshopHoursMinutes' => $workHour->getWorkshopHoursMinutes(), 'hasBreakfast' => $workHour->getHasBreakfast(), 'hasLunch' => $workHour->getHasLunch(), 'hasDinner' => $workHour->getHasDinner(), 'hasOvernight' => $workHour->getHasOvernight(), ]; } $creditedByEmployeeDate = []; $creditedPresenceByEmployeeDate = []; $absenceByEmployeeDate = []; $absentMorningByEmployeeDate = []; $absentAfternoonByEmployeeDate = []; $absenceLabelByEmployeeDate = []; $absenceColorByEmployeeDate = []; foreach ($absences as $absence) { $employeeId = $absence->getEmployee()?->getId(); if (!$employeeId) { continue; } $start = $absence->getStartDate()->format('Y-m-d'); $end = $absence->getEndDate()->format('Y-m-d'); foreach ($days as $date) { // On ne crédite que les dates couvertes par l'intervalle d'absence. if ($date < $start || $date > $end) { continue; } [$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date); if ($absentMorning || $absentAfternoon) { $absenceByEmployeeDate[$employeeId][$date] = true; $absentMorningByEmployeeDate[$employeeId][$date] = ($absentMorningByEmployeeDate[$employeeId][$date] ?? false) || $absentMorning; $absentAfternoonByEmployeeDate[$employeeId][$date] = ($absentAfternoonByEmployeeDate[$employeeId][$date] ?? false) || $absentAfternoon; if (!isset($absenceLabelByEmployeeDate[$employeeId][$date])) { $absenceLabelByEmployeeDate[$employeeId][$date] = $absence->getType()?->getLabel(); } if (!isset($absenceColorByEmployeeDate[$employeeId][$date])) { $absenceColorByEmployeeDate[$employeeId][$date] = $absence->getType()?->getColor(); } } $creditedByEmployeeDate[$employeeId][$date] = ($creditedByEmployeeDate[$employeeId][$date] ?? 0) + $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $absentMorning, $absentAfternoon); $creditedPresenceByEmployeeDate[$employeeId][$date] = ($creditedPresenceByEmployeeDate[$employeeId][$date] ?? 0.0) + $this->workedHoursCreditPolicy->computeCreditedPresenceUnits($absence, $date, $absentMorning, $absentAfternoon); } } $rows = []; foreach ($employees as $employee) { $employeeId = $employee->getId(); if (!$employeeId) { continue; } $weeklyDayMinutes = 0; $weeklyNightMinutes = 0; $weeklyWorkshopMinutes = 0; $weeklyTotalMinutes = 0; $weeklyPresenceCount = 0.0; $weeklyNightBasketCount = 0; $weeklyBreakfastCount = 0; $weeklyLunchCount = 0; $weeklyDinnerCount = 0; $weeklyOvernightCount = 0; $daily = []; // Les contrats au suivi "présence" ne manipulent pas les heures, mais des demi-journées. $weekAnchorContract = $contractsByEmployeeDate[$employeeId][$anchorDateYmd] ?? $contractsByEmployeeDate[$employeeId][$days[0]] ?? null; $isDriver = $isDriverByEmployeeDate[$employeeId][$anchorDateYmd] ?? $isDriverByEmployeeDate[$employeeId][$days[0]] ?? false; $weekAnchorContractNature = $contractNaturesByEmployeeDate[$employeeId][$anchorDateYmd] ?? $contractNaturesByEmployeeDate[$employeeId][$days[0]] ?? ContractNature::CDI; $employeeContractsByDate = []; $hasContractForWeek = false; foreach ($days as $date) { $employeeContractsByDate[$date] = $contractsByEmployeeDate[$employeeId][$date] ?? null; if (null !== $employeeContractsByDate[$date]) { $hasContractForWeek = true; } } foreach ($days as $date) { $entry = $metricsByEmployeeDate[$employeeId][$date] ?? null; $creditedMinutes = $creditedByEmployeeDate[$employeeId][$date] ?? 0; $contractAtDate = $employeeContractsByDate[$date] ?? null; $isPresenceTracking = TrackingMode::PRESENCE->value === $contractAtDate?->getTrackingMode(); $isDateDriver = $isDriverByEmployeeDate[$employeeId][$date] ?? false; $hasBreakfast = false; $hasLunch = false; $hasDinner = false; $hasOvernight = false; if ($isDateDriver) { $dayMinutes = ($entry['dayHoursMinutes'] ?? 0); $nightMinutes = ($entry['nightHoursMinutes'] ?? 0); $workshopMinutes = ($entry['workshopHoursMinutes'] ?? 0); $totalMinutes = $dayMinutes + $nightMinutes + $workshopMinutes + $creditedMinutes; $dayMinutes += $creditedMinutes; $hasBreakfast = $entry['hasBreakfast'] ?? false; $hasLunch = $entry['hasLunch'] ?? false; $hasDinner = $entry['hasDinner'] ?? false; $hasOvernight = $entry['hasOvernight'] ?? false; if ($hasBreakfast) { ++$weeklyBreakfastCount; } if ($hasLunch) { ++$weeklyLunchCount; } if ($hasDinner) { ++$weeklyDinnerCount; } if ($hasOvernight) { ++$weeklyOvernightCount; } } else { $metrics = $entry['metrics'] ?? new WorkMetrics(); // Les absences "comptées comme travaillées" alimentent le total du jour. $metrics->addCreditedMinutes($creditedMinutes); $dayMinutes = $metrics->dayMinutes; $nightMinutes = $metrics->nightMinutes; $workshopMinutes = 0; $totalMinutes = $metrics->totalMinutes; } $present = null; if ($isPresenceTracking && !$isDateDriver) { $absentMorning = $absentMorningByEmployeeDate[$employeeId][$date] ?? false; $absentAfternoon = $absentAfternoonByEmployeeDate[$employeeId][$date] ?? false; $morning = (($entry['isPresentMorning'] ?? false) && !$absentMorning) ? 0.5 : 0.0; $afternoon = (($entry['isPresentAfternoon'] ?? false) && !$absentAfternoon) ? 0.5 : 0.0; $creditedPresence = $creditedPresenceByEmployeeDate[$employeeId][$date] ?? 0.0; $present = min(1.0, $morning + $afternoon + $creditedPresence); } $hasNightBasket = ($nightMinutes > $dayMinutes && $nightMinutes > 0) || $nightMinutes >= 240; if ($hasNightBasket) { ++$weeklyNightBasketCount; } $weeklyDayMinutes += $dayMinutes; $weeklyNightMinutes += $nightMinutes; $weeklyWorkshopMinutes += $workshopMinutes; $weeklyTotalMinutes += $totalMinutes; if (null !== $present) { $weeklyPresenceCount += $present; } $daily[] = new WeeklyDaySummary( date: $date, dayMinutes: $dayMinutes, nightMinutes: $nightMinutes, workshopMinutes: $workshopMinutes, totalMinutes: $totalMinutes, present: $present, hasAbsence: $absenceByEmployeeDate[$employeeId][$date] ?? false, absenceLabel: $absenceLabelByEmployeeDate[$employeeId][$date] ?? null, absenceColor: $absenceColorByEmployeeDate[$employeeId][$date] ?? null, hasNightBasket: $hasNightBasket, hasBreakfast: $hasBreakfast, hasLunch: $hasLunch, hasDinner: $hasDinner, hasOvernight: $hasOvernight, ); } $isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode(); $disableOvertimeBonuses = $isDriver || $this->hasDisabledOvertimeBonuses($weekAnchorContract, $weekAnchorContractNature); $overtimeReferenceMinutes = $this->computeWeeklyOvertimeReferenceMinutes($days, $employeeContractsByDate); $overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($days, $employeeContractsByDate); $weeklyOvertimeTotalMinutes = ($isWeekPresenceTracking || $isDriver) ? 0 : max(0, $weeklyTotalMinutes - $overtimeReferenceMinutes); $weeklyOvertime25Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : $this->computeOvertime25BonusMinutes($weeklyTotalMinutes, $overtime25StartMinutes); $weeklyOvertime50Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : $this->computeOvertime50BonusMinutes($weeklyTotalMinutes); $weeklyRecoveryMinutes = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : $weeklyOvertimeTotalMinutes + $weeklyOvertime25Minutes + $weeklyOvertime50Minutes; $rows[] = new WeeklySummaryRow( employeeId: $employeeId, firstName: $employee->getFirstName(), lastName: $employee->getLastName(), siteName: $employee->getSite()?->getName(), contractName: $weekAnchorContract?->getName(), contractType: $weekAnchorContract?->getType()->value, trackingMode: $weekAnchorContract?->getTrackingMode(), daily: $daily, weeklyDayMinutes: $weeklyDayMinutes, weeklyNightMinutes: $weeklyNightMinutes, weeklyWorkshopMinutes: $weeklyWorkshopMinutes, weeklyTotalMinutes: $weeklyTotalMinutes, weeklyPresenceCount: $weeklyPresenceCount, weeklyOvertimeTotalMinutes: $weeklyOvertimeTotalMinutes, weeklyOvertime25Minutes: $weeklyOvertime25Minutes, weeklyOvertime50Minutes: $weeklyOvertime50Minutes, weeklyRecoveryMinutes: $weeklyRecoveryMinutes, weeklyNightBasketCount: $weeklyNightBasketCount, isDriver: $isDriver, weeklyBreakfastCount: $weeklyBreakfastCount, weeklyLunchCount: $weeklyLunchCount, weeklyDinnerCount: $weeklyDinnerCount, weeklyOvernightCount: $weeklyOvernightCount, hasContractForWeek: $hasContractForWeek, ); } return $rows; } 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; } // Si fin <= début, on considère un passage à minuit. $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; // Fenêtres de nuit: 00:00-06:00 et 21:00-24:00. $windows = [[0, 360], [1260, 1440]]; $total = 0; // On projette aussi sur J+1 pour couvrir les shifts qui traversent minuit. 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); } /** * @param array $contractsByDate */ private function computeWeeklyOvertimeReferenceMinutes(array $days, array $contractsByDate): int { $total = 0; foreach ($days as $date) { $isoDay = (int) new DateTimeImmutable($date)->format('N'); $contract = $contractsByDate[$date] ?? null; $hours = $contract?->getWeeklyHours(); $referenceHours = (null !== $hours && $hours > 0) ? max(35, $hours) : null; $total += $this->resolveDailyReferenceMinutes($referenceHours, $isoDay); } return $total; } /** * @param array $contractsByDate */ private function computeWeeklyOvertime25StartMinutes(array $days, array $contractsByDate): int { $total = 0; foreach ($days as $date) { $isoDay = (int) new DateTimeImmutable($date)->format('N'); $contract = $contractsByDate[$date] ?? null; $hours = $contract?->getWeeklyHours(); $startHours = (null !== $hours && $hours >= 39) ? 39 : 35; $total += $this->resolveDailyReferenceMinutes($startHours, $isoDay); } return $total; } private function computeOvertime25BonusMinutes(int $weeklyTotalMinutes, int $startMinutes): int { $trancheMinutes = max(0, min($weeklyTotalMinutes, 43 * 60) - $startMinutes); return (int) round($trancheMinutes * 0.25); } private function computeOvertime50BonusMinutes(int $weeklyTotalMinutes): int { // Bonus 50% appliqué au-delà de 43h. $trancheMinutes = max(0, $weeklyTotalMinutes - (43 * 60)); return (int) round($trancheMinutes * 0.5); } private function hasDisabledOvertimeBonuses(?Contract $contract, ContractNature $contractNature): bool { if (ContractNature::INTERIM === $contractNature) { return true; } $type = ContractType::resolve( $contract?->getName(), $contract?->getTrackingMode(), $contract?->getWeeklyHours() ); return ContractType::INTERIM === $type; } private function resolveDailyReferenceMinutes(?int $weeklyHours, int $isoWeekDay): int { // Week-end hors base de référence. if ($isoWeekDay >= 6) { return 0; } if (null === $weeklyHours || $weeklyHours <= 0) { return 0; } if (39 === $weeklyHours) { return 5 === $isoWeekDay ? 7 * 60 : 8 * 60; } if (35 === $weeklyHours) { return 7 * 60; } return (int) round(($weeklyHours * 60) / 5); } }