[#SIRH-34] fix RTT bascule ne fonctionne pas (#22)
Auto Tag Develop / tag (push) Successful in 7s

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>
This commit was merged in pull request #22.
This commit is contained in:
2026-06-08 08:56:19 +00:00
committed by Autin
parent 3bf48164d2
commit ac8a36eb4f
7 changed files with 348 additions and 23 deletions
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Rtt;
use App\Dto\Rtt\WeekRecoveryDetail;
use App\Service\Rtt\RttClosingBalanceService;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
/**
* The service constructor takes final-class collaborators (repositories,
* RttRecoveryComputationService) that PHPUnit cannot double. The fold logic is
* pure (no $this dependency), so it is exercised via newInstanceWithoutConstructor.
*
* Invariant under test: the bucket sum of the closing balance ALWAYS equals
* opening_report + net_earned - paid
* which is exactly the "disponible" the RTT tab shows for that exercise — so the
* report carried to the next exercise matches the displayed balance.
*
* @internal
*/
final class RttClosingBalanceServiceTest extends TestCase
{
public function testOpeningReportIsCarriedForwardOnTopOfEarned(): void
{
// Regression for the reported bug: the previous exercise's opening report
// (e.g. go-live import or unused carry) must be included, not dropped.
$opening = new WeekRecoveryDetail(base25Minutes: 600, totalMinutes: 600); // 10h report
$week = new WeekRecoveryDetail(base25Minutes: 240, bonus25Minutes: 60, totalMinutes: 300); // +5h earned
$closing = $this->service()->fold($opening, [$week], $this->payments());
// 10h report + 5h earned = 15h carried (NOT 5h).
self::assertSame(900, $closing->totalMinutes);
self::assertSame(600 + 240, $closing->base25Minutes);
self::assertSame(60, $closing->bonus25Minutes);
}
public function testPaymentsAreDeductedFromClosing(): void
{
$opening = new WeekRecoveryDetail(base25Minutes: 600, totalMinutes: 600);
$week = new WeekRecoveryDetail(base25Minutes: 240, bonus25Minutes: 60, totalMinutes: 300);
// 7h paid out of the 25% base bucket.
$closing = $this->service()->fold($opening, [$week], $this->payments(b25: 420));
self::assertSame(900 - 420, $closing->totalMinutes);
self::assertSame(600 + 240 - 420, $closing->base25Minutes);
}
public function testDeficitWeekConsumesFiftyTierBeforeTwentyFiveTier(): void
{
// Opening: 60min in 50%-base, 120min in 25%-base.
$opening = new WeekRecoveryDetail(base25Minutes: 120, base50Minutes: 60, totalMinutes: 180);
// Deficit week of 100min (worked less than reference): buckets 0, negative total.
$deficit = new WeekRecoveryDetail(totalMinutes: -100);
$closing = $this->service()->fold($opening, [$deficit], $this->payments());
// 50%-base absorbs 60 first, the remaining 40 hits the 25%-base.
self::assertSame(0, $closing->base50Minutes);
self::assertSame(80, $closing->base25Minutes);
self::assertSame(80, $closing->totalMinutes);
}
public function testCustomRecoveryWithoutBucketsStillCountsInTotal(): void
{
// CUSTOM contract: positive total recovery (1h=1h) but every 25/50 bucket is 0.
$custom = new WeekRecoveryDetail(totalMinutes: 180); // 3h plain recovery
$closing = $this->service()->fold(new WeekRecoveryDetail(), [$custom], $this->payments());
// The 3h must survive into the carried report (sum of buckets == total).
self::assertSame(180, $closing->totalMinutes);
self::assertSame(
180,
$closing->base25Minutes + $closing->bonus25Minutes + $closing->base50Minutes + $closing->bonus50Minutes,
);
}
public function testBucketSumAlwaysEqualsTotalInvariant(): void
{
$opening = new WeekRecoveryDetail(base25Minutes: 200, bonus25Minutes: 50, base50Minutes: 100, bonus50Minutes: 50, totalMinutes: 400);
$weeks = [
new WeekRecoveryDetail(base25Minutes: 240, bonus25Minutes: 60, totalMinutes: 300),
new WeekRecoveryDetail(totalMinutes: -500), // deeper deficit than tiers hold
new WeekRecoveryDetail(totalMinutes: 90), // custom-style recovery
];
$closing = $this->service()->fold($opening, $weeks, $this->payments(b25: 120, b50: 30));
$bucketSum = $closing->base25Minutes + $closing->bonus25Minutes + $closing->base50Minutes + $closing->bonus50Minutes;
self::assertSame($closing->totalMinutes, $bucketSum);
// opening 400 + earned (300 - 500 + 90 = -110) - paid 150 = 140
self::assertSame(140, $closing->totalMinutes);
}
private function service(): RttClosingBalanceService
{
return new ReflectionClass(RttClosingBalanceService::class)->newInstanceWithoutConstructor();
}
private function payments(int $b25 = 0, int $bo25 = 0, int $b50 = 0, int $bo50 = 0): WeekRecoveryDetail
{
return new WeekRecoveryDetail(
base25Minutes: $b25,
bonus25Minutes: $bo25,
base50Minutes: $b50,
bonus50Minutes: $bo50,
totalMinutes: $b25 + $bo25 + $b50 + $bo50,
);
}
}
@@ -156,8 +156,11 @@ final class EmployeeRttSummaryProviderTest extends TestCase
$provider = $this->buildProvider([]);
$year = $this->invokePrivate($provider, 'resolveYear', $currentPhase);
// Today is 2026-05-19 → current RTT exercise (Juin N-1 → Mai N) = 2026.
self::assertSame(2026, $year);
// No params → current RTT exercise (Juin N-1 → Mai N). Derive the expectation
// from today so the test is not pinned to a single calendar date.
$today = new DateTimeImmutable('today');
$expected = (int) $today->format('n') >= 6 ? (int) $today->format('Y') + 1 : (int) $today->format('Y');
self::assertSame($expected, $year);
}
public function testInvalidYearFormatReturns422(): void