From 8f355e05adf8850f77acf7d298032a034d397e93 Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 19 May 2026 11:33:06 +0200 Subject: [PATCH] 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. --- src/Service/Exercise/ExerciseYearResolver.php | 27 ++++++++ src/State/EmployeeLeaveSummaryProvider.php | 26 ++------ src/State/EmployeeRttPaymentProcessor.php | 18 ++---- src/State/EmployeeRttSummaryProvider.php | 17 ++--- .../Exercise/ExerciseYearResolverTest.php | 62 +++++++++++++++++++ .../EmployeeLeaveSummaryProviderTest.php | 2 + .../State/EmployeeRttPaymentProcessorTest.php | 2 + .../State/EmployeeRttSummaryProviderTest.php | 2 + 8 files changed, 109 insertions(+), 47 deletions(-) create mode 100644 src/Service/Exercise/ExerciseYearResolver.php create mode 100644 tests/Service/Exercise/ExerciseYearResolverTest.php diff --git a/src/Service/Exercise/ExerciseYearResolver.php b/src/Service/Exercise/ExerciseYearResolver.php new file mode 100644 index 0000000..e562438 --- /dev/null +++ b/src/Service/Exercise/ExerciseYearResolver.php @@ -0,0 +1,27 @@ += 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'); + } +} diff --git a/src/State/EmployeeLeaveSummaryProvider.php b/src/State/EmployeeLeaveSummaryProvider.php index 85749c1..a6d0b73 100644 --- a/src/State/EmployeeLeaveSummaryProvider.php +++ b/src/State/EmployeeLeaveSummaryProvider.php @@ -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) { diff --git a/src/State/EmployeeRttPaymentProcessor.php b/src/State/EmployeeRttPaymentProcessor.php index 48d5e75..c161460 100644 --- a/src/State/EmployeeRttPaymentProcessor.php +++ b/src/State/EmployeeRttPaymentProcessor.php @@ -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; } } diff --git a/src/State/EmployeeRttSummaryProvider.php b/src/State/EmployeeRttSummaryProvider.php index c4eb7c5..b670ae7 100644 --- a/src/State/EmployeeRttSummaryProvider.php +++ b/src/State/EmployeeRttSummaryProvider.php @@ -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'); diff --git a/tests/Service/Exercise/ExerciseYearResolverTest.php b/tests/Service/Exercise/ExerciseYearResolverTest.php new file mode 100644 index 0000000..77e2fa2 --- /dev/null +++ b/tests/Service/Exercise/ExerciseYearResolverTest.php @@ -0,0 +1,62 @@ +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'))); + } +} diff --git a/tests/State/EmployeeLeaveSummaryProviderTest.php b/tests/State/EmployeeLeaveSummaryProviderTest.php index 44a6e7f..584ce73 100644 --- a/tests/State/EmployeeLeaveSummaryProviderTest.php +++ b/tests/State/EmployeeLeaveSummaryProviderTest.php @@ -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; diff --git a/tests/State/EmployeeRttPaymentProcessorTest.php b/tests/State/EmployeeRttPaymentProcessorTest.php index 87e877f..8ea17ad 100644 --- a/tests/State/EmployeeRttPaymentProcessorTest.php +++ b/tests/State/EmployeeRttPaymentProcessorTest.php @@ -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; } diff --git a/tests/State/EmployeeRttSummaryProviderTest.php b/tests/State/EmployeeRttSummaryProviderTest.php index 5ae6f37..43a4609 100644 --- a/tests/State/EmployeeRttSummaryProviderTest.php +++ b/tests/State/EmployeeRttSummaryProviderTest.php @@ -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;