docs(leave) : plan for forfait mid-year entry leave calculation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 16:02:46 +02:00
parent 750f2bffa8
commit 907eb1a277

View File

@@ -0,0 +1,492 @@
# FORFAIT mid-year entry — congés à poser 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:** Quand un employé entre en FORFAIT en cours d'année civile, calculer les congés à poser comme `jours_de_repos_proratisés + CP_acquis_reportés_de_la_phase_précédente`, au lieu du `max(0, businessDays 218)` actuel qui retourne 0.
**Architecture:** Modification ciblée de `EmployeeLeaveSummaryProvider`. Uniquement la **branche FORFAIT de `resolveLeavePolicy`**, et **uniquement l'année civile d'entrée** (période partielle). Les forfaits sur année pleine gardent le calcul 218 existant (régression nulle). Les jours de repos proratisés sont une arithmétique pure (testable isolément). Les CP reportés sont sourcés en ré-exécutant `computeYearSummary` sur la phase non-forfait précédente (récursion à un seul niveau, terminée car la phase précédente n'est pas FORFAIT).
**Tech Stack:** Symfony, API Platform State Provider, PHPUnit 12 (tests par réflexion sur `newInstanceWithoutConstructor`, cf. classes finales non-mockables).
**Règle métier de référence :** mémoire `forfait-218-prorata-pending`, validée par la comptable 2026-05-20. Cas témoin : Grégory BARRIBAULT (id 41), FORFAIT depuis 2026-05-01, précédé d'une phase 39h → **13 jours à poser** (`6 repos + ~7 CP nets`).
---
## File Structure
- **Modify** `src/State/EmployeeLeaveSummaryProvider.php`
- Nouvelle constante `FORFAIT_STANDARD_CP_DAYS = 25`
- Nouvelle méthode pure `computeProratedForfaitRepoDays(int $businessDaysYear, int $businessDaysPeriod): float`
- Nouvelle méthode `isForfaitEntryYear(ContractPhase $phase, int $year): bool`
- Nouvelle méthode `resolvePhaseImmediatelyBefore(Employee $employee, ContractPhase $phase): ?ContractPhase`
- Nouvelle méthode `resolveCarriedCpFromPriorPhase(Employee $employee, ContractPhase $forfaitPhase): float`
- Modification de la branche FORFAIT de `resolveLeavePolicy` (ligne ~713)
- **Modify** `tests/State/EmployeeLeaveSummaryProviderTest.php` (nouveaux tests, helpers existants réutilisés)
- **Modify** `CLAUDE.md` (section « Vue contrat » + « Onglet Congés »)
- **Modify** `doc/contract-phase-view.md` (section transition d'exercice FORFAIT)
- **Modify** `frontend/data/documentation-content.ts` (doc in-app onglet Congés FORFAIT)
---
## Task 1: Repos proratisés — helper arithmétique pur
**Files:**
- Modify: `src/State/EmployeeLeaveSummaryProvider.php`
- Test: `tests/State/EmployeeLeaveSummaryProviderTest.php`
- [ ] **Step 1: Write the failing test**
Ajouter dans `tests/State/EmployeeLeaveSummaryProviderTest.php` :
```php
public function testComputeProratedForfaitRepoDaysGregoryCase(): void
{
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
// 2026 : 252 jours ouvrés/an, 168 sur la période 01/05→31/12.
// repos année = 252 - 218 - 25 = 9 ; proratisé = 9 × 168/252 = 6.0
$result = $this->invokePrivate($provider, 'computeProratedForfaitRepoDays', 252, 168);
self::assertEqualsWithDelta(6.0, $result, 0.001);
}
public function testComputeProratedForfaitRepoDaysFullYearEquals9(): void
{
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
// Année pleine : 9 × 252/252 = 9.0
$result = $this->invokePrivate($provider, 'computeProratedForfaitRepoDays', 252, 252);
self::assertEqualsWithDelta(9.0, $result, 0.001);
}
public function testComputeProratedForfaitRepoDaysClampsNegativeToZero(): void
{
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
// Année avec trop peu de jours ouvrés (240 - 218 - 25 < 0) → 0
$result = $this->invokePrivate($provider, 'computeProratedForfaitRepoDays', 240, 160);
self::assertSame(0.0, $result);
}
public function testComputeProratedForfaitRepoDaysZeroYearGuard(): void
{
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
self::assertSame(0.0, $this->invokePrivate($provider, 'computeProratedForfaitRepoDays', 0, 0));
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `make test` (ou `docker compose exec -T php vendor/bin/phpunit tests/State/EmployeeLeaveSummaryProviderTest.php --filter computeProratedForfaitRepoDays`)
Expected: FAIL — `Method computeProratedForfaitRepoDays does not exist`.
- [ ] **Step 3: Write minimal implementation**
Dans `src/State/EmployeeLeaveSummaryProvider.php`, ajouter la constante près de `FORFAIT_TARGET_WORKED_DAYS` (ligne ~40) :
```php
private const int FORFAIT_TARGET_WORKED_DAYS = 218;
private const int FORFAIT_STANDARD_CP_DAYS = 25;
```
Et la méthode (à placer près de `resolveLeavePolicy`) :
```php
/**
* Jours de repos forfait proratisés sur la fraction de jours ouvrés couverte.
*
* Repos année pleine = jours_ouvrés_année 218 (cible travaillée) 25 (CP standard).
* Pour 2026 : 252 218 25 = 9, proratisés au ratio jours_ouvrés_période / jours_ouvrés_année.
*/
private function computeProratedForfaitRepoDays(int $businessDaysYear, int $businessDaysPeriod): float
{
if ($businessDaysYear <= 0) {
return 0.0;
}
$repoDaysYear = max(0, $businessDaysYear - self::FORFAIT_TARGET_WORKED_DAYS - self::FORFAIT_STANDARD_CP_DAYS);
return $repoDaysYear * $businessDaysPeriod / $businessDaysYear;
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `docker compose exec -T php vendor/bin/phpunit tests/State/EmployeeLeaveSummaryProviderTest.php --filter computeProratedForfaitRepoDays`
Expected: PASS (4 tests).
- [ ] **Step 5: Commit**
```bash
git add src/State/EmployeeLeaveSummaryProvider.php tests/State/EmployeeLeaveSummaryProviderTest.php
git commit -m "feat(leave) : add prorated forfait repo days helper"
```
---
## Task 2: Détection de l'année d'entrée en forfait
**Files:**
- Modify: `src/State/EmployeeLeaveSummaryProvider.php`
- Test: `tests/State/EmployeeLeaveSummaryProviderTest.php`
- [ ] **Step 1: Write the failing test**
```php
public function testIsForfaitEntryYearTrueOnStartYear(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$forfaitPhase = new EmployeeContractPhaseResolver()->resolvePhases($employee)[0];
$provider = $this->buildProvider();
self::assertTrue($this->invokePrivate($provider, 'isForfaitEntryYear', $forfaitPhase, 2026));
}
public function testIsForfaitEntryYearFalseOnSubsequentFullYear(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$forfaitPhase = new EmployeeContractPhaseResolver()->resolvePhases($employee)[0];
$provider = $this->buildProvider();
self::assertFalse($this->invokePrivate($provider, 'isForfaitEntryYear', $forfaitPhase, 2027));
}
public function testIsForfaitEntryYearFalseWhenForfaitStartsJan1(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2025-12-31', '2026-01-01');
$forfaitPhase = new EmployeeContractPhaseResolver()->resolvePhases($employee)[0];
$provider = $this->buildProvider();
// Forfait démarrant un 1er janvier = année pleine, pas une entrée en cours d'année.
self::assertFalse($this->invokePrivate($provider, 'isForfaitEntryYear', $forfaitPhase, 2026));
}
public function testIsForfaitEntryYearFalseForNonForfaitPhase(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$h39Phase = new EmployeeContractPhaseResolver()->resolvePhases($employee)[1];
$provider = $this->buildProvider();
self::assertFalse($this->invokePrivate($provider, 'isForfaitEntryYear', $h39Phase, 2026));
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `docker compose exec -T php vendor/bin/phpunit tests/State/EmployeeLeaveSummaryProviderTest.php --filter isForfaitEntryYear`
Expected: FAIL — `Method isForfaitEntryYear does not exist`.
- [ ] **Step 3: Write minimal implementation**
```php
/**
* Vrai si la phase FORFAIT démarre en cours de l'année civile consultée
* (donc avec une période partielle), faux pour une année pleine ou un démarrage le 1er janvier.
*/
private function isForfaitEntryYear(ContractPhase $phase, int $year): bool
{
if (ContractType::FORFAIT !== $phase->contractType) {
return false;
}
return (int) $phase->startDate->format('Y') === $year
&& '01-01' !== $phase->startDate->format('m-d');
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `docker compose exec -T php vendor/bin/phpunit tests/State/EmployeeLeaveSummaryProviderTest.php --filter isForfaitEntryYear`
Expected: PASS (4 tests).
- [ ] **Step 5: Commit**
```bash
git add src/State/EmployeeLeaveSummaryProvider.php tests/State/EmployeeLeaveSummaryProviderTest.php
git commit -m "feat(leave) : detect forfait mid-year entry exercise"
```
---
## Task 3: Résolution de la phase immédiatement précédente
**Files:**
- Modify: `src/State/EmployeeLeaveSummaryProvider.php`
- Test: `tests/State/EmployeeLeaveSummaryProviderTest.php`
- [ ] **Step 1: Write the failing test**
```php
public function testResolvePhaseImmediatelyBeforeReturnsPriorH39Phase(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
$forfaitPhase = $phases[0]; // current FORFAIT
$h39Phase = $phases[1];
$provider = $this->buildProvider();
$prior = $this->invokePrivate($provider, 'resolvePhaseImmediatelyBefore', $employee, $forfaitPhase);
self::assertNotNull($prior);
self::assertSame($h39Phase->id, $prior->id);
self::assertSame(ContractType::THIRTY_NINE_HOURS, $prior->contractType);
}
public function testResolvePhaseImmediatelyBeforeReturnsNullForFirstPhase(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
$firstPhase = $phases[1]; // the H39 (earliest)
$provider = $this->buildProvider();
self::assertNull($this->invokePrivate($provider, 'resolvePhaseImmediatelyBefore', $employee, $firstPhase));
}
```
> Note : si la signature exacte de `ContractType` du 39h n'est pas `THIRTY_NINE_HOURS`, ajuster l'assertion en lisant `src/Enum/ContractType.php`. Le test reste valide via `$prior->id`.
- [ ] **Step 2: Run test to verify it fails**
Run: `docker compose exec -T php vendor/bin/phpunit tests/State/EmployeeLeaveSummaryProviderTest.php --filter resolvePhaseImmediatelyBefore`
Expected: FAIL — `Method resolvePhaseImmediatelyBefore does not exist`.
- [ ] **Step 3: Write minimal implementation**
```php
/**
* Phase dont la date de début est la plus proche en deçà de celle de $phase
* (la phase qui précède immédiatement). Null si $phase est la première.
*/
private function resolvePhaseImmediatelyBefore(Employee $employee, ContractPhase $phase): ?ContractPhase
{
$prior = null;
foreach ($this->phaseResolver->resolvePhases($employee) as $candidate) {
if ($candidate->startDate >= $phase->startDate) {
continue;
}
if (null === $prior || $candidate->startDate > $prior->startDate) {
$prior = $candidate;
}
}
return $prior;
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `docker compose exec -T php vendor/bin/phpunit tests/State/EmployeeLeaveSummaryProviderTest.php --filter resolvePhaseImmediatelyBefore`
Expected: PASS (2 tests).
- [ ] **Step 5: Commit**
```bash
git add src/State/EmployeeLeaveSummaryProvider.php tests/State/EmployeeLeaveSummaryProviderTest.php
git commit -m "feat(leave) : resolve phase immediately preceding a given phase"
```
---
## Task 4: Source des CP reportés depuis la phase précédente
**Files:**
- Modify: `src/State/EmployeeLeaveSummaryProvider.php`
**Pas de test unitaire isolé** : la méthode ré-exécute `computeYearSummary` (dépendances repository non-mockables, cf. note `buildProvider`). La validation se fait en intégration (Task 6) contre le cas Grégory.
- [ ] **Step 1: Write implementation**
```php
/**
* CP nets encore disponibles (jours + samedis) hérités de la phase non-forfait
* précédant immédiatement une entrée en FORFAIT. 0 si aucune phase précédente
* ou si la précédente est elle-même un FORFAIT (nouvel embauché → cas 2).
*
* Le total disponible = remainingDays (acquis restant) + accruingDays (généré
* restant, samedis générés inclus) + remainingSaturdays (samedis acquis restant).
* Les congés déjà posés sous la phase précédente sont déjà déduits par
* computeYearSummary, donc on récupère bien le NET (ex. Grégory : 12 acquis 5 pris ≈ 7).
*/
private function resolveCarriedCpFromPriorPhase(Employee $employee, ContractPhase $forfaitPhase): float
{
$prior = $this->resolvePhaseImmediatelyBefore($employee, $forfaitPhase);
if (null === $prior || ContractType::FORFAIT === $prior->contractType) {
return 0.0;
}
$reference = $prior->endDate ?? new DateTimeImmutable('today');
$priorYear = $this->exerciseYearResolver->forDate($reference, false);
$summary = $this->computeYearSummary($employee, $priorYear, 0.0, null, $prior);
if (null === $summary) {
return 0.0;
}
return $summary['remainingDays'] + $summary['accruingDays'] + $summary['remainingSaturdays'];
}
```
- [ ] **Step 2: Verify it compiles / no regression**
Run: `docker compose exec -T php vendor/bin/phpunit tests/State/EmployeeLeaveSummaryProviderTest.php`
Expected: PASS (suite inchangée — la méthode n'est pas encore appelée).
- [ ] **Step 3: Commit**
```bash
git add src/State/EmployeeLeaveSummaryProvider.php
git commit -m "feat(leave) : source carried CP from prior non-forfait phase"
```
---
## Task 5: Brancher dans la branche FORFAIT de `resolveLeavePolicy`
**Files:**
- Modify: `src/State/EmployeeLeaveSummaryProvider.php:713` (branche `if (ContractType::FORFAIT === $type)`)
- [ ] **Step 1: Write implementation**
Au tout début de la branche FORFAIT de `resolveLeavePolicy`, avant le calcul `$businessDaysInPeriod` existant, insérer la dérivation « année d'entrée » :
```php
$type = $phase->contractType;
if (ContractType::FORFAIT === $type) {
$year = (int) $from->format('Y'); // période forfait = année civile
// Entrée en FORFAIT en cours d'année : repos proratisés + CP reportés de
// la phase précédente (au lieu de max(0, businessDays 218) qui donne 0).
if ($this->isForfaitEntryYear($phase, $year)) {
$yearStart = new DateTimeImmutable(sprintf('%d-01-01 00:00:00', $year));
$yearEnd = new DateTimeImmutable(sprintf('%d-12-31 00:00:00', $year));
$rawYearHolidays = $this->buildRawPublicHolidayMap($yearStart, $yearEnd);
$businessDaysYear = $this->countBusinessDays($yearStart, $yearEnd, $rawYearHolidays);
$businessDaysPeriod = $this->countBusinessDays($from, $to, $rawYearHolidays);
$repoDays = $this->computeProratedForfaitRepoDays($businessDaysYear, $businessDaysPeriod);
$carriedCp = $this->resolveCarriedCpFromPriorPhase($employee, $phase);
return [
'ruleCode' => LeaveRuleCode::FORFAIT_218->value,
'acquiredDays' => $repoDays + $carriedCp,
'acquiredSaturdays' => 0.0,
'accrualPerMonth' => 0.0,
'saturdayAccrualPerMonth' => 0.0,
'countOnlyCp' => false,
'splitSaturdays' => false,
];
}
// Année pleine : calcul 218 existant (INCHANGÉ).
$businessDaysInPeriod = $this->countBusinessDays($from, $to, $this->buildRawPublicHolidayMap($from, $to));
// ... reste de la branche FORFAIT existante (bonusDays + return) ...
```
> Conserver tel quel tout le bloc existant (`$publicHolidays`, `$weekdayHolidays`, `$bonusDays`, `return [...]`) pour le chemin année pleine. N'ajouter QUE le bloc `if ($this->isForfaitEntryYear(...))` au-dessus.
- [ ] **Step 2: Run the full provider test suite**
Run: `docker compose exec -T php vendor/bin/phpunit tests/State/EmployeeLeaveSummaryProviderTest.php`
Expected: PASS — les tests existants forfait année pleine (ex. `testForfaitPhaseStartingMidYearCapsFromAtPhaseStart`) restent verts (ils testent les bornes, pas l'acquired ; vérifier qu'aucun n'assert l'ancien acquired=0 sur l'entrée — si oui, mettre à jour cette assertion vers la nouvelle valeur).
- [ ] **Step 3: Run the whole backend suite**
Run: `make test`
Expected: PASS (suite complète).
- [ ] **Step 4: Commit**
```bash
git add src/State/EmployeeLeaveSummaryProvider.php
git commit -m "feat(leave) : forfait mid-year entry credits prorated repos + carried CP"
```
---
## Task 6: Validation d'intégration contre Grégory (id 41)
**Files:** aucun (validation sur la BDD prod locale).
`resolveCarriedCpFromPriorPhase` réutilise **le calcul que l'UI affiche déjà** pour la phase 39h (1 samedi + 5 pris + 6 restant = 12 acquis, soit **7 nets** non pris). Aucune calibration de date n'est nécessaire : on consomme le même `computeYearSummary` que l'écran 39h, donc le 13 tombe mécaniquement (6 repos + 7 nets).
- [ ] **Step 1: Confirmer Grégory = 13**
Onglet Congés, phase FORFAIT courante, année 2026. Relever `acquiredDays`, `takenDays`, `remainingDays`.
Attendu : `remainingDays ≈ 13` (acquis ≈ 13 = 6 repos + 7 CP nets ; pris = 0 dans la fenêtre mai→déc, les 5 d'avril restent rattachés à la phase 39h).
Croiser avec l'écran de la phase 39h : il doit toujours afficher 1 samedi + 5 pris + 6 restant (inchangé).
- [ ] **Step 2: Vérifier le cas 2 (nouvel embauché forfait)**
Pour un employé FORFAIT sans phase précédente (ou dont la précédente est FORFAIT), `remainingDays` doit valoir uniquement les repos proratisés (ex. 6 si entrée 01/05/2026). Vérifier sur un employé réel ou un cas synthétique.
- [ ] **Step 3: Vérifier la non-régression année pleine**
Pour un FORFAIT présent toute l'année 2026 : `acquiredDays` doit rester `34` (= 252 218). Vérifier sur un employé forfait existant.
---
## Task 7: Documentation (OBLIGATOIRE — règle CLAUDE.md)
**Files:**
- Modify: `doc/contract-phase-view.md`
- Modify: `CLAUDE.md`
- Modify: `frontend/data/documentation-content.ts`
- [ ] **Step 1: `doc/contract-phase-view.md`**
Dans la section « ### Phase FORFAIT (passée ou courante) », ajouter un paragraphe :
```markdown
**Entrée en FORFAIT en cours d'année civile** : l'année d'entrée (période partielle,
ex. 01/05 → 31/12) ne calcule pas `max(0, businessDays 218)` (qui donnerait 0) mais :
jours_repos_année = jours_ouvrés_année 218 25
jours_repos_proratisés = jours_repos_année × (jours_ouvrés_période / jours_ouvrés_année)
congés_à_poser = jours_repos_proratisés + CP_nets_reportés_phase_précédente
Les CP reportés proviennent de la phase non-forfait immédiatement précédente (net des
congés déjà pris). Un nouvel embauché forfait (pas de phase précédente) n'a que les
repos proratisés. Les années pleines suivantes du forfait gardent le calcul 218.
Exemple Grégory BARRIBAULT (forfait 01/05/2026 après 39h) : 6 repos + ~7 CP = 13 jours.
```
- [ ] **Step 2: `CLAUDE.md`**
Dans la section « ## Vue contrat (sélecteur de phase) », sous-puce « Onglet Congés », ajouter :
```markdown
- **Entrée FORFAIT en cours d'année** : l'exercice d'entrée crédite `repos_proratisés + CP_nets_reportés` (et non `max(0, businessDays218)`=0). Repos année = `jours_ouvrés_année 218 25`, proratisés par jours ouvrés. CP reportés = CP nets de la phase non-forfait précédente. Nouvel embauché = repos seuls. Années pleines suivantes = calcul 218 inchangé. Service : `EmployeeLeaveSummaryProvider::resolveLeavePolicy` (branche FORFAIT) + `computeProratedForfaitRepoDays`/`resolveCarriedCpFromPriorPhase`.
```
- [ ] **Step 3: `frontend/data/documentation-content.ts`**
Localiser la section congés FORFAIT (rechercher « forfait » / « 218 ») et ajouter une explication utilisateur : un forfait qui démarre en cours d'année voit ses congés à poser = jours de repos proratisés + reliquat de CP de son contrat précédent.
- [ ] **Step 4: Commit**
```bash
git add doc/contract-phase-view.md CLAUDE.md frontend/data/documentation-content.ts
git commit -m "docs(leave) : document forfait mid-year entry leave calculation"
```
---
## Self-Review
**Spec coverage :**
- Repos proratisés (jours ouvrés) → Task 1 ✓
- Périmètre « année d'entrée seulement » → Task 2 + garde Task 5 ✓
- CP reportés depuis phase précédente (cas 1) → Task 3 + 4 ✓
- Cas 2 (nouvel embauché, pas de report) → garde `null/FORFAIT` dans Task 4, validé Task 6 Step 3 ✓
- Congés anticipés déduits → via le NET de `computeYearSummary` de la phase précédente (Task 4 doc) ✓
- Non-régression année pleine (= 34) → garde Task 5 + validation Task 6 Step 4 ✓
- Docs obligatoires → Task 7 ✓
**Pas de risque de calibration :** la source CP réutilise le calcul déjà affiché par l'écran de la phase 39h (12 acquis / 7 nets), donc le 13 est mécanique.
**Type consistency :** `computeProratedForfaitRepoDays(int,int): float`, `isForfaitEntryYear(ContractPhase,int): bool`, `resolvePhaseImmediatelyBefore(Employee,ContractPhase): ?ContractPhase`, `resolveCarriedCpFromPriorPhase(Employee,ContractPhase): float` — signatures cohérentes entre tâches et appels.