[#SIRH-36] corriger calcule rtt contrat custom (#27)
Auto Tag Develop / tag (push) Successful in 7s

| 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: #27
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #27.
This commit is contained in:
2026-06-11 08:36:57 +00:00
committed by Autin
parent 081d92b9f4
commit f0387233e4
20 changed files with 1997 additions and 57 deletions
@@ -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,522 @@
# RTT — Déficit jour de solidarité (CUSTOM < 35h) — 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:** Sur le Lundi de Pentecôte, retrancher au cumul RTT des contrats CUSTOM < 35h un déficit forfaitaire de `7/35 × heuresHebdo` (= 12 min/heure hebdo), net et inconditionnel, sans rien changer aux autres contrats.
**Architecture :** Un service pur `SolidarityDayResolver` calcule le Lundi de Pentecôte par computus (Pâques + 50 j). `RttRecoveryComputationService::computeRecoveryByWeek` (calcul partagé : onglet RTT, clôture/rollover, commande de vérification) neutralise le jour de solidarité pour les CUSTOM < 35h et applique le prorata, en le faisant transiter par `totalMinutes` via le mécanisme `isFlatRecovery` existant (reporté en N+1, ne draine pas les tranches 25/50).
**Tech Stack :** PHP 8.4, Symfony, PHPUnit. Tests purs via `ReflectionClass::newInstanceWithoutConstructor` (pattern existant dans `RttRecoveryComputationServiceTest`).
**Spec :** `docs/superpowers/specs/2026-06-11-rtt-solidarity-day-deficit-design.md`
---
## File Structure
- **Create** `src/Service/Rtt/SolidarityDayResolver.php` — service pur, computus de Pâques + Lundi de Pentecôte. Responsabilité unique : donner la date du jour de solidarité d'une année.
- **Create** `tests/Service/Rtt/SolidarityDayResolverTest.php` — tests des dates 2024/2025/2026.
- **Modify** `src/Service/Rtt/RttRecoveryComputationService.php` — injecter `SolidarityDayResolver` ; ajouter `resolveSolidarityDatesInRange()` + `computeSolidarityDeficitAdjustment()` ; appliquer dans `computeRecoveryByWeek()`.
- **Modify** `tests/Service/Rtt/RttRecoveryComputationServiceTest.php` — tests réflexion de `computeSolidarityDeficitAdjustment()`.
- **Modify (docs)** `CLAUDE.md`, `frontend/data/documentation-content.ts`, `doc/rtt-tab.md`, `doc/functional-rules.md`.
- **Inchangé** : `config/services.yaml` (autowiring : `SolidarityDayResolver` est un service autowireable, et `RttRecoveryComputationService` n'override que `$rttStartDate` — les autres args s'autowirent), `DumpVerificationSnapshotCommand.php` (consomme `WeekRecoveryDetail.totalMinutes`, hérite du déficit), `RttTab.vue`, migrations.
---
## Task 1: SolidarityDayResolver (computus Pâques + Pentecôte)
**Files:**
- Create: `src/Service/Rtt/SolidarityDayResolver.php`
- Test: `tests/Service/Rtt/SolidarityDayResolverTest.php`
- [ ] **Step 1: Write the failing test**
Create `tests/Service/Rtt/SolidarityDayResolverTest.php`:
```php
<?php
declare(strict_types=1);
namespace App\Tests\Service\Rtt;
use App\Service\Rtt\SolidarityDayResolver;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class SolidarityDayResolverTest extends TestCase
{
/**
* Lundi de Pentecôte = dimanche de Pâques + 50 jours.
* 2024 : Pâques 31/03 → 20/05 ; 2025 : Pâques 20/04 → 09/06 ; 2026 : Pâques 05/04 → 25/05.
*
* @dataProvider pentecostCases
*/
public function testPentecostMonday(int $year, string $expected): void
{
$resolver = new SolidarityDayResolver();
self::assertSame($expected, $resolver->pentecostMonday($year)->format('Y-m-d'));
}
/**
* @return iterable<string, array{int, string}>
*/
public static function pentecostCases(): iterable
{
yield '2024' => [2024, '2024-05-20'];
yield '2025' => [2025, '2025-06-09'];
yield '2026' => [2026, '2026-05-25'];
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `make test` (or `docker exec php-sirh-fpm php bin/phpunit tests/Service/Rtt/SolidarityDayResolverTest.php`)
Expected: FAIL — `Class "App\Service\Rtt\SolidarityDayResolver" not found`.
- [ ] **Step 3: Write the implementation**
Create `src/Service/Rtt/SolidarityDayResolver.php`:
```php
<?php
declare(strict_types=1);
namespace App\Service\Rtt;
use DateTimeImmutable;
/**
* Résout le jour de solidarité (Lundi de Pentecôte) d'une année.
*
* Pur et déterministe : Pâques via l'algorithme de Meeus/Jones/Butcher (calendrier
* grégorien), sans dépendance à l'extension calendar ni au réseau. Lundi de Pentecôte
* = dimanche de Pâques + 50 jours.
*/
final class SolidarityDayResolver
{
public function pentecostMonday(int $year): DateTimeImmutable
{
return $this->easterSunday($year)->modify('+50 days');
}
private function easterSunday(int $year): DateTimeImmutable
{
$a = $year % 19;
$b = intdiv($year, 100);
$c = $year % 100;
$d = intdiv($b, 4);
$e = $b % 4;
$f = intdiv($b + 8, 25);
$g = intdiv($b - $f + 1, 3);
$h = (19 * $a + $b - $d - $g + 15) % 30;
$i = intdiv($c, 4);
$k = $c % 4;
$l = (32 + 2 * $e + 2 * $i - $h - $k) % 7;
$m = intdiv($a + 11 * $h + 22 * $l, 451);
$month = intdiv($h + $l - 7 * $m + 114, 31);
$day = (($h + $l - 7 * $m + 114) % 31) + 1;
return new DateTimeImmutable(sprintf('%04d-%02d-%02d', $year, $month, $day));
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `make test`
Expected: PASS (3 nouveaux tests verts, le reste de la suite inchangé).
- [ ] **Step 5: Commit**
```bash
git add src/Service/Rtt/SolidarityDayResolver.php tests/Service/Rtt/SolidarityDayResolverTest.php
git commit -m "feat(rtt) : add SolidarityDayResolver (Pentecost Monday via computus)"
```
---
## Task 2: Déficit solidarité dans RttRecoveryComputationService
**Files:**
- Modify: `src/Service/Rtt/RttRecoveryComputationService.php`
- Test: `tests/Service/Rtt/RttRecoveryComputationServiceTest.php`
Le helper `computeSolidarityDeficitAdjustment()` est **pur** (n'utilise que `ContractType::resolve` et les getters de `Contract`) → testable via `newInstanceWithoutConstructor` comme les autres helpers du fichier. Il renvoie le **delta** à ajouter à `weeklyOvertimeTotalMinutes`.
Rappel arithmétique (Ewa, 4h, lundi, `expected = workDaysHours[lundi] = 120`, `prorata = round(4×12) = 48`) :
- RTT posé / jour vide (`worked = 0`) → delta `(1200)48 = +72` ; appliqué au naturel `120` ⇒ semaine **48 min**.
- travaillé normalement (`worked = 120`) → delta `(120120)48 = 48` ; naturel `0`**48 min**.
- travaillé en plus (`worked = 240`) → delta `(120240)48 = 168` ; naturel `+120`**48 min**.
- [ ] **Step 1: Write the failing test (pure helper)**
Add these methods to `tests/Service/Rtt/RttRecoveryComputationServiceTest.php` (before the `invokePrivate` helper). Note `use` additions at top: `use App\Enum\TrackingMode;` (already imports `App\Entity\Contract`).
```php
private static function customContract(int $weeklyHours): Contract
{
return new Contract()
->setName('Temps partiel')
->setTrackingMode(TrackingMode::TIME)
->setWeeklyHours($weeklyHours)
;
}
/**
* CUSTOM 4h, jour de solidarité non travaillé (RTT posé ou vide) : delta = (attendu 0) prorata.
* attendu lundi = workDaysHours = 120 ; prorata = round(4×12) = 48 ; delta = 120 48 = 72.
* (Combiné au naturel 120 de la semaine, donne 48 min.)
*/
public function testSolidarityAdjustmentCustomNotWorkedNeutralisesToProrata(): void
{
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
$delta = $this->invokePrivate(
$service,
'computeSolidarityDeficitAdjustment',
self::customContract(4),
120, // expectedMinutes (workDaysHours du lundi)
0, // workedMinutes (RTT posé / vide)
);
self::assertSame(72, $delta);
}
/**
* CUSTOM 4h, jour de solidarité travaillé normalement (120) : delta = (120 120) 48 = 48.
*/
public function testSolidarityAdjustmentCustomWorkedNormallyChargesProrata(): void
{
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', self::customContract(4), 120, 120);
self::assertSame(-48, $delta);
}
/**
* CUSTOM 4h, jour de solidarité travaillé en plus (240) : delta = (120 240) 48 = 168.
* Le surplus du jour de solidarité n'est PAS crédité (jour neutralisé, net forcé à prorata).
*/
public function testSolidarityAdjustmentCustomWorkedExtraStillNetsProrata(): void
{
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', self::customContract(4), 120, 240);
self::assertSame(-168, $delta);
}
/**
* CUSTOM 28h : prorata = round(28×12) = 336 (5h36). worked 0, expected 336 → delta 0.
*/
public function testSolidarityAdjustmentCustom28hUsesProrata(): void
{
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', self::customContract(28), 336, 0);
self::assertSame(0, $delta);
}
/**
* CUSTOM ≥ 35h (36h) : hors périmètre → delta 0.
*/
public function testSolidarityAdjustmentCustom36hOutOfScope(): void
{
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', self::customContract(36), 999, 0);
self::assertSame(0, $delta);
}
/**
* 35h : type H35 (pas CUSTOM) → delta 0 (comportement inchangé, RTT posé fait foi).
*/
public function testSolidarityAdjustment35hOutOfScope(): void
{
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
$contract = new Contract()->setName('35h')->setTrackingMode(TrackingMode::TIME)->setWeeklyHours(35);
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', $contract, 420, 0);
self::assertSame(0, $delta);
}
/**
* Aucun contrat ce jour-là (salarié parti / pas encore embauché) → delta 0.
*/
public function testSolidarityAdjustmentNoContractIsZero(): void
{
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', null, 0, 0);
self::assertSame(0, $delta);
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `make test`
Expected: FAIL — `computeSolidarityDeficitAdjustment` n'existe pas (réflexion : `Method ... does not exist`).
- [ ] **Step 3: Add the constructor dependency**
In `src/Service/Rtt/RttRecoveryComputationService.php`, add `SolidarityDayResolver` to the constructor (BEFORE the defaulted `$rttStartDate`, sinon erreur « param non-défaut après défaut »). `SolidarityDayResolver` est dans le même namespace `App\Service\Rtt` → aucun `use` à ajouter.
```php
public function __construct(
private WorkHourRepository $workHourRepository,
private AbsenceRepository $absenceRepository,
private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
private EmployeeContractResolver $contractResolver,
private DailyReferenceMinutesResolver $dailyReferenceResolver,
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
private SolidarityDayResolver $solidarityDayResolver,
string $rttStartDate = '',
) {
$this->rttStartDate = '' !== $rttStartDate ? new DateTimeImmutable($rttStartDate) : null;
}
```
- [ ] **Step 4: Add the two private methods**
In the same file, add these methods (e.g. just after `resolveWeekAnchorDate`, alongside the other private helpers):
```php
/**
* Lundi(s) de Pentecôte (jour de solidarité) inclus dans [from, to]. Un exercice
* Juin N-1 → Mai N couvre les années civiles N-1 et N ; on retient les dates dans la fenêtre.
*
* @return list<string> dates au format 'Y-m-d'
*/
private function resolveSolidarityDatesInRange(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$dates = [];
$firstYear = (int) $from->format('Y');
$lastYear = (int) $to->format('Y');
for ($year = $firstYear; $year <= $lastYear; ++$year) {
$candidate = $this->solidarityDayResolver->pentecostMonday($year);
if ($candidate >= $from && $candidate <= $to) {
$dates[] = $candidate->format('Y-m-d');
}
}
return $dates;
}
/**
* Déficit forfaitaire du jour de solidarité pour les contrats CUSTOM < 35h.
*
* Le jour est neutralisé puis chargé du prorata légal : on remplace la valeur réelle
* du jour ($workedMinutes : RTT posé, heures saisies, vide, ou crédit férié virtuel)
* par l'attendu contractuel du jour ($expectedMinutes = workDaysHours), puis on
* retranche le prorata = 7h/35h × heuresHebdo = 12 min par heure hebdo. Sur une
* semaine par ailleurs normale, le net vaut exactement prorata. Renvoie le delta à
* ajouter à weeklyOvertimeTotalMinutes (0 hors périmètre : non-CUSTOM ou ≥ 35h).
*/
private function computeSolidarityDeficitAdjustment(
?Contract $contractAtSolidarity,
int $expectedMinutes,
int $workedMinutes,
): int {
$weeklyHours = $contractAtSolidarity?->getWeeklyHours();
$type = ContractType::resolve(
$contractAtSolidarity?->getName(),
$contractAtSolidarity?->getTrackingMode(),
$weeklyHours,
);
if (ContractType::CUSTOM !== $type || null === $weeklyHours || $weeklyHours >= 35) {
return 0;
}
$prorata = (int) round($weeklyHours * 12);
return ($expectedMinutes - $workedMinutes) - $prorata;
}
```
- [ ] **Step 5: Wire it into `computeRecoveryByWeek`**
(a) Just before the weeks loop, after `$results = [];` (≈ line 165), resolve the solidarity dates once:
```php
$results = [];
$solidarityDates = $this->resolveSolidarityDatesInRange($periodFrom, $periodTo);
```
(b) Inside the week loop, immediately after `$weeklyOvertimeTotalMinutes = ...` is computed (the `$isWeekPresenceTracking ? 0 : $weeklyTotalMinutes - $overtimeReferenceMinutes;` assignment, ≈ line 243-245) and BEFORE the `[$rawBase25, $rawBase50] = ...` line, insert:
```php
foreach ($solidarityDates as $solidarityDate) {
// isset ⇒ le jour de solidarité fait partie du sommage de CETTE semaine
// (donc ≤ limitDate et ≥ rttStartDate). Sinon : jour futur ou hors service → pas de déficit.
if (!isset($dailyWorkedMinutes[$solidarityDate])) {
continue;
}
$contractAtSolidarity = $employeeContractsByDate[$solidarityDate] ?? null;
$solidarityIsoDay = (int) new DateTimeImmutable($solidarityDate)->format('N');
// Attendu RÉEL du jour (planning workDaysHours), pas la répartition uniforme :
// c'est ce qui rend la neutralisation correcte (cf. spec).
$solidarityExpected = $this->dailyReferenceResolver->resolve(
$contractAtSolidarity?->getWeeklyHours(),
$solidarityIsoDay,
$workDaysByDate[$employeeId][$solidarityDate] ?? null,
);
$weeklyOvertimeTotalMinutes += $this->computeSolidarityDeficitAdjustment(
$contractAtSolidarity,
$solidarityExpected,
$dailyWorkedMinutes[$solidarityDate],
);
}
```
- [ ] **Step 6: Run tests to verify they pass**
Run: `make test`
Expected: PASS — les 7 nouveaux tests verts, toute la suite verte (le pre-commit relance aussi la suite complète).
- [ ] **Step 7: Verify the service container still builds (autowiring)**
Run: `docker exec php-sirh-fpm php bin/console debug:container App\\Service\\Rtt\\RttRecoveryComputationService 2>&1 | tail -20`
Expected: le service est listé, sans erreur d'argument non résolu (`SolidarityDayResolver` autowiré). Si erreur d'autowiring : ajouter explicitement l'argument dans `config/services.yaml` sous `RttRecoveryComputationService` — mais normalement inutile.
- [ ] **Step 8: Commit**
```bash
git add src/Service/Rtt/RttRecoveryComputationService.php tests/Service/Rtt/RttRecoveryComputationServiceTest.php
git commit -m "feat(rtt) : solidarity-day deficit for CUSTOM <35h contracts"
```
---
## Task 3: Vérification sur données de production
**Files:** aucun fichier de code. Génère des snapshots « after » et compare.
Contexte : le workflow before/after existe déjà (`docs/verifications/` = avant, `docs/verifications-after/` = après). La commande `app:verification:snapshot` rend la vue onglet RTT par mois.
- [ ] **Step 1: Generate the "after" snapshot for the witnesses + a control**
Ewa (id 31, CUSTOM 4h), Nadia (id 22, CUSTOM 4h), et un témoin 35h ou 39h (choisir un id présent — vérifier en base) pour prouver la non-régression.
Run:
```bash
docker exec php-sirh-fpm php bin/console app:verification:snapshot 31 22 --rtt-year=2026 --output-dir=docs/verifications-after
```
Expected: génère les fichiers Markdown sans erreur.
- [ ] **Step 2: Inspect Ewa's solidarity week (S22, semaine du 25/05/2026)**
Ouvrir le snapshot d'Ewa (`docs/verifications-after/…`) et vérifier :
- Semaine du 2026-05-25 : **Heure** et **Total** = 0h48 (48 min), **Cumul** réduit de 48 min.
- Colonnes 25 % / 50 % = 0 sur cette semaine.
- La semaine du 2026-06-01 (lundi 1er juin) conserve son 2h existant, distinct.
Si l'écart ne vaut pas 48 min : NE PAS « ajuster jusqu'à ce que ça passe ». Relire `computeSolidarityDeficitAdjustment` et la valeur `expectedMinutes` (doit valoir `workDaysHours[lundi]`, ex. 120) — un écart signale un bug réel (utiliser systematic-debugging).
- [ ] **Step 3: Confirm non-regression for a standard contract**
Snapshot d'un employé 35h/39h ayant un RTT posé sur le 25/05/2026 : la semaine doit être **inchangée** vs `docs/verifications/` (le déficit solidarité ne s'applique pas, le RTT posé garde son effet).
- [ ] **Step 4: Commit the after-snapshots (regression baseline)**
```bash
git add docs/verifications-after
git commit -m "test(rtt) : after-snapshot proving solidarity deficit on Ewa/Nadia S22"
```
---
## Task 4: Documentation (règle projet obligatoire)
**Files:**
- Modify: `CLAUDE.md`
- Modify: `doc/functional-rules.md`
- Modify: `doc/rtt-tab.md`
- Modify: `frontend/data/documentation-content.ts`
- [ ] **Step 1: CLAUDE.md — section Overtime Rules**
Sous la puce CUSTOM existante (« CUSTOM contracts … Le déficit … réduit le cumul RTT 1:1 »), ajouter une sous-puce :
```markdown
- **Jour de solidarité (Lundi de Pentecôte) — CUSTOM < 35h** : le jour est neutralisé et chargé d'un déficit forfaitaire `7/35 × weeklyHours` = **12 min par heure hebdo** (4h→48 min, 25h→5h, 28h→5h36), retranché du cumul RTT (signé, reporté N+1, ne draine pas les tranches 25/50). Net = exactement prorata quel que soit ce qui est posé ce jour-là (RTT, heures, vide) → pas de double comptage avec le RTT que la RH pose aussi sur ce jour. Hors périmètre : 35h/39h/Forfait/Intérim et CUSTOM ≥ 35h (inchangés ; la RH pose un RTT qui draine ~7h). Date via `App\Service\Rtt\SolidarityDayResolver` (computus, indépendant d'`EXCLUDED_PUBLIC_HOLIDAYS`). Appliqué dans `RttRecoveryComputationService::{resolveSolidarityDatesInRange, computeSolidarityDeficitAdjustment}`.
```
- [ ] **Step 2: doc/functional-rules.md**
Dans la section RTT / heures supplémentaires (près des règles CUSTOM), ajouter un paragraphe :
```markdown
### Jour de solidarité (contrats CUSTOM < 35h)
Le Lundi de Pentecôte (jour de solidarité) impose une contribution proratisée aux temps
partiels < 35h. La RH pose un RTT sur ce jour pour tous les salariés ; pour les contrats
standard (35h/39h) cela draine ~7h du cumul RTT (comportement inchangé). Pour les CUSTOM
< 35h, poser un RTT entier n'a pas de sens : le logiciel **neutralise** le jour (quel que
soit ce qui y est saisi) et applique un déficit forfaitaire `7/35 × heuresHebdo`
(= 12 min par heure hebdo : 4h → 48 min, 28h → 5h36). Ce déficit réduit le cumul RTT
(peut le rendre négatif, reporté à l'exercice suivant) et se cumule avec les autres
déficits/surplus de la semaine. Date calculée par computus (Pâques + 50 jours),
indépendante de la liste `EXCLUDED_PUBLIC_HOLIDAYS`.
```
- [ ] **Step 3: doc/rtt-tab.md**
Dans la section « Règle de calcul — contrats CUSTOM », ajouter un sous-bloc :
```markdown
#### Jour de solidarité (CUSTOM < 35h)
Sur la semaine du Lundi de Pentecôte, un contrat CUSTOM < 35h porte un déficit
forfaitaire de `7/35 × heuresHebdo` (12 min/h hebdo, ex. 4h → 0h48) dans les colonnes
Heure / Total / Cumul (25 %/50 % restent à 0). Le montant est fixe et inconditionnel :
il ne dépend pas des heures saisies ni du RTT que la RH pose ce jour-là. Les contrats
35h/39h ne sont pas concernés ici (leur RTT posé draine le cumul normalement).
```
- [ ] **Step 4: frontend/data/documentation-content.ts**
Repérer l'article RTT pour les contrats partiels / CUSTOM (recherche `CUSTOM` ou `rtt-compteurs`). Ajouter un bloc de texte (échapper les apostrophes `\'`) décrivant la règle :
```
Jour de solidarité : pour un contrat de moins de 35h, le Lundi de Pentecôte applique un
déficit fixe proportionnel (7/35 des heures hebdo, soit 12 minutes par heure
hebdomadaire : 4h → 48 min). Ce déficit réduit le cumul RTT, peu importe ce qui est saisi
ce jour-là.
```
Respecter la structure `DocBlock` existante (même type de bloc que les paragraphes voisins).
- [ ] **Step 5: Run the suite and commit**
Run: `make test`
Expected: PASS (les docs ne cassent rien ; la suite reste verte).
```bash
git add CLAUDE.md doc/functional-rules.md doc/rtt-tab.md frontend/data/documentation-content.ts
git commit -m "docs(rtt) : document solidarity-day deficit for CUSTOM <35h"
```
---
## Self-review (rempli pendant la rédaction)
- **Couverture spec** : SolidarityDayResolver (T1) ✓ ; injection + neutralisation + prorata dans computeRecoveryByWeek (T2) ✓ ; périmètre CUSTOM < 35h + garde ≥ 35h (T2, `computeSolidarityDeficitAdjustment`) ✓ ; robustesse limitDate/rttStartDate via `isset($dailyWorkedMinutes)` (T2 step 5) ✓ ; contrat lu au jour de solidarité (T2 step 5) ✓ ; propagation clôture/rollover/snapshot via totalMinutes (inchangé, vérifié T3) ✓ ; cas limites (T2 tests + T3) ✓ ; docs (T4) ✓.
- **Pas de placeholder** : tout le code est fourni.
- **Cohérence des noms** : `pentecostMonday`, `resolveSolidarityDatesInRange`, `computeSolidarityDeficitAdjustment`, `$solidarityDayResolver`, `$dailyReferenceResolver`, `$workDaysByDate`, `$employeeId`, `$dailyWorkedMinutes`, `$employeeContractsByDate` — alignés avec le code existant de `computeRecoveryByWeek`.