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); $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(), ]; } $creditedByEmployeeDate = []; $creditedPresenceByEmployeeDate = []; $absenceByEmployeeDate = []; $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; 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; $weeklyTotalMinutes = 0; $weeklyPresenceCount = 0.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; $employeeContractsByDate = []; foreach ($days as $date) { $employeeContractsByDate[$date] = $contractsByEmployeeDate[$employeeId][$date] ?? null; } foreach ($days as $date) { $entry = $metricsByEmployeeDate[$employeeId][$date] ?? null; $metrics = $entry['metrics'] ?? new WorkMetrics(); $creditedMinutes = $creditedByEmployeeDate[$employeeId][$date] ?? 0; $contractAtDate = $employeeContractsByDate[$date] ?? null; $isPresenceTracking = TrackingMode::PRESENCE->value === $contractAtDate?->getTrackingMode(); // Les absences "comptées comme travaillées" alimentent le total du jour. $metrics->addCreditedMinutes($creditedMinutes); $present = null; if ($isPresenceTracking) { $morning = ($entry['isPresentMorning'] ?? false) ? 0.5 : 0.0; $afternoon = ($entry['isPresentAfternoon'] ?? false) ? 0.5 : 0.0; $creditedPresence = $creditedPresenceByEmployeeDate[$employeeId][$date] ?? 0.0; $present = min(1.0, $morning + $afternoon + $creditedPresence); } $weeklyDayMinutes += $metrics->dayMinutes; $weeklyNightMinutes += $metrics->nightMinutes; $weeklyTotalMinutes += $metrics->totalMinutes; if (null !== $present) { $weeklyPresenceCount += $present; } $daily[] = new WeeklyDaySummary( date: $date, dayMinutes: $metrics->dayMinutes, nightMinutes: $metrics->nightMinutes, totalMinutes: $metrics->totalMinutes, present: $present, hasAbsence: $absenceByEmployeeDate[$employeeId][$date] ?? false, absenceLabel: $absenceLabelByEmployeeDate[$employeeId][$date] ?? null, absenceColor: $absenceColorByEmployeeDate[$employeeId][$date] ?? null, ); } $isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode(); $disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract); $overtimeReferenceMinutes = $this->computeWeeklyOvertimeReferenceMinutes($days, $employeeContractsByDate); $overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($days, $employeeContractsByDate); $weeklyOvertimeTotalMinutes = $isWeekPresenceTracking ? 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, weeklyTotalMinutes: $weeklyTotalMinutes, weeklyPresenceCount: $weeklyPresenceCount, weeklyOvertimeTotalMinutes: $weeklyOvertimeTotalMinutes, weeklyOvertime25Minutes: $weeklyOvertime25Minutes, weeklyOvertime50Minutes: $weeklyOvertime50Minutes, weeklyRecoveryMinutes: $weeklyRecoveryMinutes ); } 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): bool { $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); } }