diff --git a/CLAUDE.md b/CLAUDE.md index 7a7e7dd..8927fd8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,7 @@ ## Vue contrat (sélecteur de phase) - Picker `Vue contrat` en haut de la fiche employé (`pages/employees/[id].vue`). Caché si l'employé n'a qu'une phase. - Phase = groupe d'`EmployeeContractPeriod` consécutifs partageant la signature `(contract.type, weeklyHours, isDriver)`. Résolu par `App\Service\Contracts\EmployeeContractPhaseResolver`. +- **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`. diff --git a/config/services.yaml b/config/services.yaml index 81d3957..9a32ff2 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -39,6 +39,10 @@ services: arguments: $dataStartDate: '%env(RTT_START_DATE)%' + App\Service\Contracts\EmployeeContractPhaseResolver: + arguments: + $dataStartDate: '%env(RTT_START_DATE)%' + App\Repository\Contract\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository' App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface: '@App\Repository\EmployeeContractPeriodRepository' App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository' diff --git a/doc/contract-phase-view.md b/doc/contract-phase-view.md index 7683555..62b3569 100644 --- a/doc/contract-phase-view.md +++ b/doc/contract-phase-view.md @@ -10,6 +10,19 @@ Une **phase** = groupe d'`EmployeeContractPeriod` consécutifs (triés par `star Une transition de signature (35h → 39h, 39h → FORFAIT, driver false→true, weeklyHours 28→30, etc.) ouvre une nouvelle phase. Un type différent entre deux périodes de même signature empêche leur fusion (39h → INTERIM → 39h = 3 phases). +## Filtrage par `RTT_START_DATE` + +Les phases dont la date de fin est strictement antérieure à `RTT_START_DATE` (env, date de mise en service du logiciel) sont **masquées** du picker. Aucune donnée logiciel (heures, absences) n'existe avant cette date, donc consulter une phase entièrement antérieure n'a pas de sens fonctionnel. + +Exemple : un employé sous 35h CDI de 2014 à 2025-10-31 puis 39h CDI depuis 2025-11-01, avec `RTT_START_DATE=2026-02-23` : +- Phase 35h (2014 → 2025-10-31) : entièrement avant → masquée +- Phase 39h (depuis 2025-11-01) : chevauche → visible +- Résultat : 1 seule phase visible → picker caché (seuil ≥ 2 phases) + +Une phase dont la date de fin est égale à `RTT_START_DATE` est conservée (inégalité `>=`, non stricte). + +`EmployeeContractPhaseResolver` reçoit `RTT_START_DATE` via DI (`services.yaml`). L'entité `Employee::getContractPhases()` lit la valeur depuis `$_SERVER`/`$_ENV` directement (l'entité n'a pas d'injection) et passe la chaîne au constructeur du resolver. + ## Picker UI - Position : en haut de la fiche employé, sous le nom et au-dessus des onglets. diff --git a/src/Entity/Employee.php b/src/Entity/Employee.php index e254f64..58e16e7 100644 --- a/src/Entity/Employee.php +++ b/src/Entity/Employee.php @@ -446,7 +446,10 @@ class Employee #[Groups(['employee:read'])] public function getContractPhases(): array { - $resolver = new EmployeeContractPhaseResolver(); + // Read RTT_START_DATE directly here: the entity has no DI but must filter + // out contract phases that ended before the application's data start. + $rawDate = $_SERVER['RTT_START_DATE'] ?? $_ENV['RTT_START_DATE'] ?? ''; + $resolver = new EmployeeContractPhaseResolver(is_string($rawDate) ? $rawDate : ''); return array_map( static fn (ContractPhase $phase): array => [ diff --git a/src/Service/Contracts/EmployeeContractPhaseResolver.php b/src/Service/Contracts/EmployeeContractPhaseResolver.php index b3001c5..be1f644 100644 --- a/src/Service/Contracts/EmployeeContractPhaseResolver.php +++ b/src/Service/Contracts/EmployeeContractPhaseResolver.php @@ -12,6 +12,21 @@ use LogicException; final readonly class EmployeeContractPhaseResolver { + private ?DateTimeImmutable $dataStartDate; + + public function __construct(string $dataStartDate = '') + { + $trimmed = trim($dataStartDate); + if ('' === $trimmed) { + $this->dataStartDate = null; + + return; + } + + $parsed = DateTimeImmutable::createFromFormat('!Y-m-d', $trimmed); + $this->dataStartDate = $parsed instanceof DateTimeImmutable ? $parsed : null; + } + /** * @return list */ @@ -42,6 +57,17 @@ final readonly class EmployeeContractPhaseResolver $phases[] = $this->buildPhase($group, $today); } + // Hide phases entirely before the application's data start date: no usable + // work-hour or absence data exists before that date, so exposing them would + // confuse HR (e.g. legacy contract periods predating the software launch). + if (null !== $this->dataStartDate) { + $dataStart = $this->dataStartDate; + $phases = array_values(array_filter( + $phases, + static fn (ContractPhase $phase): bool => null === $phase->endDate || $phase->endDate >= $dataStart, + )); + } + // Most recent first. return array_reverse($phases); } diff --git a/tests/Service/Contracts/EmployeeContractPhaseResolverTest.php b/tests/Service/Contracts/EmployeeContractPhaseResolverTest.php index eed47d4..e0c032c 100644 --- a/tests/Service/Contracts/EmployeeContractPhaseResolverTest.php +++ b/tests/Service/Contracts/EmployeeContractPhaseResolverTest.php @@ -112,6 +112,58 @@ final class EmployeeContractPhaseResolverTest extends TestCase self::assertFalse($phases[1]->isDriver); } + public function testPhasesEntirelyBeforeDataStartDateAreFilteredOut(): void + { + // H35 phase ends before 2026-02-23 → must be hidden; H39 phase spans the date → kept. + $employee = $this->buildEmployee([ + ['type' => ContractType::H35, 'hours' => 35, 'driver' => false, 'start' => '2014-07-01', 'end' => '2025-10-31'], + ['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2025-11-01', 'end' => null], + ]); + + $phases = new EmployeeContractPhaseResolver('2026-02-23')->resolvePhases($employee); + + self::assertCount(1, $phases); + self::assertSame(ContractType::H39, $phases[0]->contractType); + } + + public function testPhaseEndingExactlyOnDataStartDateIsKept(): void + { + // Edge case: a phase whose endDate equals the data start date is kept + // (the inequality is `>= $dataStart`, not strict). + $employee = $this->buildEmployee([ + ['type' => ContractType::H35, 'hours' => 35, 'driver' => false, 'start' => '2020-01-01', 'end' => '2026-02-23'], + ['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2026-02-24', 'end' => null], + ]); + + $phases = new EmployeeContractPhaseResolver('2026-02-23')->resolvePhases($employee); + + self::assertCount(2, $phases); + } + + public function testNoFilteringWhenDataStartDateIsEmpty(): void + { + $employee = $this->buildEmployee([ + ['type' => ContractType::H35, 'hours' => 35, 'driver' => false, 'start' => '2014-07-01', 'end' => '2020-12-31'], + ['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2021-01-01', 'end' => null], + ]); + + $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); + + self::assertCount(2, $phases); + } + + public function testInvalidDataStartDateStringIsTreatedAsNull(): void + { + $employee = $this->buildEmployee([ + ['type' => ContractType::H35, 'hours' => 35, 'driver' => false, 'start' => '2014-07-01', 'end' => '2020-12-31'], + ['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2021-01-01', 'end' => null], + ]); + + $phases = new EmployeeContractPhaseResolver('not-a-date')->resolvePhases($employee); + + self::assertCount(2, $phases); + } + /** * @param list $periodsSpec */