diff --git a/CLAUDE.md b/CLAUDE.md index 57321a6..f53bf72 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,8 @@ ## Overtime Rules - Contracts <= 35h: +25% from 35h to 43h, +50% beyond - Contracts >= 39h: +25% from 39h to 43h, +50% beyond -- CUSTOM contracts (weeklyHours ≠ 35 and ≠ 39, not INTERIM/FORFAIT): reference = actual contractual hours, no 25%/50% bonuses (1h overtime = 1h recovery), deficit doesn't impact balance +- CUSTOM contracts (weeklyHours ≠ 35 and ≠ 39, not INTERIM/FORFAIT): reference = actual contractual hours, no 25%/50% bonuses (1h overtime = 1h recovery). **Le déficit (heures travaillées < heures contractuelles) réduit le cumul RTT 1:1** (peut devenir négatif, reporté à l'exercice suivant). Implémenté via `WeekRecoveryDetail::isFlatRecovery` / `EmployeeRttWeekSummary::isFlatRecovery` : ces semaines portent leur récup/déficit signé dans `totalMinutes` (`RttRecoveryComputationService::buildWeekRecoveryDetail`) et `EmployeeRttSummaryProvider::applyDeficitCascade` **ne draine pas** les tranches 25/50 pour elles (colonnes 25%/50% restent à 0). Le `RttClosingBalanceService::fold` reporte le déficit en N+1. + - **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 qui restent à 0). 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 y pose un RTT qui draine ~7h). Date via `App\Service\Rtt\SolidarityDayResolver` (computus, indépendant d'`EXCLUDED_PUBLIC_HOLIDAYS`). Appliqué dans `RttRecoveryComputationService::{resolveSolidarityDatesInRange, computeSolidarityDeficitAdjustment}`. - **Ancre de semaine (type de contrat)** : le type/nature de contrat d'une semaine RTT est résolu sur le **premier jour contracté** de la semaine, pas sur le lundi (`RttRecoveryComputationService::resolveWeekAnchorDate`). Sinon une semaine d'embauche en milieu de semaine (lundi hors contrat) serait classée CUSTOM → bonus 25%/50% désactivés à tort. Ex. CDD 39h embauché le jeudi : la semaine reste 39h, le seuil 25% est proraté aux jours contractés (`computeWeeklyOvertime25StartMinutes`), donc les heures au-delà ouvrent bien le +25%. - **Plafond 25%/50% proraté (mi-semaine)** : le plafond séparant 25% et 50% n'est **pas** codé en dur à 43h mais vaut `seuil_départ_proraté + largeur_bande_25%` (`RttRecoveryComputationService::{resolveOvertime25BandWidthMinutes, computeOvertimeBaseMinutes}`). Largeur = 43h − base (4h pour un 39h, 8h pour un 35h). Pour une semaine pleine le plafond redonne 43h (aucune régression) ; pour une embauche mi-semaine il se décale avec le départ, ouvrant la tranche 50%. Témoin Dylan (CDD 39h embauché jeudi, 22h) : 4h à 25% + 3h à 50%. **Hors périmètre** : l'écran Heures (`WorkHourWeeklySummaryProvider`) n'a pas cette proratisation (calcul dupliqué, laissé tel quel par décision métier). - INTERIM: no overtime bonuses, no recovery time diff --git a/doc/functional-rules.md b/doc/functional-rules.md index 149582d..160f37b 100644 --- a/doc/functional-rules.md +++ b/doc/functional-rules.md @@ -140,7 +140,20 @@ Documents complementaires: - Contrats CUSTOM (heures hebdo ≠ 35h et ≠ 39h, hors INTERIM/FORFAIT): - référence heures sup = heures contractuelles réelles (ex: 4h → référence 4h) - pas de bonus 25% ni 50% : 1 heure sup = 1 heure de récupération - - le déficit (travail < contrat) ne génère pas de récup mais n'impacte pas le solde + - le déficit (travail < contrat) réduit le cumul RTT 1:1 (peut devenir négatif, reporté N+1) + +### 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`. + - Nature `INTERIM`: - pas de bonus 25% - pas de bonus 50% diff --git a/doc/rtt-rollover.md b/doc/rtt-rollover.md index fa6d8c1..c69159e 100644 --- a/doc/rtt-rollover.md +++ b/doc/rtt-rollover.md @@ -96,6 +96,12 @@ Traitement par employe: > Regle clef : le report d'un exercice a l'autre reprend exactement le **disponible** affiche sur l'onglet RTT (cf. `EmployeeRttSummaryProvider`). Le report deja present au debut de l'exercice precedent n'est jamais perdu, et les heures deja payees ne sont pas re-creditees. Service mutualise : `App\Service\Rtt\RttClosingBalanceService`. +> Contrats CUSTOM : le solde de clôture intègre désormais les **déficits** hebdomadaires +> (semaines travaillées sous les heures contractuelles), via `RttClosingBalanceService::fold` +> qui gère les totaux négatifs. La clôture (donc le report d'ouverture N+1) peut être négative. +> Après une mise à jour de cette règle, rejouer `app:rtt:rollover --force --recompute` pour +> recalculer les lignes `employee_rtt_balances` non verrouillées calculées avec l'ancienne règle. + > Bug historique corrige : la version initiale ne reportait que `acquis N-1` (ni report d'ouverture, ni deduction des paiements), ce qui faisait disparaitre le solde de depart. Pour corriger des lignes deja creees a tort, relancer avec `--force --recompute`. ## 7) Donnees a fournir au go-live diff --git a/doc/rtt-tab.md b/doc/rtt-tab.md index 36ed0f8..c0287c0 100644 --- a/doc/rtt-tab.md +++ b/doc/rtt-tab.md @@ -16,6 +16,25 @@ L'onglet est **masqué pour les contrats FORFAIT** (filtre `showRttTab` dans `us Toujours **Juin (Y-1) → Mai (Y)**. Le champ `EmployeeRttSummary.year` correspond à `Y` (année de fin d'exercice) ; ex. `year=2026` = `01/06/2025 → 31/05/2026`. +## Règle de calcul — contrats CUSTOM (4h, 25h…) + +Pour un contrat CUSTOM, la récupération est **plate** (1h sup = 1h récup, sans bonus 25 %/50 %). +Depuis 2026-06, une semaine **travaillée sous les heures contractuelles** produit un **déficit +signé** dans la colonne « Heure » qui **réduit le « Total » et le « Cumul »** (1h manquante = +-1h). Les colonnes Base/25 %/50 % restent à **0** (pas de tranches pour ces contrats). Le cumul +peut devenir négatif ; il est reporté à l'exercice suivant. + +Techniquement : `WeekRecoveryDetail::isFlatRecovery` marque ces semaines ; +`EmployeeRttSummaryProvider::applyDeficitCascade` les exclut du drainage des tranches 25/50. + +#### 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). + ## Sélecteur d'année Position : sous la table, à l'intérieur de la zone scrollable, à gauche. diff --git a/docs/superpowers/plans/2026-06-09-rtt-custom-deficit.md b/docs/superpowers/plans/2026-06-09-rtt-custom-deficit.md new file mode 100644 index 0000000..f313609 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-rtt-custom-deficit.md @@ -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 $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 $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 $weeks + * + * @return list + */ + 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. ✓ diff --git a/docs/superpowers/plans/2026-06-11-rtt-solidarity-day-deficit.md b/docs/superpowers/plans/2026-06-11-rtt-solidarity-day-deficit.md new file mode 100644 index 0000000..f6a4fc4 --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-rtt-solidarity-day-deficit.md @@ -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 +pentecostMonday($year)->format('Y-m-d')); + } + + /** + * @return iterable + */ + 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 +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 `(120−0)−48 = +72` ; appliqué au naturel `−120` ⇒ semaine **−48 min**. +- travaillé normalement (`worked = 120`) → delta `(120−120)−48 = −48` ; naturel `0` ⇒ **−48 min**. +- travaillé en plus (`worked = 240`) → delta `(120−240)−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 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`. diff --git a/docs/superpowers/specs/2026-06-09-rtt-custom-deficit-design.md b/docs/superpowers/specs/2026-06-09-rtt-custom-deficit-design.md new file mode 100644 index 0000000..7678920 --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-rtt-custom-deficit-design.md @@ -0,0 +1,118 @@ +# Déficit RTT pris en compte pour les contrats CUSTOM (4h, etc.) + +Date : 2026-06-09 +Branche : `feature/SIRH-36-corriger-le-calcule-des-rtt-des-contrat-4h` + +## Contexte & problème + +Les salariées avec un contrat 4h (type CUSTOM : `weeklyHours` ≠ 35/39, non INTERIM/FORFAIT, +mode TIME) voient, dans l'onglet RTT, des semaines travaillées **en dessous** de leurs heures +contractuelles afficher un déficit dans la colonne « Heure » (ex. Ewa S23 : −2h) **sans aucun +effet** : « Total » = 0 et « Cumul » = 0. + +Cause : `RttRecoveryComputationService::computeRecoveryByWeek` écrête le total hebdo des CUSTOM +avec `totalMinutes = max(0, $weeklyOvertimeTotalMinutes)`. Le déficit est donc supprimé. C'est +le comportement métier documenté jusqu'ici (« CUSTOM : le déficit n'impacte pas le solde »). + +Décision métier (validée avec le client) : **le déficit doit être pris en compte et réduire le +cumul**, comme pour les 35h/39h, avec un affichage propre dans l'onglet RTT. + +## Décisions validées + +1. **Le cumul peut devenir négatif** (identique aux 35h/39h, comportement déjà assumé dans le + code — cf. `RttClosingBalanceService::fold` ligne 98 « leftover may push the balance + negative, as on screen »). Le négatif est reporté à l'exercice suivant. +2. **Déficit visible** en colonnes « Heure » + « Total » + « Cumul ». Les colonnes 25%/50% + restent à **0** pour un contrat CUSTOM (un 4h n'a pas de bonus, donc pas de tranches). + +## Principe technique + +Retirer l'écrêtage `max(0, …)` pour les semaines CUSTOM : le déficit (négatif) circule dans +`WeekRecoveryDetail::totalMinutes` et réduit le cumul. La seule spécificité CUSTOM reste : +récupération 1h = 1h, sans bonus 25/50. + +### Le point délicat : ne pas drainer les tranches 25/50 + +Pour un 35h/39h, une semaine déficitaire **draine** les tranches 25/50 accumulées via la cascade +de `EmployeeRttSummaryProvider` (lignes 149-174) et de `RttClosingBalanceService::fold` (lignes +92-99), ce qui affiche des valeurs négatives en « Total 25% / 50% ». + +Pour un CUSTOM, la récup n'est jamais bucketisée (elle vit uniquement dans `totalMinutes`). La +cascade ne doit donc **pas** s'appliquer aux semaines CUSTOM, sinon le déficit apparaîtrait en +négatif dans « Total 25% » (affichage sale, et incohérent avec les récups positives qui, elles, +n'y figurent pas). + +Solution : un drapeau **`isFlatRecovery`** (récupération plate 1:1, sans tranches) porté par la +semaine, qui désactive la cascade dans le provider. + +## Changements + +### Backend + +1. **`src/Dto/Rtt/WeekRecoveryDetail.php`** : ajout `public bool $isFlatRecovery = false`. +2. **`src/Dto/Rtt/EmployeeRttWeekSummary.php`** : ajout `public bool $isFlatRecovery = false`. +3. **`src/Service/Rtt/RttRecoveryComputationService.php`** + (`computeRecoveryByWeek`, branche `$isCustomContract`) : + - `totalMinutes = $weeklyOvertimeTotalMinutes` (signé — plus de `max(0, …)`). + - `isFlatRecovery: true` sur le `WeekRecoveryDetail` retourné. + - Les buckets `base25/bonus25/base50/bonus50` restent à 0 (inchangé). + - Cas non-CUSTOM et PRESENCE/INTERIM inchangés (`isFlatRecovery` reste `false`). +4. **`src/State/EmployeeRttSummaryProvider.php`** + - `buildWeekSummaries` : propager `isFlatRecovery` du `WeekRecoveryDetail` vers + l'`EmployeeRttWeekSummary` — dans le cas mono-mois **et** dans le cas semaine à cheval sur + deux mois (les deux instances héritent du drapeau). + - Cascade déficit (ligne 150) : condition devient + `if ($week->totalMinutes >= 0 || $week->isFlatRecovery)`. Pour une semaine CUSTOM + déficitaire, on ne draine pas : les buckets restent 0, `cumulativeBalanceMinutes` + (déjà basé sur `totalMinutes`, ligne 197) intègre le déficit. + - Dans la reconstruction de la branche `else` (semaine déficitaire normale), conserver + explicitement `isFlatRecovery: $week->isFlatRecovery` (toujours `false` à ce point, mais + explicite pour la clarté). +5. **`src/Command/DumpVerificationSnapshotCommand.php`** : ce command duplique la cascade du + provider (lignes ~695-716). Mettre à jour la condition de cascade pour respecter + `isFlatRecovery`, et propager le drapeau dans sa reconstruction des week summaries, afin que + les snapshots before/after restent fidèles à l'app. + +### Aucun changement + +- **`src/Service/Rtt/RttClosingBalanceService.php`** : `fold` gère déjà `totalMinutes` négatif + (branche déficit lignes 92-99) et le remainder CUSTOM (lignes 83-87). Le report N+1 intègre + donc automatiquement le déficit. Pas de modification. +- **`frontend/components/employees/RttTab.vue`** : aucun. Les sous-colonnes Base/25%/50% sont + déjà écrêtées à 0 sur les semaines déficitaires (`totalMinutes >= 0 ? … : 0`). Avec buckets = + 0 côté back, « Total 25%/50% » = 0, et « Heure »/« Total »/« Cumul » affichent le déficit. +- **Pas de migration** : aucun changement de schéma. + +## Effets de bord (assumés, cohérents) + +- **Récap congés** (`LeaveRecapRowBuilder::…` via `computeTotalRecoveryForExercise`) : la valeur + RTT reflètera aussi les déficits et peut devenir négative. Cohérent avec la décision. +- **Rollover / report** : la clôture d'exercice (`computeClosingBalance`) intègre désormais les + déficits. Les lignes `employee_rtt_balances` déjà stockées (calculées avec l'ancienne logique, + déficit = 0) doivent être rafraîchies après déploiement : + `php bin/console app:rtt:rollover --force --recompute` (ne touche pas les lignes + `is_locked`). Ex. Ewa : clôture 2026 passe de 0 à −2h, donc report d'ouverture 2027 = −2h. +- **Carry / Report row** : pour un CUSTOM, le report reste stocké/affiché dans la tranche + `base25` (convention pré-existante du `fold`, remainder parking) — comportement inchangé, hors + périmètre. + +## Tests (TDD) + +- **`tests/Service/Rtt/RttClosingBalanceServiceTest.php`** : nouveau cas — un `WeekRecoveryDetail` + CUSTOM **déficitaire** (`totalMinutes` négatif) diminue bien la clôture (somme = report + + Σ semaines − payés, négatif inclus). +- **`tests/State/EmployeeRttSummaryProviderTest.php`** : semaine CUSTOM déficitaire + (`isFlatRecovery = true`, `totalMinutes < 0`) → buckets 25/50 restent 0, `cumulativeBalance` + réduit du déficit (pas de drainage des tranches). Vérifier aussi qu'une semaine 35h/39h + déficitaire continue de drainer (non-régression). +- **`tests/Service/Rtt/RttRecoveryComputationServiceTest.php`** : si réalisable en intégration — + contrat CUSTOM, semaine travaillée sous les heures → `totalMinutes` négatif et + `isFlatRecovery = true`. + +## Documentation (obligatoire, même intervention) + +- `doc/rtt-rollover.md` et/ou `doc/rtt-tab.md` : mettre à jour la règle CUSTOM (déficit + désormais compté). +- `frontend/data/documentation-content.ts` : section RTT — déficit des contrats CUSTOM. +- `CLAUDE.md` : section « Overtime Rules » — corriger « deficit doesn't impact balance » pour les + CUSTOM ; documenter `isFlatRecovery`. diff --git a/docs/superpowers/specs/2026-06-11-rtt-solidarity-day-deficit-design.md b/docs/superpowers/specs/2026-06-11-rtt-solidarity-day-deficit-design.md new file mode 100644 index 0000000..eaa9f92 --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-rtt-solidarity-day-deficit-design.md @@ -0,0 +1,170 @@ +# Design — Déficit « jour de solidarité » pour les contrats CUSTOM < 35h + +Date : 2026-06-11 +Branche : `feature/SIRH-36-corriger-le-calcule-des-rtt-des-contrat-4h` +Statut : validé (brainstorming) + +## Contexte + +Le jour de solidarité (Lundi de Pentecôte) impose à chaque salarié une contribution +de travail non rémunérée : 7h/an pour un temps plein (35h), proratisée pour les temps +partiels. La RH matérialise cette contribution en **posant une absence de type RTT** +sur le Lundi de Pentecôte pour **tous** les salariés, y compris les contrats < 35h. + +Pour les contrats standard (35h/39h), poser un RTT d'une journée draine ~7h du cumul RTT +accumulé — ce qui correspond exactement à l'obligation. Ce comportement **fonctionne +déjà et ne doit pas changer**. + +Pour les contrats **CUSTOM < 35h** (ex. 4h, 25h, 28h — `weeklyHours ≠ 35 et ≠ 39`, +mode TIME), poser une absence RTT (type `R`, `countAsWorkedHours = false`) produit un +déficit égal au **créneau travaillé du jour** (ex. Ewa, 4h, travaille 2h le lundi → +−2h), et non au prorata légal attendu (`7/35 × 4h = 48 min`). Le montant naturel dépend +du planning du jour, pas de l'obligation. C'est le bug à corriger. + +## Règle métier validée + +- **Périmètre** : contrats **CUSTOM avec `weeklyHours < 35`** uniquement. 35h, 39h, + Forfait, Intérim, et CUSTOM ≥ 35h : aucun changement. +- **Date** : Lundi de Pentecôte (= Pâques + 50 jours), calculé par computus, indépendant + de l'env `EXCLUDED_PUBLIC_HOLIDAYS` (qui n'est plus la source de vérité). +- **Montant** : `prorata = round(weeklyHours × 12)` minutes (7h/35h × 60 = 12 min par + heure hebdo). Ex. 4h → 48 min, 25h → 5h00, 28h → 5h36. +- **Net forfaitaire et inconditionnel** : au net, le jour de solidarité vaut **exactement + `−prorata`** dans le cumul RTT, quel que soit ce qui est posé ce jour-là (absence RTT, + heures travaillées, ou rien). On **neutralise** l'effet naturel du jour puis on applique + le forfait. Garantit l'absence de double comptage avec le RTT posé par la RH, et reste + correct même si la RH oublie de poser le RTT. +- **Cumul** : le déficit se cumule avec tout autre déficit/surplus de la même semaine, + réduit le cumul RTT (peut le rendre négatif), et est reporté à l'exercice suivant. + +## Architecture + +### Point d'injection unique + +Tout passe par `App\Service\Rtt\RttRecoveryComputationService::computeRecoveryByWeek`, +le calcul **partagé** consommé par : +- `EmployeeRttSummaryProvider` (onglet RTT), +- `computeTotalRecoveryForExercise` → `RttClosingBalanceService` (clôture / rollover), +- `DumpVerificationSnapshotCommand` (commande de vérification). + +En posant le déficit dans `totalMinutes` / `overtimeMinutes` à cet endroit, il se propage +partout sans duplication. Le drapeau `isFlatRecovery` (déjà existant pour les CUSTOM) +reste `true` → le provider ne draine pas les tranches 25/50 et le fold reporte le déficit +en N+1. + +### Nouveau service pur : `SolidarityDayResolver` + +``` +final class SolidarityDayResolver +{ + // Lundi de Pentecôte = dimanche de Pâques + 50 jours. + public function pentecostMonday(int $year): DateTimeImmutable; + + // Easter via l'algorithme de Meeus/Jones/Butcher (calendrier grégorien), + // sans dépendance à l'extension calendar PHP. + private function easterSunday(int $year): DateTimeImmutable; +} +``` + +Pur, déterministe, aucune dépendance réseau (le chemin de calcul RTT n'a aujourd'hui +aucune dépendance HTTP — on le préserve). Trivial à tester unitairement. + +### Modification de `computeRecoveryByWeek` + +Le service reçoit `SolidarityDayResolver` par injection. Avant la boucle des semaines, on +résout les Lundi de Pentecôte des années civiles couvertes par `[periodFrom, periodTo]` +(exercice Juin N-1 → Mai N → années N-1 et N) et on retient ceux dans la fenêtre. + +Dans la boucle, après le calcul de `weeklyOvertimeTotalMinutes` et **uniquement** quand +un jour de solidarité `S` tombe dans la semaine **et** a été inclus dans le sommage +(`isset($dailyWorkedMinutes[S])`, donc `S ≤ limitDate` et `S ≥ rttStartDate`) : + +``` +$contractAtS = $employeeContractsByDate[$S] ?? null; +$weeklyHours = $contractAtS?->getWeeklyHours(); +$typeAtS = ContractType::resolve($contractAtS?->getName(), $contractAtS?->getTrackingMode(), $weeklyHours); + +if (ContractType::CUSTOM === $typeAtS && null !== $weeklyHours && $weeklyHours < 35) { + $isoDayS = (int) (new DateTimeImmutable($S))->format('N'); + $workDaysForS = $workDaysByDate[$employeeId][$S] ?? null; // {iso_day: minutes} + // Heures contractuelles RÉELLES du jour (planning workDaysHours), PAS la + // répartition uniforme weeklyHours/5 — c'est ce qui rend le net = -prorata. + $expectedS = $this->dailyReferenceResolver->resolve($weeklyHours, $isoDayS, $workDaysForS); + $workedS = $dailyWorkedMinutes[$S]; // déjà calculé dans la boucle des jours + $prorata = (int) round($weeklyHours * 12); + + // 1) faire compter le jour comme s'il était travaillé normalement (annule la + // valeur réelle du jour, quelle qu'elle soit : RTT posé, heures, vide, crédit + // férié virtuel) ; 2) appliquer le forfait solidarité. + $weeklyOvertimeTotalMinutes += ($expectedS - $workedS) - $prorata; +} +``` + +Puis `buildWeekRecoveryDetail(...)` est appelé tel quel : pour un CUSTOM, +`totalMinutes = overtimeMinutes = weeklyOvertimeTotalMinutes` (signé), bandes 25/50 = 0, +`isFlatRecovery = true`. + +> **Pourquoi `workDaysHours` et pas la référence hebdo CUSTOM** : la référence CUSTOM +> (`computeWeeklyCustomReferenceMinutes`) répartit `weeklyHours` uniformément sur les 5 +> jours ouvrés (`weeklyHours/5`), sans tenir compte du planning réel. Neutraliser le jour +> avec cette valeur uniforme (48 min pour le lundi d'Ewa) laisserait le manque des autres +> jours → −2h au lieu de −48 min. En neutralisant avec l'attendu RÉEL du jour +> (`workDaysHours[lundi] = 120 min`), le terme `(attendu − travaillé)` ramène la semaine à +> son net « normal » (0 pour une semaine pleine), puis le forfait applique exactement +> −prorata. `DailyReferenceMinutesResolver::resolve(weeklyHours, isoDay, workDaysMinutes)` +> renvoie déjà cet attendu réel quand `workDaysMinutes` est fourni (obligatoire pour tout +> CUSTOM < 35h). Fallback uniforme si absent. +> +> **Robustesse `EXCLUDED` / férié** : `(attendu − travaillé)` annule n'importe quelle +> valeur de `$workedS`, y compris un éventuel crédit férié virtuel si le Lundi de Pentecôte +> cessait d'être exclu. Le résultat ne dépend donc pas de l'état d'`EXCLUDED_PUBLIC_HOLIDAYS`. + +## Cas limites + +| Cas | Comportement | +|-----|--------------| +| Jour de solidarité futur (`> limitDate`) | Pas de déficit (semaine/jour non sommés). Appliqué une fois le jour passé. | +| Jour de solidarité avant `rttStartDate` | Pas de déficit (semaine zéro-ée en amont). | +| Changement de contrat dans la semaine | Contrat lu **au jour de solidarité**, pas à l'ancre de semaine. | +| Salarié non contracté ce jour-là | `contractAtS = null` → pas de déficit. | +| CUSTOM ≥ 35h (36–38h) | Hors périmètre → pas de déficit. | +| 35h/39h avec RTT posé | Inchangé (drainage ~7h via la cascade existante). | +| Autre déficit/surplus la même semaine | Le forfait s'y cumule. | + +## Tests + +### `SolidarityDayResolverTest` +- Pentecôte 2024 = 20 mai 2024 ; 2025 = 9 juin 2025 ; 2026 = 25 mai 2026. +- (optionnel) Pâques pivot : 2025 = 20 avril. + +### `RttRecoveryComputationServiceTest` (ajouts) +- CUSTOM 4h, RTT posé sur le jour de solidarité → semaine = −48 min, `isFlatRecovery = true`, + base/bonus 25/50 = 0. +- CUSTOM 4h, heures travaillées ce jour-là → semaine = −48 min (net forcé). +- CUSTOM 4h, rien de posé → semaine = −48 min. +- CUSTOM 4h avec un autre jour vide la même semaine → −48 min + l'autre déficit (cumul). +- CUSTOM 36h → 0 (hors périmètre). +- 35h avec RTT posé sur le jour de solidarité → inchangé (déficit plein, drainage tranches). +- Jour de solidarité au-delà de `limitDate` → 0. +- `computeTotalRecoveryForExercise` : le déficit solidarité se retrouve dans le total + d'exercice (→ clôture/report N+1). + +### Vérification données prod +- Ewa (id 31, 4h, Lun+Jeu) : semaine du 25 mai 2026 (S22) = −48 min ; le −2h de la S23 + (lundi 1er juin non saisi) reste distinct et inchangé. + +## Hors scope / inchangé + +- Front `RttTab.vue` : déjà propre (clamp des sous-colonnes 25/50 à 0 pour les semaines + déficitaires) → aucun changement. +- Migrations : aucune. +- `EXCLUDED_PUBLIC_HOLIDAYS`, `HolidayVirtualHoursResolver`, traitement des autres fériés : + inchangés. +- Comportement des contrats 35h/39h/Forfait/Intérim sur le jour de solidarité : inchangé. + +## Documentation à mettre à jour (règle projet) + +- `CLAUDE.md` — section Overtime Rules / contrats CUSTOM : ajouter la règle du jour de + solidarité (prorata 12 min/h, net forcé, périmètre < 35h). +- `frontend/data/documentation-content.ts` — doc in-app RTT. +- `doc/rtt-tab.md` et/ou `doc/functional-rules.md` — règle métier détaillée. diff --git a/frontend/data/documentation-content.ts b/frontend/data/documentation-content.ts index 3938a4a..ceca4da 100644 --- a/frontend/data/documentation-content.ts +++ b/frontend/data/documentation-content.ts @@ -375,8 +375,8 @@ export const documentationSections: DocSection[] = [ requiredLevel: 'admin', blocks: [ { type: 'paragraph', content: 'Les règles de calcul des heures supplémentaires dépendent du type de contrat.' }, - { type: 'list', content: 'Contrats ≤ 35h : +25% de 35h à 43h, +50% au-delà de 43h\nContrats ≥ 39h : +25% de 39h à 43h, +50% au-delà de 43h\nContrats CUSTOM (4h, 25h, etc.) : 1h supplémentaire = 1h de récupération, pas de bonus\nINTERIM : aucune récupération, aucun bonus' }, - { type: 'note', content: 'En cas de déficit hebdomadaire (heures travaillées < heures contrat), le déficit est déduit du cumul RTT : d\'abord des heures à 50%, puis des heures à 25%.' }, + { type: 'list', content: 'Contrats ≤ 35h : +25% de 35h à 43h, +50% au-delà de 43h\nContrats ≥ 39h : +25% de 39h à 43h, +50% au-delà de 43h\nContrats CUSTOM (4h, 25h, etc.) : 1h supplémentaire = 1h de récupération, pas de bonus. Une semaine sous les heures contractuelles réduit le cumul RTT (1h manquante = -1h), sans passer par les tranches 25/50\nINTERIM : aucune récupération, aucun bonus' }, + { type: 'note', content: 'En cas de déficit hebdomadaire (heures travaillées < heures contrat), le déficit est déduit du cumul RTT. Pour un 35h/39h, il est puisé d\'abord dans les heures à 50%, puis à 25%. Pour un contrat CUSTOM (4h, etc.), il réduit directement le cumul (pas de tranches 25/50) ; le cumul peut devenir négatif et est reporté à l\'exercice suivant.' }, ], }, { @@ -536,6 +536,8 @@ export const documentationSections: DocSection[] = [ { type: 'note', content: 'Les contrats INTERIM et le mode PRESENCE n\'accumulent pas de RTT (affiché à 0).' }, { type: 'note', content: 'Au passage à l\'exercice suivant (1er juin), le « Report N-1 » du nouvel exercice reprend exactement le « Disponible » de fin d\'exercice précédent, c\'est-à-dire report précédent + acquis − RTT payés. Le report déjà présent en début d\'année n\'est donc jamais perdu.' }, { type: 'paragraph', content: 'La colonne "Cumul" affiche le solde RTT à la fin de chaque semaine : Report N-1 + somme des heures hebdomadaires jusqu\'à la semaine concernée − paiements RTT des mois précédents. Un paiement enregistré sur le mois M n\'est déduit qu\'à partir des semaines du mois M+1. Permet la comparaison ligne à ligne avec un suivi RH externe (Excel).' }, + { type: 'note', content: 'Contrats CUSTOM (ex. 4h) : une semaine travaillée sous les heures contractuelles génère un déficit qui réduit le cumul RTT (1h manquante = -1h), sans tranches 25/50. Le cumul peut devenir négatif et est reporté à l\'exercice suivant.' }, + { type: 'paragraph', content: '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 hebdomadaires, soit 12 minutes par heure : 4h donne 48 min). Ce déficit réduit le cumul RTT, quel que soit ce qui est saisi ce jour-là.' }, ], }, { diff --git a/frontend/services/dto/employee-rtt-summary.ts b/frontend/services/dto/employee-rtt-summary.ts index bf09a03..358918f 100644 --- a/frontend/services/dto/employee-rtt-summary.ts +++ b/frontend/services/dto/employee-rtt-summary.ts @@ -10,6 +10,7 @@ export type EmployeeRttWeekSummary = { bonus50Minutes: number totalMinutes: number cumulativeBalanceMinutes: number + isFlatRecovery: boolean } export type RttMonthPayment = { diff --git a/src/Command/DumpVerificationSnapshotCommand.php b/src/Command/DumpVerificationSnapshotCommand.php index e6b6167..e5114ec 100644 --- a/src/Command/DumpVerificationSnapshotCommand.php +++ b/src/Command/DumpVerificationSnapshotCommand.php @@ -636,6 +636,7 @@ final class DumpVerificationSnapshotCommand extends Command base50Minutes: $detail->base50Minutes, bonus50Minutes: $detail->bonus50Minutes, totalMinutes: $detail->totalMinutes, + isFlatRecovery: $detail->isFlatRecovery, ); continue; @@ -672,6 +673,7 @@ final class DumpVerificationSnapshotCommand extends Command base50Minutes: (int) round($detail->base50Minutes * $ratio), bonus50Minutes: (int) round($detail->bonus50Minutes * $ratio), totalMinutes: (int) round($detail->totalMinutes * $ratio), + isFlatRecovery: $detail->isFlatRecovery, ); } } @@ -692,7 +694,7 @@ final class DumpVerificationSnapshotCommand extends Command $cumulative25 = $carry->base25Minutes + $carry->bonus25Minutes; foreach ($weeks as $i => $week) { - if ($week->totalMinutes >= 0) { + if ($week->totalMinutes >= 0 || $week->isFlatRecovery) { $cumulative50 += $week->base50Minutes + $week->bonus50Minutes; $cumulative25 += $week->base25Minutes + $week->bonus25Minutes; @@ -714,6 +716,7 @@ final class DumpVerificationSnapshotCommand extends Command base50Minutes: $from50 > 0 ? -$from50 : 0, bonus50Minutes: 0, totalMinutes: $week->totalMinutes, + isFlatRecovery: $week->isFlatRecovery, ); } diff --git a/src/Dto/Rtt/EmployeeRttWeekSummary.php b/src/Dto/Rtt/EmployeeRttWeekSummary.php index 600a80b..8ee8e86 100644 --- a/src/Dto/Rtt/EmployeeRttWeekSummary.php +++ b/src/Dto/Rtt/EmployeeRttWeekSummary.php @@ -18,5 +18,6 @@ final class EmployeeRttWeekSummary public int $bonus50Minutes = 0, public int $totalMinutes = 0, public int $cumulativeBalanceMinutes = 0, + public bool $isFlatRecovery = false, ) {} } diff --git a/src/Dto/Rtt/WeekRecoveryDetail.php b/src/Dto/Rtt/WeekRecoveryDetail.php index 8254a2a..7899361 100644 --- a/src/Dto/Rtt/WeekRecoveryDetail.php +++ b/src/Dto/Rtt/WeekRecoveryDetail.php @@ -17,5 +17,6 @@ final class WeekRecoveryDetail public int $bonus50Minutes = 0, public int $totalMinutes = 0, public array $dailyMinutes = [], + public bool $isFlatRecovery = false, ) {} } diff --git a/src/Service/Rtt/RttRecoveryComputationService.php b/src/Service/Rtt/RttRecoveryComputationService.php index e8d4bc7..19b8c1d 100644 --- a/src/Service/Rtt/RttRecoveryComputationService.php +++ b/src/Service/Rtt/RttRecoveryComputationService.php @@ -33,6 +33,7 @@ final readonly class RttRecoveryComputationService private EmployeeContractResolver $contractResolver, private DailyReferenceMinutesResolver $dailyReferenceResolver, private HolidayVirtualHoursResolver $holidayVirtualHoursResolver, + private SolidarityDayResolver $solidarityDayResolver, string $rttStartDate = '', ) { $this->rttStartDate = '' !== $rttStartDate ? new DateTimeImmutable($rttStartDate) : null; @@ -162,7 +163,8 @@ final readonly class RttRecoveryComputationService } } - $results = []; + $results = []; + $solidarityDates = $this->resolveSolidarityDatesInRange($periodFrom, $periodTo); foreach ($weeks as $week) { $weekStart = $week['start']; $weekEnd = $week['end']; @@ -235,7 +237,7 @@ final readonly class RttRecoveryComputationService $overtimeReferenceMinutes = $isCustomContract ? $this->computeWeeklyCustomReferenceMinutes($weekDays, $employeeContractsByDate) : $this->computeWeeklyOvertimeReferenceMinutes($weekDays, $employeeContractsByDate); - $overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($weekDays, $employeeContractsByDate); + $overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($weekDays, $employeeContractsByDate); // Plafond séparant 25 %/50 % : seuil de départ proraté + largeur de la bande +25 % // (4h pour un 39h, 8h pour un 35h). Il se décale ainsi avec une embauche en milieu // de semaine au lieu de rester bloqué à 43h, ce qui ouvre la tranche 50 %. @@ -244,35 +246,96 @@ final readonly class RttRecoveryComputationService ? 0 : $weeklyTotalMinutes - $overtimeReferenceMinutes; - [$rawBase25, $rawBase50] = $this->computeOvertimeBaseMinutes($weeklyTotalMinutes, $overtime25StartMinutes, $overtime50StartMinutes); + 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; + } - $base25 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : $rawBase25; - $bonus25 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : (int) round($base25 * 0.25); - $base50 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : $rawBase50; - $bonus50 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : (int) round($base50 * 0.5); + $contractAtSolidarity = $employeeContractsByDate[$solidarityDate] ?? null; + // Le Lundi de Pentecôte est toujours un lundi (ISO 1), mais on le dérive pour rester explicite. + $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, + ); - if ($isWeekPresenceTracking || $disableOvertimeBonuses) { - $totalMinutes = 0; - } elseif ($isCustomContract) { - $totalMinutes = max(0, $weeklyOvertimeTotalMinutes); - } else { - $totalMinutes = $weeklyOvertimeTotalMinutes + $bonus25 + $bonus50; + $weeklyOvertimeTotalMinutes += $this->computeSolidarityDeficitAdjustment( + $contractAtSolidarity, + $solidarityExpected, + $dailyWorkedMinutes[$solidarityDate], + ); } - $results[$weekKey] = new WeekRecoveryDetail( - overtimeMinutes: $weeklyOvertimeTotalMinutes, - base25Minutes: $base25, - bonus25Minutes: $bonus25, - base50Minutes: $base50, - bonus50Minutes: $bonus50, - totalMinutes: $totalMinutes, - dailyMinutes: $dailyWorkedMinutes, + [$rawBase25, $rawBase50] = $this->computeOvertimeBaseMinutes($weeklyTotalMinutes, $overtime25StartMinutes, $overtime50StartMinutes); + + $results[$weekKey] = $this->buildWeekRecoveryDetail( + $isWeekPresenceTracking, + $disableOvertimeBonuses, + $isCustomContract, + $weeklyOvertimeTotalMinutes, + $rawBase25, + $rawBase50, + $dailyWorkedMinutes, ); } return $results; } + /** + * Assemble le détail de récupération d'une semaine à partir des drapeaux résolus et + * des bandes d'heures sup brutes. + * + * - PRESENCE / INTERIM (bonus désactivés) : aucune récupération. + * - CUSTOM : récupération plate 1h = 1h, sans tranches 25/50 ; l'heure sup signée EST + * le total, donc une semaine travaillée sous les heures contractuelles produit un + * total négatif (déficit qui réduit le solde). Marquée isFlatRecovery pour que le + * provider ne draine pas les tranches 25/50. + * - Standard 35h/39h : heures sup + bonus 25 %/50 %. + * + * @param array $dailyMinutes + */ + private function buildWeekRecoveryDetail( + bool $isPresence, + bool $disableBonuses, + bool $isCustom, + int $overtimeTotalMinutes, + int $rawBase25, + int $rawBase50, + array $dailyMinutes, + ): WeekRecoveryDetail { + $noBands = $isPresence || $disableBonuses || $isCustom; + + $base25 = $noBands ? 0 : $rawBase25; + $bonus25 = $noBands ? 0 : (int) round($base25 * 0.25); + $base50 = $noBands ? 0 : $rawBase50; + $bonus50 = $noBands ? 0 : (int) round($base50 * 0.5); + + if ($isPresence || $disableBonuses) { + $totalMinutes = 0; + } elseif ($isCustom) { + $totalMinutes = $overtimeTotalMinutes; // signé : le déficit réduit le solde + } else { + $totalMinutes = $overtimeTotalMinutes + $bonus25 + $bonus50; + } + + return new WeekRecoveryDetail( + overtimeMinutes: $overtimeTotalMinutes, + base25Minutes: $base25, + bonus25Minutes: $bonus25, + base50Minutes: $base50, + bonus50Minutes: $bonus50, + totalMinutes: $totalMinutes, + dailyMinutes: $dailyMinutes, + isFlatRecovery: $isCustom, + ); + } + private function computeMetrics(WorkHour $workHour): WorkMetrics { $driverDay = $workHour->getDayHoursMinutes() ?? 0; @@ -415,6 +478,59 @@ final readonly class RttRecoveryComputationService return $weekDays[0]; } + /** + * 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 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; + } + /** * @param list $days * @param array $contractsByDate diff --git a/src/Service/Rtt/SolidarityDayResolver.php b/src/Service/Rtt/SolidarityDayResolver.php new file mode 100644 index 0000000..10c2bab --- /dev/null +++ b/src/Service/Rtt/SolidarityDayResolver.php @@ -0,0 +1,43 @@ +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)); + } +} diff --git a/src/State/EmployeeRttSummaryProvider.php b/src/State/EmployeeRttSummaryProvider.php index 33dc033..98aa0b6 100644 --- a/src/State/EmployeeRttSummaryProvider.php +++ b/src/State/EmployeeRttSummaryProvider.php @@ -142,36 +142,13 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface $summary->rttStartDate = $this->rttStartDate; $summary->weeks = $this->buildWeekSummaries($weekRanges, $currentByWeekStart, $periodFrom, $periodTo); - // Post-process: distribute deficit weeks across cumulative balance (50% first, then 25%) - $cumulative50 = $carry->base50Minutes + $carry->bonus50Minutes; - $cumulative25 = $carry->base25Minutes + $carry->bonus25Minutes; - - foreach ($summary->weeks as $i => $week) { - if ($week->totalMinutes >= 0) { - $cumulative50 += $week->base50Minutes + $week->bonus50Minutes; - $cumulative25 += $week->base25Minutes + $week->bonus25Minutes; - } else { - $deficit = -$week->totalMinutes; - $from50 = min($deficit, max(0, $cumulative50)); - $from25 = $deficit - $from50; - - $cumulative50 -= $from50; - $cumulative25 -= $from25; - - $summary->weeks[$i] = new EmployeeRttWeekSummary( - month: $week->month, - weekNumber: $week->weekNumber, - weekStart: $week->weekStart, - weekEnd: $week->weekEnd, - overtimeMinutes: $week->overtimeMinutes, - base25Minutes: $from25 > 0 ? -$from25 : 0, - bonus25Minutes: 0, - base50Minutes: $from50 > 0 ? -$from50 : 0, - bonus50Minutes: 0, - totalMinutes: $week->totalMinutes, - ); - } - } + // Post-process: distribute deficit weeks across cumulative balance (50% first, then 25%). + // Flat-recovery (CUSTOM) weeks are skipped — their deficit only reduces the running cumul. + $summary->weeks = $this->applyDeficitCascade( + $summary->weeks, + $carry->base25Minutes + $carry->bonus25Minutes, + $carry->base50Minutes + $carry->bonus50Minutes, + ); $payments = $this->rttPaymentRepository->findByEmployeeAndYear($employee, $year); $monthBuckets = []; @@ -356,6 +333,54 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface return $weekEnd; } + /** + * Distribue les semaines déficitaires sur les tranches 25/50 accumulées (50 % d'abord, + * puis 25 %), en réécrivant les buckets affichés de chaque semaine déficitaire avec les + * montants négatifs drainés. + * + * Les semaines à récupération plate (CUSTOM 1h = 1h) sont ignorées : elles n'ont pas de + * tranches 25/50, donc leur déficit ne réduit que le cumul courant (calculé ensuite à + * partir de totalMinutes) et les colonnes 25/50 restent à 0. + * + * @param list $weeks + * + * @return list + */ + private function applyDeficitCascade(array $weeks, int $cumulative25, int $cumulative50): array + { + foreach ($weeks as $i => $week) { + if ($week->totalMinutes >= 0 || $week->isFlatRecovery) { + $cumulative50 += $week->base50Minutes + $week->bonus50Minutes; + $cumulative25 += $week->base25Minutes + $week->bonus25Minutes; + + continue; + } + + $deficit = -$week->totalMinutes; + $from50 = min($deficit, max(0, $cumulative50)); + $from25 = $deficit - $from50; + + $cumulative50 -= $from50; + $cumulative25 -= $from25; + + $weeks[$i] = new EmployeeRttWeekSummary( + month: $week->month, + weekNumber: $week->weekNumber, + weekStart: $week->weekStart, + weekEnd: $week->weekEnd, + overtimeMinutes: $week->overtimeMinutes, + base25Minutes: $from25 > 0 ? -$from25 : 0, + bonus25Minutes: 0, + base50Minutes: $from50 > 0 ? -$from50 : 0, + bonus50Minutes: 0, + totalMinutes: $week->totalMinutes, + isFlatRecovery: $week->isFlatRecovery, + ); + } + + return $weeks; + } + /** * Build week summaries, splitting weeks that span two months into two entries * with values distributed proportionally based on daily worked minutes. @@ -393,6 +418,7 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface base50Minutes: $detail->base50Minutes, bonus50Minutes: $detail->bonus50Minutes, totalMinutes: $detail->totalMinutes, + isFlatRecovery: $detail->isFlatRecovery, ); continue; @@ -433,6 +459,7 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface base50Minutes: (int) round($detail->base50Minutes * $ratio), bonus50Minutes: (int) round($detail->bonus50Minutes * $ratio), totalMinutes: (int) round($detail->totalMinutes * $ratio), + isFlatRecovery: $detail->isFlatRecovery, ); } } diff --git a/tests/Service/Rtt/RttClosingBalanceServiceTest.php b/tests/Service/Rtt/RttClosingBalanceServiceTest.php index 173f904..6e0837e 100644 --- a/tests/Service/Rtt/RttClosingBalanceServiceTest.php +++ b/tests/Service/Rtt/RttClosingBalanceServiceTest.php @@ -80,6 +80,23 @@ final class RttClosingBalanceServiceTest extends TestCase ); } + public function testCustomDeficitWeekReducesClosingBalance(): void + { + // CUSTOM (4h) : une semaine de récup +3h puis une semaine déficitaire -1h + // (toutes deux sans tranches 25/50). Le déficit doit réduire la clôture. + $recovery = new WeekRecoveryDetail(totalMinutes: 180, isFlatRecovery: true); // +3h + $deficit = new WeekRecoveryDetail(totalMinutes: -60, isFlatRecovery: true); // -1h + + $closing = $this->service()->fold(new WeekRecoveryDetail(), [$recovery, $deficit], $this->payments()); + + // 3h - 1h = 2h reportées, et la somme des buckets égale toujours le total. + self::assertSame(120, $closing->totalMinutes); + self::assertSame( + 120, + $closing->base25Minutes + $closing->bonus25Minutes + $closing->base50Minutes + $closing->bonus50Minutes, + ); + } + public function testBucketSumAlwaysEqualsTotalInvariant(): void { $opening = new WeekRecoveryDetail(base25Minutes: 200, bonus25Minutes: 50, base50Minutes: 100, bonus50Minutes: 50, totalMinutes: 400); diff --git a/tests/Service/Rtt/RttRecoveryComputationServiceTest.php b/tests/Service/Rtt/RttRecoveryComputationServiceTest.php index b0c9cf5..b6c9630 100644 --- a/tests/Service/Rtt/RttRecoveryComputationServiceTest.php +++ b/tests/Service/Rtt/RttRecoveryComputationServiceTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Tests\Service\Rtt; use App\Entity\Contract; +use App\Enum\TrackingMode; use App\Service\Rtt\RttRecoveryComputationService; use PHPUnit\Framework\TestCase; use ReflectionClass; @@ -113,6 +114,159 @@ final class RttRecoveryComputationServiceTest extends TestCase self::assertSame(3 * 60, $base50); } + public function testBuildWeekDetailCustomDeficitKeepsSignedTotalAndFlatFlag(): void + { + $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); + + // CUSTOM, semaine sous les heures : overtime -120 (worked 2h sur réf 4h). + $detail = $this->invokePrivate( + $service, + 'buildWeekRecoveryDetail', + false, // isPresence + false, // disableBonuses + true, // isCustom + -120, // overtimeTotalMinutes + 0, // rawBase25 + 0, // rawBase50 + [], // dailyMinutes + ); + + self::assertSame(-120, $detail->totalMinutes); + self::assertTrue($detail->isFlatRecovery); + self::assertSame(0, $detail->base25Minutes); + self::assertSame(0, $detail->base50Minutes); + } + + public function testBuildWeekDetailCustomPositiveIsFlatOneToOne(): void + { + $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); + + $detail = $this->invokePrivate($service, 'buildWeekRecoveryDetail', false, false, true, 180, 0, 0, []); + + self::assertSame(180, $detail->totalMinutes); // 1h = 1h + self::assertTrue($detail->isFlatRecovery); + } + + public function testBuildWeekDetailStandardKeepsBucketsAndBonuses(): void + { + $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); + + // 39h : overtime 300, base25 240, base50 60. + $detail = $this->invokePrivate($service, 'buildWeekRecoveryDetail', false, false, false, 300, 240, 60, []); + + self::assertFalse($detail->isFlatRecovery); + self::assertSame(240, $detail->base25Minutes); + self::assertSame(60, $detail->bonus25Minutes); // round(240 * 0.25) + self::assertSame(60, $detail->base50Minutes); + self::assertSame(30, $detail->bonus50Minutes); // round(60 * 0.5) + self::assertSame(300 + 60 + 30, $detail->totalMinutes); + } + + /** + * 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. + * Le delta est nul ici par coïncidence du fallback uniforme (expected = prorata) ; avec un vrai + * workDaysHours où la valeur du lundi diffère, expected ≠ prorata et le delta serait non nul. + */ + 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); + } + + private static function customContract(int $weeklyHours): Contract + { + return new Contract() + ->setName('Temps partiel') + ->setTrackingMode(TrackingMode::TIME) + ->setWeeklyHours($weeklyHours) + ; + } + private function invokePrivate(object $obj, string $method, mixed ...$args): mixed { return new ReflectionClass($obj::class)->getMethod($method)->invoke($obj, ...$args); diff --git a/tests/Service/Rtt/SolidarityDayResolverTest.php b/tests/Service/Rtt/SolidarityDayResolverTest.php new file mode 100644 index 0000000..388c0a0 --- /dev/null +++ b/tests/Service/Rtt/SolidarityDayResolverTest.php @@ -0,0 +1,63 @@ +pentecostMonday($year)->format('Y-m-d')); + } + + /** + * @return iterable + */ + public static function pentecostCases(): iterable + { + yield '2024' => [2024, '2024-05-20']; + + yield '2025' => [2025, '2025-06-09']; + + yield '2026' => [2026, '2026-05-25']; + + // Century-boundary year: Easter 2000-04-23 → Whit Monday 2000-06-12 + // (verified with: easter_date(2000) → date('+50 days')) + yield '2000' => [2000, '2000-06-12']; + + // Late-April Easter (2011-04-24) → Whit Monday 2011-06-13 + // (verified with: easter_date(2011) → date('+50 days')) + yield '2011' => [2011, '2011-06-13']; + + // Easter on April 25 — exercises the computus corrective $m branch: + // Easter 2038-04-25 → Whit Monday 2038-06-14 + // (verified with: easter_date(2038) → date('+50 days')) + yield '2038' => [2038, '2038-06-14']; + } + + /** + * The returned date must always be a Monday (ISO weekday = 1). + * Verified for 2025 as a representative case. + */ + public function testPentecostMondayIsAMonday(): void + { + $resolver = new SolidarityDayResolver(); + + self::assertSame('1', $resolver->pentecostMonday(2025)->format('N')); + } +} diff --git a/tests/State/EmployeeRttSummaryProviderTest.php b/tests/State/EmployeeRttSummaryProviderTest.php index 3e4ba0b..bb4002e 100644 --- a/tests/State/EmployeeRttSummaryProviderTest.php +++ b/tests/State/EmployeeRttSummaryProviderTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Tests\State; use App\Dto\Contracts\ContractPhase; +use App\Dto\Rtt\EmployeeRttWeekSummary; use App\Entity\Contract; use App\Entity\Employee; use App\Entity\EmployeeContractPeriod; @@ -201,6 +202,45 @@ final class EmployeeRttSummaryProviderTest extends TestCase self::assertSame(2030, $year); } + public function testFlatDeficitWeekIsNotDrainedFromTiers(): void + { + $provider = $this->buildProvider([]); + + // Semaine CUSTOM déficitaire (-120), aucune tranche accumulée. + $weeks = [$this->weekSummary(-120, true)]; + $result = $this->invokePrivate($provider, 'applyDeficitCascade', $weeks, 0, 0); + + // Buckets restent à 0 ; le total négatif est conservé (le cumul est calculé ailleurs). + self::assertSame(0, $result[0]->base25Minutes); + self::assertSame(0, $result[0]->base50Minutes); + self::assertSame(-120, $result[0]->totalMinutes); + self::assertTrue($result[0]->isFlatRecovery); + } + + public function testStandardDeficitWeekDrainsFiftyThenTwentyFive(): void + { + $provider = $this->buildProvider([]); + + // Semaine 35h/39h déficitaire (-100), avec 60 en 50% et 120 en 25% accumulés. + $weeks = [$this->weekSummary(-100, false)]; + $result = $this->invokePrivate($provider, 'applyDeficitCascade', $weeks, 120, 60); + + self::assertSame(-60, $result[0]->base50Minutes); // 60 drainés du 50% + self::assertSame(-40, $result[0]->base25Minutes); // 40 restants drainés du 25% + self::assertSame(-100, $result[0]->totalMinutes); + } + + public function testFlatPositiveWeekIsUntouched(): void + { + $provider = $this->buildProvider([]); + + $weeks = [$this->weekSummary(180, true)]; + $result = $this->invokePrivate($provider, 'applyDeficitCascade', $weeks, 0, 0); + + self::assertSame(180, $result[0]->totalMinutes); + self::assertSame(0, $result[0]->base25Minutes); + } + // ----------------------------------------------------------------------- // Test harness helpers. // ----------------------------------------------------------------------- @@ -247,6 +287,21 @@ final class EmployeeRttSummaryProviderTest extends TestCase return $employee; } + private function weekSummary(int $totalMinutes, bool $isFlat, int $base25 = 0, int $base50 = 0): EmployeeRttWeekSummary + { + return new EmployeeRttWeekSummary( + month: 6, + weekNumber: 1, + weekStart: '2026-06-01', + weekEnd: '2026-06-07', + overtimeMinutes: $totalMinutes, + base25Minutes: $base25, + base50Minutes: $base50, + totalMinutes: $totalMinutes, + isFlatRecovery: $isFlat, + ); + } + /** * Build an uninitialized provider with a RequestStack pre-loaded with the given query. * @@ -256,7 +311,7 @@ final class EmployeeRttSummaryProviderTest extends TestCase * only setting the properties that the tested private methods actually read: * `requestStack` and `phaseResolver`. * - * @param array $request query parameters (year, phaseId, ...) + * @param array $request */ private function buildProvider(array $request = []): EmployeeRttSummaryProvider {