# 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. |
| 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. |

## 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.
