| 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>
23 KiB
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— injecterSolidarityDayResolver; ajouterresolveSolidarityDatesInRange()+computeSolidarityDeficitAdjustment(); appliquer danscomputeRecoveryByWeek(). - Modify
tests/Service/Rtt/RttRecoveryComputationServiceTest.php— tests réflexion decomputeSolidarityDeficitAdjustment(). - Modify (docs)
CLAUDE.md,frontend/data/documentation-content.ts,doc/rtt-tab.md,doc/functional-rules.md. - Inchangé :
config/services.yaml(autowiring :SolidarityDayResolverest un service autowireable, etRttRecoveryComputationServicen'override que$rttStartDate— les autres args s'autowirent),DumpVerificationSnapshotCommand.php(consommeWeekRecoveryDetail.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
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
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
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(120−0)−48 = +72; appliqué au naturel−120⇒ semaine −48 min. -
travaillé normalement (
worked = 120) → delta(120−120)−48 = −48; naturel0⇒ −48 min. -
travaillé en plus (
worked = 240) → delta(120−240)−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).
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.
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):
/**
* 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:
$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:
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
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:
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)
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 :
- **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 :
### 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 :
#### 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).
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 viaisset($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 decomputeRecoveryByWeek.