[#SIRH-34] fix RTT bascule ne fonctionne pas (#22)
Auto Tag Develop / tag (push) Successful in 7s
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:
@@ -90,6 +90,14 @@
|
|||||||
- Sur un exercice passé, le bouton **+ Payer les RTT** est désactivé (pas de paiement rétroactif).
|
- Sur un exercice passé, le bouton **+ Payer les RTT** est désactivé (pas de paiement rétroactif).
|
||||||
- Doc : `doc/rtt-tab.md`.
|
- Doc : `doc/rtt-tab.md`.
|
||||||
|
|
||||||
|
## Rollover RTT (cron `app:rtt:rollover`)
|
||||||
|
- Bascule le **1er juin** (idempotente) : crée la ligne `employee_rtt_balances` du nouvel exercice (`targetYear`) pour chaque employé éligible (ni INTERIM, ni PRESENCE).
|
||||||
|
- **Report = solde de clôture de l'exercice N-1**, pas seulement l'acquis : `report_ouverture(N-1) + acquis(N-1) − RTT payés(N-1)`. C'est exactement le **disponible** affiché par `EmployeeRttSummaryProvider` (`carry + currentYearRecovery − totalPaid`). Le report stocké pour N reprend donc le disponible de fin N-1 ; le report déjà présent en début d'année n'est jamais perdu, et les heures payées ne sont pas re-créditées.
|
||||||
|
- Service mutualisé : `App\Service\Rtt\RttClosingBalanceService` (méthode `computeClosingBalance` + `fold` pur testable). `fold` garantit `somme(tranches) = report + acquis − payés` ; la cascade des semaines déficitaires draine la tranche 50% avant la 25%, et la récup non bucketisée (CUSTOM 1h=1h, arrondis) atterrit en `base25` pour que la somme égale le total.
|
||||||
|
- Options : `--force` (hors 01/06) ; `--recompute` (recalcule/écrase les lignes existantes au lieu de les sauter ; **ne touche jamais** une ligne verrouillée `is_locked`). Reprise d'une bascule erronée : `app:rtt:rollover --force --recompute`.
|
||||||
|
- ⚠️ Bug historique : la 1ʳᵉ version ne reportait que `acquis(N-1)` (report d'ouverture perdu, paiements non déduits). Corrigé via `RttClosingBalanceService`.
|
||||||
|
- Doc : `doc/rtt-rollover.md`.
|
||||||
|
|
||||||
## Vue contrat (sélecteur de phase)
|
## Vue contrat (sélecteur de phase)
|
||||||
- Picker `Vue contrat` en haut de la fiche employé (`pages/employees/[id].vue`). Caché si l'employé n'a qu'une phase.
|
- Picker `Vue contrat` en haut de la fiche employé (`pages/employees/[id].vue`). Caché si l'employé n'a qu'une phase.
|
||||||
- Phase = groupe d'`EmployeeContractPeriod` consécutifs partageant la signature `(contract.type, weeklyHours, isDriver)`. Résolu par `App\Service\Contracts\EmployeeContractPhaseResolver`.
|
- Phase = groupe d'`EmployeeContractPeriod` consécutifs partageant la signature `(contract.type, weeklyHours, isDriver)`. Résolu par `App\Service\Contracts\EmployeeContractPhaseResolver`.
|
||||||
|
|||||||
+12
-3
@@ -79,15 +79,24 @@ Commande quotidienne (cron) idempotente.
|
|||||||
- le `01/06`: calcule et persiste le report pour chaque employe eligible
|
- le `01/06`: calcule et persiste le report pour chaque employe eligible
|
||||||
- les autres jours: sortie sans action
|
- les autres jours: sortie sans action
|
||||||
- option manuelle: `--force` pour executer hors date metier (reprise/correction)
|
- option manuelle: `--force` pour executer hors date metier (reprise/correction)
|
||||||
|
- option manuelle: `--recompute` pour recalculer et **ecraser** les lignes existantes au lieu de les sauter (reprise apres correction). Les lignes verrouillees (`is_locked = true`, validees RH) ne sont jamais ecrasees.
|
||||||
|
|
||||||
Date d'effet:
|
Date d'effet:
|
||||||
- au `1er juin` (meme date que le rollover conges non forfait)
|
- au `1er juin` (meme date que le rollover conges non forfait)
|
||||||
|
|
||||||
Traitement par employe:
|
Traitement par employe:
|
||||||
1. verifier l'eligibilite (ni INTERIM, ni suivi PRESENCE)
|
1. verifier l'eligibilite (ni INTERIM, ni suivi PRESENCE)
|
||||||
2. verifier qu'aucune ligne n'existe deja pour `(employee, targetYear)` (idempotence)
|
2. en mode normal: si une ligne existe deja pour `(employee, targetYear)`, la sauter (idempotence). En mode `--recompute`: la recalculer, sauf si elle est verrouillee.
|
||||||
3. calculer la somme des minutes de recuperation de l'exercice N-1
|
3. calculer le **solde de cloture** de l'exercice N-1 (= disponible affiche en fin d'exercice) :
|
||||||
4. creer la ligne du nouvel exercice avec ce total en `opening_minutes`
|
`report d'ouverture N-1 + acquis N-1 − RTT payes N-1`
|
||||||
|
- le **report d'ouverture N-1** vient de la ligne `employee_rtt_balances` de l'exercice N-1 (import go-live ou rollover precedent) ; a defaut, calcul dynamique des acquis de N-2.
|
||||||
|
- l'**acquis N-1** = somme des minutes de recuperation hebdomadaires de l'exercice N-1.
|
||||||
|
- les **RTT payes N-1** (`employee_rtt_payments`) sont deduits.
|
||||||
|
4. creer (ou mettre a jour) la ligne du nouvel exercice avec ce solde, reparti sur les 4 tranches `opening_base25/bonus25/base50/bonus50`.
|
||||||
|
|
||||||
|
> Regle clef : le report d'un exercice a l'autre reprend exactement le **disponible** affiche sur l'onglet RTT (cf. `EmployeeRttSummaryProvider`). Le report deja present au debut de l'exercice precedent n'est jamais perdu, et les heures deja payees ne sont pas re-creditees. Service mutualise : `App\Service\Rtt\RttClosingBalanceService`.
|
||||||
|
|
||||||
|
> Bug historique corrige : la version initiale ne reportait que `acquis N-1` (ni report d'ouverture, ni deduction des paiements), ce qui faisait disparaitre le solde de depart. Pour corriger des lignes deja creees a tort, relancer avec `--force --recompute`.
|
||||||
|
|
||||||
## 7) Donnees a fournir au go-live
|
## 7) Donnees a fournir au go-live
|
||||||
|
|
||||||
|
|||||||
@@ -523,6 +523,7 @@ export const documentationSections: DocSection[] = [
|
|||||||
blocks: [
|
blocks: [
|
||||||
{ type: 'list', content: 'Report N-1 : solde de l\'exercice précédent\nAcquis : cumul des heures supplémentaires de l\'exercice en cours\nDisponible : report + acquis − payé\nPayé : RTT convertis en salaire (soustraits du disponible)' },
|
{ type: 'list', content: 'Report N-1 : solde de l\'exercice précédent\nAcquis : cumul des heures supplémentaires de l\'exercice en cours\nDisponible : report + acquis − payé\nPayé : RTT convertis en salaire (soustraits du disponible)' },
|
||||||
{ type: 'note', content: 'Les contrats INTERIM et le mode PRESENCE n\'accumulent pas de RTT (affiché à 0).' },
|
{ type: 'note', content: 'Les contrats INTERIM et le mode PRESENCE n\'accumulent pas de RTT (affiché à 0).' },
|
||||||
|
{ type: 'note', content: 'Au passage à l\'exercice suivant (1er juin), le « Report N-1 » du nouvel exercice reprend exactement le « Disponible » de fin d\'exercice précédent, c\'est-à-dire report précédent + acquis − RTT payés. Le report déjà présent en début d\'année n\'est donc jamais perdu.' },
|
||||||
{ type: 'paragraph', content: 'La colonne "Cumul" affiche le solde RTT à la fin de chaque semaine : Report N-1 + somme des heures hebdomadaires jusqu\'à la semaine concernée − paiements RTT des mois précédents. Un paiement enregistré sur le mois M n\'est déduit qu\'à partir des semaines du mois M+1. Permet la comparaison ligne à ligne avec un suivi RH externe (Excel).' },
|
{ type: 'paragraph', content: 'La colonne "Cumul" affiche le solde RTT à la fin de chaque semaine : Report N-1 + somme des heures hebdomadaires jusqu\'à la semaine concernée − paiements RTT des mois précédents. Un paiement enregistré sur le mois M n\'est déduit qu\'à partir des semaines du mois M+1. Permet la comparaison ligne à ligne avec un suivi RH externe (Excel).' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use App\Enum\ContractType;
|
|||||||
use App\Enum\TrackingMode;
|
use App\Enum\TrackingMode;
|
||||||
use App\Repository\EmployeeRepository;
|
use App\Repository\EmployeeRepository;
|
||||||
use App\Repository\EmployeeRttBalanceRepository;
|
use App\Repository\EmployeeRttBalanceRepository;
|
||||||
use App\Service\Rtt\RttRecoveryComputationService;
|
use App\Service\Rtt\RttClosingBalanceService;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
@@ -32,7 +32,7 @@ final class RttRolloverCommand extends Command
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly EmployeeRepository $employeeRepository,
|
private readonly EmployeeRepository $employeeRepository,
|
||||||
private readonly EmployeeRttBalanceRepository $rttBalanceRepository,
|
private readonly EmployeeRttBalanceRepository $rttBalanceRepository,
|
||||||
private readonly RttRecoveryComputationService $rttRecoveryService,
|
private readonly RttClosingBalanceService $rttClosingService,
|
||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
#[Autowire(service: 'monolog.logger.cron')]
|
#[Autowire(service: 'monolog.logger.cron')]
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
@@ -48,6 +48,12 @@ final class RttRolloverCommand extends Command
|
|||||||
InputOption::VALUE_NONE,
|
InputOption::VALUE_NONE,
|
||||||
'Run rollover regardless of business date (manual recovery mode).'
|
'Run rollover regardless of business date (manual recovery mode).'
|
||||||
);
|
);
|
||||||
|
$this->addOption(
|
||||||
|
'recompute',
|
||||||
|
null,
|
||||||
|
InputOption::VALUE_NONE,
|
||||||
|
'Recompute and overwrite existing (non-locked) balances instead of skipping them.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
@@ -55,8 +61,9 @@ final class RttRolloverCommand extends Command
|
|||||||
$io = new SymfonyStyle($input, $output);
|
$io = new SymfonyStyle($input, $output);
|
||||||
$today = new DateTimeImmutable('today');
|
$today = new DateTimeImmutable('today');
|
||||||
$force = (bool) $input->getOption('force');
|
$force = (bool) $input->getOption('force');
|
||||||
|
$recompute = (bool) $input->getOption('recompute');
|
||||||
|
|
||||||
$this->logger->info('app:rtt:rollover started.', ['date' => $today->format('Y-m-d'), 'force' => $force]);
|
$this->logger->info('app:rtt:rollover started.', ['date' => $today->format('Y-m-d'), 'force' => $force, 'recompute' => $recompute]);
|
||||||
|
|
||||||
if (!$force && '06-01' !== $today->format('m-d')) {
|
if (!$force && '06-01' !== $today->format('m-d')) {
|
||||||
$message = 'No RTT rollover today: business date is not 01/06.';
|
$message = 'No RTT rollover today: business date is not 01/06.';
|
||||||
@@ -68,6 +75,7 @@ final class RttRolloverCommand extends Command
|
|||||||
|
|
||||||
$targetYear = $this->resolveTargetYear($today);
|
$targetYear = $this->resolveTargetYear($today);
|
||||||
$created = 0;
|
$created = 0;
|
||||||
|
$updated = 0;
|
||||||
$skipped = 0;
|
$skipped = 0;
|
||||||
|
|
||||||
foreach ($this->employeeRepository->findAll() as $employee) {
|
foreach ($this->employeeRepository->findAll() as $employee) {
|
||||||
@@ -83,36 +91,53 @@ final class RttRolloverCommand extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
$existing = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $targetYear);
|
$existing = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $targetYear);
|
||||||
if (null !== $existing) {
|
if (null !== $existing && !$recompute) {
|
||||||
$this->logger->info('Employee skipped: balance already exists.', ['employeeId' => $employee->getId(), 'year' => $targetYear]);
|
$this->logger->info('Employee skipped: balance already exists.', ['employeeId' => $employee->getId(), 'year' => $targetYear]);
|
||||||
++$skipped;
|
++$skipped;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (null !== $existing && $existing->isLocked()) {
|
||||||
|
// Never overwrite a balance an RH user has validated/frozen.
|
||||||
|
$this->logger->info('Employee skipped: balance is locked.', ['employeeId' => $employee->getId(), 'year' => $targetYear]);
|
||||||
|
++$skipped;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$previousYear = $targetYear - 1;
|
$previousYear = $targetYear - 1;
|
||||||
$carry = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $previousYear);
|
// Closing of the previous exercise = opening report + earned − paid.
|
||||||
|
$closing = $this->rttClosingService->computeClosingBalance($employee, $previousYear);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$this->logger->error('Error computing carry for employee.', ['employeeId' => $employee->getId(), 'error' => $e->getMessage()]);
|
$this->logger->error('Error computing closing balance for employee.', ['employeeId' => $employee->getId(), 'error' => $e->getMessage()]);
|
||||||
++$skipped;
|
++$skipped;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$balance = new EmployeeRttBalance()
|
$balance = $existing ?? new EmployeeRttBalance()
|
||||||
->setEmployee($employee)
|
->setEmployee($employee)
|
||||||
->setYear($targetYear)
|
->setYear($targetYear)
|
||||||
->setOpeningBase25Minutes($carry->base25Minutes)
|
|
||||||
->setOpeningBonus25Minutes($carry->bonus25Minutes)
|
|
||||||
->setOpeningBase50Minutes($carry->base50Minutes)
|
|
||||||
->setOpeningBonus50Minutes($carry->bonus50Minutes)
|
|
||||||
->setIsLocked(false)
|
->setIsLocked(false)
|
||||||
;
|
;
|
||||||
|
|
||||||
|
$balance
|
||||||
|
->setOpeningBase25Minutes($closing->base25Minutes)
|
||||||
|
->setOpeningBonus25Minutes($closing->bonus25Minutes)
|
||||||
|
->setOpeningBase50Minutes($closing->base50Minutes)
|
||||||
|
->setOpeningBonus50Minutes($closing->bonus50Minutes)
|
||||||
|
;
|
||||||
|
|
||||||
|
if (null === $existing) {
|
||||||
$this->entityManager->persist($balance);
|
$this->entityManager->persist($balance);
|
||||||
$this->logger->info('Balance created.', ['employeeId' => $employee->getId(), 'year' => $targetYear, 'carryMinutes' => $carry->totalMinutes]);
|
$this->logger->info('Balance created.', ['employeeId' => $employee->getId(), 'year' => $targetYear, 'carryMinutes' => $closing->totalMinutes]);
|
||||||
++$created;
|
++$created;
|
||||||
|
} else {
|
||||||
|
$balance->touch();
|
||||||
|
$this->logger->info('Balance recomputed.', ['employeeId' => $employee->getId(), 'year' => $targetYear, 'carryMinutes' => $closing->totalMinutes]);
|
||||||
|
++$updated;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -124,7 +149,7 @@ final class RttRolloverCommand extends Command
|
|||||||
return Command::FAILURE;
|
return Command::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
$message = sprintf('RTT rollover done: %d created, %d skipped.', $created, $skipped);
|
$message = sprintf('RTT rollover done: %d created, %d recomputed, %d skipped.', $created, $updated, $skipped);
|
||||||
$this->logger->info($message);
|
$this->logger->info($message);
|
||||||
$io->success($message);
|
$io->success($message);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
<?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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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([]);
|
$provider = $this->buildProvider([]);
|
||||||
$year = $this->invokePrivate($provider, 'resolveYear', $currentPhase);
|
$year = $this->invokePrivate($provider, 'resolveYear', $currentPhase);
|
||||||
|
|
||||||
// Today is 2026-05-19 → current RTT exercise (Juin N-1 → Mai N) = 2026.
|
// No params → current RTT exercise (Juin N-1 → Mai N). Derive the expectation
|
||||||
self::assertSame(2026, $year);
|
// 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
|
public function testInvalidYearFormatReturns422(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user