feat(rtt) : phaseId support in EmployeeRttSummaryProvider
Mirror Task 3 (leave provider) on the RTT side: accept an optional `?phaseId` query parameter and cap the exercise window to the phase boundaries when set. - Inject EmployeeContractPhaseResolver. - New helpers: resolveTargetPhase, clampYearToPhase, exerciseYearForDate. - resolveYear now takes the phase: default year falls back to the phase end date when phaseId is provided; explicit year is silently clamped to the phase range. - provide() narrows periodFrom / periodTo / limitDate to the phase end date for past phases. - Default behavior (no phaseId) unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ namespace App\State;
|
|||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
use ApiPlatform\State\ProviderInterface;
|
use ApiPlatform\State\ProviderInterface;
|
||||||
use App\ApiResource\EmployeeRttSummary;
|
use App\ApiResource\EmployeeRttSummary;
|
||||||
|
use App\Dto\Contracts\ContractPhase;
|
||||||
use App\Dto\Rtt\EmployeeRttWeekSummary;
|
use App\Dto\Rtt\EmployeeRttWeekSummary;
|
||||||
use App\Dto\Rtt\RttMonthPayment;
|
use App\Dto\Rtt\RttMonthPayment;
|
||||||
use App\Dto\Rtt\WeekRecoveryDetail;
|
use App\Dto\Rtt\WeekRecoveryDetail;
|
||||||
@@ -17,6 +18,7 @@ use App\Repository\EmployeeRttBalanceRepository;
|
|||||||
use App\Repository\EmployeeRttPaymentRepository;
|
use App\Repository\EmployeeRttPaymentRepository;
|
||||||
use App\Repository\WorkHourRepository;
|
use App\Repository\WorkHourRepository;
|
||||||
use App\Security\EmployeeScopeService;
|
use App\Security\EmployeeScopeService;
|
||||||
|
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
||||||
use App\Service\Rtt\RttRecoveryComputationService;
|
use App\Service\Rtt\RttRecoveryComputationService;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
@@ -38,6 +40,7 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
|||||||
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
||||||
private RttRecoveryComputationService $rttRecoveryService,
|
private RttRecoveryComputationService $rttRecoveryService,
|
||||||
private WorkHourRepository $workHourRepository,
|
private WorkHourRepository $workHourRepository,
|
||||||
|
private EmployeeContractPhaseResolver $phaseResolver,
|
||||||
string $rttStartDate = '',
|
string $rttStartDate = '',
|
||||||
) {
|
) {
|
||||||
$this->rttStartDate = '' !== $rttStartDate ? $rttStartDate : null;
|
$this->rttStartDate = '' !== $rttStartDate ? $rttStartDate : null;
|
||||||
@@ -64,12 +67,22 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
|||||||
throw new AccessDeniedHttpException('Employee outside your scope.');
|
throw new AccessDeniedHttpException('Employee outside your scope.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$year = $this->resolveYear();
|
$phase = $this->resolveTargetPhase($employee);
|
||||||
|
$year = $this->resolveYear($phase);
|
||||||
$today = new DateTimeImmutable('today');
|
$today = new DateTimeImmutable('today');
|
||||||
$currentExerciseYear = $this->resolveCurrentExerciseYear($today);
|
$currentExerciseYear = $this->resolveCurrentExerciseYear($today);
|
||||||
[$periodFrom, $periodTo] = $this->rttRecoveryService->resolveExerciseBounds($year);
|
[$periodFrom, $periodTo] = $this->rttRecoveryService->resolveExerciseBounds($year);
|
||||||
$weeks = $this->rttRecoveryService->buildWeeksForExercise($periodFrom, $periodTo);
|
|
||||||
$weekRanges = array_map(
|
// Cap exercise bounds to the phase boundaries.
|
||||||
|
if (!$phase->isCurrent && null !== $phase->endDate && $phase->endDate < $periodTo) {
|
||||||
|
$periodTo = $phase->endDate;
|
||||||
|
}
|
||||||
|
if ($phase->startDate > $periodFrom) {
|
||||||
|
$periodFrom = $phase->startDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
$weeks = $this->rttRecoveryService->buildWeeksForExercise($periodFrom, $periodTo);
|
||||||
|
$weekRanges = array_map(
|
||||||
static fn (array $week): array => [
|
static fn (array $week): array => [
|
||||||
'weekNumber' => (int) $week['weekNumber'],
|
'weekNumber' => (int) $week['weekNumber'],
|
||||||
'start' => $week['start'],
|
'start' => $week['start'],
|
||||||
@@ -96,6 +109,12 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For a closed phase: cap the week-computation limit at the phase end date,
|
||||||
|
// so weeks beyond the phase are not counted.
|
||||||
|
if (!$phase->isCurrent && null !== $phase->endDate && $phase->endDate < $limitDate) {
|
||||||
|
$limitDate = $phase->endDate;
|
||||||
|
}
|
||||||
|
|
||||||
$currentByWeekStart = $this->rttRecoveryService->computeRecoveryByWeek($employee, $weekRanges, $periodFrom, $periodTo, $limitDate);
|
$currentByWeekStart = $this->rttRecoveryService->computeRecoveryByWeek($employee, $weekRanges, $periodFrom, $periodTo, $limitDate);
|
||||||
[$carry, $carryMonth] = $this->resolveCarry($employee, $year);
|
[$carry, $carryMonth] = $this->resolveCarry($employee, $year);
|
||||||
|
|
||||||
@@ -213,10 +232,21 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveYear(): int
|
private function resolveYear(ContractPhase $phase): int
|
||||||
{
|
{
|
||||||
$raw = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? '');
|
$raw = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? '');
|
||||||
|
$phaseIdRaw = $this->requestStack->getCurrentRequest()?->query->get('phaseId');
|
||||||
|
$phaseIdProvided = null !== $phaseIdRaw && '' !== (string) $phaseIdRaw;
|
||||||
|
|
||||||
if ('' === $raw) {
|
if ('' === $raw) {
|
||||||
|
// When a phaseId is explicitly provided, default to the exercise year derived from
|
||||||
|
// the phase's end date (or today if the phase is still current).
|
||||||
|
if ($phaseIdProvided) {
|
||||||
|
$reference = $phase->endDate ?? new DateTimeImmutable('today');
|
||||||
|
|
||||||
|
return $this->resolveCurrentExerciseYear($reference);
|
||||||
|
}
|
||||||
|
|
||||||
return $this->resolveCurrentExerciseYear(new DateTimeImmutable('today'));
|
return $this->resolveCurrentExerciseYear(new DateTimeImmutable('today'));
|
||||||
}
|
}
|
||||||
if (!preg_match('/^\d{4}$/', $raw)) {
|
if (!preg_match('/^\d{4}$/', $raw)) {
|
||||||
@@ -228,9 +258,75 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
|||||||
throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.');
|
throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When a phaseId is explicit, silently clamp the requested year to the
|
||||||
|
// first/last exercise covered by the phase.
|
||||||
|
if ($phaseIdProvided) {
|
||||||
|
$year = $this->clampYearToPhase($year, $phase);
|
||||||
|
}
|
||||||
|
|
||||||
return $year;
|
return $year;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function clampYearToPhase(int $year, ContractPhase $phase): int
|
||||||
|
{
|
||||||
|
$firstYear = $this->exerciseYearForDate($phase->startDate);
|
||||||
|
$lastYear = $phase->endDate instanceof DateTimeImmutable
|
||||||
|
? $this->exerciseYearForDate($phase->endDate)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if ($year < $firstYear) {
|
||||||
|
return $firstYear;
|
||||||
|
}
|
||||||
|
if (null !== $lastYear && $year > $lastYear) {
|
||||||
|
return $lastYear;
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
$phases = $this->phaseResolver->resolvePhases($employee);
|
||||||
|
if ([] === $phases) {
|
||||||
|
throw new UnprocessableEntityHttpException('Employee has no contract phase.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $raw || '' === (string) $raw) {
|
||||||
|
// Phase courante par défaut = celle marquée isCurrent ou, à défaut, la plus récente.
|
||||||
|
foreach ($phases as $phase) {
|
||||||
|
if ($phase->isCurrent) {
|
||||||
|
return $phase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $phases[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preg_match('/^\d+$/', (string) $raw)) {
|
||||||
|
throw new UnprocessableEntityHttpException('phaseId must be a positive integer.');
|
||||||
|
}
|
||||||
|
$phaseId = (int) $raw;
|
||||||
|
foreach ($phases as $phase) {
|
||||||
|
if ($phase->id === $phaseId) {
|
||||||
|
return $phase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new UnprocessableEntityHttpException('phaseId does not match any phase of this employee.');
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveCurrentExerciseYear(DateTimeImmutable $today): int
|
private function resolveCurrentExerciseYear(DateTimeImmutable $today): int
|
||||||
{
|
{
|
||||||
$year = (int) $today->format('Y');
|
$year = (int) $today->format('Y');
|
||||||
|
|||||||
291
tests/State/EmployeeRttSummaryProviderTest.php
Normal file
291
tests/State/EmployeeRttSummaryProviderTest.php
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\State;
|
||||||
|
|
||||||
|
use App\Dto\Contracts\ContractPhase;
|
||||||
|
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 App\State\EmployeeRttSummaryProvider;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use ReflectionClass;
|
||||||
|
use ReflectionProperty;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class EmployeeRttSummaryProviderTest extends TestCase
|
||||||
|
{
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Phase resolution tests (Task 4 — phaseId support).
|
||||||
|
// The repository / service dependencies are typed against final classes
|
||||||
|
// which PHPUnit cannot double, so phase resolution is exercised via
|
||||||
|
// reflection on private methods to avoid instantiating the full DI graph.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function testResolveTargetPhasePicksH39PhaseFromPhaseId(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
$h39Phase = $phases[1]; // oldest = 39h
|
||||||
|
|
||||||
|
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id]);
|
||||||
|
$resolved = $this->invokePrivate($provider, 'resolveTargetPhase', $employee);
|
||||||
|
|
||||||
|
self::assertInstanceOf(ContractPhase::class, $resolved);
|
||||||
|
self::assertSame($h39Phase->id, $resolved->id);
|
||||||
|
self::assertSame(ContractType::H39, $resolved->contractType);
|
||||||
|
self::assertFalse($resolved->isCurrent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResolveTargetPhaseDefaultsToCurrentPhaseWhenPhaseIdAbsent(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
$currentPhase = $phases[0]; // most recent = FORFAIT
|
||||||
|
|
||||||
|
$provider = $this->buildProvider([]);
|
||||||
|
$resolved = $this->invokePrivate($provider, 'resolveTargetPhase', $employee);
|
||||||
|
|
||||||
|
self::assertSame($currentPhase->id, $resolved->id);
|
||||||
|
self::assertTrue($resolved->isCurrent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPastH39PhaseRttSummaryIsCappedAtPhaseEndDate(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
$h39Phase = $phases[1];
|
||||||
|
|
||||||
|
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2026']);
|
||||||
|
$year = $this->invokePrivate($provider, 'resolveYear', $h39Phase);
|
||||||
|
|
||||||
|
self::assertSame(2026, $year);
|
||||||
|
|
||||||
|
// The phase-cap branch in provide() narrows periodTo to the phase end date.
|
||||||
|
// Reproduce that logic to validate the resulting window.
|
||||||
|
$periodFrom = new DateTimeImmutable('2025-06-01');
|
||||||
|
$periodTo = new DateTimeImmutable('2026-05-31');
|
||||||
|
|
||||||
|
if (!$h39Phase->isCurrent && null !== $h39Phase->endDate && $h39Phase->endDate < $periodTo) {
|
||||||
|
$periodTo = $h39Phase->endDate;
|
||||||
|
}
|
||||||
|
if ($h39Phase->startDate > $periodFrom) {
|
||||||
|
$periodFrom = $h39Phase->startDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertSame('2025-06-01', $periodFrom->format('Y-m-d'));
|
||||||
|
self::assertSame('2026-04-30', $periodTo->format('Y-m-d'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testYearOutsidePhaseRangeIsSilentlyClampedToPhaseLastExercise(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
$h39Phase = $phases[1];
|
||||||
|
|
||||||
|
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2030']);
|
||||||
|
$year = $this->invokePrivate($provider, 'resolveYear', $h39Phase);
|
||||||
|
|
||||||
|
// Phase ends 2026-04-30 → exercice (Juin-Mai) containing it = 2026.
|
||||||
|
self::assertSame(2026, $year);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testYearBeforePhaseIsClampedToPhaseFirstExercise(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
$h39Phase = $phases[1];
|
||||||
|
|
||||||
|
// Phase starts 2020-06-01 → first exercise (Juin-Mai) = 2021.
|
||||||
|
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2010']);
|
||||||
|
$year = $this->invokePrivate($provider, 'resolveYear', $h39Phase);
|
||||||
|
|
||||||
|
self::assertSame(2021, $year);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInvalidPhaseIdReturns422(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$provider = $this->buildProvider(['phaseId' => '99999']);
|
||||||
|
|
||||||
|
$this->expectException(UnprocessableEntityHttpException::class);
|
||||||
|
$this->invokePrivate($provider, 'resolveTargetPhase', $employee);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNonNumericPhaseIdReturns422(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$provider = $this->buildProvider(['phaseId' => 'abc']);
|
||||||
|
|
||||||
|
$this->expectException(UnprocessableEntityHttpException::class);
|
||||||
|
$this->invokePrivate($provider, 'resolveTargetPhase', $employee);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDefaultYearForPhaseIdOnClosedPhaseUsesPhaseEndDate(): void
|
||||||
|
{
|
||||||
|
// No `year` param + explicit phaseId → default year is derived from $phase->endDate.
|
||||||
|
// H39 phase ends 2026-04-30 → RTT exercise containing that date = 2026.
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
$h39Phase = $phases[1];
|
||||||
|
|
||||||
|
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id]);
|
||||||
|
$year = $this->invokePrivate($provider, 'resolveYear', $h39Phase);
|
||||||
|
|
||||||
|
self::assertSame(2026, $year);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNoQueryParamsKeepsLegacyYearDefaulting(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
$currentPhase = $phases[0];
|
||||||
|
|
||||||
|
$provider = $this->buildProvider([]);
|
||||||
|
$year = $this->invokePrivate($provider, 'resolveYear', $currentPhase);
|
||||||
|
|
||||||
|
// Today is 2026-05-19 → current RTT exercise (Juin N-1 → Mai N) = 2026.
|
||||||
|
self::assertSame(2026, $year);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInvalidYearFormatReturns422(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
$currentPhase = $phases[0];
|
||||||
|
|
||||||
|
$provider = $this->buildProvider(['year' => '20XX']);
|
||||||
|
|
||||||
|
$this->expectException(UnprocessableEntityHttpException::class);
|
||||||
|
$this->invokePrivate($provider, 'resolveYear', $currentPhase);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testYearOutsideBoundsReturns422(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
$currentPhase = $phases[0];
|
||||||
|
|
||||||
|
$provider = $this->buildProvider(['year' => '1900']);
|
||||||
|
|
||||||
|
$this->expectException(UnprocessableEntityHttpException::class);
|
||||||
|
$this->invokePrivate($provider, 'resolveYear', $currentPhase);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testYearWithoutPhaseIdIsNotClamped(): void
|
||||||
|
{
|
||||||
|
// No `phaseId` → legacy callers must keep their requested year as-is,
|
||||||
|
// even if it falls outside the current phase range.
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
$currentPhase = $phases[0];
|
||||||
|
|
||||||
|
$provider = $this->buildProvider(['year' => '2030']);
|
||||||
|
$year = $this->invokePrivate($provider, 'resolveYear', $currentPhase);
|
||||||
|
|
||||||
|
self::assertSame(2030, $year);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 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 provider with a RequestStack pre-loaded with the given query.
|
||||||
|
*
|
||||||
|
* The provider's repository/service dependencies are typed against final classes
|
||||||
|
* (EmployeeRepository, RttRecoveryComputationService, etc.) which PHPUnit cannot
|
||||||
|
* double. We bypass full instantiation by using newInstanceWithoutConstructor and
|
||||||
|
* only setting the properties that the tested private methods actually read:
|
||||||
|
* `requestStack` and `phaseResolver`.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $request query parameters (year, phaseId, ...)
|
||||||
|
*/
|
||||||
|
private function buildProvider(array $request = []): EmployeeRttSummaryProvider
|
||||||
|
{
|
||||||
|
$requestStack = new RequestStack();
|
||||||
|
$requestStack->push(new Request(query: $request));
|
||||||
|
|
||||||
|
$reflection = new ReflectionClass(EmployeeRttSummaryProvider::class);
|
||||||
|
$provider = $reflection->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
$this->setReadonlyProperty($provider, 'requestStack', $requestStack);
|
||||||
|
$this->setReadonlyProperty($provider, 'phaseResolver', new EmployeeContractPhaseResolver());
|
||||||
|
$this->setReadonlyProperty($provider, 'rttStartDate', null);
|
||||||
|
|
||||||
|
return $provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
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