From acbf1ccecb229a5c898b2285f126d490ccc32831 Mon Sep 17 00:00:00 2001 From: tristan Date: Fri, 12 Jun 2026 11:01:24 +0200 Subject: [PATCH] =?UTF-8?q?fix(rtt)=20:=20jour=20de=20solidarit=C3=A9=20sa?= =?UTF-8?q?ns=20d=C3=A9ficit=20si=20le=20salari=C3=A9=20ne=20travaille=20p?= =?UTF-8?q?as=20le=20lundi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un contrat CUSTOM < 35h qui ne travaille pas le lundi (jour de solidarité, workDaysHours[lundi] absent → attendu = 0) ne portait à tort un déficit forfaitaire ((0 − 0) − prorata = −prorata). Garde ajoutée : aucun déficit quand expectedMinutes === 0. Ewa (Lun+Jeu) reste à −0h48 ; Nadia (Mar+Ven) passe de −0h48 à 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- doc/functional-rules.md | 4 ++++ doc/rtt-tab.md | 6 ++++-- ...2026-06-11-rtt-solidarity-day-deficit-design.md | 1 + frontend/data/documentation-content.ts | 2 +- src/Service/Rtt/RttRecoveryComputationService.php | 8 ++++++++ .../Rtt/RttRecoveryComputationServiceTest.php | 14 ++++++++++++++ 7 files changed, 33 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a4386ee..290894f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,7 @@ - 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). **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}`. + - **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. **Garde : uniquement si le salarié travaille le lundi** (`workDaysHours[lundi] > 0`, i.e. `expectedMinutes > 0`) ; un temps partiel ne travaillant jamais le lundi (ex. Nadia, Mar+Ven) **ne porte aucun déficit** (sinon `(0 − 0) − prorata` lui facturerait à tort le prorata). 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 aaeb11e..628cc88 100644 --- a/doc/functional-rules.md +++ b/doc/functional-rules.md @@ -154,6 +154,10 @@ soit ce qui y est saisi) et applique un déficit forfaitaire `7/35 × heuresHebd déficits/surplus de la semaine. Date calculée par computus (Pâques + 50 jours), indépendante de la liste `EXCLUDED_PUBLIC_HOLIDAYS`. +Le déficit ne s'applique **que si le salarié travaille le lundi** (jour de solidarité +planifié au contrat, `workDaysHours[lundi] > 0`). Un temps partiel ne travaillant jamais +le lundi (ex. Mar+Ven) n'est pas concerné : aucun déficit n'est imputé. + - Nature `INTERIM`: - pas de bonus 25% - pas de bonus 50% diff --git a/doc/rtt-tab.md b/doc/rtt-tab.md index c0287c0..0f38f96 100644 --- a/doc/rtt-tab.md +++ b/doc/rtt-tab.md @@ -32,8 +32,10 @@ Techniquement : `WeekRecoveryDetail::isFlatRecovery` marque ces semaines ; 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). +il ne dépend pas des heures saisies ni du RTT que la RH pose ce jour-là. Un salarié qui +ne travaille pas le lundi (lundi non planifié au contrat) n'est pas concerné : aucun +déficit. Les contrats 35h/39h ne sont pas concernés ici (leur RTT posé draine le cumul +normalement). ## Sélecteur d'année 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 eaa9f92..9efabce 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 @@ -127,6 +127,7 @@ Puis `buildWeekRecoveryDetail(...)` est appelé tel quel : pour un CUSTOM, | 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. | +| Salarié CUSTOM < 35h ne travaillant pas le lundi (ex. Mar+Ven) | `expectedMinutes = workDaysHours[lundi] = 0` → pas de déficit (garde `0 === $expectedMinutes`). | | 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. | diff --git a/frontend/data/documentation-content.ts b/frontend/data/documentation-content.ts index 2aaa5ab..96a49bf 100644 --- a/frontend/data/documentation-content.ts +++ b/frontend/data/documentation-content.ts @@ -537,7 +537,7 @@ export const documentationSections: DocSection[] = [ { 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à.' }, + { 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à. Un salarié qui ne travaille pas le lundi n\'est pas concerné : aucun déficit ne lui est imputé.' }, ], }, { diff --git a/src/Service/Rtt/RttRecoveryComputationService.php b/src/Service/Rtt/RttRecoveryComputationService.php index 539e0f9..ff8992a 100644 --- a/src/Service/Rtt/RttRecoveryComputationService.php +++ b/src/Service/Rtt/RttRecoveryComputationService.php @@ -498,6 +498,14 @@ final readonly class RttRecoveryComputationService return 0; } + // Le salarié ne travaille pas le jour de solidarité (lundi non planifié au contrat, + // workDaysHours[lundi] absent → attendu = 0) : le jour ne le concerne pas, aucun + // déficit n'est imputé. Sans cette garde, (0 − 0) − prorata facturerait à tort le prorata + // à un temps partiel qui ne travaille jamais le lundi (ex. Nadia, Mar+Ven). + if (0 === $expectedMinutes) { + return 0; + } + $prorata = (int) round($weeklyHours * 12); return ($expectedMinutes - $workedMinutes) - $prorata; diff --git a/tests/Service/Rtt/RttRecoveryComputationServiceTest.php b/tests/Service/Rtt/RttRecoveryComputationServiceTest.php index b6c9630..8d8861e 100644 --- a/tests/Service/Rtt/RttRecoveryComputationServiceTest.php +++ b/tests/Service/Rtt/RttRecoveryComputationServiceTest.php @@ -207,6 +207,20 @@ final class RttRecoveryComputationServiceTest extends TestCase self::assertSame(-168, $delta); } + /** + * CUSTOM 4h NE travaillant PAS le jour de solidarité (lundi non planifié, ex. Nadia Mar+Ven) : + * workDaysHours[lundi] absent → expected = 0. Le jour de solidarité ne la concerne pas → delta 0, + * aucun déficit imputé. C'est la correction du bug : (0 − 0) − 48 ne doit PAS donner −48. + */ + public function testSolidarityAdjustmentCustomNotScheduledThatDayIsZero(): void + { + $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); + + $delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', self::customContract(4), 0, 0); + + self::assertSame(0, $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