[#SIRH-36] corriger calcule rtt contrat custom (#27)
Auto Tag Develop / tag (push) Successful in 7s
Auto Tag Develop / tag (push) Successful in 7s
| Numéro du ticket | Titre du ticket | |------------------|-----------------| | | | ## Description de la PR ## Modification du .env ## Check list - [ ] Pas de régression - [ ] TU/TI/TF rédigée - [ ] TU/TI/TF OK - [ ] CHANGELOG modifié Reviewed-on: #27 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #27.
This commit is contained in:
@@ -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`.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user