feat(contracts) : add EmployeeContractPhaseResolver service
This commit is contained in:
25
src/Dto/Contracts/ContractPhase.php
Normal file
25
src/Dto/Contracts/ContractPhase.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Dto\Contracts;
|
||||||
|
|
||||||
|
use App\Enum\ContractType;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
final readonly class ContractPhase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<int> $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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
83
src/Service/Contracts/EmployeeContractPhaseResolver.php
Normal file
83
src/Service/Contracts/EmployeeContractPhaseResolver.php
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Contracts;
|
||||||
|
|
||||||
|
use App\Dto\Contracts\ContractPhase;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeContractPeriod;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use LogicException;
|
||||||
|
|
||||||
|
final readonly class EmployeeContractPhaseResolver
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return list<ContractPhase>
|
||||||
|
*/
|
||||||
|
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<EmployeeContractPeriod> $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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
143
tests/Service/Contracts/EmployeeContractPhaseResolverTest.php
Normal file
143
tests/Service/Contracts/EmployeeContractPhaseResolverTest.php
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service\Contracts;
|
||||||
|
|
||||||
|
use App\Entity\Contract;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeContractPeriod;
|
||||||
|
use App\Enum\ContractNature;
|
||||||
|
use App\Enum\ContractType;
|
||||||
|
use App\Enum\TrackingMode;
|
||||||
|
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use ReflectionProperty;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class EmployeeContractPhaseResolverTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testSinglePeriodYieldsSinglePhaseMarkedCurrent(): void
|
||||||
|
{
|
||||||
|
$employee = $this->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<array{type: ContractType, hours: ?int, driver: bool, start: string, end: ?string}> $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user