- Extract private helper `exerciseYearForDate(date, isForfait)` to dedupe the date->leave-exercise-year expression duplicated across `clampYearToPhase` and `resolveFirstComputationYear` (4 copies collapsed into 1 helper + 4 call sites). - Remove the unused `ContractPhase $phase` parameter from `resolveLeavePeriodBounds`: the body never reads it (the phase cap is applied later by `resolvePeriodBounds`). - Add `ContractNature $contractNature` to `ContractPhase` DTO, populated from the first period of the group by `EmployeeContractPhaseResolver`. Drop the `resolveNatureForPhase` lookup in `EmployeeLeaveSummaryProvider` in favor of `$phase->contractNature`. Expose `contractNature` in `Employee::getContractPhases()` array shape for frontend use. - Fix regression for terminated employees calling `computeYearSummary` without an explicit phase (LeaveRecapRowBuilder, DumpVerificationSnapshotCommand). Before the refactor the period bounds, accrual end and taken end were NOT capped at the contract end for terminated employees, because `Employee::getCurrentContractEndDate()` returns null when no period covers "today". The new fallback phase (`isCurrent=false`, real `endDate`) was silently capping `to`. Add an internal `applyPhaseEndCap` flag, true when phase is explicit, false for legacy callers, threaded through `resolvePeriodBounds`, `resolveAccrualCalculationEndDate` and `resolveTakenCalculationEndDate`. - Add regression test `testTerminatedEmployeeWithoutExplicitPhaseSkipsPhaseEndCap` proving that legacy callers keep the natural exercise upper bound while explicit phase callers get the cap. - Add `contractNature` assertion in `EmployeeContractPhaseResolverTest`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
85 lines
2.6 KiB
PHP
85 lines
2.6 KiB
PHP
<?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,
|
|
contractNature: $first->getContractNatureEnum(),
|
|
);
|
|
}
|
|
}
|