fix(contracts) : hide contract phases entirely before RTT_START_DATE
EmployeeContractPhaseResolver now accepts the data-start date and filters out phases whose endDate is strictly before it. No work-hour or absence data exists before the application launch date, so legacy contract periods that ended before that date would surface meaningless RTT/CP figures in the phase picker. For employees who joined long before the software (typical legacy 35h contracts, in production since 2014), only the current phase remains visible — which also collapses the picker (threshold ≥ 2 phases). The Employee entity reads RTT_START_DATE from $_SERVER/$_ENV directly since it has no DI. The resolver service is wired via services.yaml. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -86,6 +86,7 @@
|
|||||||
## Vue contrat (sélecteur de phase)
|
## 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.
|
- 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`.
|
- 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.
|
- 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 :
|
- 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). Exercices bornés à la phase, exercice de transition capé sur `phase.endDate`.
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ services:
|
|||||||
arguments:
|
arguments:
|
||||||
$dataStartDate: '%env(RTT_START_DATE)%'
|
$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\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository'
|
||||||
App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface: '@App\Repository\EmployeeContractPeriodRepository'
|
App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface: '@App\Repository\EmployeeContractPeriodRepository'
|
||||||
App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository'
|
App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository'
|
||||||
|
|||||||
@@ -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).
|
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
|
## Picker UI
|
||||||
|
|
||||||
- Position : en haut de la fiche employé, sous le nom et au-dessus des onglets.
|
- Position : en haut de la fiche employé, sous le nom et au-dessus des onglets.
|
||||||
|
|||||||
@@ -446,7 +446,10 @@ class Employee
|
|||||||
#[Groups(['employee:read'])]
|
#[Groups(['employee:read'])]
|
||||||
public function getContractPhases(): array
|
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(
|
return array_map(
|
||||||
static fn (ContractPhase $phase): array => [
|
static fn (ContractPhase $phase): array => [
|
||||||
|
|||||||
@@ -12,6 +12,21 @@ use LogicException;
|
|||||||
|
|
||||||
final readonly class EmployeeContractPhaseResolver
|
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<ContractPhase>
|
* @return list<ContractPhase>
|
||||||
*/
|
*/
|
||||||
@@ -42,6 +57,17 @@ final readonly class EmployeeContractPhaseResolver
|
|||||||
$phases[] = $this->buildPhase($group, $today);
|
$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.
|
// Most recent first.
|
||||||
return array_reverse($phases);
|
return array_reverse($phases);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,58 @@ final class EmployeeContractPhaseResolverTest extends TestCase
|
|||||||
self::assertFalse($phases[1]->isDriver);
|
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<array{type: ContractType, hours: ?int, driver: bool, start: string, end: ?string}> $periodsSpec
|
* @param list<array{type: ContractType, hours: ?int, driver: bool, start: string, end: ?string}> $periodsSpec
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user