From 5e8cec7067a11013e7daeccfa405a90858cce455 Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 11 Jun 2026 09:51:11 +0200 Subject: [PATCH] docs(rtt) : implementation plan + spec fix (workDaysHours neutralisation) for solidarity deficit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-11-rtt-solidarity-day-deficit.md | 522 ++++++++++++++++++ ...06-11-rtt-solidarity-day-deficit-design.md | 42 +- 2 files changed, 551 insertions(+), 13 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-11-rtt-solidarity-day-deficit.md 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-11-rtt-solidarity-day-deficit-design.md b/docs/superpowers/specs/2026-06-11-rtt-solidarity-day-deficit-design.md index 5310352..eaa9f92 100644 --- 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 @@ -81,16 +81,22 @@ un jour de solidarité `S` tombe dans la semaine **et** a été inclus dans le s ``` $contractAtS = $employeeContractsByDate[$S] ?? null; -if ($contractAtS est CUSTOM && $contractAtS->getWeeklyHours() < 35) { - $isoDayS = (int) (new DateTimeImmutable($S))->format('N'); - $refS = $this->resolveDailyReferenceMinutes($contractAtS->getWeeklyHours(), $isoDayS); - $workedS = $dailyWorkedMinutes[$S]; // déjà calculé dans la boucle des jours - $prorata = (int) round($contractAtS->getWeeklyHours() * 12); +$weeklyHours = $contractAtS?->getWeeklyHours(); +$typeAtS = ContractType::resolve($contractAtS?->getName(), $contractAtS?->getTrackingMode(), $weeklyHours); - // 1) neutraliser le net naturel du jour (annule worked - ref, même fonction de - // référence que computeWeeklyCustomReferenceMinutes → annulation exacte) - // 2) appliquer le forfait - $weeklyOvertimeTotalMinutes += $refS - $workedS - $prorata; +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; } ``` @@ -98,10 +104,20 @@ Puis `buildWeekRecoveryDetail(...)` est appelé tel quel : pour un CUSTOM, `totalMinutes = overtimeMinutes = weeklyOvertimeTotalMinutes` (signé), bandes 25/50 = 0, `isFlatRecovery = true`. -> Note : la référence `$refS` doit être calculée avec la **même** fonction que -> `computeWeeklyCustomReferenceMinutes` (`resolveDailyReferenceMinutes(weeklyHours, isoDay)`) -> pour que la neutralisation annule exactement la contribution du jour, quelle que soit la -> façon dont la référence journalière est répartie. +> **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