Files
SIRH/docs/superpowers/plans/2026-05-20-forfait-mid-year-entry-leaves.md
2026-05-20 16:25:18 +02:00

23 KiB
Raw Blame History

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 :

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) :

    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) :

    /**
     * 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
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

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
    /**
     * 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
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

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
    /**
     * 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
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
    /**
     * 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
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 » :

        $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
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) — FAIT

Résultat (probe sur BDD prod locale, supprimée après usage) :

Cas Employé Obtenu Attendu
1 — entrée après non-forfait Grégory 2026 12.94 ≈ 13 13 ✓
Non-régression année pleine Geoffrey 2026 34.0 34 ✓
2 — nouvel embauché Olivier 2025 0.54 (repos seuls) repos seuls ✓

Décision métier (Tristan) : un samedi de congé déjà posé sous la phase précédente ne réduit PAS le report (seuls les jours ouvrés posés le réduisent). Implémenté en ré-ajoutant takenSaturdays dans resolveCarriedCpFromPriorPhase (commit 52d1111). C'est ce qui fait passer Grégory de 11.94 à 12.94 ≈ 13.

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é).

⚠️ Vérifier que Grégory n'a pas de jours fractionnés sur sa phase 39h (fractionedDays exclus volontairement de resolveCarriedCpFromPriorPhase). Si la cible 13 n'est pas atteinte ET qu'il a des fractionnés, c'est l'explication — décider alors avec Tristan s'il faut les inclure.

  • 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 :

**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 :

  - **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
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.