fix(leave) : do not cap from at phase.startDate for non-forfait phases

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 14:31:56 +02:00
parent f48f1d2f3a
commit bbde6ddcf3
4 changed files with 113 additions and 10 deletions

View File

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

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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();