Files
SIRH/docs/superpowers/plans/2026-06-09-rtt-custom-deficit.md
T
tristan f0387233e4
Auto Tag Develop / tag (push) Successful in 7s
[#SIRH-36] corriger calcule rtt contrat custom (#27)
| 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>
2026-06-11 08:36:57 +00:00

26 KiB
Raw Blame History

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

    /**
     * @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) :

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

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

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

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

            [$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
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à) :

    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 :

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

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

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

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

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

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

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