Compare commits

...

7 Commits

Author SHA1 Message Date
gitea-actions 2745f4e476 chore: bump version to v0.1.107
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 34s
2026-06-08 13:27:41 +00:00
tristan 1edb8d956f feat(rtt) : paiement RTT rétroactif sur l'exercice précédent (#23)
Auto Tag Develop / tag (push) Successful in 7s
## Besoin RH
Pouvoir saisir un paiement RTT sur l'exercice précédent (ex. RTT de mai réglés après la bascule du 1er juin).

## Implémentation (Option B)
- Paiement autorisé sur l'exercice courant + l'exercice immédiatement précédent (N-1).
- Après saisie sur N-1, le report d'ouverture de l'exercice courant est recalculé automatiquement (computeClosingBalance) dans une transaction → aucun double comptage.
- Refus si ce report est verrouillé (is_locked) : la RH le déverrouille d'abord.
- Fallback EmployeeRttSummaryProvider::resolveCarry aligné sur computeClosingBalance : disponible correct même sans ligne stockée.
- Front : bouton « + Payer les RTT » actif sur l'exercice précédent.
- Docs : CLAUDE.md, doc/rtt-tab.md, documentation-content.ts.

## Vérification
-  172 tests OK, cs-fixer OK, conteneur compile.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #23
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-08 13:27:34 +00:00
gitea-actions c01e1f89a7 chore: bump version to v0.1.106
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 34s
2026-06-08 08:56:27 +00:00
tristan ac8a36eb4f [#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>
2026-06-08 08:56:19 +00:00
gitea-actions 3bf48164d2 chore: bump version to v0.1.105
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 53s
2026-06-08 08:18:50 +00:00
tristan 516ba45794 Merge remote-tracking branch 'origin/develop' into develop
Auto Tag Develop / tag (push) Successful in 7s
2026-06-08 10:18:41 +02:00
tristan 387cff2293 docs: spec vue jour résolution contrat à la date affichée
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:37:26 +02:00
13 changed files with 488 additions and 35 deletions
+15
View File
@@ -90,6 +90,21 @@
- Sur un exercice passé, le bouton **+ Payer les RTT** est désactivé (pas de paiement rétroactif).
- 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`.
- **Fallback provider** : quand aucune ligne `employee_rtt_balances` n'existe pour l'exercice affiché (avant la bascule), `EmployeeRttSummaryProvider::resolveCarry` calcule le report en direct via `RttClosingBalanceService::computeClosingBalance($year-1)` (et non plus `computeTotalRecoveryForExercise`) — le disponible reste donc correct (report d'ouverture + acquis payés) même sans ligne stockée.
- Doc : `doc/rtt-rollover.md`.
## Paiement RTT rétroactif (exercice précédent) — Option B
- Le paiement RTT est autorisé sur : l'**exercice courant**, l'**exercice immédiatement précédent** (N-1), ou le dernier exercice d'une phase clôturée. Garde back : `EmployeeRttPaymentProcessor::assertYearAllowedForPayment`. Garde front : `RttTab.vue` `isPayDisabled` (bouton actif sur `selectedYear === currentYear - 1`).
- **Cohérence du report** : un paiement sur N-1 modifie la clôture de N-1 = ouverture de N. Le processor **recalcule automatiquement** la ligne `employee_rtt_balances` de l'exercice courant (`computeClosingBalance(N-1)`) dans une **transaction** (le `flush` du paiement le rend visible au recalcul). Pas de double comptage.
- **Verrou** : si le report de l'exercice courant est `is_locked`, le paiement rétroactif est **refusé** (`assertReportNotLocked`) — la RH doit déverrouiller d'abord.
- Portée limitée à N-1 (chaîne de recalcul = 1 étape). Si la ligne courante n'existe pas encore, le fallback provider couvre l'affichage (cf. ci-dessus).
## 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.
- Phase = groupe d'`EmployeeContractPeriod` consécutifs partageant la signature `(contract.type, weeklyHours, isDriver)`. Résolu par `App\Service\Contracts\EmployeeContractPhaseResolver`.
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.104'
app.version: '0.1.107'
+12 -3
View File
@@ -79,15 +79,24 @@ Commande quotidienne (cron) idempotente.
- le `01/06`: calcule et persiste le report pour chaque employe eligible
- les autres jours: sortie sans action
- 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:
- au `1er juin` (meme date que le rollover conges non forfait)
Traitement par employe:
1. verifier l'eligibilite (ni INTERIM, ni suivi PRESENCE)
2. verifier qu'aucune ligne n'existe deja pour `(employee, targetYear)` (idempotence)
3. calculer la somme des minutes de recuperation de l'exercice N-1
4. creer la ligne du nouvel exercice avec ce total en `opening_minutes`
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 le **solde de cloture** de l'exercice N-1 (= disponible affiche en fin d'exercice) :
`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
+11 -2
View File
@@ -34,9 +34,18 @@ Comportement :
## Verrouillage des éditions sur exercices passés
Quand `selectedYear !== currentYear` (consultation d'un exercice antérieur), le bouton **+ Payer les RTT** est désactivé. Justification : un paiement rétroactif sur un exercice clos décalerait les soldes courants et le report N-1 calculé.
Quand `selectedYear !== currentYear` (consultation d'un exercice antérieur), le bouton **+ Payer les RTT** est désactivé**sauf sur l'exercice immédiatement précédent** (`selectedYear === currentYear - 1`), où le paiement rétroactif est autorisé (Option B).
La consultation reste possible, l'édition non.
La consultation des exercices plus anciens reste possible, l'édition non.
### Paiement rétroactif sur l'exercice précédent (Option B)
Un paiement enregistré sur l'exercice N-1 modifie sa clôture, donc le **report d'ouverture de l'exercice courant N**. Pour éviter tout décalage / double comptage :
- garde back `EmployeeRttPaymentProcessor::assertYearAllowedForPayment` : accepte courant, **N-1**, ou dernier exercice d'une phase clôturée ;
- après enregistrement, le processor **recalcule automatiquement** la ligne `employee_rtt_balances` de l'exercice courant via `RttClosingBalanceService::computeClosingBalance(N-1)`, dans une **transaction** (le `flush` du paiement le rend visible au recalcul) ;
- si le report de l'exercice courant est **verrouillé** (`is_locked`), le paiement est **refusé** (`assertReportNotLocked`) : la RH doit déverrouiller d'abord ;
- portée volontairement limitée à N-1 (chaîne de recalcul = 1 étape). Si la ligne courante n'existe pas encore, l'affichage reste correct grâce au fallback de `EmployeeRttSummaryProvider::resolveCarry` (calcul dynamique de la clôture N-1).
## Sélecteur de phase de contrat
+9 -1
View File
@@ -313,8 +313,16 @@ const isLastExerciseOfPhase = computed(() => {
return props.selectedYear === endYear
})
// Retroactive payment is allowed on the immediately previous exercise (Option B):
// the backend recomputes the next exercise's report so the carry stays correct.
const isPreviousExercise = computed(() =>
props.selectedYear !== null
&& props.currentYear !== null
&& props.selectedYear === props.currentYear - 1
)
const isPayDisabled = computed(() =>
isHistoricalYear.value && !isLastExerciseOfPhase.value
isHistoricalYear.value && !isLastExerciseOfPhase.value && !isPreviousExercise.value
)
const handleYearChange = (event: Event) => {
+3
View File
@@ -523,6 +523,7 @@ export const documentationSections: DocSection[] = [
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: '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).' },
],
},
@@ -533,6 +534,8 @@ export const documentationSections: DocSection[] = [
blocks: [
{ type: 'paragraph', content: 'L\'administrateur RH peut enregistrer un paiement RTT depuis l\'onglet RTT de la fiche employé.' },
{ type: 'list', content: 'Saisie : mois, nombre de minutes, taux (25% ou 50%)\nPlusieurs paiements possibles par mois\nLes heures payées sont soustraites du solde disponible' },
{ type: 'paragraph', content: 'Le paiement est possible sur l\'exercice courant et sur l\'exercice immédiatement précédent (paiement rétroactif, ex. des RTT de mai réglés après la bascule du 1er juin).' },
{ type: 'note', content: 'Un paiement saisi sur l\'exercice précédent recalcule automatiquement le « Report N-1 » de l\'exercice courant : aucun double comptage. Si ce report a déjà été verrouillé (validé), le paiement rétroactif est refusé — déverrouillez-le d\'abord.' },
],
},
{
+43 -18
View File
@@ -10,7 +10,7 @@ use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Repository\EmployeeRepository;
use App\Repository\EmployeeRttBalanceRepository;
use App\Service\Rtt\RttRecoveryComputationService;
use App\Service\Rtt\RttClosingBalanceService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
@@ -32,7 +32,7 @@ final class RttRolloverCommand extends Command
public function __construct(
private readonly EmployeeRepository $employeeRepository,
private readonly EmployeeRttBalanceRepository $rttBalanceRepository,
private readonly RttRecoveryComputationService $rttRecoveryService,
private readonly RttClosingBalanceService $rttClosingService,
private readonly EntityManagerInterface $entityManager,
#[Autowire(service: 'monolog.logger.cron')]
private readonly LoggerInterface $logger,
@@ -48,15 +48,22 @@ final class RttRolloverCommand extends Command
InputOption::VALUE_NONE,
'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
{
$io = new SymfonyStyle($input, $output);
$today = new DateTimeImmutable('today');
$force = (bool) $input->getOption('force');
$io = new SymfonyStyle($input, $output);
$today = new DateTimeImmutable('today');
$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')) {
$message = 'No RTT rollover today: business date is not 01/06.';
@@ -68,6 +75,7 @@ final class RttRolloverCommand extends Command
$targetYear = $this->resolveTargetYear($today);
$created = 0;
$updated = 0;
$skipped = 0;
foreach ($this->employeeRepository->findAll() as $employee) {
@@ -83,36 +91,53 @@ final class RttRolloverCommand extends Command
}
$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]);
++$skipped;
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 {
$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) {
$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;
continue;
}
$balance = new EmployeeRttBalance()
$balance = $existing ?? new EmployeeRttBalance()
->setEmployee($employee)
->setYear($targetYear)
->setOpeningBase25Minutes($carry->base25Minutes)
->setOpeningBonus25Minutes($carry->bonus25Minutes)
->setOpeningBase50Minutes($carry->base50Minutes)
->setOpeningBonus50Minutes($carry->bonus50Minutes)
->setIsLocked(false)
;
$this->entityManager->persist($balance);
$this->logger->info('Balance created.', ['employeeId' => $employee->getId(), 'year' => $targetYear, 'carryMinutes' => $carry->totalMinutes]);
++$created;
$balance
->setOpeningBase25Minutes($closing->base25Minutes)
->setOpeningBonus25Minutes($closing->bonus25Minutes)
->setOpeningBase50Minutes($closing->base50Minutes)
->setOpeningBonus50Minutes($closing->bonus50Minutes)
;
if (null === $existing) {
$this->entityManager->persist($balance);
$this->logger->info('Balance created.', ['employeeId' => $employee->getId(), 'year' => $targetYear, 'carryMinutes' => $closing->totalMinutes]);
++$created;
} else {
$balance->touch();
$this->logger->info('Balance recomputed.', ['employeeId' => $employee->getId(), 'year' => $targetYear, 'carryMinutes' => $closing->totalMinutes]);
++$updated;
}
}
try {
@@ -124,7 +149,7 @@ final class RttRolloverCommand extends Command
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);
$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];
}
}
+54 -7
View File
@@ -8,12 +8,15 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\EmployeeRttPaymentInput;
use App\Entity\Employee;
use App\Entity\EmployeeRttBalance;
use App\Entity\EmployeeRttPayment;
use App\Repository\EmployeeRepository;
use App\Repository\EmployeeRttBalanceRepository;
use App\Repository\EmployeeRttPaymentRepository;
use App\Service\AuditLogger;
use App\Service\Contracts\EmployeeContractPhaseResolver;
use App\Service\Exercise\ExerciseYearResolver;
use App\Service\Rtt\RttClosingBalanceService;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Clock\ClockInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -24,11 +27,13 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
public function __construct(
private EmployeeRepository $employeeRepository,
private EmployeeRttPaymentRepository $rttPaymentRepository,
private EmployeeRttBalanceRepository $rttBalanceRepository,
private EntityManagerInterface $entityManager,
private AuditLogger $auditLogger,
private EmployeeContractPhaseResolver $phaseResolver,
private ClockInterface $clock,
private ExerciseYearResolver $exerciseYearResolver,
private RttClosingBalanceService $rttClosingService,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EmployeeRttPaymentInput
@@ -51,10 +56,20 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
throw new UnprocessableEntityHttpException('month must be between 1 and 12.');
}
$year = $data->year ?? $this->resolveCurrentExerciseYear();
$year = $data->year ?? $this->resolveCurrentExerciseYear();
$currentExerciseYear = $this->resolveCurrentExerciseYear();
$this->assertYearAllowedForPayment($employee, $year);
// Option B — retroactive payment on the previous exercise: the next exercise's
// opening report (a frozen snapshot) must be recomputed so the carry stays correct.
// Refuse upfront if that report has been locked (validated) by RH.
$downstreamBalance = null;
if ($year === $currentExerciseYear - 1) {
$downstreamBalance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $currentExerciseYear);
$this->assertReportNotLocked($downstreamBalance);
}
$payment = $this->rttPaymentRepository->findOneByEmployeeYearMonth($employee, $year, $data->month);
if (null === $payment) {
@@ -81,7 +96,24 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
['new' => ['month' => $data->month, 'year' => $year, 'base25' => $data->base25Minutes, 'bonus25' => $data->bonus25Minutes, 'base50' => $data->base50Minutes, 'bonus50' => $data->bonus50Minutes]],
);
$this->entityManager->flush();
// Persist the payment and, atomically, refresh the next exercise's opening report.
// The flush inside the transaction makes the new payment visible to the closing
// recomputation (same DB connection), so the carry reflects it.
$this->entityManager->wrapInTransaction(function () use ($employee, $year, $downstreamBalance): void {
$this->entityManager->flush();
if (null !== $downstreamBalance) {
$closing = $this->rttClosingService->computeClosingBalance($employee, $year);
$downstreamBalance
->setOpeningBase25Minutes($closing->base25Minutes)
->setOpeningBonus25Minutes($closing->bonus25Minutes)
->setOpeningBase50Minutes($closing->base50Minutes)
->setOpeningBonus50Minutes($closing->bonus50Minutes)
->touch()
;
$this->entityManager->flush();
}
});
$data->year = $year;
@@ -94,14 +126,15 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
}
/**
* Allow payment when the requested exercise is either the current one
* or the last exercise of a closed contract phase (the one containing
* the phase end date). Reject any other exercise (past or future).
* Allow payment when the requested exercise is the current one, the
* immediately previous one (retroactive payment — Option B), or the last
* exercise of a closed contract phase (the one containing the phase end
* date). Reject any other exercise (older past or future).
*/
private function assertYearAllowedForPayment(Employee $employee, int $year): void
{
$currentExerciseYear = $this->resolveCurrentExerciseYear();
if ($year === $currentExerciseYear) {
if ($year === $currentExerciseYear || $year === $currentExerciseYear - 1) {
return;
}
@@ -116,7 +149,21 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
}
throw new UnprocessableEntityHttpException(
'RTT payment is only allowed on the current exercise or the last exercise of a closed contract phase.'
'RTT payment is only allowed on the current exercise, the previous one, or the last exercise of a closed contract phase.'
);
}
/**
* Refuse a retroactive payment when the next exercise's opening report has
* been locked (validated) by RH: recomputing it would either be impossible
* or silently desync the carry. A missing report (null) never blocks.
*/
private function assertReportNotLocked(?EmployeeRttBalance $downstreamBalance): void
{
if (null !== $downstreamBalance && $downstreamBalance->isLocked()) {
throw new UnprocessableEntityHttpException(
'Impossible : le report RTT de l\'exercice suivant est verrouillé. Déverrouillez-le pour saisir un paiement rétroactif.'
);
}
}
}
+7 -1
View File
@@ -20,6 +20,7 @@ use App\Repository\WorkHourRepository;
use App\Security\EmployeeScopeService;
use App\Service\Contracts\EmployeeContractPhaseResolver;
use App\Service\Exercise\ExerciseYearResolver;
use App\Service\Rtt\RttClosingBalanceService;
use App\Service\Rtt\RttRecoveryComputationService;
use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
@@ -43,6 +44,7 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
private WorkHourRepository $workHourRepository,
private EmployeeContractPhaseResolver $phaseResolver,
private ExerciseYearResolver $exerciseYearResolver,
private RttClosingBalanceService $rttClosingService,
string $rttStartDate = '',
) {
$this->rttStartDate = '' !== $rttStartDate ? $rttStartDate : null;
@@ -231,8 +233,12 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
];
}
// No stored report row yet (before the 1st-June rollover materialises it):
// compute the previous exercise's full closing (opening + earned paid) so the
// carry already reflects retroactive payments and the incoming report — matching
// what the rollover would persist. Falling back to earned-only would drop both.
return [
$this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $year - 1),
$this->rttClosingService->computeClosingBalance($employee, $year - 1),
5,
];
}
@@ -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,
);
}
}
@@ -7,6 +7,7 @@ namespace App\Tests\State;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use App\Entity\EmployeeRttBalance;
use App\Enum\ContractNature;
use App\Enum\TrackingMode;
use App\Service\Contracts\EmployeeContractPhaseResolver;
@@ -74,6 +75,54 @@ final class EmployeeRttPaymentProcessorTest extends TestCase
$this->invokePrivate($processor, 'assertYearAllowedForPayment', $employee, 2030);
}
public function testPaymentAllowedOnPreviousExercise(): void
{
// Today = 2026-05-19 → current exercise = 2026. Retroactive payment on the
// immediately previous exercise (2025) is now allowed (Option B).
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$processor = $this->buildProcessorWithClock(new DateTimeImmutable('2026-05-19'));
$this->invokePrivate($processor, 'assertYearAllowedForPayment', $employee, 2025);
// No exception → previous exercise accepted.
self::assertTrue(true);
}
public function testPaymentStillRejectedTwoExercisesBack(): void
{
// 2024 is two exercises before current (2026) and not a closed-phase end → still rejected.
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$processor = $this->buildProcessorWithClock(new DateTimeImmutable('2026-05-19'));
$this->expectException(UnprocessableEntityHttpException::class);
$this->invokePrivate($processor, 'assertYearAllowedForPayment', $employee, 2024);
}
public function testRetroactivePaymentRefusedWhenDownstreamReportLocked(): void
{
$processor = $this->buildProcessorWithClock(new DateTimeImmutable('2026-05-19'));
$locked = new EmployeeRttBalance();
$locked->setIsLocked(true);
$this->expectException(UnprocessableEntityHttpException::class);
$this->invokePrivate($processor, 'assertReportNotLocked', $locked);
}
public function testRetroactivePaymentAllowedWhenDownstreamReportMissingOrUnlocked(): void
{
$processor = $this->buildProcessorWithClock(new DateTimeImmutable('2026-05-19'));
$unlocked = new EmployeeRttBalance();
$unlocked->setIsLocked(false);
// Neither a missing (null) nor an unlocked downstream report must block payment.
$this->invokePrivate($processor, 'assertReportNotLocked', null);
$this->invokePrivate($processor, 'assertReportNotLocked', $unlocked);
self::assertTrue(true);
}
// -----------------------------------------------------------------------
// Test harness helpers.
// -----------------------------------------------------------------------
@@ -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