From 8a0eb40616ed4194e4f4ef881848651f230bf39c Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 9 Jun 2026 10:07:55 +0200 Subject: [PATCH] feat(rtt) : skip 25/50 deficit cascade for flat (custom) recovery weeks --- src/State/EmployeeRttSummaryProvider.php | 87 ++++++++++++------- .../State/EmployeeRttSummaryProviderTest.php | 75 +++++++++++++--- 2 files changed, 121 insertions(+), 41 deletions(-) diff --git a/src/State/EmployeeRttSummaryProvider.php b/src/State/EmployeeRttSummaryProvider.php index 33dc033..98aa0b6 100644 --- a/src/State/EmployeeRttSummaryProvider.php +++ b/src/State/EmployeeRttSummaryProvider.php @@ -142,36 +142,13 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface $summary->rttStartDate = $this->rttStartDate; $summary->weeks = $this->buildWeekSummaries($weekRanges, $currentByWeekStart, $periodFrom, $periodTo); - // 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, - ); - } - } + // Post-process: distribute deficit weeks across cumulative balance (50% first, then 25%). + // Flat-recovery (CUSTOM) weeks are skipped — their deficit only reduces the running cumul. + $summary->weeks = $this->applyDeficitCascade( + $summary->weeks, + $carry->base25Minutes + $carry->bonus25Minutes, + $carry->base50Minutes + $carry->bonus50Minutes, + ); $payments = $this->rttPaymentRepository->findByEmployeeAndYear($employee, $year); $monthBuckets = []; @@ -356,6 +333,54 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface return $weekEnd; } + /** + * Distribue les semaines déficitaires sur les tranches 25/50 accumulées (50 % d'abord, + * puis 25 %), en réécrivant les buckets affichés de chaque semaine déficitaire avec les + * montants négatifs drainés. + * + * Les semaines à récupération plate (CUSTOM 1h = 1h) sont ignorées : elles n'ont pas de + * tranches 25/50, donc leur déficit ne réduit que le cumul courant (calculé ensuite à + * partir de totalMinutes) et les colonnes 25/50 restent à 0. + * + * @param list $weeks + * + * @return list + */ + private function applyDeficitCascade(array $weeks, int $cumulative25, int $cumulative50): array + { + foreach ($weeks as $i => $week) { + if ($week->totalMinutes >= 0 || $week->isFlatRecovery) { + $cumulative50 += $week->base50Minutes + $week->bonus50Minutes; + $cumulative25 += $week->base25Minutes + $week->bonus25Minutes; + + continue; + } + + $deficit = -$week->totalMinutes; + $from50 = min($deficit, max(0, $cumulative50)); + $from25 = $deficit - $from50; + + $cumulative50 -= $from50; + $cumulative25 -= $from25; + + $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, + isFlatRecovery: $week->isFlatRecovery, + ); + } + + return $weeks; + } + /** * Build week summaries, splitting weeks that span two months into two entries * with values distributed proportionally based on daily worked minutes. @@ -393,6 +418,7 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface base50Minutes: $detail->base50Minutes, bonus50Minutes: $detail->bonus50Minutes, totalMinutes: $detail->totalMinutes, + isFlatRecovery: $detail->isFlatRecovery, ); continue; @@ -433,6 +459,7 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface base50Minutes: (int) round($detail->base50Minutes * $ratio), bonus50Minutes: (int) round($detail->bonus50Minutes * $ratio), totalMinutes: (int) round($detail->totalMinutes * $ratio), + isFlatRecovery: $detail->isFlatRecovery, ); } } diff --git a/tests/State/EmployeeRttSummaryProviderTest.php b/tests/State/EmployeeRttSummaryProviderTest.php index 3e4ba0b..ff3580a 100644 --- a/tests/State/EmployeeRttSummaryProviderTest.php +++ b/tests/State/EmployeeRttSummaryProviderTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Tests\State; use App\Dto\Contracts\ContractPhase; +use App\Dto\Rtt\EmployeeRttWeekSummary; use App\Entity\Contract; use App\Entity\Employee; use App\Entity\EmployeeContractPeriod; @@ -201,6 +202,54 @@ final class EmployeeRttSummaryProviderTest extends TestCase self::assertSame(2030, $year); } + /** + * Build an uninitialized provider with a RequestStack pre-loaded with the given query. + * + * The provider's repository/service dependencies are typed against final classes + * (EmployeeRepository, RttRecoveryComputationService, etc.) which PHPUnit cannot + * double. We bypass full instantiation by using newInstanceWithoutConstructor and + * only setting the properties that the tested private methods actually read: + * `requestStack` and `phaseResolver`. + */ + public function testFlatDeficitWeekIsNotDrainedFromTiers(): void + { + $provider = $this->buildProvider([]); + + // Semaine CUSTOM déficitaire (-120), aucune tranche accumulée. + $weeks = [$this->weekSummary(-120, true)]; + $result = $this->invokePrivate($provider, 'applyDeficitCascade', $weeks, 0, 0); + + // Buckets restent à 0 ; le total négatif est conservé (le cumul est calculé ailleurs). + self::assertSame(0, $result[0]->base25Minutes); + self::assertSame(0, $result[0]->base50Minutes); + self::assertSame(-120, $result[0]->totalMinutes); + self::assertTrue($result[0]->isFlatRecovery); + } + + public function testStandardDeficitWeekDrainsFiftyThenTwentyFive(): void + { + $provider = $this->buildProvider([]); + + // Semaine 35h/39h déficitaire (-100), avec 60 en 50% et 120 en 25% accumulés. + $weeks = [$this->weekSummary(-100, false)]; + $result = $this->invokePrivate($provider, 'applyDeficitCascade', $weeks, 120, 60); + + self::assertSame(-60, $result[0]->base50Minutes); // 60 drainés du 50% + self::assertSame(-40, $result[0]->base25Minutes); // 40 restants drainés du 25% + self::assertSame(-100, $result[0]->totalMinutes); + } + + public function testFlatPositiveWeekIsUntouched(): void + { + $provider = $this->buildProvider([]); + + $weeks = [$this->weekSummary(180, true)]; + $result = $this->invokePrivate($provider, 'applyDeficitCascade', $weeks, 0, 0); + + self::assertSame(180, $result[0]->totalMinutes); + self::assertSame(0, $result[0]->base25Minutes); + } + // ----------------------------------------------------------------------- // Test harness helpers. // ----------------------------------------------------------------------- @@ -247,17 +296,21 @@ final class EmployeeRttSummaryProviderTest extends TestCase return $employee; } - /** - * Build an uninitialized provider with a RequestStack pre-loaded with the given query. - * - * The provider's repository/service dependencies are typed against final classes - * (EmployeeRepository, RttRecoveryComputationService, etc.) which PHPUnit cannot - * double. We bypass full instantiation by using newInstanceWithoutConstructor and - * only setting the properties that the tested private methods actually read: - * `requestStack` and `phaseResolver`. - * - * @param array $request query parameters (year, phaseId, ...) - */ + private function weekSummary(int $totalMinutes, bool $isFlat, int $base25 = 0, int $base50 = 0): EmployeeRttWeekSummary + { + return new EmployeeRttWeekSummary( + month: 6, + weekNumber: 1, + weekStart: '2026-06-01', + weekEnd: '2026-06-07', + overtimeMinutes: $totalMinutes, + base25Minutes: $base25, + base50Minutes: $base50, + totalMinutes: $totalMinutes, + isFlatRecovery: $isFlat, + ); + } + private function buildProvider(array $request = []): EmployeeRttSummaryProvider { $requestStack = new RequestStack();