[#SIRH-36] corriger calcule rtt contrat custom #27

Merged
tristan merged 16 commits from feature/SIRH-36-corriger-le-calcule-des-rtt-des-contrat-4h into develop 2026-06-11 08:36:57 +00:00
Showing only changes of commit 67988f73e4 - Show all commits
@@ -0,0 +1,154 @@
# 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;
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);
// 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;
}
```
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.
## 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 (3638h) | 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.