From a56f797ed772b7dc7efbb89208670d4e2f1cb5a3 Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 19 May 2026 10:30:41 +0200 Subject: [PATCH] feat(contracts) : add EmployeeContractPhaseResolver service --- src/Dto/Contracts/ContractPhase.php | 25 +++ .../EmployeeContractPhaseResolver.php | 83 ++++++++++ .../EmployeeContractPhaseResolverTest.php | 143 ++++++++++++++++++ 3 files changed, 251 insertions(+) create mode 100644 src/Dto/Contracts/ContractPhase.php create mode 100644 src/Service/Contracts/EmployeeContractPhaseResolver.php create mode 100644 tests/Service/Contracts/EmployeeContractPhaseResolverTest.php diff --git a/src/Dto/Contracts/ContractPhase.php b/src/Dto/Contracts/ContractPhase.php new file mode 100644 index 0000000..2161449 --- /dev/null +++ b/src/Dto/Contracts/ContractPhase.php @@ -0,0 +1,25 @@ + $periodIds + */ + public function __construct( + public int $id, + public ContractType $contractType, + public ?int $weeklyHours, + public bool $isDriver, + public DateTimeImmutable $startDate, + public ?DateTimeImmutable $endDate, + public array $periodIds, + public bool $isCurrent, + ) {} +} diff --git a/src/Service/Contracts/EmployeeContractPhaseResolver.php b/src/Service/Contracts/EmployeeContractPhaseResolver.php new file mode 100644 index 0000000..5109905 --- /dev/null +++ b/src/Service/Contracts/EmployeeContractPhaseResolver.php @@ -0,0 +1,83 @@ + + */ + public function resolvePhases(Employee $employee): array + { + $periods = $employee->getContractPeriods()->toArray(); + usort( + $periods, + static fn (EmployeeContractPeriod $a, EmployeeContractPeriod $b): int => $a->getStartDate() <=> $b->getStartDate() + ); + + $today = new DateTimeImmutable('today'); + $phases = []; + $group = []; + $signature = null; + + foreach ($periods as $period) { + $currentSignature = $this->signature($period); + if (null !== $signature && $currentSignature !== $signature) { + $phases[] = $this->buildPhase($group, $today); + $group = []; + } + $group[] = $period; + $signature = $currentSignature; + } + + if ([] !== $group) { + $phases[] = $this->buildPhase($group, $today); + } + + // Most recent first. + return array_reverse($phases); + } + + private function signature(EmployeeContractPeriod $period): string + { + $contract = $period->getContract(); + $type = $contract?->getType()->value ?? ''; + $hours = $contract?->getWeeklyHours() ?? -1; + $driver = $period->getIsDriver() ? '1' : '0'; + + return sprintf('%s|%d|%s', $type, $hours, $driver); + } + + /** + * @param non-empty-list $group + */ + private function buildPhase(array $group, DateTimeImmutable $today): ContractPhase + { + $first = $group[0]; + $last = end($group); + + $endDate = $last->getEndDate(); + $isCurrent = null === $endDate || $endDate >= $today; + + $contract = $first->getContract(); + + return new ContractPhase( + id: (int) $first->getId(), + contractType: $contract?->getType() ?? throw new LogicException('Phase requires a contract type'), + weeklyHours: $contract?->getWeeklyHours(), + isDriver: $first->getIsDriver(), + startDate: $first->getStartDate(), + endDate: $endDate, + periodIds: array_map(static fn (EmployeeContractPeriod $p): int => (int) $p->getId(), $group), + isCurrent: $isCurrent, + ); + } +} diff --git a/tests/Service/Contracts/EmployeeContractPhaseResolverTest.php b/tests/Service/Contracts/EmployeeContractPhaseResolverTest.php new file mode 100644 index 0000000..746fde3 --- /dev/null +++ b/tests/Service/Contracts/EmployeeContractPhaseResolverTest.php @@ -0,0 +1,143 @@ +buildEmployee([ + ['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2020-06-01', 'end' => null], + ]); + + $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); + + self::assertCount(1, $phases); + self::assertSame(ContractType::H39, $phases[0]->contractType); + self::assertTrue($phases[0]->isCurrent); + self::assertNull($phases[0]->endDate); + } + + public function testThreeConsecutivePeriodsSameSignatureCollapseIntoSinglePhase(): void + { + $employee = $this->buildEmployee([ + ['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2020-06-01', 'end' => '2021-05-31'], + ['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2021-06-01', 'end' => '2022-05-31'], + ['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2022-06-01', 'end' => null], + ]); + + $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); + + self::assertCount(1, $phases); + self::assertCount(3, $phases[0]->periodIds); + self::assertSame('2020-06-01', $phases[0]->startDate->format('Y-m-d')); + self::assertNull($phases[0]->endDate); + } + + public function testSwitchFromH39ToForfaitProducesTwoPhasesMostRecentFirst(): void + { + $employee = $this->buildEmployee([ + ['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2020-06-01', 'end' => '2026-04-30'], + ['type' => ContractType::FORFAIT, 'hours' => 39, 'driver' => false, 'start' => '2026-05-01', 'end' => null], + ]); + + $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); + + self::assertCount(2, $phases); + self::assertSame(ContractType::FORFAIT, $phases[0]->contractType); + self::assertTrue($phases[0]->isCurrent); + self::assertSame(ContractType::H39, $phases[1]->contractType); + self::assertFalse($phases[1]->isCurrent); + self::assertSame('2026-04-30', $phases[1]->endDate?->format('Y-m-d')); + } + + public function testInterimBetweenTwoH39PeriodsBreaksThePhases(): void + { + $employee = $this->buildEmployee([ + ['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2020-06-01', 'end' => '2023-12-31'], + ['type' => ContractType::INTERIM, 'hours' => null, 'driver' => false, 'start' => '2024-01-01', 'end' => '2024-04-30'], + ['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2024-05-01', 'end' => null], + ]); + + $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); + + self::assertCount(3, $phases); + self::assertSame(ContractType::H39, $phases[0]->contractType); + self::assertSame(ContractType::INTERIM, $phases[1]->contractType); + self::assertSame(ContractType::H39, $phases[2]->contractType); + } + + public function testCustomPhasesSplitOnWeeklyHoursChange(): void + { + $employee = $this->buildEmployee([ + ['type' => ContractType::CUSTOM, 'hours' => 28, 'driver' => false, 'start' => '2024-01-01', 'end' => '2024-12-31'], + ['type' => ContractType::CUSTOM, 'hours' => 30, 'driver' => false, 'start' => '2025-01-01', 'end' => null], + ]); + + $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); + + self::assertCount(2, $phases); + self::assertSame(30, $phases[0]->weeklyHours); + self::assertSame(28, $phases[1]->weeklyHours); + } + + public function testPhasesSplitOnIsDriverChange(): void + { + $employee = $this->buildEmployee([ + ['type' => ContractType::H35, 'hours' => 35, 'driver' => false, 'start' => '2023-01-01', 'end' => '2024-12-31'], + ['type' => ContractType::H35, 'hours' => 35, 'driver' => true, 'start' => '2025-01-01', 'end' => null], + ]); + + $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); + + self::assertCount(2, $phases); + self::assertTrue($phases[0]->isDriver); + self::assertFalse($phases[1]->isDriver); + } + + /** + * @param list $periodsSpec + */ + private function buildEmployee(array $periodsSpec): Employee + { + $employee = new Employee(); + $id = 0; + foreach ($periodsSpec as $spec) { + $contract = new Contract(); + $contract->setName($spec['type']->value); + $contract->setTrackingMode( + ContractType::FORFAIT === $spec['type'] ? TrackingMode::PRESENCE->value : TrackingMode::TIME->value + ); + $contract->setWeeklyHours($spec['hours']); + + $period = new EmployeeContractPeriod(); + $reflection = new ReflectionProperty(EmployeeContractPeriod::class, 'id'); + $reflection->setValue($period, ++$id); + $period->setEmployee($employee); + $period->setContract($contract); + $period->setStartDate(new DateTimeImmutable($spec['start'])); + $period->setEndDate(null !== $spec['end'] ? new DateTimeImmutable($spec['end']) : null); + $period->setContractNature(ContractNature::CDI); + $period->setIsDriver($spec['driver']); + $employee->getContractPeriods()->add($period); + } + + return $employee; + } +}