docs(rtt) : implementation plan + spec fix (workDaysHours neutralisation) for solidarity deficit

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 09:51:11 +02:00
parent 67988f73e4
commit 5e8cec7067
2 changed files with 551 additions and 13 deletions
@@ -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
<?php
declare(strict_types=1);
namespace App\Tests\Service\Rtt;
use App\Service\Rtt\SolidarityDayResolver;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class SolidarityDayResolverTest extends TestCase
{
/**
* Lundi de Pentecôte = dimanche de Pâques + 50 jours.
* 2024 : Pâques 31/03 → 20/05 ; 2025 : Pâques 20/04 → 09/06 ; 2026 : Pâques 05/04 → 25/05.
*
* @dataProvider pentecostCases
*/
public function testPentecostMonday(int $year, string $expected): void
{
$resolver = new SolidarityDayResolver();
self::assertSame($expected, $resolver->pentecostMonday($year)->format('Y-m-d'));
}
/**
* @return iterable<string, array{int, string}>
*/
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
<?php
declare(strict_types=1);
namespace App\Service\Rtt;
use DateTimeImmutable;
/**
* Résout le jour de solidarité (Lundi de Pentecôte) d'une année.
*
* Pur et déterministe : Pâques via l'algorithme de Meeus/Jones/Butcher (calendrier
* grégorien), sans dépendance à l'extension calendar ni au réseau. Lundi de Pentecôte
* = dimanche de Pâques + 50 jours.
*/
final class SolidarityDayResolver
{
public function pentecostMonday(int $year): DateTimeImmutable
{
return $this->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 `(1200)48 = +72` ; appliqué au naturel `120` ⇒ semaine **48 min**.
- travaillé normalement (`worked = 120`) → delta `(120120)48 = 48` ; naturel `0`**48 min**.
- travaillé en plus (`worked = 240`) → delta `(120240)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<string> 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`.
@@ -81,16 +81,22 @@ un jour de solidarité `S` tombe dans la semaine **et** a été inclus dans le s
``` ```
$contractAtS = $employeeContractsByDate[$S] ?? null; $contractAtS = $employeeContractsByDate[$S] ?? null;
if ($contractAtS est CUSTOM && $contractAtS->getWeeklyHours() < 35) { $weeklyHours = $contractAtS?->getWeeklyHours();
$isoDayS = (int) (new DateTimeImmutable($S))->format('N'); $typeAtS = ContractType::resolve($contractAtS?->getName(), $contractAtS?->getTrackingMode(), $weeklyHours);
$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 if (ContractType::CUSTOM === $typeAtS && null !== $weeklyHours && $weeklyHours < 35) {
// référence que computeWeeklyCustomReferenceMinutes → annulation exacte) $isoDayS = (int) (new DateTimeImmutable($S))->format('N');
// 2) appliquer le forfait $workDaysForS = $workDaysByDate[$employeeId][$S] ?? null; // {iso_day: minutes}
$weeklyOvertimeTotalMinutes += $refS - $workedS - $prorata; // 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, `totalMinutes = overtimeMinutes = weeklyOvertimeTotalMinutes` (signé), bandes 25/50 = 0,
`isFlatRecovery = true`. `isFlatRecovery = true`.
> Note : la référence `$refS` doit être calculée avec la **même** fonction que > **Pourquoi `workDaysHours` et pas la référence hebdo CUSTOM** : la référence CUSTOM
> `computeWeeklyCustomReferenceMinutes` (`resolveDailyReferenceMinutes(weeklyHours, isoDay)`) > (`computeWeeklyCustomReferenceMinutes`) répartit `weeklyHours` uniformément sur les 5
> pour que la neutralisation annule exactement la contribution du jour, quelle que soit la > jours ouvrés (`weeklyHours/5`), sans tenir compte du planning réel. Neutraliser le jour
> façon dont la référence journalière est répartie. > 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 limites