Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54286ecfa4 | |||
| ffa4a5c9d2 | |||
| f9b3edcf08 | |||
| f14723c234 | |||
| 0ff055ec76 | |||
| 8a0eb40616 | |||
| fa3861b122 | |||
| f344eb536c | |||
| 294e3c3a25 |
@@ -65,7 +65,7 @@
|
||||
## Overtime Rules
|
||||
- Contracts <= 35h: +25% from 35h to 43h, +50% beyond
|
||||
- Contracts >= 39h: +25% from 39h to 43h, +50% beyond
|
||||
- CUSTOM contracts (weeklyHours ≠ 35 and ≠ 39, not INTERIM/FORFAIT): reference = actual contractual hours, no 25%/50% bonuses (1h overtime = 1h recovery), deficit doesn't impact balance
|
||||
- CUSTOM contracts (weeklyHours ≠ 35 and ≠ 39, not INTERIM/FORFAIT): reference = actual contractual hours, no 25%/50% bonuses (1h overtime = 1h recovery). **Le déficit (heures travaillées < heures contractuelles) réduit le cumul RTT 1:1** (peut devenir négatif, reporté à l'exercice suivant). Implémenté via `WeekRecoveryDetail::isFlatRecovery` / `EmployeeRttWeekSummary::isFlatRecovery` : ces semaines portent leur récup/déficit signé dans `totalMinutes` (`RttRecoveryComputationService::buildWeekRecoveryDetail`) et `EmployeeRttSummaryProvider::applyDeficitCascade` **ne draine pas** les tranches 25/50 pour elles (colonnes 25%/50% restent à 0). Le `RttClosingBalanceService::fold` reporte le déficit en N+1.
|
||||
- **Ancre de semaine (type de contrat)** : le type/nature de contrat d'une semaine RTT est résolu sur le **premier jour contracté** de la semaine, pas sur le lundi (`RttRecoveryComputationService::resolveWeekAnchorDate`). Sinon une semaine d'embauche en milieu de semaine (lundi hors contrat) serait classée CUSTOM → bonus 25%/50% désactivés à tort. Ex. CDD 39h embauché le jeudi : la semaine reste 39h, le seuil 25% est proraté aux jours contractés (`computeWeeklyOvertime25StartMinutes`), donc les heures au-delà ouvrent bien le +25%.
|
||||
- **Plafond 25%/50% proraté (mi-semaine)** : le plafond séparant 25% et 50% n'est **pas** codé en dur à 43h mais vaut `seuil_départ_proraté + largeur_bande_25%` (`RttRecoveryComputationService::{resolveOvertime25BandWidthMinutes, computeOvertimeBaseMinutes}`). Largeur = 43h − base (4h pour un 39h, 8h pour un 35h). Pour une semaine pleine le plafond redonne 43h (aucune régression) ; pour une embauche mi-semaine il se décale avec le départ, ouvrant la tranche 50%. Témoin Dylan (CDD 39h embauché jeudi, 22h) : 4h à 25% + 3h à 50%. **Hors périmètre** : l'écran Heures (`WorkHourWeeklySummaryProvider`) n'a pas cette proratisation (calcul dupliqué, laissé tel quel par décision métier).
|
||||
- INTERIM: no overtime bonuses, no recovery time
|
||||
|
||||
@@ -96,6 +96,12 @@ Traitement par employe:
|
||||
|
||||
> 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`.
|
||||
|
||||
> Contrats CUSTOM : le solde de clôture intègre désormais les **déficits** hebdomadaires
|
||||
> (semaines travaillées sous les heures contractuelles), via `RttClosingBalanceService::fold`
|
||||
> qui gère les totaux négatifs. La clôture (donc le report d'ouverture N+1) peut être négative.
|
||||
> Après une mise à jour de cette règle, rejouer `app:rtt:rollover --force --recompute` pour
|
||||
> recalculer les lignes `employee_rtt_balances` non verrouillées calculées avec l'ancienne règle.
|
||||
|
||||
> 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
|
||||
|
||||
@@ -16,6 +16,17 @@ L'onglet est **masqué pour les contrats FORFAIT** (filtre `showRttTab` dans `us
|
||||
|
||||
Toujours **Juin (Y-1) → Mai (Y)**. Le champ `EmployeeRttSummary.year` correspond à `Y` (année de fin d'exercice) ; ex. `year=2026` = `01/06/2025 → 31/05/2026`.
|
||||
|
||||
## Règle de calcul — contrats CUSTOM (4h, 25h…)
|
||||
|
||||
Pour un contrat CUSTOM, la récupération est **plate** (1h sup = 1h récup, sans bonus 25 %/50 %).
|
||||
Depuis 2026-06, une semaine **travaillée sous les heures contractuelles** produit un **déficit
|
||||
signé** dans la colonne « Heure » qui **réduit le « Total » et le « Cumul »** (1h manquante =
|
||||
-1h). Les colonnes Base/25 %/50 % restent à **0** (pas de tranches pour ces contrats). Le cumul
|
||||
peut devenir négatif ; il est reporté à l'exercice suivant.
|
||||
|
||||
Techniquement : `WeekRecoveryDetail::isFlatRecovery` marque ces semaines ;
|
||||
`EmployeeRttSummaryProvider::applyDeficitCascade` les exclut du drainage des tranches 25/50.
|
||||
|
||||
## Sélecteur d'année
|
||||
|
||||
Position : sous la table, à l'intérieur de la zone scrollable, à gauche.
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
# RTT — Déficit pris en compte pour les contrats CUSTOM — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Pour les contrats CUSTOM (4h, 25h…), une semaine travaillée sous les heures contractuelles doit réduire le cumul RTT (déficit compté), avec un affichage propre (colonnes 25%/50% à 0).
|
||||
|
||||
**Architecture:** On retire l'écrêtage `max(0, …)` du total hebdo CUSTOM dans `RttRecoveryComputationService` (le déficit signé circule dans `totalMinutes`) et on marque ces semaines `isFlatRecovery = true`. Ce drapeau désactive la cascade de drainage 25/50 dans `EmployeeRttSummaryProvider`, de sorte que le déficit n'impacte que les colonnes Heure/Total/Cumul. Le `RttClosingBalanceService::fold` gère déjà les totaux négatifs (report N+1 cohérent).
|
||||
|
||||
**Tech Stack:** Symfony, API Platform, Doctrine ORM, PHPUnit. Frontend Nuxt/Vue (aucun changement de code, docs uniquement).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-09-rtt-custom-deficit-design.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Ajouter le drapeau `isFlatRecovery` aux DTOs
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Dto/Rtt/WeekRecoveryDetail.php`
|
||||
- Modify: `src/Dto/Rtt/EmployeeRttWeekSummary.php`
|
||||
|
||||
Ces deux DTOs sont de simples porteurs de données ; ils sont couverts par les tests des tâches 3 et 4. Pas de test dédié.
|
||||
|
||||
- [ ] **Step 1: Ajouter le champ à `WeekRecoveryDetail`**
|
||||
|
||||
Dans `src/Dto/Rtt/WeekRecoveryDetail.php`, ajouter un dernier paramètre au constructeur (après `$dailyMinutes`) :
|
||||
|
||||
```php
|
||||
/**
|
||||
* @param array<string, int> $dailyMinutes date (Y-m-d) => worked minutes
|
||||
*/
|
||||
public function __construct(
|
||||
public int $overtimeMinutes = 0,
|
||||
public int $base25Minutes = 0,
|
||||
public int $bonus25Minutes = 0,
|
||||
public int $base50Minutes = 0,
|
||||
public int $bonus50Minutes = 0,
|
||||
public int $totalMinutes = 0,
|
||||
public array $dailyMinutes = [],
|
||||
public bool $isFlatRecovery = false,
|
||||
) {}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Ajouter le champ à `EmployeeRttWeekSummary`**
|
||||
|
||||
Dans `src/Dto/Rtt/EmployeeRttWeekSummary.php`, ajouter un dernier paramètre au constructeur (après `$cumulativeBalanceMinutes`) :
|
||||
|
||||
```php
|
||||
public function __construct(
|
||||
public int $month,
|
||||
public int $weekNumber,
|
||||
public string $weekStart,
|
||||
public string $weekEnd,
|
||||
public int $overtimeMinutes = 0,
|
||||
public int $base25Minutes = 0,
|
||||
public int $bonus25Minutes = 0,
|
||||
public int $base50Minutes = 0,
|
||||
public int $bonus50Minutes = 0,
|
||||
public int $totalMinutes = 0,
|
||||
public int $cumulativeBalanceMinutes = 0,
|
||||
public bool $isFlatRecovery = false,
|
||||
) {}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Vérifier que rien n'est cassé (DTO à valeur par défaut)**
|
||||
|
||||
Run: `make test`
|
||||
Expected: PASS (aucun appel existant ne casse — le nouveau paramètre a une valeur par défaut).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Dto/Rtt/WeekRecoveryDetail.php src/Dto/Rtt/EmployeeRttWeekSummary.php
|
||||
git commit -m "feat(rtt): add isFlatRecovery flag to recovery DTOs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Test de clôture — déficit CUSTOM diminue le report (aucun code à changer)
|
||||
|
||||
**Files:**
|
||||
- Test: `tests/Service/Rtt/RttClosingBalanceServiceTest.php`
|
||||
|
||||
`RttClosingBalanceService::fold` gère déjà les `totalMinutes` négatifs. On ajoute un test explicite « déficit CUSTOM » pour verrouiller le comportement.
|
||||
|
||||
- [ ] **Step 1: Écrire le test**
|
||||
|
||||
Ajouter cette méthode dans `tests/Service/Rtt/RttClosingBalanceServiceTest.php` (après `testCustomRecoveryWithoutBucketsStillCountsInTotal`) :
|
||||
|
||||
```php
|
||||
public function testCustomDeficitWeekReducesClosingBalance(): void
|
||||
{
|
||||
// CUSTOM (4h) : une semaine de récup +3h puis une semaine déficitaire -1h
|
||||
// (toutes deux sans tranches 25/50). Le déficit doit réduire la clôture.
|
||||
$recovery = new WeekRecoveryDetail(totalMinutes: 180, isFlatRecovery: true); // +3h
|
||||
$deficit = new WeekRecoveryDetail(totalMinutes: -60, isFlatRecovery: true); // -1h
|
||||
|
||||
$closing = $this->service()->fold(new WeekRecoveryDetail(), [$recovery, $deficit], $this->payments());
|
||||
|
||||
// 3h - 1h = 2h reportées, et la somme des buckets égale toujours le total.
|
||||
self::assertSame(120, $closing->totalMinutes);
|
||||
self::assertSame(
|
||||
120,
|
||||
$closing->base25Minutes + $closing->bonus25Minutes + $closing->base50Minutes + $closing->bonus50Minutes,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer le test**
|
||||
|
||||
Run: `docker exec php-sirh-fpm sh -c 'cd /var/www/html && vendor/bin/phpunit tests/Service/Rtt/RttClosingBalanceServiceTest.php --filter testCustomDeficitWeekReducesClosingBalance'`
|
||||
Expected: PASS (le `fold` gère déjà les totaux négatifs).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/Service/Rtt/RttClosingBalanceServiceTest.php
|
||||
git commit -m "test(rtt): custom deficit week reduces closing balance"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `RttRecoveryComputationService` — récup plate signée pour CUSTOM
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Service/Rtt/RttRecoveryComputationService.php` (`computeRecoveryByWeek` lignes ~243-270, + nouvelle méthode privée)
|
||||
- Test: `tests/Service/Rtt/RttRecoveryComputationServiceTest.php`
|
||||
|
||||
On extrait la construction du `WeekRecoveryDetail` dans une méthode pure `buildWeekRecoveryDetail`, testable par réflexion (style existant du fichier de test), et on y applique le changement CUSTOM.
|
||||
|
||||
- [ ] **Step 1: Écrire les tests (méthode pure)**
|
||||
|
||||
Ajouter dans `tests/Service/Rtt/RttRecoveryComputationServiceTest.php` (le fichier instancie déjà le service via `newInstanceWithoutConstructor` et possède le helper `invokePrivate`) :
|
||||
|
||||
```php
|
||||
public function testBuildWeekDetailCustomDeficitKeepsSignedTotalAndFlatFlag(): void
|
||||
{
|
||||
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||
|
||||
// CUSTOM, semaine sous les heures : overtime -120 (worked 2h sur réf 4h).
|
||||
$detail = $this->invokePrivate(
|
||||
$service,
|
||||
'buildWeekRecoveryDetail',
|
||||
false, // isPresence
|
||||
false, // disableBonuses
|
||||
true, // isCustom
|
||||
-120, // overtimeTotalMinutes
|
||||
0, // rawBase25
|
||||
0, // rawBase50
|
||||
[], // dailyMinutes
|
||||
);
|
||||
|
||||
self::assertSame(-120, $detail->totalMinutes);
|
||||
self::assertTrue($detail->isFlatRecovery);
|
||||
self::assertSame(0, $detail->base25Minutes);
|
||||
self::assertSame(0, $detail->base50Minutes);
|
||||
}
|
||||
|
||||
public function testBuildWeekDetailCustomPositiveIsFlatOneToOne(): void
|
||||
{
|
||||
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||
|
||||
$detail = $this->invokePrivate($service, 'buildWeekRecoveryDetail', false, false, true, 180, 0, 0, []);
|
||||
|
||||
self::assertSame(180, $detail->totalMinutes); // 1h = 1h
|
||||
self::assertTrue($detail->isFlatRecovery);
|
||||
}
|
||||
|
||||
public function testBuildWeekDetailStandardKeepsBucketsAndBonuses(): void
|
||||
{
|
||||
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||
|
||||
// 39h : overtime 300, base25 240, base50 60.
|
||||
$detail = $this->invokePrivate($service, 'buildWeekRecoveryDetail', false, false, false, 300, 240, 60, []);
|
||||
|
||||
self::assertFalse($detail->isFlatRecovery);
|
||||
self::assertSame(240, $detail->base25Minutes);
|
||||
self::assertSame(60, $detail->bonus25Minutes); // round(240 * 0.25)
|
||||
self::assertSame(60, $detail->base50Minutes);
|
||||
self::assertSame(30, $detail->bonus50Minutes); // round(60 * 0.5)
|
||||
self::assertSame(300 + 60 + 30, $detail->totalMinutes);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent**
|
||||
|
||||
Run: `docker exec php-sirh-fpm sh -c 'cd /var/www/html && vendor/bin/phpunit tests/Service/Rtt/RttRecoveryComputationServiceTest.php --filter testBuildWeekDetail'`
|
||||
Expected: FAIL — `buildWeekRecoveryDetail` n'existe pas encore (ReflectionException / method does not exist).
|
||||
|
||||
- [ ] **Step 3: Ajouter la méthode privée `buildWeekRecoveryDetail`**
|
||||
|
||||
Dans `src/Service/Rtt/RttRecoveryComputationService.php`, ajouter cette méthode (par ex. juste après `computeRecoveryByWeek`, avant `computeMetrics`) :
|
||||
|
||||
```php
|
||||
/**
|
||||
* Assemble le détail de récupération d'une semaine à partir des drapeaux résolus et
|
||||
* des bandes d'heures sup brutes.
|
||||
*
|
||||
* - PRESENCE / INTERIM (bonus désactivés) : aucune récupération.
|
||||
* - CUSTOM : récupération plate 1h = 1h, sans tranches 25/50 ; l'heure sup signée EST
|
||||
* le total, donc une semaine travaillée sous les heures contractuelles produit un
|
||||
* total négatif (déficit qui réduit le solde). Marquée isFlatRecovery pour que le
|
||||
* provider ne draine pas les tranches 25/50.
|
||||
* - Standard 35h/39h : heures sup + bonus 25 %/50 %.
|
||||
*
|
||||
* @param array<string, int> $dailyMinutes
|
||||
*/
|
||||
private function buildWeekRecoveryDetail(
|
||||
bool $isPresence,
|
||||
bool $disableBonuses,
|
||||
bool $isCustom,
|
||||
int $overtimeTotalMinutes,
|
||||
int $rawBase25,
|
||||
int $rawBase50,
|
||||
array $dailyMinutes,
|
||||
): WeekRecoveryDetail {
|
||||
$noBands = $isPresence || $disableBonuses || $isCustom;
|
||||
|
||||
$base25 = $noBands ? 0 : $rawBase25;
|
||||
$bonus25 = $noBands ? 0 : (int) round($base25 * 0.25);
|
||||
$base50 = $noBands ? 0 : $rawBase50;
|
||||
$bonus50 = $noBands ? 0 : (int) round($base50 * 0.5);
|
||||
|
||||
if ($isPresence || $disableBonuses) {
|
||||
$totalMinutes = 0;
|
||||
} elseif ($isCustom) {
|
||||
$totalMinutes = $overtimeTotalMinutes; // signé : le déficit réduit le solde
|
||||
} else {
|
||||
$totalMinutes = $overtimeTotalMinutes + $bonus25 + $bonus50;
|
||||
}
|
||||
|
||||
return new WeekRecoveryDetail(
|
||||
overtimeMinutes: $overtimeTotalMinutes,
|
||||
base25Minutes: $base25,
|
||||
bonus25Minutes: $bonus25,
|
||||
base50Minutes: $base50,
|
||||
bonus50Minutes: $bonus50,
|
||||
totalMinutes: $totalMinutes,
|
||||
dailyMinutes: $dailyMinutes,
|
||||
isFlatRecovery: $isCustom,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Brancher l'appelant sur la nouvelle méthode**
|
||||
|
||||
Dans `computeRecoveryByWeek`, remplacer le bloc existant (depuis `[$rawBase25, $rawBase50] = …` jusqu'à la fin du `new WeekRecoveryDetail(...)` qui assigne `$results[$weekKey]`) par :
|
||||
|
||||
```php
|
||||
[$rawBase25, $rawBase50] = $this->computeOvertimeBaseMinutes($weeklyTotalMinutes, $overtime25StartMinutes, $overtime50StartMinutes);
|
||||
|
||||
$results[$weekKey] = $this->buildWeekRecoveryDetail(
|
||||
$isWeekPresenceTracking,
|
||||
$disableOvertimeBonuses,
|
||||
$isCustomContract,
|
||||
$weeklyOvertimeTotalMinutes,
|
||||
$rawBase25,
|
||||
$rawBase50,
|
||||
$dailyWorkedMinutes,
|
||||
);
|
||||
```
|
||||
|
||||
(Conserver les lignes précédentes qui calculent `$weeklyOvertimeTotalMinutes`, `$overtime25StartMinutes`, `$overtime50StartMinutes`.)
|
||||
|
||||
- [ ] **Step 5: Lancer les tests pour vérifier qu'ils passent**
|
||||
|
||||
Run: `docker exec php-sirh-fpm sh -c 'cd /var/www/html && vendor/bin/phpunit tests/Service/Rtt/RttRecoveryComputationServiceTest.php'`
|
||||
Expected: PASS (nouveaux tests + tests existants des helpers).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Service/Rtt/RttRecoveryComputationService.php tests/Service/Rtt/RttRecoveryComputationServiceTest.php
|
||||
git commit -m "feat(rtt): custom contract deficit counts as signed recovery (1h=1h, no bands)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `EmployeeRttSummaryProvider` — sauter la cascade pour les semaines plates
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/State/EmployeeRttSummaryProvider.php` (cascade lignes ~145-174 → méthode extraite ; `buildWeekSummaries` lignes ~385-396 et ~425-436)
|
||||
- Test: `tests/State/EmployeeRttSummaryProviderTest.php`
|
||||
|
||||
- [ ] **Step 1: Écrire les tests (méthode pure `applyDeficitCascade`)**
|
||||
|
||||
Ajouter dans `tests/State/EmployeeRttSummaryProviderTest.php`. Le fichier importe déjà `EmployeeRttSummaryProvider`, `ReflectionClass`, et possède `invokePrivate` / `buildProvider`. Ajouter d'abord ce petit helper de fabrication en bas de la classe (si un helper équivalent n'existe pas déjà) :
|
||||
|
||||
```php
|
||||
private function weekSummary(int $totalMinutes, bool $isFlat, int $base25 = 0, int $base50 = 0): \App\Dto\Rtt\EmployeeRttWeekSummary
|
||||
{
|
||||
return new \App\Dto\Rtt\EmployeeRttWeekSummary(
|
||||
month: 6,
|
||||
weekNumber: 1,
|
||||
weekStart: '2026-06-01',
|
||||
weekEnd: '2026-06-07',
|
||||
overtimeMinutes: $totalMinutes,
|
||||
base25Minutes: $base25,
|
||||
base50Minutes: $base50,
|
||||
totalMinutes: $totalMinutes,
|
||||
isFlatRecovery: $isFlat,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Puis les tests :
|
||||
|
||||
```php
|
||||
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);
|
||||
}
|
||||
```
|
||||
|
||||
NB : si `invokePrivate` n'accepte pas d'arguments variadiques dans ce fichier, vérifier sa signature en haut du fichier de test et adapter (l'autre fichier de test du dépôt l'utilise déjà avec des arguments).
|
||||
|
||||
- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent**
|
||||
|
||||
Run: `docker exec php-sirh-fpm sh -c 'cd /var/www/html && vendor/bin/phpunit tests/State/EmployeeRttSummaryProviderTest.php --filter Deficit'`
|
||||
Expected: FAIL — `applyDeficitCascade` n'existe pas encore.
|
||||
|
||||
- [ ] **Step 3: Extraire la cascade en méthode privée**
|
||||
|
||||
Dans `src/State/EmployeeRttSummaryProvider.php`, ajouter cette méthode privée (par ex. juste avant `buildWeekSummaries`) :
|
||||
|
||||
```php
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Brancher `provide()` sur la méthode extraite**
|
||||
|
||||
Dans `provide()`, remplacer le bloc commentaire + boucle (depuis `// Post-process: distribute deficit weeks across cumulative balance (50% first, then 25%)` et la déclaration de `$cumulative50`/`$cumulative25` jusqu'à la fin du `foreach ($summary->weeks as $i => $week) { … }`) par :
|
||||
|
||||
```php
|
||||
// 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,
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Propager `isFlatRecovery` dans `buildWeekSummaries`**
|
||||
|
||||
Dans `buildWeekSummaries`, ajouter `isFlatRecovery: $detail->isFlatRecovery,` comme dernier argument des DEUX appels `new EmployeeRttWeekSummary(...)` :
|
||||
- le cas mono-mois (`if ($startMonth === $endMonth)`, après `totalMinutes: $detail->totalMinutes,`)
|
||||
- le cas semaine à cheval (boucle `foreach ([$startMonth, $endMonth] …`, après `totalMinutes: (int) round($detail->totalMinutes * $ratio),`)
|
||||
|
||||
- [ ] **Step 6: Lancer les tests**
|
||||
|
||||
Run: `docker exec php-sirh-fpm sh -c 'cd /var/www/html && vendor/bin/phpunit tests/State/EmployeeRttSummaryProviderTest.php'`
|
||||
Expected: PASS (nouveaux tests + tests existants du provider).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/State/EmployeeRttSummaryProvider.php tests/State/EmployeeRttSummaryProviderTest.php
|
||||
git commit -m "feat(rtt): skip 25/50 deficit cascade for flat (custom) recovery weeks"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `DumpVerificationSnapshotCommand` — refléter le drapeau
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Command/DumpVerificationSnapshotCommand.php` (`distributeDeficits` ligne ~689 ; build des week summaries lignes ~628-639 et ~664-675)
|
||||
|
||||
Ce command duplique la logique du provider pour produire les snapshots de vérification. Sans mise à jour, les snapshots « after » seraient faux pour les semaines CUSTOM.
|
||||
|
||||
- [ ] **Step 1: Propager `isFlatRecovery` dans les week summaries dupliquées**
|
||||
|
||||
Ajouter `isFlatRecovery: $detail->isFlatRecovery,` comme dernier argument des deux appels `new EmployeeRttWeekSummary(...)` (cas mono-mois ligne ~638 après `totalMinutes: $detail->totalMinutes,`, et cas à cheval ligne ~674 après `totalMinutes: (int) round($detail->totalMinutes * $ratio),`).
|
||||
|
||||
- [ ] **Step 2: Sauter la cascade pour les semaines plates dans `distributeDeficits`**
|
||||
|
||||
Modifier la condition de la boucle :
|
||||
|
||||
```php
|
||||
if ($week->totalMinutes >= 0 || $week->isFlatRecovery) {
|
||||
```
|
||||
|
||||
et ajouter `isFlatRecovery: $week->isFlatRecovery,` comme dernier argument du `new EmployeeRttWeekSummary(...)` de reconstruction (après `totalMinutes: $week->totalMinutes,`).
|
||||
|
||||
- [ ] **Step 3: Vérifier la compilation (lint)**
|
||||
|
||||
Run: `docker exec php-sirh-fpm sh -c 'cd /var/www/html && php -l src/Command/DumpVerificationSnapshotCommand.php'`
|
||||
Expected: `No syntax errors detected`.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Command/DumpVerificationSnapshotCommand.php
|
||||
git commit -m "chore(rtt): mirror flat-recovery cascade skip in verification snapshot command"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Documentation (obligatoire — même intervention)
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md` (ligne ~68)
|
||||
- Modify: `frontend/data/documentation-content.ts` (lignes ~367-368 et section RTT ~520)
|
||||
- Modify: `doc/rtt-tab.md`
|
||||
- Modify: `doc/rtt-rollover.md`
|
||||
|
||||
- [ ] **Step 1: `CLAUDE.md`**
|
||||
|
||||
Remplacer la ligne :
|
||||
|
||||
```
|
||||
- CUSTOM contracts (weeklyHours ≠ 35 and ≠ 39, not INTERIM/FORFAIT): reference = actual contractual hours, no 25%/50% bonuses (1h overtime = 1h recovery), deficit doesn't impact balance
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```
|
||||
- CUSTOM contracts (weeklyHours ≠ 35 and ≠ 39, not INTERIM/FORFAIT): reference = actual contractual hours, no 25%/50% bonuses (1h overtime = 1h recovery). **Le déficit (heures travaillées < heures contractuelles) réduit le cumul RTT 1:1** (peut devenir négatif, reporté à l'exercice suivant). Implémenté via `WeekRecoveryDetail::isFlatRecovery` / `EmployeeRttWeekSummary::isFlatRecovery` : ces semaines portent leur récup/déficit signé dans `totalMinutes` (`RttRecoveryComputationService::buildWeekRecoveryDetail`) et `EmployeeRttSummaryProvider::applyDeficitCascade` **ne draine pas** les tranches 25/50 pour elles (colonnes 25%/50% restent à 0). Le `RttClosingBalanceService::fold` reporte le déficit en N+1.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: In-app docs — règles d'heures sup (ligne ~367)**
|
||||
|
||||
Dans `frontend/data/documentation-content.ts`, remplacer le contenu de la liste ligne ~367 :
|
||||
|
||||
```
|
||||
Contrats CUSTOM (4h, 25h, etc.) : 1h supplémentaire = 1h de récupération, pas de bonus
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```
|
||||
Contrats CUSTOM (4h, 25h, etc.) : 1h supplémentaire = 1h de récupération, pas de bonus. Une semaine sous les heures contractuelles réduit le cumul RTT (1h manquante = -1h), sans passer par les tranches 25/50
|
||||
```
|
||||
|
||||
- [ ] **Step 3: In-app docs — note déficit (ligne ~368)**
|
||||
|
||||
Remplacer le contenu de la note ligne ~368 :
|
||||
|
||||
```
|
||||
En cas de déficit hebdomadaire (heures travaillées < heures contrat), le déficit est déduit du cumul RTT : d'abord des heures à 50%, puis des heures à 25%.
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```
|
||||
En cas de déficit hebdomadaire (heures travaillées < heures contrat), le déficit est déduit du cumul RTT. Pour un 35h/39h, il est puisé d'abord dans les heures à 50%, puis à 25%. Pour un contrat CUSTOM (4h, etc.), il réduit directement le cumul (pas de tranches 25/50) ; le cumul peut devenir négatif et est reporté à l'exercice suivant.
|
||||
```
|
||||
|
||||
- [ ] **Step 4: In-app docs — section RTT « Compteurs » (ajout d'une note)**
|
||||
|
||||
Dans l'article `rtt-compteurs` (id `'rtt-compteurs'`, vers la ligne ~520), ajouter une note à la fin du tableau `blocks` (après le dernier `paragraph`) :
|
||||
|
||||
```javascript
|
||||
{ type: 'note', content: 'Contrats CUSTOM (ex. 4h) : une semaine travaillée sous les heures contractuelles génère un déficit qui réduit le cumul RTT (1h manquante = -1h), sans tranches 25/50. Le cumul peut devenir négatif et est reporté à l\'exercice suivant.' },
|
||||
```
|
||||
|
||||
- [ ] **Step 5: `doc/rtt-tab.md` — ajouter une sous-section règle CUSTOM**
|
||||
|
||||
Après la section « Période affichée » (avant « Sélecteur d'année »), insérer :
|
||||
|
||||
```markdown
|
||||
## Règle de calcul — contrats CUSTOM (4h, 25h…)
|
||||
|
||||
Pour un contrat CUSTOM, la récupération est **plate** (1h sup = 1h récup, sans bonus 25 %/50 %).
|
||||
Depuis 2026-06, une semaine **travaillée sous les heures contractuelles** produit un **déficit
|
||||
signé** dans la colonne « Heure » qui **réduit le « Total » et le « Cumul »** (1h manquante =
|
||||
-1h). Les colonnes Base/25 %/50 % restent à **0** (pas de tranches pour ces contrats). Le cumul
|
||||
peut devenir négatif ; il est reporté à l'exercice suivant.
|
||||
|
||||
Techniquement : `WeekRecoveryDetail::isFlatRecovery` marque ces semaines ;
|
||||
`EmployeeRttSummaryProvider::applyDeficitCascade` les exclut du drainage des tranches 25/50.
|
||||
```
|
||||
|
||||
- [ ] **Step 6: `doc/rtt-rollover.md` — préciser que le déficit CUSTOM est reporté**
|
||||
|
||||
Sous le point 3 (« calculer le solde de clôture… ») / la « Règle clef » (ligne ~97), ajouter :
|
||||
|
||||
```markdown
|
||||
> Contrats CUSTOM : le solde de clôture intègre désormais les **déficits** hebdomadaires
|
||||
> (semaines travaillées sous les heures contractuelles), via `RttClosingBalanceService::fold`
|
||||
> qui gère les totaux négatifs. La clôture (donc le report d'ouverture N+1) peut être négative.
|
||||
> Après une mise à jour de cette règle, rejouer `app:rtt:rollover --force --recompute` pour
|
||||
> recalculer les lignes `employee_rtt_balances` non verrouillées calculées avec l'ancienne règle.
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Build frontend (vérifier que le TS compile)**
|
||||
|
||||
Run: `docker exec php-sirh-fpm true` (no-op) puis localement : ne PAS lancer `npm run build` (préférence utilisateur). Vérifier visuellement que les chaînes ajoutées échappent bien les apostrophes (`\'`).
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md frontend/data/documentation-content.ts doc/rtt-tab.md doc/rtt-rollover.md
|
||||
git commit -m "docs(rtt): custom contract deficit now reduces the balance"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Vérification métier sur données prod + suite complète
|
||||
|
||||
**Files:** aucun (vérification).
|
||||
|
||||
- [ ] **Step 1: Lancer toute la suite de tests**
|
||||
|
||||
Run: `make test`
|
||||
Expected: PASS (aucune régression).
|
||||
|
||||
- [ ] **Step 2: Vérifier Ewa / Nadia via le snapshot de vérification (ou requête API)**
|
||||
|
||||
Générer un snapshot « after » et confirmer que pour Ewa (id 31, exercice 2027) la semaine 23 affiche : Heure −2h, Total −2h, Cumul −2h, colonnes 25/50 = 0 ; et que Nadia (id 22) reste cohérente.
|
||||
|
||||
Run (exemple, adapter à la signature réelle du command) :
|
||||
`docker exec php-sirh-fpm sh -c 'cd /var/www/html && php bin/console app:dump-verification-snapshot --help'`
|
||||
|
||||
Comparer `docs/verifications/` (before) et le nouveau snapshot.
|
||||
|
||||
- [ ] **Step 3: Note de déploiement**
|
||||
|
||||
Consigner dans la PR : après déploiement, exécuter
|
||||
`php bin/console app:rtt:rollover --force --recompute`
|
||||
pour rafraîchir les reports stockés (lignes non verrouillées) calculés avec l'ancienne règle (déficit = 0).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- **Spec coverage** : (1) déficit signé CUSTOM → Task 3 ; (2) cumul réduit + cascade non drainée → Task 4 ; (3) report N+1 → Task 2 (fold déjà OK) + note rollover Task 6/7 ; (4) affichage propre (frontend inchangé) → couvert par buckets 0 ; (5) command de vérification → Task 5 ; (6) docs → Task 6. ✓
|
||||
- **Placeholders** : aucun — code complet à chaque étape.
|
||||
- **Cohérence des types** : `isFlatRecovery` (bool) ajouté de façon identique aux 2 DTOs ; `buildWeekRecoveryDetail` et `applyDeficitCascade` ont des signatures fixes utilisées de manière cohérente entre tâches et tests. ✓
|
||||
@@ -0,0 +1,118 @@
|
||||
# Déficit RTT pris en compte pour les contrats CUSTOM (4h, etc.)
|
||||
|
||||
Date : 2026-06-09
|
||||
Branche : `feature/SIRH-36-corriger-le-calcule-des-rtt-des-contrat-4h`
|
||||
|
||||
## Contexte & problème
|
||||
|
||||
Les salariées avec un contrat 4h (type CUSTOM : `weeklyHours` ≠ 35/39, non INTERIM/FORFAIT,
|
||||
mode TIME) voient, dans l'onglet RTT, des semaines travaillées **en dessous** de leurs heures
|
||||
contractuelles afficher un déficit dans la colonne « Heure » (ex. Ewa S23 : −2h) **sans aucun
|
||||
effet** : « Total » = 0 et « Cumul » = 0.
|
||||
|
||||
Cause : `RttRecoveryComputationService::computeRecoveryByWeek` écrête le total hebdo des CUSTOM
|
||||
avec `totalMinutes = max(0, $weeklyOvertimeTotalMinutes)`. Le déficit est donc supprimé. C'est
|
||||
le comportement métier documenté jusqu'ici (« CUSTOM : le déficit n'impacte pas le solde »).
|
||||
|
||||
Décision métier (validée avec le client) : **le déficit doit être pris en compte et réduire le
|
||||
cumul**, comme pour les 35h/39h, avec un affichage propre dans l'onglet RTT.
|
||||
|
||||
## Décisions validées
|
||||
|
||||
1. **Le cumul peut devenir négatif** (identique aux 35h/39h, comportement déjà assumé dans le
|
||||
code — cf. `RttClosingBalanceService::fold` ligne 98 « leftover may push the balance
|
||||
negative, as on screen »). Le négatif est reporté à l'exercice suivant.
|
||||
2. **Déficit visible** en colonnes « Heure » + « Total » + « Cumul ». Les colonnes 25%/50%
|
||||
restent à **0** pour un contrat CUSTOM (un 4h n'a pas de bonus, donc pas de tranches).
|
||||
|
||||
## Principe technique
|
||||
|
||||
Retirer l'écrêtage `max(0, …)` pour les semaines CUSTOM : le déficit (négatif) circule dans
|
||||
`WeekRecoveryDetail::totalMinutes` et réduit le cumul. La seule spécificité CUSTOM reste :
|
||||
récupération 1h = 1h, sans bonus 25/50.
|
||||
|
||||
### Le point délicat : ne pas drainer les tranches 25/50
|
||||
|
||||
Pour un 35h/39h, une semaine déficitaire **draine** les tranches 25/50 accumulées via la cascade
|
||||
de `EmployeeRttSummaryProvider` (lignes 149-174) et de `RttClosingBalanceService::fold` (lignes
|
||||
92-99), ce qui affiche des valeurs négatives en « Total 25% / 50% ».
|
||||
|
||||
Pour un CUSTOM, la récup n'est jamais bucketisée (elle vit uniquement dans `totalMinutes`). La
|
||||
cascade ne doit donc **pas** s'appliquer aux semaines CUSTOM, sinon le déficit apparaîtrait en
|
||||
négatif dans « Total 25% » (affichage sale, et incohérent avec les récups positives qui, elles,
|
||||
n'y figurent pas).
|
||||
|
||||
Solution : un drapeau **`isFlatRecovery`** (récupération plate 1:1, sans tranches) porté par la
|
||||
semaine, qui désactive la cascade dans le provider.
|
||||
|
||||
## Changements
|
||||
|
||||
### Backend
|
||||
|
||||
1. **`src/Dto/Rtt/WeekRecoveryDetail.php`** : ajout `public bool $isFlatRecovery = false`.
|
||||
2. **`src/Dto/Rtt/EmployeeRttWeekSummary.php`** : ajout `public bool $isFlatRecovery = false`.
|
||||
3. **`src/Service/Rtt/RttRecoveryComputationService.php`**
|
||||
(`computeRecoveryByWeek`, branche `$isCustomContract`) :
|
||||
- `totalMinutes = $weeklyOvertimeTotalMinutes` (signé — plus de `max(0, …)`).
|
||||
- `isFlatRecovery: true` sur le `WeekRecoveryDetail` retourné.
|
||||
- Les buckets `base25/bonus25/base50/bonus50` restent à 0 (inchangé).
|
||||
- Cas non-CUSTOM et PRESENCE/INTERIM inchangés (`isFlatRecovery` reste `false`).
|
||||
4. **`src/State/EmployeeRttSummaryProvider.php`**
|
||||
- `buildWeekSummaries` : propager `isFlatRecovery` du `WeekRecoveryDetail` vers
|
||||
l'`EmployeeRttWeekSummary` — dans le cas mono-mois **et** dans le cas semaine à cheval sur
|
||||
deux mois (les deux instances héritent du drapeau).
|
||||
- Cascade déficit (ligne 150) : condition devient
|
||||
`if ($week->totalMinutes >= 0 || $week->isFlatRecovery)`. Pour une semaine CUSTOM
|
||||
déficitaire, on ne draine pas : les buckets restent 0, `cumulativeBalanceMinutes`
|
||||
(déjà basé sur `totalMinutes`, ligne 197) intègre le déficit.
|
||||
- Dans la reconstruction de la branche `else` (semaine déficitaire normale), conserver
|
||||
explicitement `isFlatRecovery: $week->isFlatRecovery` (toujours `false` à ce point, mais
|
||||
explicite pour la clarté).
|
||||
5. **`src/Command/DumpVerificationSnapshotCommand.php`** : ce command duplique la cascade du
|
||||
provider (lignes ~695-716). Mettre à jour la condition de cascade pour respecter
|
||||
`isFlatRecovery`, et propager le drapeau dans sa reconstruction des week summaries, afin que
|
||||
les snapshots before/after restent fidèles à l'app.
|
||||
|
||||
### Aucun changement
|
||||
|
||||
- **`src/Service/Rtt/RttClosingBalanceService.php`** : `fold` gère déjà `totalMinutes` négatif
|
||||
(branche déficit lignes 92-99) et le remainder CUSTOM (lignes 83-87). Le report N+1 intègre
|
||||
donc automatiquement le déficit. Pas de modification.
|
||||
- **`frontend/components/employees/RttTab.vue`** : aucun. Les sous-colonnes Base/25%/50% sont
|
||||
déjà écrêtées à 0 sur les semaines déficitaires (`totalMinutes >= 0 ? … : 0`). Avec buckets =
|
||||
0 côté back, « Total 25%/50% » = 0, et « Heure »/« Total »/« Cumul » affichent le déficit.
|
||||
- **Pas de migration** : aucun changement de schéma.
|
||||
|
||||
## Effets de bord (assumés, cohérents)
|
||||
|
||||
- **Récap congés** (`LeaveRecapRowBuilder::…` via `computeTotalRecoveryForExercise`) : la valeur
|
||||
RTT reflètera aussi les déficits et peut devenir négative. Cohérent avec la décision.
|
||||
- **Rollover / report** : la clôture d'exercice (`computeClosingBalance`) intègre désormais les
|
||||
déficits. Les lignes `employee_rtt_balances` déjà stockées (calculées avec l'ancienne logique,
|
||||
déficit = 0) doivent être rafraîchies après déploiement :
|
||||
`php bin/console app:rtt:rollover --force --recompute` (ne touche pas les lignes
|
||||
`is_locked`). Ex. Ewa : clôture 2026 passe de 0 à −2h, donc report d'ouverture 2027 = −2h.
|
||||
- **Carry / Report row** : pour un CUSTOM, le report reste stocké/affiché dans la tranche
|
||||
`base25` (convention pré-existante du `fold`, remainder parking) — comportement inchangé, hors
|
||||
périmètre.
|
||||
|
||||
## Tests (TDD)
|
||||
|
||||
- **`tests/Service/Rtt/RttClosingBalanceServiceTest.php`** : nouveau cas — un `WeekRecoveryDetail`
|
||||
CUSTOM **déficitaire** (`totalMinutes` négatif) diminue bien la clôture (somme = report +
|
||||
Σ semaines − payés, négatif inclus).
|
||||
- **`tests/State/EmployeeRttSummaryProviderTest.php`** : semaine CUSTOM déficitaire
|
||||
(`isFlatRecovery = true`, `totalMinutes < 0`) → buckets 25/50 restent 0, `cumulativeBalance`
|
||||
réduit du déficit (pas de drainage des tranches). Vérifier aussi qu'une semaine 35h/39h
|
||||
déficitaire continue de drainer (non-régression).
|
||||
- **`tests/Service/Rtt/RttRecoveryComputationServiceTest.php`** : si réalisable en intégration —
|
||||
contrat CUSTOM, semaine travaillée sous les heures → `totalMinutes` négatif et
|
||||
`isFlatRecovery = true`.
|
||||
|
||||
## Documentation (obligatoire, même intervention)
|
||||
|
||||
- `doc/rtt-rollover.md` et/ou `doc/rtt-tab.md` : mettre à jour la règle CUSTOM (déficit
|
||||
désormais compté).
|
||||
- `frontend/data/documentation-content.ts` : section RTT — déficit des contrats CUSTOM.
|
||||
- `CLAUDE.md` : section « Overtime Rules » — corriger « deficit doesn't impact balance » pour les
|
||||
CUSTOM ; documenter `isFlatRecovery`.
|
||||
@@ -364,8 +364,8 @@ export const documentationSections: DocSection[] = [
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Les règles de calcul des heures supplémentaires dépendent du type de contrat.' },
|
||||
{ type: 'list', content: 'Contrats ≤ 35h : +25% de 35h à 43h, +50% au-delà de 43h\nContrats ≥ 39h : +25% de 39h à 43h, +50% au-delà de 43h\nContrats CUSTOM (4h, 25h, etc.) : 1h supplémentaire = 1h de récupération, pas de bonus\nINTERIM : aucune récupération, aucun bonus' },
|
||||
{ type: 'note', content: 'En cas de déficit hebdomadaire (heures travaillées < heures contrat), le déficit est déduit du cumul RTT : d\'abord des heures à 50%, puis des heures à 25%.' },
|
||||
{ type: 'list', content: 'Contrats ≤ 35h : +25% de 35h à 43h, +50% au-delà de 43h\nContrats ≥ 39h : +25% de 39h à 43h, +50% au-delà de 43h\nContrats CUSTOM (4h, 25h, etc.) : 1h supplémentaire = 1h de récupération, pas de bonus. Une semaine sous les heures contractuelles réduit le cumul RTT (1h manquante = -1h), sans passer par les tranches 25/50\nINTERIM : aucune récupération, aucun bonus' },
|
||||
{ type: 'note', content: 'En cas de déficit hebdomadaire (heures travaillées < heures contrat), le déficit est déduit du cumul RTT. Pour un 35h/39h, il est puisé d\'abord dans les heures à 50%, puis à 25%. Pour un contrat CUSTOM (4h, etc.), il réduit directement le cumul (pas de tranches 25/50) ; le cumul peut devenir négatif et est reporté à l\'exercice suivant.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -525,6 +525,7 @@ export const documentationSections: DocSection[] = [
|
||||
{ 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: 'note', content: 'Contrats CUSTOM (ex. 4h) : une semaine travaillée sous les heures contractuelles génère un déficit qui réduit le cumul RTT (1h manquante = -1h), sans tranches 25/50. Le cumul peut devenir négatif et est reporté à l\'exercice suivant.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ export type EmployeeRttWeekSummary = {
|
||||
bonus50Minutes: number
|
||||
totalMinutes: number
|
||||
cumulativeBalanceMinutes: number
|
||||
isFlatRecovery: boolean
|
||||
}
|
||||
|
||||
export type RttMonthPayment = {
|
||||
|
||||
@@ -636,6 +636,7 @@ final class DumpVerificationSnapshotCommand extends Command
|
||||
base50Minutes: $detail->base50Minutes,
|
||||
bonus50Minutes: $detail->bonus50Minutes,
|
||||
totalMinutes: $detail->totalMinutes,
|
||||
isFlatRecovery: $detail->isFlatRecovery,
|
||||
);
|
||||
|
||||
continue;
|
||||
@@ -672,6 +673,7 @@ final class DumpVerificationSnapshotCommand extends Command
|
||||
base50Minutes: (int) round($detail->base50Minutes * $ratio),
|
||||
bonus50Minutes: (int) round($detail->bonus50Minutes * $ratio),
|
||||
totalMinutes: (int) round($detail->totalMinutes * $ratio),
|
||||
isFlatRecovery: $detail->isFlatRecovery,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -692,7 +694,7 @@ final class DumpVerificationSnapshotCommand extends Command
|
||||
$cumulative25 = $carry->base25Minutes + $carry->bonus25Minutes;
|
||||
|
||||
foreach ($weeks as $i => $week) {
|
||||
if ($week->totalMinutes >= 0) {
|
||||
if ($week->totalMinutes >= 0 || $week->isFlatRecovery) {
|
||||
$cumulative50 += $week->base50Minutes + $week->bonus50Minutes;
|
||||
$cumulative25 += $week->base25Minutes + $week->bonus25Minutes;
|
||||
|
||||
@@ -714,6 +716,7 @@ final class DumpVerificationSnapshotCommand extends Command
|
||||
base50Minutes: $from50 > 0 ? -$from50 : 0,
|
||||
bonus50Minutes: 0,
|
||||
totalMinutes: $week->totalMinutes,
|
||||
isFlatRecovery: $week->isFlatRecovery,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,5 +18,6 @@ final class EmployeeRttWeekSummary
|
||||
public int $bonus50Minutes = 0,
|
||||
public int $totalMinutes = 0,
|
||||
public int $cumulativeBalanceMinutes = 0,
|
||||
public bool $isFlatRecovery = false,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -17,5 +17,6 @@ final class WeekRecoveryDetail
|
||||
public int $bonus50Minutes = 0,
|
||||
public int $totalMinutes = 0,
|
||||
public array $dailyMinutes = [],
|
||||
public bool $isFlatRecovery = false,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@ final readonly class RttRecoveryComputationService
|
||||
$overtimeReferenceMinutes = $isCustomContract
|
||||
? $this->computeWeeklyCustomReferenceMinutes($weekDays, $employeeContractsByDate)
|
||||
: $this->computeWeeklyOvertimeReferenceMinutes($weekDays, $employeeContractsByDate);
|
||||
$overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($weekDays, $employeeContractsByDate);
|
||||
$overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($weekDays, $employeeContractsByDate);
|
||||
// Plafond séparant 25 %/50 % : seuil de départ proraté + largeur de la bande +25 %
|
||||
// (4h pour un 39h, 8h pour un 35h). Il se décale ainsi avec une embauche en milieu
|
||||
// de semaine au lieu de rester bloqué à 43h, ce qui ouvre la tranche 50 %.
|
||||
@@ -246,33 +246,69 @@ final readonly class RttRecoveryComputationService
|
||||
|
||||
[$rawBase25, $rawBase50] = $this->computeOvertimeBaseMinutes($weeklyTotalMinutes, $overtime25StartMinutes, $overtime50StartMinutes);
|
||||
|
||||
$base25 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : $rawBase25;
|
||||
$bonus25 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : (int) round($base25 * 0.25);
|
||||
$base50 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : $rawBase50;
|
||||
$bonus50 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : (int) round($base50 * 0.5);
|
||||
|
||||
if ($isWeekPresenceTracking || $disableOvertimeBonuses) {
|
||||
$totalMinutes = 0;
|
||||
} elseif ($isCustomContract) {
|
||||
$totalMinutes = max(0, $weeklyOvertimeTotalMinutes);
|
||||
} else {
|
||||
$totalMinutes = $weeklyOvertimeTotalMinutes + $bonus25 + $bonus50;
|
||||
}
|
||||
|
||||
$results[$weekKey] = new WeekRecoveryDetail(
|
||||
overtimeMinutes: $weeklyOvertimeTotalMinutes,
|
||||
base25Minutes: $base25,
|
||||
bonus25Minutes: $bonus25,
|
||||
base50Minutes: $base50,
|
||||
bonus50Minutes: $bonus50,
|
||||
totalMinutes: $totalMinutes,
|
||||
dailyMinutes: $dailyWorkedMinutes,
|
||||
$results[$weekKey] = $this->buildWeekRecoveryDetail(
|
||||
$isWeekPresenceTracking,
|
||||
$disableOvertimeBonuses,
|
||||
$isCustomContract,
|
||||
$weeklyOvertimeTotalMinutes,
|
||||
$rawBase25,
|
||||
$rawBase50,
|
||||
$dailyWorkedMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble le détail de récupération d'une semaine à partir des drapeaux résolus et
|
||||
* des bandes d'heures sup brutes.
|
||||
*
|
||||
* - PRESENCE / INTERIM (bonus désactivés) : aucune récupération.
|
||||
* - CUSTOM : récupération plate 1h = 1h, sans tranches 25/50 ; l'heure sup signée EST
|
||||
* le total, donc une semaine travaillée sous les heures contractuelles produit un
|
||||
* total négatif (déficit qui réduit le solde). Marquée isFlatRecovery pour que le
|
||||
* provider ne draine pas les tranches 25/50.
|
||||
* - Standard 35h/39h : heures sup + bonus 25 %/50 %.
|
||||
*
|
||||
* @param array<string, int> $dailyMinutes
|
||||
*/
|
||||
private function buildWeekRecoveryDetail(
|
||||
bool $isPresence,
|
||||
bool $disableBonuses,
|
||||
bool $isCustom,
|
||||
int $overtimeTotalMinutes,
|
||||
int $rawBase25,
|
||||
int $rawBase50,
|
||||
array $dailyMinutes,
|
||||
): WeekRecoveryDetail {
|
||||
$noBands = $isPresence || $disableBonuses || $isCustom;
|
||||
|
||||
$base25 = $noBands ? 0 : $rawBase25;
|
||||
$bonus25 = $noBands ? 0 : (int) round($base25 * 0.25);
|
||||
$base50 = $noBands ? 0 : $rawBase50;
|
||||
$bonus50 = $noBands ? 0 : (int) round($base50 * 0.5);
|
||||
|
||||
if ($isPresence || $disableBonuses) {
|
||||
$totalMinutes = 0;
|
||||
} elseif ($isCustom) {
|
||||
$totalMinutes = $overtimeTotalMinutes; // signé : le déficit réduit le solde
|
||||
} else {
|
||||
$totalMinutes = $overtimeTotalMinutes + $bonus25 + $bonus50;
|
||||
}
|
||||
|
||||
return new WeekRecoveryDetail(
|
||||
overtimeMinutes: $overtimeTotalMinutes,
|
||||
base25Minutes: $base25,
|
||||
bonus25Minutes: $bonus25,
|
||||
base50Minutes: $base50,
|
||||
bonus50Minutes: $bonus50,
|
||||
totalMinutes: $totalMinutes,
|
||||
dailyMinutes: $dailyMinutes,
|
||||
isFlatRecovery: $isCustom,
|
||||
);
|
||||
}
|
||||
|
||||
private function computeMetrics(WorkHour $workHour): WorkMetrics
|
||||
{
|
||||
$driverDay = $workHour->getDayHoursMinutes() ?? 0;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,23 @@ final class RttClosingBalanceServiceTest extends TestCase
|
||||
);
|
||||
}
|
||||
|
||||
public function testCustomDeficitWeekReducesClosingBalance(): void
|
||||
{
|
||||
// CUSTOM (4h) : une semaine de récup +3h puis une semaine déficitaire -1h
|
||||
// (toutes deux sans tranches 25/50). Le déficit doit réduire la clôture.
|
||||
$recovery = new WeekRecoveryDetail(totalMinutes: 180, isFlatRecovery: true); // +3h
|
||||
$deficit = new WeekRecoveryDetail(totalMinutes: -60, isFlatRecovery: true); // -1h
|
||||
|
||||
$closing = $this->service()->fold(new WeekRecoveryDetail(), [$recovery, $deficit], $this->payments());
|
||||
|
||||
// 3h - 1h = 2h reportées, et la somme des buckets égale toujours le total.
|
||||
self::assertSame(120, $closing->totalMinutes);
|
||||
self::assertSame(
|
||||
120,
|
||||
$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);
|
||||
|
||||
@@ -113,6 +113,54 @@ final class RttRecoveryComputationServiceTest extends TestCase
|
||||
self::assertSame(3 * 60, $base50);
|
||||
}
|
||||
|
||||
public function testBuildWeekDetailCustomDeficitKeepsSignedTotalAndFlatFlag(): void
|
||||
{
|
||||
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||
|
||||
// CUSTOM, semaine sous les heures : overtime -120 (worked 2h sur réf 4h).
|
||||
$detail = $this->invokePrivate(
|
||||
$service,
|
||||
'buildWeekRecoveryDetail',
|
||||
false, // isPresence
|
||||
false, // disableBonuses
|
||||
true, // isCustom
|
||||
-120, // overtimeTotalMinutes
|
||||
0, // rawBase25
|
||||
0, // rawBase50
|
||||
[], // dailyMinutes
|
||||
);
|
||||
|
||||
self::assertSame(-120, $detail->totalMinutes);
|
||||
self::assertTrue($detail->isFlatRecovery);
|
||||
self::assertSame(0, $detail->base25Minutes);
|
||||
self::assertSame(0, $detail->base50Minutes);
|
||||
}
|
||||
|
||||
public function testBuildWeekDetailCustomPositiveIsFlatOneToOne(): void
|
||||
{
|
||||
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||
|
||||
$detail = $this->invokePrivate($service, 'buildWeekRecoveryDetail', false, false, true, 180, 0, 0, []);
|
||||
|
||||
self::assertSame(180, $detail->totalMinutes); // 1h = 1h
|
||||
self::assertTrue($detail->isFlatRecovery);
|
||||
}
|
||||
|
||||
public function testBuildWeekDetailStandardKeepsBucketsAndBonuses(): void
|
||||
{
|
||||
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||
|
||||
// 39h : overtime 300, base25 240, base50 60.
|
||||
$detail = $this->invokePrivate($service, 'buildWeekRecoveryDetail', false, false, false, 300, 240, 60, []);
|
||||
|
||||
self::assertFalse($detail->isFlatRecovery);
|
||||
self::assertSame(240, $detail->base25Minutes);
|
||||
self::assertSame(60, $detail->bonus25Minutes); // round(240 * 0.25)
|
||||
self::assertSame(60, $detail->base50Minutes);
|
||||
self::assertSame(30, $detail->bonus50Minutes); // round(60 * 0.5)
|
||||
self::assertSame(300 + 60 + 30, $detail->totalMinutes);
|
||||
}
|
||||
|
||||
private function invokePrivate(object $obj, string $method, mixed ...$args): mixed
|
||||
{
|
||||
return new ReflectionClass($obj::class)->getMethod($method)->invoke($obj, ...$args);
|
||||
|
||||
@@ -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,45 @@ final class EmployeeRttSummaryProviderTest extends TestCase
|
||||
self::assertSame(2030, $year);
|
||||
}
|
||||
|
||||
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,6 +287,21 @@ final class EmployeeRttSummaryProviderTest extends TestCase
|
||||
return $employee;
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an uninitialized provider with a RequestStack pre-loaded with the given query.
|
||||
*
|
||||
@@ -256,7 +311,7 @@ final class EmployeeRttSummaryProviderTest extends TestCase
|
||||
* only setting the properties that the tested private methods actually read:
|
||||
* `requestStack` and `phaseResolver`.
|
||||
*
|
||||
* @param array<string, string> $request query parameters (year, phaseId, ...)
|
||||
* @param array<string, string> $request
|
||||
*/
|
||||
private function buildProvider(array $request = []): EmployeeRttSummaryProvider
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user