Files
SIRH/src/Service/Rtt/RttClosingBalanceService.php
T
tristan ac8a36eb4f
Auto Tag Develop / tag (push) Successful in 7s
[#SIRH-34] fix RTT bascule ne fonctionne pas (#22)
La bascule app:rtt:rollover ne reprenait que les RTT acquis de l'exercice qui
se terminait : le report d'ouverture déjà présent était perdu et les paiements
n'étaient pas déduits. Le nouveau report reprend le solde de clôture =
report d'ouverture(N-1) + acquis(N-1) − RTT payés(N-1), soit le "Disponible"
affiché par EmployeeRttSummaryProvider.

- nouveau RttClosingBalanceService (fold pur testé : invariant somme tranches =
  disponible, cascade déficit 50% avant 25%, récup CUSTOM non perdue)
- RttRolloverCommand branché dessus + option --recompute (écrase les lignes
  existantes non verrouillées, pour reprise d'une bascule erronée)
- test date-sensible EmployeeRttSummaryProviderTest rendu robuste
- docs: doc/rtt-rollover.md, CLAUDE.md, documentation-content.ts

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #22
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-08 08:56:19 +00:00

165 lines
6.3 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Service\Rtt;
use App\Dto\Rtt\WeekRecoveryDetail;
use App\Entity\Employee;
use App\Repository\EmployeeRttBalanceRepository;
use App\Repository\EmployeeRttPaymentRepository;
use App\State\EmployeeRttSummaryProvider;
/**
* Computes the closing RTT balance of an exercise — the amount that must become the
* opening report of the next exercise.
*
* Closing = opening report (N) + net earned (N) RTT paid (N).
*
* This mirrors the "disponible" exposed by {@see EmployeeRttSummaryProvider}
* (carry + currentYearRecovery totalPaid), so the report carried to N+1 always equals
* the balance the RTT tab displayed for N. The previous rollover only took the earned
* minutes and dropped both the incoming report and the payments.
*/
final readonly class RttClosingBalanceService
{
public function __construct(
private RttRecoveryComputationService $recoveryService,
private EmployeeRttBalanceRepository $balanceRepository,
private EmployeeRttPaymentRepository $paymentRepository,
) {}
public function computeClosingBalance(Employee $employee, int $exerciseYear): WeekRecoveryDetail
{
[$from, $to] = $this->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<WeekRecoveryDetail> $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];
}
}