rttStartDate = '' !== $rttStartDate ? new DateTimeImmutable($rttStartDate) : null; } /** * @return array{DateTimeImmutable, DateTimeImmutable} */ public function resolveExerciseBounds(int $exerciseYear): array { return [ new DateTimeImmutable(sprintf('%d-06-01', $exerciseYear - 1)), new DateTimeImmutable(sprintf('%d-05-31', $exerciseYear)), ]; } /** * @return list */ public function buildWeeksForExercise(DateTimeImmutable $from, DateTimeImmutable $to): array { $dayOfWeek = (int) $from->format('N'); $weekStart = $from->modify(sprintf('-%d days', $dayOfWeek - 1)); $weeks = []; while ($weekStart <= $to) { $start = $weekStart; $end = $start->modify('+6 days'); $effectiveStart = $start < $from ? $from : $start; $effectiveEnd = $end > $to ? $to : $end; if ($effectiveEnd >= $effectiveStart) { $weeks[] = [ 'weekNumber' => (int) $effectiveStart->format('W'), 'start' => $start, 'end' => $end, ]; } $weekStart = $weekStart->modify('+7 days'); } return $weeks; } public function computeTotalRecoveryForExercise(Employee $employee, int $exerciseYear, ?DateTimeImmutable $limitDate = null): WeekRecoveryDetail { [$from, $to] = $this->resolveExerciseBounds($exerciseYear); $weeks = $this->buildWeeksForExercise($from, $to); $weekRanges = array_map( static fn (array $week): array => [ 'weekNumber' => (int) $week['weekNumber'], 'start' => $week['start'], 'end' => $week['end'], ], $weeks ); $byWeek = $this->computeRecoveryByWeek($employee, $weekRanges, $from, $to, $limitDate); $total = new WeekRecoveryDetail(); foreach ($byWeek as $detail) { $total = new WeekRecoveryDetail( overtimeMinutes: $total->overtimeMinutes + $detail->overtimeMinutes, base25Minutes: $total->base25Minutes + $detail->base25Minutes, bonus25Minutes: $total->bonus25Minutes + $detail->bonus25Minutes, base50Minutes: $total->base50Minutes + $detail->base50Minutes, bonus50Minutes: $total->bonus50Minutes + $detail->bonus50Minutes, totalMinutes: $total->totalMinutes + $detail->totalMinutes, ); } return $total; } /** * @param list $weeks * * @return array */ public function computeRecoveryByWeek( Employee $employee, array $weeks, DateTimeImmutable $periodFrom, DateTimeImmutable $periodTo, ?DateTimeImmutable $limitDate ): array { if ([] === $weeks) { return []; } $days = []; for ($cursor = $periodFrom; $cursor <= $periodTo; $cursor = $cursor->modify('+1 day')) { $days[] = $cursor->format('Y-m-d'); } $contractsByDate = $this->contractResolver->resolveForEmployeesAndDays([$employee], $days); $naturesByDate = $this->contractResolver->resolveNaturesForEmployeesAndDays([$employee], $days); $workDaysByDate = $this->contractResolver->resolveWorkDaysMinutesForEmployeesAndDays([$employee], $days); $employeeId = (int) $employee->getId(); $workHours = $this->workHourRepository->findByDateRangeAndEmployees($periodFrom, $periodTo, [$employee]); $absences = $this->absenceRepository->findForPrint($periodFrom, $periodTo, [$employee]); $metricsByDate = []; foreach ($workHours as $workHour) { $dateKey = $workHour->getWorkDate()->format('Y-m-d'); $metricsByDate[$dateKey] = $this->computeMetrics($workHour); } $creditedByDate = []; $hasAbsenceByDate = []; foreach ($absences as $absence) { $start = $absence->getStartDate()->format('Y-m-d'); $end = $absence->getEndDate()->format('Y-m-d'); for ($cursor = $periodFrom; $cursor <= $periodTo; $cursor = $cursor->modify('+1 day')) { $date = $cursor->format('Y-m-d'); if ($date < $start || $date > $end) { continue; } [$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date); if ($absentMorning || $absentAfternoon) { $hasAbsenceByDate[$date] = true; } $creditedByDate[$date] = ($creditedByDate[$date] ?? 0) + $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $absentMorning, $absentAfternoon); } } $results = []; $solidarityDates = $this->resolveSolidarityDatesInRange($periodFrom, $periodTo); foreach ($weeks as $week) { $weekStart = $week['start']; $weekEnd = $week['end']; $weekKey = $weekStart->format('Y-m-d'); $effectiveStart = $weekStart < $periodFrom ? $periodFrom : $weekStart; $effectiveEnd = $weekEnd > $periodTo ? $periodTo : $weekEnd; if ($effectiveEnd < $effectiveStart) { $results[$weekKey] = new WeekRecoveryDetail(); continue; } if ($limitDate instanceof DateTimeImmutable && $effectiveStart > $limitDate) { $results[$weekKey] = new WeekRecoveryDetail(); continue; } if ($this->rttStartDate instanceof DateTimeImmutable && $effectiveEnd < $this->rttStartDate) { $results[$weekKey] = new WeekRecoveryDetail(); continue; } $weekDays = []; for ($cursor = $effectiveStart; $cursor <= $effectiveEnd; $cursor = $cursor->modify('+1 day')) { $weekDays[] = $cursor->format('Y-m-d'); } $weeklyTotalMinutes = 0; $dailyWorkedMinutes = []; $employeeContractsByDate = []; foreach ($weekDays as $date) { $contractAtDate = $contractsByDate[$employeeId][$date] ?? null; $employeeContractsByDate[$date] = $contractAtDate; if ($limitDate instanceof DateTimeImmutable && new DateTimeImmutable($date) > $limitDate) { continue; } $metrics = $metricsByDate[$date] ?? new WorkMetrics(); $metrics->addCreditedMinutes($creditedByDate[$date] ?? 0); $effectiveMinutes = $this->holidayVirtualHoursResolver->resolveEffectiveDailyMinutes( $contractAtDate, new DateTimeImmutable($date), $metrics->totalMinutes, $hasAbsenceByDate[$date] ?? false, $workDaysByDate[$employeeId][$date] ?? null, ); $weeklyTotalMinutes += $effectiveMinutes; $dailyWorkedMinutes[$date] = $effectiveMinutes; } if ([] === $weekDays) { $results[$weekKey] = new WeekRecoveryDetail(); continue; } $weekAnchorDate = $this->resolveWeekAnchorDate($weekDays, $employeeContractsByDate); $weekAnchorNature = $naturesByDate[$employeeId][$weekAnchorDate] ?? ContractNature::CDI; $weekAnchorContract = $employeeContractsByDate[$weekAnchorDate] ?? null; $isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode(); $disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract, $weekAnchorNature); $weekContractType = ContractType::resolve( $weekAnchorContract?->getName(), $weekAnchorContract?->getTrackingMode(), $weekAnchorContract?->getWeeklyHours() ); $isCustomContract = ContractType::CUSTOM === $weekContractType; $overtimeReferenceMinutes = $isCustomContract ? $this->computeWeeklyCustomReferenceMinutes($weekDays, $employeeContractsByDate) : $this->computeWeeklyOvertimeReferenceMinutes($weekDays, $employeeContractsByDate); $overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($weekDays, $employeeContractsByDate); // Plafond séparant 25 %/50 % : seuil de départ proraté + largeur de la bande +25 % // (4h pour un 39h, 8h pour un 35h). Il se décale ainsi avec une embauche en milieu // de semaine au lieu de rester bloqué à 43h, ce qui ouvre la tranche 50 %. $overtime50StartMinutes = $overtime25StartMinutes + $this->resolveOvertime25BandWidthMinutes($weekAnchorContract); $weeklyOvertimeTotalMinutes = $isWeekPresenceTracking ? 0 : $weeklyTotalMinutes - $overtimeReferenceMinutes; foreach ($solidarityDates as $solidarityDate) { // isset ⇒ le jour de solidarité fait partie du sommage de CETTE semaine // (donc ≤ limitDate et ≥ rttStartDate). Sinon : jour futur ou hors service → pas de déficit. if (!isset($dailyWorkedMinutes[$solidarityDate])) { continue; } $contractAtSolidarity = $employeeContractsByDate[$solidarityDate] ?? null; // Le Lundi de Pentecôte est toujours un lundi (ISO 1), mais on le dérive pour rester explicite. $solidarityIsoDay = (int) new DateTimeImmutable($solidarityDate)->format('N'); // Attendu RÉEL du jour (planning workDaysHours), pas la répartition uniforme : // c'est ce qui rend la neutralisation correcte (cf. spec). $solidarityExpected = $this->dailyReferenceResolver->resolve( $contractAtSolidarity?->getWeeklyHours(), $solidarityIsoDay, $workDaysByDate[$employeeId][$solidarityDate] ?? null, ); $weeklyOvertimeTotalMinutes += $this->computeSolidarityDeficitAdjustment( $contractAtSolidarity, $solidarityExpected, $dailyWorkedMinutes[$solidarityDate], ); } [$rawBase25, $rawBase50] = $this->computeOvertimeBaseMinutes($weeklyTotalMinutes, $overtime25StartMinutes, $overtime50StartMinutes); $results[$weekKey] = $this->buildWeekRecoveryDetail( $isWeekPresenceTracking, $disableOvertimeBonuses, $isCustomContract, $weeklyOvertimeTotalMinutes, $rawBase25, $rawBase50, $dailyWorkedMinutes, ); } return $results; } /** * Assemble le détail de récupération d'une semaine à partir des drapeaux résolus et * des bandes d'heures sup brutes. * * - PRESENCE / INTERIM (bonus désactivés) : aucune récupération. * - CUSTOM : récupération plate 1h = 1h, sans tranches 25/50 ; l'heure sup signée EST * le total, donc une semaine travaillée sous les heures contractuelles produit un * total négatif (déficit qui réduit le solde). Marquée isFlatRecovery pour que le * provider ne draine pas les tranches 25/50. * - Standard 35h/39h : heures sup + bonus 25 %/50 %. * * @param array $dailyMinutes */ private function buildWeekRecoveryDetail( bool $isPresence, bool $disableBonuses, bool $isCustom, int $overtimeTotalMinutes, int $rawBase25, int $rawBase50, array $dailyMinutes, ): WeekRecoveryDetail { $noBands = $isPresence || $disableBonuses || $isCustom; $base25 = $noBands ? 0 : $rawBase25; $bonus25 = $noBands ? 0 : (int) round($base25 * 0.25); $base50 = $noBands ? 0 : $rawBase50; $bonus50 = $noBands ? 0 : (int) round($base50 * 0.5); if ($isPresence || $disableBonuses) { $totalMinutes = 0; } elseif ($isCustom) { $totalMinutes = $overtimeTotalMinutes; // signé : le déficit réduit le solde } else { $totalMinutes = $overtimeTotalMinutes + $bonus25 + $bonus50; } return new WeekRecoveryDetail( overtimeMinutes: $overtimeTotalMinutes, base25Minutes: $base25, bonus25Minutes: $bonus25, base50Minutes: $base50, bonus50Minutes: $bonus50, totalMinutes: $totalMinutes, dailyMinutes: $dailyMinutes, isFlatRecovery: $isCustom, ); } private function computeMetrics(WorkHour $workHour): WorkMetrics { $driverDay = $workHour->getDayHoursMinutes() ?? 0; $driverNight = $workHour->getNightHoursMinutes() ?? 0; $driverWorkshop = $workHour->getWorkshopHoursMinutes() ?? 0; if ($driverDay > 0 || $driverNight > 0 || $driverWorkshop > 0) { $totalMinutes = $driverDay + $driverNight + $driverWorkshop; return new WorkMetrics( dayMinutes: $driverDay + $driverWorkshop, nightMinutes: $driverNight, totalMinutes: $totalMinutes, ); } $ranges = [ [$workHour->getMorningFrom(), $workHour->getMorningTo()], [$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()], [$workHour->getEveningFrom(), $workHour->getEveningTo()], ]; $totalMinutes = 0; foreach ($ranges as [$from, $to]) { $totalMinutes += $this->intervalMinutes($from, $to); } $nightMinutes = $this->nightHoursCalculator->nightMinutesFromRanges($workHour); $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); } /** * @param list $days * @param array $contractsByDate */ private function computeWeeklyCustomReferenceMinutes(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(); $total += $this->resolveDailyReferenceMinutes($hours, $isoDay); } return $total; } /** * Date d'ancrage de la semaine pour résoudre le type/nature de contrat : premier jour * de la semaine couvert par un contrat. Évite qu'une semaine d'embauche en milieu de * semaine (premiers jours hors contrat) soit classée CUSTOM — ce qui désactiverait à * tort les bonus 25 %/50 % d'un contrat 35h/39h. Fallback sur le 1er jour si aucun jour * n'est contracté (semaine entièrement hors contrat → 0 de toute façon). * * @param list $weekDays * @param array $contractsByDate */ private function resolveWeekAnchorDate(array $weekDays, array $contractsByDate): string { foreach ($weekDays as $date) { if (null !== ($contractsByDate[$date] ?? null)) { return $date; } } return $weekDays[0]; } /** * Lundi(s) de Pentecôte (jour de solidarité) inclus dans [from, to]. Un exercice * Juin N-1 → Mai N couvre les années civiles N-1 et N ; on retient les dates dans la fenêtre. * * @return list dates au format 'Y-m-d' */ private function resolveSolidarityDatesInRange(DateTimeImmutable $from, DateTimeImmutable $to): array { $dates = []; $firstYear = (int) $from->format('Y'); $lastYear = (int) $to->format('Y'); for ($year = $firstYear; $year <= $lastYear; ++$year) { $candidate = $this->solidarityDayResolver->pentecostMonday($year); if ($candidate >= $from && $candidate <= $to) { $dates[] = $candidate->format('Y-m-d'); } } return $dates; } /** * Déficit forfaitaire du jour de solidarité pour les contrats CUSTOM < 35h. * * Le jour est neutralisé puis chargé du prorata légal : on remplace la valeur réelle * du jour ($workedMinutes : RTT posé, heures saisies, vide, ou crédit férié virtuel) * par l'attendu contractuel du jour ($expectedMinutes = workDaysHours), puis on * retranche le prorata = 7h/35h × heuresHebdo = 12 min par heure hebdo. Sur une * semaine par ailleurs normale, le net vaut exactement −prorata. Renvoie le delta à * ajouter à weeklyOvertimeTotalMinutes (0 hors périmètre : non-CUSTOM ou ≥ 35h). */ private function computeSolidarityDeficitAdjustment( ?Contract $contractAtSolidarity, int $expectedMinutes, int $workedMinutes, ): int { $weeklyHours = $contractAtSolidarity?->getWeeklyHours(); $type = ContractType::resolve( $contractAtSolidarity?->getName(), $contractAtSolidarity?->getTrackingMode(), $weeklyHours, ); if (ContractType::CUSTOM !== $type || null === $weeklyHours || $weeklyHours >= 35) { return 0; } $prorata = (int) round($weeklyHours * 12); return ($expectedMinutes - $workedMinutes) - $prorata; } /** * @param list $days * @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 list $days * @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(); // Days without an active contract (pre-hire, post-termination, contract // gaps) must NOT contribute to the weekly 25% overtime threshold — // otherwise hiring mid-week artificially inflates the threshold and // erases legitimate overtime. if (null === $hours || $hours <= 0) { continue; } $startHours = $hours >= 39 ? 39 : 35; $total += $this->resolveDailyReferenceMinutes($startHours, $isoDay); } return $total; } /** * Largeur (en minutes) de la tranche +25 % pour le contrat d'ancrage de la semaine : * 4h pour un 39h (39→43), 8h pour un 35h (35→43). Ajoutée au seuil de départ proraté * pour obtenir le plafond 25 %/50 %. */ private function resolveOvertime25BandWidthMinutes(?Contract $contract): int { $hours = $contract?->getWeeklyHours(); $startHours = (null !== $hours && $hours >= 39) ? 39 : 35; return (43 - $startHours) * 60; } /** * Répartit les heures supplémentaires hebdomadaires entre les bases 25 % et 50 %. * La tranche 25 % court du seuil de départ au plafond ; au-delà du plafond, c'est du 50 %. * * @return array{int, int} [base25Minutes, base50Minutes] */ private function computeOvertimeBaseMinutes(int $weeklyTotalMinutes, int $overtime25StartMinutes, int $overtime50StartMinutes): array { $base25 = max(0, min($weeklyTotalMinutes, $overtime50StartMinutes) - $overtime25StartMinutes); $base50 = max(0, $weeklyTotalMinutes - $overtime50StartMinutes); return [$base25, $base50]; } 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 { return $this->dailyReferenceResolver->resolve($weeklyHours, $isoWeekDay); } }