feat(rtt) : allow payment on closed phase last exercise
This commit is contained in:
@@ -12,8 +12,10 @@ use App\Entity\EmployeeRttPayment;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use App\Repository\EmployeeRttPaymentRepository;
|
||||
use App\Service\AuditLogger;
|
||||
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Clock\ClockInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
@@ -24,6 +26,8 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
|
||||
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
||||
private EntityManagerInterface $entityManager,
|
||||
private AuditLogger $auditLogger,
|
||||
private EmployeeContractPhaseResolver $phaseResolver,
|
||||
private ClockInterface $clock,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EmployeeRttPaymentInput
|
||||
@@ -48,6 +52,8 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
|
||||
|
||||
$year = $data->year ?? $this->resolveCurrentExerciseYear();
|
||||
|
||||
$this->assertYearAllowedForPayment($employee, $year);
|
||||
|
||||
$payment = $this->rttPaymentRepository->findOneByEmployeeYearMonth($employee, $year, $data->month);
|
||||
|
||||
if (null === $payment) {
|
||||
@@ -83,10 +89,44 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
|
||||
|
||||
private function resolveCurrentExerciseYear(): int
|
||||
{
|
||||
$today = new DateTimeImmutable('today');
|
||||
$year = (int) $today->format('Y');
|
||||
$month = (int) $today->format('n');
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow payment when the requested exercise is either the current one
|
||||
* or the last exercise of a closed contract phase (the one containing
|
||||
* the phase end date). Reject any other exercise (past or future).
|
||||
*/
|
||||
private function assertYearAllowedForPayment(Employee $employee, int $year): void
|
||||
{
|
||||
$currentExerciseYear = $this->resolveCurrentExerciseYear();
|
||||
if ($year === $currentExerciseYear) {
|
||||
return;
|
||||
}
|
||||
|
||||
$phases = $this->phaseResolver->resolvePhases($employee);
|
||||
foreach ($phases as $phase) {
|
||||
if ($phase->isCurrent || null === $phase->endDate) {
|
||||
continue;
|
||||
}
|
||||
if ($year === $this->resolveExerciseYearForDate($phase->endDate)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new UnprocessableEntityHttpException(
|
||||
'RTT payment is only allowed on the current exercise or the last exercise of a closed contract phase.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
167
tests/State/EmployeeRttPaymentProcessorTest.php
Normal file
167
tests/State/EmployeeRttPaymentProcessorTest.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\State;
|
||||
|
||||
use App\Entity\Contract;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\EmployeeContractPeriod;
|
||||
use App\Enum\ContractNature;
|
||||
use App\Enum\TrackingMode;
|
||||
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
||||
use App\State\EmployeeRttPaymentProcessor;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Clock\ClockInterface;
|
||||
use ReflectionClass;
|
||||
use ReflectionProperty;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* Exercises the year-acceptance guard of EmployeeRttPaymentProcessor.
|
||||
*
|
||||
* The processor depends on final repositories (EmployeeRepository,
|
||||
* EmployeeRttPaymentRepository) which PHPUnit cannot double. The guard logic
|
||||
* lives in a private helper (assertYearAllowedForPayment) tested directly via
|
||||
* reflection — same pattern used in EmployeeRttSummaryProviderTest.
|
||||
*/
|
||||
final class EmployeeRttPaymentProcessorTest extends TestCase
|
||||
{
|
||||
public function testPaymentAllowedOnCurrentExercise(): void
|
||||
{
|
||||
// Today = 2026-05-19 (env clock) → current exercise = 2026.
|
||||
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||
$processor = $this->buildProcessorWithClock(new DateTimeImmutable('2026-05-19'));
|
||||
|
||||
$this->invokePrivate($processor, 'assertYearAllowedForPayment', $employee, 2026);
|
||||
|
||||
// No exception → guard accepts current exercise.
|
||||
self::assertTrue(true);
|
||||
}
|
||||
|
||||
public function testPaymentAllowedOnLastExerciseOfClosedPhase(): void
|
||||
{
|
||||
// Phase 39h closed 2026-04-30, FORFAIT from 2026-05-01.
|
||||
// Exercise 2026 (Juin 2025 → Mai 2026) contains the H39 phase end date.
|
||||
// Payment must be allowed on exercise 2026 even when current exercise is 2027.
|
||||
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||
$processor = $this->buildProcessorWithClock(new DateTimeImmutable('2027-01-15'));
|
||||
|
||||
$this->invokePrivate($processor, 'assertYearAllowedForPayment', $employee, 2026);
|
||||
|
||||
self::assertTrue(true);
|
||||
}
|
||||
|
||||
public function testPaymentRejectedOnEarlierExerciseOfClosedPhase(): void
|
||||
{
|
||||
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||
$processor = $this->buildProcessorWithClock(new DateTimeImmutable('2027-01-15'));
|
||||
|
||||
$this->expectException(UnprocessableEntityHttpException::class);
|
||||
$this->invokePrivate($processor, 'assertYearAllowedForPayment', $employee, 2024);
|
||||
}
|
||||
|
||||
public function testPaymentRejectedOnFutureExercise(): void
|
||||
{
|
||||
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||
$processor = $this->buildProcessorWithClock(new DateTimeImmutable('2026-05-19'));
|
||||
|
||||
$this->expectException(UnprocessableEntityHttpException::class);
|
||||
$this->invokePrivate($processor, 'assertYearAllowedForPayment', $employee, 2030);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test harness helpers.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a two-period employee transitioning from H39 to FORFAIT.
|
||||
*/
|
||||
private function buildEmployeeWithTransition(string $h39Start, string $h39End, string $forfaitStart): Employee
|
||||
{
|
||||
$employee = new Employee();
|
||||
$this->setEntityId($employee, 1);
|
||||
|
||||
$h39Contract = new Contract();
|
||||
$h39Contract->setName('39H');
|
||||
$h39Contract->setTrackingMode(TrackingMode::TIME->value);
|
||||
$h39Contract->setWeeklyHours(39);
|
||||
|
||||
$forfaitContract = new Contract();
|
||||
$forfaitContract->setName('Forfait');
|
||||
$forfaitContract->setTrackingMode(TrackingMode::PRESENCE->value);
|
||||
$forfaitContract->setWeeklyHours(null);
|
||||
|
||||
$h39Period = new EmployeeContractPeriod();
|
||||
$this->setEntityId($h39Period, 1);
|
||||
$h39Period->setEmployee($employee);
|
||||
$h39Period->setContract($h39Contract);
|
||||
$h39Period->setStartDate(new DateTimeImmutable($h39Start));
|
||||
$h39Period->setEndDate(new DateTimeImmutable($h39End));
|
||||
$h39Period->setContractNature(ContractNature::CDI);
|
||||
$h39Period->setIsDriver(false);
|
||||
|
||||
$forfaitPeriod = new EmployeeContractPeriod();
|
||||
$this->setEntityId($forfaitPeriod, 2);
|
||||
$forfaitPeriod->setEmployee($employee);
|
||||
$forfaitPeriod->setContract($forfaitContract);
|
||||
$forfaitPeriod->setStartDate(new DateTimeImmutable($forfaitStart));
|
||||
$forfaitPeriod->setEndDate(null);
|
||||
$forfaitPeriod->setContractNature(ContractNature::CDI);
|
||||
$forfaitPeriod->setIsDriver(false);
|
||||
|
||||
$employee->getContractPeriods()->add($h39Period);
|
||||
$employee->getContractPeriods()->add($forfaitPeriod);
|
||||
|
||||
return $employee;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an uninitialized processor with a fixed clock. The repositories are
|
||||
* declared on final classes that PHPUnit cannot double, so we bypass full
|
||||
* instantiation via newInstanceWithoutConstructor and only seed the
|
||||
* properties the tested private guard reads: phaseResolver + clock.
|
||||
*/
|
||||
private function buildProcessorWithClock(DateTimeImmutable $today): EmployeeRttPaymentProcessor
|
||||
{
|
||||
$reflection = new ReflectionClass(EmployeeRttPaymentProcessor::class);
|
||||
$processor = $reflection->newInstanceWithoutConstructor();
|
||||
|
||||
$clock = new readonly class($today) implements ClockInterface {
|
||||
public function __construct(private DateTimeImmutable $now) {}
|
||||
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return $this->now;
|
||||
}
|
||||
};
|
||||
|
||||
$this->setReadonlyProperty($processor, 'phaseResolver', new EmployeeContractPhaseResolver());
|
||||
$this->setReadonlyProperty($processor, 'clock', $clock);
|
||||
|
||||
return $processor;
|
||||
}
|
||||
|
||||
private function invokePrivate(object $obj, string $method, mixed ...$args): mixed
|
||||
{
|
||||
$reflection = new ReflectionClass($obj::class);
|
||||
$m = $reflection->getMethod($method);
|
||||
|
||||
return $m->invoke($obj, ...$args);
|
||||
}
|
||||
|
||||
private function setReadonlyProperty(object $obj, string $property, mixed $value): void
|
||||
{
|
||||
$reflection = new ReflectionProperty($obj::class, $property);
|
||||
$reflection->setValue($obj, $value);
|
||||
}
|
||||
|
||||
private function setEntityId(object $entity, int $id): void
|
||||
{
|
||||
$reflection = new ReflectionProperty($entity::class, 'id');
|
||||
$reflection->setValue($entity, $id);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user