From bbde6ddcf3343e117d997d08ea5bd627f2bc6a1a Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 19 May 2026 14:31:56 +0200 Subject: [PATCH] fix(leave) : do not cap from at phase.startDate for non-forfait phases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CP exercise (Juin N-1 → Mai N) is annual and continuous across contract-signature changes within the same leave rule (e.g. 35h → 39h, isDriver flip, weeklyHours bump). Capping `from` at the phase start truncated the accrual to just the months under the latest phase, producing wrong "en cours d'acquisition" values and dropping presence days from earlier months on the leave-tab calendar. For Damien GUILLOT (35h until 2025-10-31, then 39h), this gave 15 days acquired (6 months Nov→Apr) instead of the expected 27.5 days (11 months Jun→Apr at 2.5/month). After this fix, the H39 view shows the full annual accrual as expected. FORFAIT phases keep the from cap: the 218-day target is calendar-year scoped and only counts the FORFAIT portion of the year. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 2 +- doc/contract-phase-view.md | 24 +++++- src/State/EmployeeLeaveSummaryProvider.php | 20 +++-- .../EmployeeLeaveSummaryProviderTest.php | 77 +++++++++++++++++++ 4 files changed, 113 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8927fd8..b8d4a80 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,7 +89,7 @@ - **Filtre `RTT_START_DATE`** : les phases dont `endDate < RTT_START_DATE` sont masquées (aucune donnée logiciel avant la mise en service). Le resolver reçoit la date via DI (`services.yaml`) ; `Employee::getContractPhases()` lit `$_SERVER['RTT_START_DATE']` pour instancier le resolver côté entité. - Exposé via `Employee.contractPhases` (`employee:read`). Endpoints `GET /employees/{id}/leave-summary` et `GET /employees/{id}/rtt-summary` acceptent `?phaseId=N` ; défaut = phase courante. - Sélectionner une phase passée : - - Onglet **Congés** : période et règles de la phase (Juin→Mai non-forfait, Jan→Déc FORFAIT). Exercices bornés à la phase, exercice de transition capé sur `phase.endDate`. + - Onglet **Congés** : période et règles de la phase (Juin→Mai non-forfait, Jan→Déc FORFAIT). Exercice de transition capé sur `phase.endDate`. **Cap `from` au `phase.startDate` uniquement pour FORFAIT** (sémantique année civile). Pour le non-forfait, l'exercice CP reste annuel et continu à travers les changements d'heures (35h→39h, etc.) — seul `resolveEffectivePeriodStart` clampe sur la date d'entrée en contrat des nouveaux embauchés. - Onglet **RTT** : visible ssi `phase.contractType !== FORFAIT`. `+ Payer les RTT` actif uniquement sur l'exercice contenant `phase.endDate`. - Bandeau jaune affiché en mode phase passée. Édition d'absences et des stocks de report (jours fractionnés, Année N-1 payés) désactivée. - Sélection non persistée — chaque ouverture de fiche démarre sur la phase courante. diff --git a/doc/contract-phase-view.md b/doc/contract-phase-view.md index 62b3569..10729c2 100644 --- a/doc/contract-phase-view.md +++ b/doc/contract-phase-view.md @@ -49,9 +49,27 @@ Affiché quand le picker est sur une phase passée. Indique que le mode lecture ## Transition d'exercice -Quand un exercice chevauche deux phases (ex. switch 39h→FORFAIT au 01/05/2026 fait que l'exercice Juin 2025 → Mai 2026 est à cheval) : -- Vu depuis la phase 39h, l'exercice est borné à `phase.endDate` (30/04/2026). -- Vu depuis la phase FORFAIT, la période civile 2026 est bornée à `phase.startDate` (01/05/2026). +Quand un exercice chevauche deux phases, les bornes sont capées différemment selon le type de phase consultée : + +### Phase FORFAIT (passée ou courante) + +Le cumul 218 jours est **par année civile**. Toute consultation FORFAIT cape : +- `from` à `max(phase.startDate, 1er janvier de l'année)` +- `to` à `min(phase.endDate, 31 décembre de l'année)` + +Ex. switch 39h → FORFAIT au 01/05/2026, vue FORFAIT année 2026 → période = [01/05/2026, 31/12/2026]. + +### Phase non-forfait (35h / 39h / CUSTOM / INTERIM) + +L'exercice CP est **annuel** (Juin N-1 → Mai N) et continu à travers les changements d'heures contractuelles dans le même régime non-forfait. La cap **n'applique pas** sur `from` : +- `from` reste à 1er juin de l'année (le contrat-entry-date est géré par `resolveEffectivePeriodStart` pour les nouveaux embauchés) +- `to` est borné à `phase.endDate` uniquement quand on consulte une **phase passée** + +Ex. employé 35h jusqu'au 31/10/2025 puis 39h depuis le 01/11/2025 : +- Vue 39h (courante) sur exercice 2026 → période = [01/06/2025, 31/05/2026]. Acquis CP = exercice complet (~27.5 jours à fin avril). +- Vue 35h (passée) sur exercice 2026 → période = [01/06/2025, 31/10/2025]. Acquis CP = 5 mois de l'exercice. + +**Important** : c'est intentionnel que la vue courante 39h inclue les mois Juin-Octobre travaillés en 35h dans son cumul. Le stock CP est annuel, pas par phase ; un changement d'heures ne reset pas le compteur. ## API diff --git a/src/State/EmployeeLeaveSummaryProvider.php b/src/State/EmployeeLeaveSummaryProvider.php index a6d0b73..f4c3896 100644 --- a/src/State/EmployeeLeaveSummaryProvider.php +++ b/src/State/EmployeeLeaveSummaryProvider.php @@ -943,16 +943,24 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface { if (ContractType::FORFAIT === $phase->contractType) { [$from, $to] = $this->resolveForfaitYearBounds($employee, $year, $phase); + + // For FORFAIT, cap from at phase.startDate: the 218-day FORFAIT accrual + // is calendar-year scoped and only counts the FORFAIT portion of the year. + if ($phase->startDate > $from) { + $from = $phase->startDate; + } } else { [$from, $to] = $this->resolveLeavePeriodBounds($year); + + // For non-forfait, do NOT cap from at phase.startDate: CP accrual is + // annual (Juin→Mai) and continuous across signature changes within the + // same leave rule (e.g. 35h → 39h, driver flag flip, weeklyHours bump). + // The contract-entry-date cap is handled by resolveEffectivePeriodStart(). } - // Cap to the phase boundaries (applies to both modes). - // The end cap is skipped when the phase was not explicitly provided (legacy callers), - // to preserve pre-phase-cap behavior for terminated employees. - if ($phase->startDate > $from) { - $from = $phase->startDate; - } + // End cap applies to both modes. Skipped when the phase was not explicitly + // provided (legacy callers) to preserve pre-phase-cap behavior for + // terminated employees. if ($applyPhaseEndCap && null !== $phase->endDate && $phase->endDate < $to) { $to = $phase->endDate; } diff --git a/tests/State/EmployeeLeaveSummaryProviderTest.php b/tests/State/EmployeeLeaveSummaryProviderTest.php index 584ce73..4360828 100644 --- a/tests/State/EmployeeLeaveSummaryProviderTest.php +++ b/tests/State/EmployeeLeaveSummaryProviderTest.php @@ -172,6 +172,44 @@ final class EmployeeLeaveSummaryProviderTest extends TestCase self::assertEqualsWithDelta(22.92, $acquired, 0.1); } + public function testNonForfaitPhaseStartingMidExerciseUsesFullExerciseFromAsStart(): void + { + // Scenario: 35h CDI from 2014-07-01 to 2025-10-31, then 39h CDI from 2025-11-01. + // Both phases are non-forfait (same leave rule CDI_CDD_NON_FORFAIT). + // Viewing exercise 2026 on the current 39h phase, accrual must run from the + // exercise start (June 1, 2025), NOT from the phase start (November 1, 2025). + // Otherwise the 5 months of June-October under 35h would be lost from the + // annual CP accrual, which is wrong (CP exercise is annual, not per-phase). + $employee = $this->buildH35ToH39Transition('2014-07-01', '2025-10-31', '2025-11-01'); + $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); + $h39Phase = $phases[0]; // current + + $provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2026']); + + [$from, $to] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2026, $h39Phase); + + self::assertSame('2025-06-01', $from->format('Y-m-d')); + self::assertSame('2026-05-31', $to->format('Y-m-d')); + } + + public function testForfaitPhaseStartingMidYearCapsFromAtPhaseStart(): void + { + // Scenario: 39h CDI ends 2026-04-30, FORFAIT from 2026-05-01. + // Viewing year 2026 on the FORFAIT phase, the period must be capped at + // phase start (May 1) so that only the FORFAIT portion of the calendar + // year is counted. + $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); + $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); + $forfaitPhase = $phases[0]; // current FORFAIT + + $provider = $this->buildProvider(['phaseId' => (string) $forfaitPhase->id, 'year' => '2026']); + + [$from, $to] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2026, $forfaitPhase); + + self::assertSame('2026-05-01', $from->format('Y-m-d')); + self::assertSame('2026-12-31', $to->format('Y-m-d')); + } + public function testYearOutsidePhaseRangeIsSilentlyClampedToPhaseLastExercise(): void { $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); @@ -311,6 +349,45 @@ final class EmployeeLeaveSummaryProviderTest extends TestCase /** * Build a two-period employee transitioning from H39 to FORFAIT. */ + private function buildH35ToH39Transition(string $h35Start, string $h35End, string $h39Start): Employee + { + $employee = new Employee(); + $this->setEntityId($employee, 1); + + $h35Contract = new Contract(); + $h35Contract->setName('35H'); + $h35Contract->setTrackingMode(TrackingMode::TIME->value); + $h35Contract->setWeeklyHours(35); + + $h39Contract = new Contract(); + $h39Contract->setName('39H'); + $h39Contract->setTrackingMode(TrackingMode::TIME->value); + $h39Contract->setWeeklyHours(39); + + $h35Period = new EmployeeContractPeriod(); + $this->setEntityId($h35Period, 1); + $h35Period->setEmployee($employee); + $h35Period->setContract($h35Contract); + $h35Period->setStartDate(new DateTimeImmutable($h35Start)); + $h35Period->setEndDate(new DateTimeImmutable($h35End)); + $h35Period->setContractNature(ContractNature::CDI); + $h35Period->setIsDriver(false); + + $h39Period = new EmployeeContractPeriod(); + $this->setEntityId($h39Period, 2); + $h39Period->setEmployee($employee); + $h39Period->setContract($h39Contract); + $h39Period->setStartDate(new DateTimeImmutable($h39Start)); + $h39Period->setEndDate(null); + $h39Period->setContractNature(ContractNature::CDI); + $h39Period->setIsDriver(false); + + $employee->getContractPeriods()->add($h35Period); + $employee->getContractPeriods()->add($h39Period); + + return $employee; + } + private function buildEmployeeWithTransition(string $h39Start, string $h39End, string $forfaitStart): Employee { $employee = new Employee();