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) { $saturday = $start->modify('+5 days'); $monthAnchor = $saturday < $from ? $from : ($saturday > $to ? $to : $saturday); $weeks[] = [ 'month' => (int) $monthAnchor->format('n'), '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 => [ 'month' => (int) $week['month'], '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); $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 = []; 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); $creditedByDate[$date] = ($creditedByDate[$date] ?? 0) + $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $absentMorning, $absentAfternoon); } } $results = []; 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; $employeeContractsByDate = []; foreach ($weekDays as $date) { $employeeContractsByDate[$date] = $contractsByDate[$employeeId][$date] ?? null; if ($limitDate instanceof DateTimeImmutable && new DateTimeImmutable($date) > $limitDate) { continue; } $metrics = $metricsByDate[$date] ?? new WorkMetrics(); $metrics->addCreditedMinutes($creditedByDate[$date] ?? 0); $weeklyTotalMinutes += $metrics->totalMinutes; } if ([] === $weekDays) { $results[$weekKey] = new WeekRecoveryDetail(); continue; } $weekAnchorNature = $naturesByDate[$employeeId][$weekDays[0]] ?? ContractNature::CDI; $weekAnchorContract = $employeeContractsByDate[$weekDays[0]] ?? null; $isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode(); $disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract, $weekAnchorNature); $overtimeReferenceMinutes = $this->computeWeeklyOvertimeReferenceMinutes($weekDays, $employeeContractsByDate); $overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($weekDays, $employeeContractsByDate); $weeklyOvertimeTotalMinutes = $isWeekPresenceTracking ? 0 : $weeklyTotalMinutes - $overtimeReferenceMinutes; $base25 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : max(0, min($weeklyTotalMinutes, 43 * 60) - $overtime25StartMinutes); $bonus25 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : (int) round($base25 * 0.25); $base50 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : max(0, $weeklyTotalMinutes - 43 * 60); $bonus50 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : (int) round($base50 * 0.5); $results[$weekKey] = new WeekRecoveryDetail( overtimeMinutes: $weeklyOvertimeTotalMinutes, base25Minutes: $base25, bonus25Minutes: $bonus25, base50Minutes: $base50, bonus50Minutes: $bonus50, totalMinutes: ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : $weeklyOvertimeTotalMinutes + $bonus25 + $bonus50, ); } return $results; } 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); } /** * @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(); $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 { $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 { if ($isoWeekDay >= 6 || 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); } }