refactor(exercise) : extract ExerciseYearResolver to dedup year formula

Pull the "date -> leave/RTT exercise year" formula out of
EmployeeRttPaymentProcessor, EmployeeRttSummaryProvider and
EmployeeLeaveSummaryProvider into a single
App\Service\Exercise\ExerciseYearResolver. Forfait flag is parameterised
so the leave (calendar year) and RTT (Juin N-1 -> Mai N) variants share
the same implementation. Pure refactor, no behavioural change.
This commit is contained in:
2026-05-19 11:33:06 +02:00
parent 613ac02e1d
commit 8f355e05ad
8 changed files with 109 additions and 47 deletions

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Service\Exercise;
use DateTimeImmutable;
final readonly class ExerciseYearResolver
{
/**
* Convert a date to its leave/RTT exercise year.
*
* - Forfait: calendar year (Jan→Dec) — returns $date.Y.
* - Non-forfait: leave year (Juin N-1 → Mai N) — returns $date.Y+1 if month >= 6, else $date.Y.
*/
public function forDate(DateTimeImmutable $date, bool $isForfait = false): int
{
if ($isForfait) {
return (int) $date->format('Y');
}
return (int) $date->format('n') >= 6
? (int) $date->format('Y') + 1
: (int) $date->format('Y');
}
}

View File

@@ -22,6 +22,7 @@ use App\Repository\EmployeeRepository;
use App\Repository\WorkHourRepository;
use App\Security\EmployeeScopeService;
use App\Service\Contracts\EmployeeContractPhaseResolver;
use App\Service\Exercise\ExerciseYearResolver;
use App\Service\Leave\LeaveBalanceComputationService;
use App\Service\Leave\LongMaladieService;
use App\Service\Leave\SuspensionDaysCalculator;
@@ -63,6 +64,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
private SuspensionDaysCalculator $suspensionDaysCalculator,
private WorkHourRepository $workHourRepository,
private EmployeeContractPhaseResolver $phaseResolver,
private ExerciseYearResolver $exerciseYearResolver,
string $dataStartDate = '',
) {
$this->dataStartDate = '' !== $dataStartDate ? $dataStartDate : null;
@@ -480,9 +482,9 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
private function clampYearToPhase(int $year, ContractPhase $phase, bool $isForfait): int
{
$firstYear = $this->exerciseYearForDate($phase->startDate, $isForfait);
$firstYear = $this->exerciseYearResolver->forDate($phase->startDate, $isForfait);
$lastYear = $phase->endDate instanceof DateTimeImmutable
? $this->exerciseYearForDate($phase->endDate, $isForfait)
? $this->exerciseYearResolver->forDate($phase->endDate, $isForfait)
: null;
if ($year < $firstYear) {
@@ -495,22 +497,6 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
return $year;
}
/**
* Map a date to the leave exercise year it belongs to.
* - Forfait: exercise = calendar year.
* - Non-forfait: exercise N runs from June (N-1) to May (N); dates in June-December
* map to N+1, January-May map to N.
*/
private function exerciseYearForDate(DateTimeImmutable $date, bool $isForfait): int
{
$year = (int) $date->format('Y');
if ($isForfait) {
return $year;
}
return (int) $date->format('n') >= 6 ? $year + 1 : $year;
}
private function resolveTargetPhase(Employee $employee): ContractPhase
{
$raw = $this->requestStack->getCurrentRequest()?->query->get('phaseId');
@@ -1041,7 +1027,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
: $this->resolveCurrentLeaveYear(new DateTimeImmutable('today'));
// Do not go before the exercice containing $phase->startDate.
$phaseFirstYear = $this->exerciseYearForDate($phase->startDate, $isForfait);
$phaseFirstYear = $this->exerciseYearResolver->forDate($phase->startDate, $isForfait);
$history = $employee->getContractHistory();
if ([] === $history) {
@@ -1066,7 +1052,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
return max($phaseFirstYear, $candidate);
}
$firstYear = $this->exerciseYearForDate($oldestStartDate, $isForfait);
$firstYear = $this->exerciseYearResolver->forDate($oldestStartDate, $isForfait);
$oldestBalanceYear = $this->leaveBalanceRepository->findEarliestYearForEmployee($employee);
if (null !== $oldestBalanceYear && $oldestBalanceYear < $firstYear) {

View File

@@ -13,7 +13,7 @@ use App\Repository\EmployeeRepository;
use App\Repository\EmployeeRttPaymentRepository;
use App\Service\AuditLogger;
use App\Service\Contracts\EmployeeContractPhaseResolver;
use DateTimeImmutable;
use App\Service\Exercise\ExerciseYearResolver;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Clock\ClockInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -28,6 +28,7 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
private AuditLogger $auditLogger,
private EmployeeContractPhaseResolver $phaseResolver,
private ClockInterface $clock,
private ExerciseYearResolver $exerciseYearResolver,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EmployeeRttPaymentInput
@@ -89,18 +90,7 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
private function resolveCurrentExerciseYear(): int
{
return $this->resolveExerciseYearForDate($this->clock->now());
}
/**
* Map a date to the RTT exercise year it belongs to (Juin N-1 → Mai N convention).
*/
private function resolveExerciseYearForDate(DateTimeImmutable $date): int
{
$year = (int) $date->format('Y');
$month = (int) $date->format('n');
return $month >= 6 ? $year + 1 : $year;
return $this->exerciseYearResolver->forDate($this->clock->now());
}
/**
@@ -120,7 +110,7 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
if ($phase->isCurrent || null === $phase->endDate) {
continue;
}
if ($year === $this->resolveExerciseYearForDate($phase->endDate)) {
if ($year === $this->exerciseYearResolver->forDate($phase->endDate)) {
return;
}
}

View File

@@ -19,6 +19,7 @@ use App\Repository\EmployeeRttPaymentRepository;
use App\Repository\WorkHourRepository;
use App\Security\EmployeeScopeService;
use App\Service\Contracts\EmployeeContractPhaseResolver;
use App\Service\Exercise\ExerciseYearResolver;
use App\Service\Rtt\RttRecoveryComputationService;
use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
@@ -41,6 +42,7 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
private RttRecoveryComputationService $rttRecoveryService,
private WorkHourRepository $workHourRepository,
private EmployeeContractPhaseResolver $phaseResolver,
private ExerciseYearResolver $exerciseYearResolver,
string $rttStartDate = '',
) {
$this->rttStartDate = '' !== $rttStartDate ? $rttStartDate : null;
@@ -269,9 +271,9 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
private function clampYearToPhase(int $year, ContractPhase $phase): int
{
$firstYear = $this->exerciseYearForDate($phase->startDate);
$firstYear = $this->exerciseYearResolver->forDate($phase->startDate);
$lastYear = $phase->endDate instanceof DateTimeImmutable
? $this->exerciseYearForDate($phase->endDate)
? $this->exerciseYearResolver->forDate($phase->endDate)
: null;
if ($year < $firstYear) {
@@ -284,17 +286,6 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
return $year;
}
/**
* Map a date to the RTT exercise year it belongs to (Juin N-1 → Mai N convention).
*/
private function exerciseYearForDate(DateTimeImmutable $date): int
{
$year = (int) $date->format('Y');
$month = (int) $date->format('n');
return $month >= 6 ? $year + 1 : $year;
}
private function resolveTargetPhase(Employee $employee): ContractPhase
{
$raw = $this->requestStack->getCurrentRequest()?->query->get('phaseId');

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Exercise;
use App\Service\Exercise\ExerciseYearResolver;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class ExerciseYearResolverTest extends TestCase
{
public function testNonForfaitJuneMapsToNextYear(): void
{
$resolver = new ExerciseYearResolver();
self::assertSame(2026, $resolver->forDate(new DateTimeImmutable('2025-06-01')));
self::assertSame(2026, $resolver->forDate(new DateTimeImmutable('2025-06-30')));
}
public function testNonForfaitMayMapsToSameYear(): void
{
$resolver = new ExerciseYearResolver();
self::assertSame(2025, $resolver->forDate(new DateTimeImmutable('2025-05-01')));
self::assertSame(2025, $resolver->forDate(new DateTimeImmutable('2025-05-31')));
}
public function testNonForfaitDecemberMapsToNextYear(): void
{
$resolver = new ExerciseYearResolver();
self::assertSame(2026, $resolver->forDate(new DateTimeImmutable('2025-12-31')));
}
public function testNonForfaitJanuaryMapsToSameYear(): void
{
$resolver = new ExerciseYearResolver();
self::assertSame(2025, $resolver->forDate(new DateTimeImmutable('2025-01-15')));
}
public function testForfaitReturnsCalendarYearRegardlessOfMonth(): void
{
$resolver = new ExerciseYearResolver();
self::assertSame(2025, $resolver->forDate(new DateTimeImmutable('2025-01-15'), true));
self::assertSame(2025, $resolver->forDate(new DateTimeImmutable('2025-06-01'), true));
self::assertSame(2025, $resolver->forDate(new DateTimeImmutable('2025-12-31'), true));
}
public function testForfaitFlagDefaultsToFalse(): void
{
$resolver = new ExerciseYearResolver();
// June without explicit flag must follow non-forfait rule (year + 1).
self::assertSame(2026, $resolver->forDate(new DateTimeImmutable('2025-06-01')));
}
}

View File

@@ -12,6 +12,7 @@ use App\Enum\ContractNature;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Service\Contracts\EmployeeContractPhaseResolver;
use App\Service\Exercise\ExerciseYearResolver;
use App\State\EmployeeLeaveSummaryProvider;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
@@ -371,6 +372,7 @@ final class EmployeeLeaveSummaryProviderTest extends TestCase
$this->setReadonlyProperty($provider, 'requestStack', $requestStack);
$this->setReadonlyProperty($provider, 'phaseResolver', new EmployeeContractPhaseResolver());
$this->setReadonlyProperty($provider, 'exerciseYearResolver', new ExerciseYearResolver());
$this->setReadonlyProperty($provider, 'dataStartDate', null);
return $provider;

View File

@@ -10,6 +10,7 @@ use App\Entity\EmployeeContractPeriod;
use App\Enum\ContractNature;
use App\Enum\TrackingMode;
use App\Service\Contracts\EmployeeContractPhaseResolver;
use App\Service\Exercise\ExerciseYearResolver;
use App\State\EmployeeRttPaymentProcessor;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
@@ -141,6 +142,7 @@ final class EmployeeRttPaymentProcessorTest extends TestCase
$this->setReadonlyProperty($processor, 'phaseResolver', new EmployeeContractPhaseResolver());
$this->setReadonlyProperty($processor, 'clock', $clock);
$this->setReadonlyProperty($processor, 'exerciseYearResolver', new ExerciseYearResolver());
return $processor;
}

View File

@@ -12,6 +12,7 @@ use App\Enum\ContractNature;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Service\Contracts\EmployeeContractPhaseResolver;
use App\Service\Exercise\ExerciseYearResolver;
use App\State\EmployeeRttSummaryProvider;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
@@ -264,6 +265,7 @@ final class EmployeeRttSummaryProviderTest extends TestCase
$this->setReadonlyProperty($provider, 'requestStack', $requestStack);
$this->setReadonlyProperty($provider, 'phaseResolver', new EmployeeContractPhaseResolver());
$this->setReadonlyProperty($provider, 'exerciseYearResolver', new ExerciseYearResolver());
$this->setReadonlyProperty($provider, 'rttStartDate', null);
return $provider;