rttStartDate = '' !== $rttStartDate ? $rttStartDate : null; } public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmployeeRttSummary { $user = $this->security->getUser(); if (!$user instanceof User) { throw new AccessDeniedHttpException('Authentication required.'); } $employeeId = (int) ($uriVariables['id'] ?? 0); if ($employeeId <= 0) { throw new UnprocessableEntityHttpException('id must be a positive integer.'); } $employee = $this->employeeRepository->find($employeeId); if (!$employee instanceof Employee) { throw new NotFoundHttpException('Employee not found.'); } if (!$this->employeeScopeService->canAccessEmployee($user, $employee)) { throw new AccessDeniedHttpException('Employee outside your scope.'); } $year = $this->resolveYear(); $today = new DateTimeImmutable('today'); $currentExerciseYear = $this->resolveCurrentExerciseYear($today); [$periodFrom, $periodTo] = $this->rttRecoveryService->resolveExerciseBounds($year); $weeks = $this->rttRecoveryService->buildWeeksForExercise($periodFrom, $periodTo); $weekRanges = array_map( static fn (array $week): array => [ 'month' => (int) $week['month'], 'weekNumber' => (int) $week['weekNumber'], 'start' => $week['start'], 'end' => $week['end'], ], $weeks ); if ($year > $currentExerciseYear) { $limitDate = $periodFrom->modify('-1 day'); } else { // Exclude the current (incomplete) week: limit to last Sunday $isoDay = (int) $today->format('N'); // 1=Monday .. 7=Sunday $limitDate = 7 === $isoDay ? $today : $today->modify('last sunday'); // Include the current week if all existing days are admin-validated if (7 !== $isoDay) { $currentWeekStart = $today->modify('monday this week'); $currentWeekEnd = $currentWeekStart->modify('+6 days'); $checkEnd = $this->resolveWeekEndForEmployee($employee, $currentWeekStart, $currentWeekEnd, $today); if ($this->workHourRepository->isWeekFullyValidated($employee, $currentWeekStart, $checkEnd)) { $limitDate = $currentWeekEnd; } } } $currentByWeekStart = $this->rttRecoveryService->computeRecoveryByWeek($employee, $weekRanges, $periodFrom, $periodTo, $limitDate); [$carry, $carryMonth] = $this->resolveCarry($employee, $year); $summary = new EmployeeRttSummary(); $summary->year = $year; $summary->carryMonth = $carryMonth; $summary->carryFromPreviousYearMinutes = $carry->totalMinutes; $summary->carryBase25Minutes = $carry->base25Minutes; $summary->carryBonus25Minutes = $carry->bonus25Minutes; $summary->carryBase50Minutes = $carry->base50Minutes; $summary->carryBonus50Minutes = $carry->bonus50Minutes; $summary->currentYearRecoveryMinutes = array_sum(array_map(static fn ($d) => $d->totalMinutes, $currentByWeekStart)); $summary->availableMinutes = $summary->carryFromPreviousYearMinutes + $summary->currentYearRecoveryMinutes; // Pass rttStartDate only if it falls within this exercise if (null !== $this->rttStartDate) { $startDate = new DateTimeImmutable($this->rttStartDate); if ($startDate >= $periodFrom && $startDate <= $periodTo) { $summary->rttStartDate = $this->rttStartDate; } } $summary->weeks = array_map( static function (array $week) use ($currentByWeekStart) { $detail = $currentByWeekStart[$week['start']->format('Y-m-d')] ?? new WeekRecoveryDetail(); return new EmployeeRttWeekSummary( month: (int) $week['month'], weekNumber: (int) $week['weekNumber'], weekStart: $week['start']->format('Y-m-d'), weekEnd: $week['end']->format('Y-m-d'), overtimeMinutes: $detail->overtimeMinutes, base25Minutes: $detail->base25Minutes, bonus25Minutes: $detail->bonus25Minutes, base50Minutes: $detail->base50Minutes, bonus50Minutes: $detail->bonus50Minutes, totalMinutes: $detail->totalMinutes, ); }, $weekRanges ); // Post-process: distribute deficit weeks across cumulative balance (50% first, then 25%) $cumulative50 = $carry->base50Minutes + $carry->bonus50Minutes; $cumulative25 = $carry->base25Minutes + $carry->bonus25Minutes; foreach ($summary->weeks as $i => $week) { if ($week->totalMinutes >= 0) { $cumulative50 += $week->base50Minutes + $week->bonus50Minutes; $cumulative25 += $week->base25Minutes + $week->bonus25Minutes; } else { $deficit = -$week->totalMinutes; $from50 = min($deficit, max(0, $cumulative50)); $from25 = $deficit - $from50; $cumulative50 -= $from50; $cumulative25 -= $from25; $summary->weeks[$i] = new EmployeeRttWeekSummary( month: $week->month, weekNumber: $week->weekNumber, weekStart: $week->weekStart, weekEnd: $week->weekEnd, overtimeMinutes: $week->overtimeMinutes, base25Minutes: $from25 > 0 ? -$from25 : 0, bonus25Minutes: 0, base50Minutes: $from50 > 0 ? -$from50 : 0, bonus50Minutes: 0, totalMinutes: $week->totalMinutes, ); } } $payments = $this->rttPaymentRepository->findByEmployeeAndYear($employee, $year); $monthBuckets = []; foreach ($payments as $payment) { $m = $payment->getMonth(); if (!isset($monthBuckets[$m])) { $monthBuckets[$m] = ['base25' => 0, 'bonus25' => 0, 'base50' => 0, 'bonus50' => 0]; } $monthBuckets[$m]['base25'] += $payment->getBase25Minutes(); $monthBuckets[$m]['bonus25'] += $payment->getBonus25Minutes(); $monthBuckets[$m]['base50'] += $payment->getBase50Minutes(); $monthBuckets[$m]['bonus50'] += $payment->getBonus50Minutes(); } $monthPayments = []; $totalPaidMinutes = 0; foreach ($monthBuckets as $m => $bucket) { $monthPayments[] = new RttMonthPayment($m, $bucket['base25'], $bucket['bonus25'], $bucket['base50'], $bucket['bonus50']); $totalPaidMinutes += $bucket['base25'] + $bucket['bonus25'] + $bucket['base50'] + $bucket['bonus50']; } $summary->totalPaidMinutes = $totalPaidMinutes; $summary->monthPayments = $monthPayments; $summary->availableMinutes -= $totalPaidMinutes; return $summary; } /** * @return array{WeekRecoveryDetail, int} [carry, month] */ private function resolveCarry(Employee $employee, int $year): array { $balance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $year); if (null !== $balance) { return [ new WeekRecoveryDetail( base25Minutes: $balance->getOpeningBase25Minutes(), bonus25Minutes: $balance->getOpeningBonus25Minutes(), base50Minutes: $balance->getOpeningBase50Minutes(), bonus50Minutes: $balance->getOpeningBonus50Minutes(), totalMinutes: $balance->getTotalOpeningMinutes(), ), $balance->getMonth(), ]; } return [ $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $year - 1), 5, ]; } private function resolveYear(): int { $raw = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? ''); if ('' === $raw) { return $this->resolveCurrentExerciseYear(new DateTimeImmutable('today')); } if (!preg_match('/^\d{4}$/', $raw)) { throw new UnprocessableEntityHttpException('year must use YYYY format.'); } $year = (int) $raw; if ($year < 2000 || $year > 2100) { throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.'); } return $year; } private function resolveCurrentExerciseYear(DateTimeImmutable $today): int { $year = (int) $today->format('Y'); $month = (int) $today->format('n'); return $month >= 6 ? $year + 1 : $year; } /** * If the employee's contract ends within the current week, cap the check range to that end date. */ private function resolveWeekEndForEmployee(Employee $employee, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd, DateTimeImmutable $today): DateTimeImmutable { foreach ($employee->getContractPeriods() as $period) { if ($period->getStartDate() > $today) { continue; } $endDate = $period->getEndDate(); if (null === $endDate) { continue; } if ($endDate >= $weekStart && $endDate <= $weekEnd) { return $endDate; } } return $weekEnd; } }