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