feat(rtt) : skip 25/50 deficit cascade for flat (custom) recovery weeks
This commit is contained in:
@@ -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<EmployeeRttWeekSummary> $weeks
|
||||
*
|
||||
* @return list<EmployeeRttWeekSummary>
|
||||
*/
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string> $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();
|
||||
|
||||
Reference in New Issue
Block a user