ac8a36eb4f
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>
116 lines
4.9 KiB
PHP
116 lines
4.9 KiB
PHP
<?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,
|
|
);
|
|
}
|
|
}
|