recoveryService->resolveExerciseBounds($exerciseYear); $weeks = $this->recoveryService->buildWeeksForExercise($from, $to); $weekRanges = array_map( static fn (array $week): array => [ 'weekNumber' => (int) $week['weekNumber'], 'start' => $week['start'], 'end' => $week['end'], ], $weeks ); // The exercise is fully closed at rollover time, so count every week up to its end. $byWeek = $this->recoveryService->computeRecoveryByWeek($employee, $weekRanges, $from, $to, $to); $orderedDetails = []; foreach ($weekRanges as $week) { $key = $week['start']->format('Y-m-d'); $orderedDetails[] = $byWeek[$key] ?? new WeekRecoveryDetail(); } $opening = $this->resolveOpeningReport($employee, $exerciseYear); $payments = $this->sumPayments($employee, $exerciseYear); return $this->fold($opening, $orderedDetails, $payments); } /** * Pure accumulation of the closing balance per bucket. * * Guarantees `sum(buckets) === opening.total + Σ week.total − payments.total`, * i.e. the carried report matches the displayed disponible regardless of how the * deficit cascade or the custom-recovery remainder is distributed across buckets. * * @param list $weeks chronological order */ public function fold(WeekRecoveryDetail $opening, array $weeks, WeekRecoveryDetail $payments): WeekRecoveryDetail { $b25 = $opening->base25Minutes; $bo25 = $opening->bonus25Minutes; $b50 = $opening->base50Minutes; $bo50 = $opening->bonus50Minutes; foreach ($weeks as $week) { if ($week->totalMinutes >= 0) { $b25 += $week->base25Minutes; $bo25 += $week->bonus25Minutes; $b50 += $week->base50Minutes; $bo50 += $week->bonus50Minutes; // Recovery not attributed to any 25/50 bucket (CUSTOM 1h=1h, rounding): // park it in the plain 25%-base bucket so the bucket sum keeps the total. $remainder = $week->totalMinutes - ($week->base25Minutes + $week->bonus25Minutes + $week->base50Minutes + $week->bonus50Minutes); $b25 += $remainder; continue; } // Deficit week: drain the 50%-tier before the 25%-tier (mirrors // EmployeeRttSummaryProvider's cumulative cascade). $deficit = -$week->totalMinutes; [$b50, $deficit] = $this->consume($b50, $deficit); [$bo50, $deficit] = $this->consume($bo50, $deficit); [$b25, $deficit] = $this->consume($b25, $deficit); $bo25 -= $deficit; // leftover may push the balance negative, as on screen } $b25 -= $payments->base25Minutes; $bo25 -= $payments->bonus25Minutes; $b50 -= $payments->base50Minutes; $bo50 -= $payments->bonus50Minutes; return new WeekRecoveryDetail( base25Minutes: $b25, bonus25Minutes: $bo25, base50Minutes: $b50, bonus50Minutes: $bo50, totalMinutes: $b25 + $bo25 + $b50 + $bo50, ); } /** * The opening report of $year: the stored balance row when present, else the * dynamic fallback (earned in $year-1). Same resolution as * EmployeeRttSummaryProvider::resolveCarry. */ private function resolveOpeningReport(Employee $employee, int $year): WeekRecoveryDetail { $balance = $this->balanceRepository->findOneByEmployeeAndYear($employee, $year); if (null !== $balance) { return new WeekRecoveryDetail( base25Minutes: $balance->getOpeningBase25Minutes(), bonus25Minutes: $balance->getOpeningBonus25Minutes(), base50Minutes: $balance->getOpeningBase50Minutes(), bonus50Minutes: $balance->getOpeningBonus50Minutes(), totalMinutes: $balance->getTotalOpeningMinutes(), ); } return $this->recoveryService->computeTotalRecoveryForExercise($employee, $year - 1); } private function sumPayments(Employee $employee, int $year): WeekRecoveryDetail { $b25 = $bo25 = $b50 = $bo50 = 0; foreach ($this->paymentRepository->findByEmployeeAndYear($employee, $year) as $payment) { $b25 += $payment->getBase25Minutes(); $bo25 += $payment->getBonus25Minutes(); $b50 += $payment->getBase50Minutes(); $bo50 += $payment->getBonus50Minutes(); } return new WeekRecoveryDetail( base25Minutes: $b25, bonus25Minutes: $bo25, base50Minutes: $b50, bonus50Minutes: $bo50, totalMinutes: $b25 + $bo25 + $b50 + $bo50, ); } /** * @return array{int, int} [remaining bucket, remaining deficit] */ private function consume(int $bucket, int $deficit): array { $take = min($deficit, max(0, $bucket)); return [$bucket - $take, $deficit - $take]; } }