feat(leave) : phaseId support in EmployeeLeaveSummaryProvider
The provider now resolves a target ContractPhase from a ?phaseId query parameter and propagates it through resolveYear, resolvePeriodBounds, resolveLeavePolicy, resolveAccrualCalculationEndDate, resolveTakenCalculationEndDate, and resolveFirstComputationYear. - phaseId missing → current phase (legacy behavior preserved) - phaseId valid → past/current phase used for rule code, weekly hours and period bounds (capped at phase end) - phaseId invalid (non-numeric or unknown) → 422 - year missing + phaseId → year derived from phase end date - year out of phase range + phaseId → silent clamp to phase boundaries Public methods computeYearSummary/resolvePaidLeaveDays/resolveLeaveYearForToday remain backward-compatible for external callers (LeaveRecapRowBuilder, DumpVerificationSnapshotCommand).
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\EmployeeLeaveSummary;
|
use App\ApiResource\EmployeeLeaveSummary;
|
||||||
|
use App\Dto\Contracts\ContractPhase;
|
||||||
use App\Entity\Absence;
|
use App\Entity\Absence;
|
||||||
use App\Entity\ContractSuspension;
|
use App\Entity\ContractSuspension;
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
@@ -20,6 +21,7 @@ use App\Repository\EmployeeLeaveBalanceRepository;
|
|||||||
use App\Repository\EmployeeRepository;
|
use App\Repository\EmployeeRepository;
|
||||||
use App\Repository\WorkHourRepository;
|
use App\Repository\WorkHourRepository;
|
||||||
use App\Security\EmployeeScopeService;
|
use App\Security\EmployeeScopeService;
|
||||||
|
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
||||||
use App\Service\Leave\LeaveBalanceComputationService;
|
use App\Service\Leave\LeaveBalanceComputationService;
|
||||||
use App\Service\Leave\LongMaladieService;
|
use App\Service\Leave\LongMaladieService;
|
||||||
use App\Service\Leave\SuspensionDaysCalculator;
|
use App\Service\Leave\SuspensionDaysCalculator;
|
||||||
@@ -60,6 +62,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
private PublicHolidayServiceInterface $publicHolidayService,
|
private PublicHolidayServiceInterface $publicHolidayService,
|
||||||
private SuspensionDaysCalculator $suspensionDaysCalculator,
|
private SuspensionDaysCalculator $suspensionDaysCalculator,
|
||||||
private WorkHourRepository $workHourRepository,
|
private WorkHourRepository $workHourRepository,
|
||||||
|
private EmployeeContractPhaseResolver $phaseResolver,
|
||||||
string $dataStartDate = '',
|
string $dataStartDate = '',
|
||||||
) {
|
) {
|
||||||
$this->dataStartDate = '' !== $dataStartDate ? $dataStartDate : null;
|
$this->dataStartDate = '' !== $dataStartDate ? $dataStartDate : null;
|
||||||
@@ -86,14 +89,15 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
throw new AccessDeniedHttpException('Employee outside your scope.');
|
throw new AccessDeniedHttpException('Employee outside your scope.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$year = $this->resolveYear($employee);
|
$phase = $this->resolveTargetPhase($employee);
|
||||||
|
$year = $this->resolveYear($employee, $phase);
|
||||||
|
|
||||||
$summary = new EmployeeLeaveSummary();
|
$summary = new EmployeeLeaveSummary();
|
||||||
$summary->year = $year;
|
$summary->year = $year;
|
||||||
$summary->ruleCode = LeaveRuleCode::UNSUPPORTED->value;
|
$summary->ruleCode = LeaveRuleCode::UNSUPPORTED->value;
|
||||||
$summary->dataStartDate = $this->dataStartDate;
|
$summary->dataStartDate = $this->dataStartDate;
|
||||||
|
|
||||||
$yearSummary = $this->computeYearSummary($employee, $year);
|
$yearSummary = $this->computeYearSummary($employee, $year, 0.0, null, $phase);
|
||||||
if (null === $yearSummary) {
|
if (null === $yearSummary) {
|
||||||
return $summary;
|
return $summary;
|
||||||
}
|
}
|
||||||
@@ -104,7 +108,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
// For forfait contracts, paid days reduce N-1 stock before taken-day attribution.
|
// For forfait contracts, paid days reduce N-1 stock before taken-day attribution.
|
||||||
// Recompute with paidLeaveDays so taken days shift from N-1 to N when N-1 is consumed by payment.
|
// Recompute with paidLeaveDays so taken days shift from N-1 to N when N-1 is consumed by payment.
|
||||||
if ($paidLeaveDays > 0.0) {
|
if ($paidLeaveDays > 0.0) {
|
||||||
$yearSummary = $this->computeYearSummary($employee, $year, $paidLeaveDays);
|
$yearSummary = $this->computeYearSummary($employee, $year, $paidLeaveDays, null, $phase);
|
||||||
if (null === $yearSummary) {
|
if (null === $yearSummary) {
|
||||||
return $summary;
|
return $summary;
|
||||||
}
|
}
|
||||||
@@ -125,7 +129,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
$summary->previousYearRemainingDays = $yearSummary['previousYearRemainingDays'];
|
$summary->previousYearRemainingDays = $yearSummary['previousYearRemainingDays'];
|
||||||
$summary->previousYearPaidDays = $paidLeaveDays;
|
$summary->previousYearPaidDays = $paidLeaveDays;
|
||||||
|
|
||||||
[$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year);
|
[$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year, $phase);
|
||||||
// Forfait-only: leaves taken from N-1 stock do NOT decrement presence days.
|
// Forfait-only: leaves taken from N-1 stock do NOT decrement presence days.
|
||||||
// For non-forfait, previousYearTakenDays is always 0, so the budget has no effect.
|
// For non-forfait, previousYearTakenDays is always 0, so the budget has no effect.
|
||||||
$n1AbsencesBudget = $yearSummary['previousYearTakenDays'];
|
$n1AbsencesBudget = $yearSummary['previousYearTakenDays'];
|
||||||
@@ -167,9 +171,14 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
* previousYearRemainingDays: float
|
* previousYearRemainingDays: float
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function computeYearSummary(Employee $employee, int $targetYear, float $paidLeaveDays = 0.0, ?DateTimeImmutable $asOfDate = null): ?array
|
public function computeYearSummary(Employee $employee, int $targetYear, float $paidLeaveDays = 0.0, ?DateTimeImmutable $asOfDate = null, ?ContractPhase $phase = null): ?array
|
||||||
{
|
{
|
||||||
$firstYear = max($this->resolveFirstComputationYear($employee), $targetYear - 1);
|
$phase ??= $this->resolveCurrentPhase($employee);
|
||||||
|
if (null === $phase) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$firstYear = max($this->resolveFirstComputationYear($employee, $phase), $targetYear - 1);
|
||||||
if ($targetYear < $firstYear) {
|
if ($targetYear < $firstYear) {
|
||||||
$targetYear = $firstYear;
|
$targetYear = $firstYear;
|
||||||
}
|
}
|
||||||
@@ -179,8 +188,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
$targetSummary = null;
|
$targetSummary = null;
|
||||||
|
|
||||||
for ($year = $firstYear; $year <= $targetYear; ++$year) {
|
for ($year = $firstYear; $year <= $targetYear; ++$year) {
|
||||||
[$from, $to] = $this->resolvePeriodBounds($employee, $year);
|
[$from, $to] = $this->resolvePeriodBounds($employee, $year, $phase);
|
||||||
$leavePolicy = $this->resolveLeavePolicy($employee, $from, $to);
|
$leavePolicy = $this->resolveLeavePolicy($employee, $phase, $from, $to);
|
||||||
if (null === $leavePolicy) {
|
if (null === $leavePolicy) {
|
||||||
if ($year === $targetYear) {
|
if ($year === $targetYear) {
|
||||||
return null;
|
return null;
|
||||||
@@ -224,8 +233,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
$effectiveAsOfDate = ($year === $targetYear) ? $asOfDate : null;
|
$effectiveAsOfDate = ($year === $targetYear) ? $asOfDate : null;
|
||||||
$accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee, $effectiveAsOfDate);
|
$accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee, $phase, $effectiveAsOfDate);
|
||||||
$takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee, $effectiveAsOfDate);
|
$takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee, $phase, $effectiveAsOfDate);
|
||||||
$suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace(
|
$suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace(
|
||||||
$this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to)
|
$this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to)
|
||||||
);
|
);
|
||||||
@@ -233,7 +242,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
$longMaladiePeriods = [];
|
$longMaladiePeriods = [];
|
||||||
$longMaladieReductionFactor = 1.0;
|
$longMaladieReductionFactor = 1.0;
|
||||||
if (LeaveRuleCode::CDI_CDD_NON_FORFAIT->value === $leavePolicy['ruleCode']
|
if (LeaveRuleCode::CDI_CDD_NON_FORFAIT->value === $leavePolicy['ruleCode']
|
||||||
&& 4 !== $employee->getContract()?->getWeeklyHours()
|
&& 4 !== $phase->weeklyHours
|
||||||
&& null !== $accrualCalculationEnd
|
&& null !== $accrualCalculationEnd
|
||||||
) {
|
) {
|
||||||
$longMaladiePeriods = $this->longMaladieService->findReducedRatePeriods($employee, $effectiveFrom, $accrualCalculationEnd);
|
$longMaladiePeriods = $this->longMaladieService->findReducedRatePeriods($employee, $effectiveFrom, $accrualCalculationEnd);
|
||||||
@@ -420,16 +429,29 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
return $earliest;
|
return $earliest;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveYear(Employee $employee): int
|
private function resolveYear(Employee $employee, ContractPhase $phase): int
|
||||||
{
|
{
|
||||||
$raw = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? '');
|
$isForfait = ContractType::FORFAIT === $phase->contractType;
|
||||||
|
$raw = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? '');
|
||||||
|
$phaseIdRaw = $this->requestStack->getCurrentRequest()?->query->get('phaseId');
|
||||||
|
$phaseIdProvided = null !== $phaseIdRaw && '' !== (string) $phaseIdRaw;
|
||||||
|
|
||||||
if ('' === $raw) {
|
if ('' === $raw) {
|
||||||
$today = new DateTimeImmutable('today');
|
// When a phaseId is explicitly provided, default to the year derived from
|
||||||
if (ContractType::FORFAIT === $employee->getContract()?->getType()) {
|
// the phase's end date (or today if the phase is still current).
|
||||||
return (int) $today->format('Y');
|
if ($phaseIdProvided) {
|
||||||
|
$reference = $phase->endDate ?? new DateTimeImmutable('today');
|
||||||
|
|
||||||
|
return $isForfait
|
||||||
|
? (int) $reference->format('Y')
|
||||||
|
: $this->resolveCurrentLeaveYear($reference);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->resolveCurrentLeaveYear($today);
|
$today = new DateTimeImmutable('today');
|
||||||
|
|
||||||
|
return $isForfait
|
||||||
|
? (int) $today->format('Y')
|
||||||
|
: $this->resolveCurrentLeaveYear($today);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!preg_match('/^\d{4}$/', $raw)) {
|
if (!preg_match('/^\d{4}$/', $raw)) {
|
||||||
@@ -441,9 +463,90 @@ final readonly class EmployeeLeaveSummaryProvider 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, $isForfait);
|
||||||
|
}
|
||||||
|
|
||||||
return $year;
|
return $year;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function clampYearToPhase(int $year, ContractPhase $phase, bool $isForfait): int
|
||||||
|
{
|
||||||
|
$firstYear = $isForfait
|
||||||
|
? (int) $phase->startDate->format('Y')
|
||||||
|
: ((int) $phase->startDate->format('n') >= 6
|
||||||
|
? (int) $phase->startDate->format('Y') + 1
|
||||||
|
: (int) $phase->startDate->format('Y'));
|
||||||
|
|
||||||
|
$endDate = $phase->endDate;
|
||||||
|
$lastYear = null;
|
||||||
|
if ($endDate instanceof DateTimeImmutable) {
|
||||||
|
$lastYear = $isForfait
|
||||||
|
? (int) $endDate->format('Y')
|
||||||
|
: ((int) $endDate->format('n') >= 6
|
||||||
|
? (int) $endDate->format('Y') + 1
|
||||||
|
: (int) $endDate->format('Y'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($year < $firstYear) {
|
||||||
|
return $firstYear;
|
||||||
|
}
|
||||||
|
if (null !== $lastYear && $year > $lastYear) {
|
||||||
|
return $lastYear;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $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 resolveCurrentPhase(Employee $employee): ?ContractPhase
|
||||||
|
{
|
||||||
|
$phases = $this->phaseResolver->resolvePhases($employee);
|
||||||
|
if ([] === $phases) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
foreach ($phases as $phase) {
|
||||||
|
if ($phase->isCurrent) {
|
||||||
|
return $phase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $phases[0];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<ContractSuspension> $suspensions
|
* @param list<ContractSuspension> $suspensions
|
||||||
* @param list<array{start: DateTimeImmutable, end: DateTimeImmutable}> $longMaladiePeriods
|
* @param list<array{start: DateTimeImmutable, end: DateTimeImmutable}> $longMaladiePeriods
|
||||||
@@ -518,6 +621,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
int $year,
|
int $year,
|
||||||
DateTimeImmutable $periodEnd,
|
DateTimeImmutable $periodEnd,
|
||||||
Employee $employee,
|
Employee $employee,
|
||||||
|
ContractPhase $phase,
|
||||||
?DateTimeImmutable $asOfDate = null
|
?DateTimeImmutable $asOfDate = null
|
||||||
): ?DateTimeImmutable {
|
): ?DateTimeImmutable {
|
||||||
$reference = $asOfDate ?? new DateTimeImmutable('today');
|
$reference = $asOfDate ?? new DateTimeImmutable('today');
|
||||||
@@ -525,7 +629,11 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
? (int) $reference->format('Y')
|
? (int) $reference->format('Y')
|
||||||
: $this->resolveCurrentLeaveYear($reference);
|
: $this->resolveCurrentLeaveYear($reference);
|
||||||
|
|
||||||
if ($year < $currentYear) {
|
// When viewing a closed phase, treat its end date as the reference cutoff:
|
||||||
|
// accrual is bounded to the phase end, never running to "today".
|
||||||
|
if (!$phase->isCurrent && null !== $phase->endDate) {
|
||||||
|
$end = $phase->endDate < $periodEnd ? $phase->endDate : $periodEnd;
|
||||||
|
} elseif ($year < $currentYear) {
|
||||||
$end = $periodEnd;
|
$end = $periodEnd;
|
||||||
} elseif ($year > $currentYear) {
|
} elseif ($year > $currentYear) {
|
||||||
$end = null;
|
$end = null;
|
||||||
@@ -538,12 +646,15 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
$end = $lastDayPreviousMonth < $periodEnd ? $lastDayPreviousMonth : $periodEnd;
|
$end = $lastDayPreviousMonth < $periodEnd ? $lastDayPreviousMonth : $periodEnd;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cap at contract end date if the employee has left.
|
// Cap at contract end date if the employee has left (only meaningful when
|
||||||
$contractEndRaw = $employee->getCurrentContractEndDate();
|
// viewing the current phase; closed phases are already capped above).
|
||||||
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
if ($phase->isCurrent) {
|
||||||
$contractEnd = $this->parseYmdDate($contractEndRaw);
|
$contractEndRaw = $employee->getCurrentContractEndDate();
|
||||||
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
|
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
||||||
$end = $contractEnd;
|
$contractEnd = $this->parseYmdDate($contractEndRaw);
|
||||||
|
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
|
||||||
|
$end = $contractEnd;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,6 +664,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
private function resolveTakenCalculationEndDate(
|
private function resolveTakenCalculationEndDate(
|
||||||
DateTimeImmutable $periodEnd,
|
DateTimeImmutable $periodEnd,
|
||||||
Employee $employee,
|
Employee $employee,
|
||||||
|
ContractPhase $phase,
|
||||||
?DateTimeImmutable $asOfDate = null
|
?DateTimeImmutable $asOfDate = null
|
||||||
): ?DateTimeImmutable {
|
): ?DateTimeImmutable {
|
||||||
$end = $periodEnd;
|
$end = $periodEnd;
|
||||||
@@ -561,12 +673,18 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
$end = $asOfDate;
|
$end = $asOfDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cap at contract end date if the employee has left.
|
// Closed phase: cap taken-absence accounting at the phase end.
|
||||||
$contractEndRaw = $employee->getCurrentContractEndDate();
|
if (!$phase->isCurrent && null !== $phase->endDate && $phase->endDate < $end) {
|
||||||
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
$end = $phase->endDate;
|
||||||
$contractEnd = $this->parseYmdDate($contractEndRaw);
|
}
|
||||||
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
|
|
||||||
$end = $contractEnd;
|
if ($phase->isCurrent) {
|
||||||
|
$contractEndRaw = $employee->getCurrentContractEndDate();
|
||||||
|
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
||||||
|
$contractEnd = $this->parseYmdDate($contractEndRaw);
|
||||||
|
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
|
||||||
|
$end = $contractEnd;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,9 +702,9 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
* splitSaturdays: bool
|
* splitSaturdays: bool
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
private function resolveLeavePolicy(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): ?array
|
private function resolveLeavePolicy(Employee $employee, ContractPhase $phase, DateTimeImmutable $from, DateTimeImmutable $to): ?array
|
||||||
{
|
{
|
||||||
$type = $employee->getContract()?->getType();
|
$type = $phase->contractType;
|
||||||
if (ContractType::FORFAIT === $type) {
|
if (ContractType::FORFAIT === $type) {
|
||||||
// Business days for forfait must use the RAW holiday list (excluded holidays like
|
// Business days for forfait must use the RAW holiday list (excluded holidays like
|
||||||
// "Lundi de Pentecôte" / journée de solidarité still count as non-working days for
|
// "Lundi de Pentecôte" / journée de solidarité still count as non-working days for
|
||||||
@@ -615,12 +733,13 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$nature = ContractNature::tryFrom($employee->getCurrentContractNature());
|
// Resolve nature from the period defining the phase (use the phase's first period).
|
||||||
|
$nature = $this->resolveNatureForPhase($employee, $phase);
|
||||||
if (ContractNature::CDI !== $nature && ContractNature::CDD !== $nature) {
|
if (ContractNature::CDI !== $nature && ContractNature::CDD !== $nature) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$weeklyHours = $employee->getContract()?->getWeeklyHours();
|
$weeklyHours = $phase->weeklyHours;
|
||||||
if (4 === $weeklyHours) {
|
if (4 === $weeklyHours) {
|
||||||
return [
|
return [
|
||||||
'ruleCode' => LeaveRuleCode::CDI_CDD_NON_FORFAIT->value,
|
'ruleCode' => LeaveRuleCode::CDI_CDD_NON_FORFAIT->value,
|
||||||
@@ -815,19 +934,29 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
/**
|
/**
|
||||||
* @return array{DateTimeImmutable, DateTimeImmutable}
|
* @return array{DateTimeImmutable, DateTimeImmutable}
|
||||||
*/
|
*/
|
||||||
private function resolvePeriodBounds(Employee $employee, int $year): array
|
private function resolvePeriodBounds(Employee $employee, int $year, ContractPhase $phase): array
|
||||||
{
|
{
|
||||||
if (ContractType::FORFAIT === $employee->getContract()?->getType()) {
|
if (ContractType::FORFAIT === $phase->contractType) {
|
||||||
return $this->resolveForfaitYearBounds($employee, $year);
|
[$from, $to] = $this->resolveForfaitYearBounds($employee, $year, $phase);
|
||||||
|
} else {
|
||||||
|
[$from, $to] = $this->resolveLeavePeriodBounds($year, $phase);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->resolveLeavePeriodBounds($year);
|
// Cap to the phase boundaries (applies to both modes).
|
||||||
|
if ($phase->startDate > $from) {
|
||||||
|
$from = $phase->startDate;
|
||||||
|
}
|
||||||
|
if (null !== $phase->endDate && $phase->endDate < $to) {
|
||||||
|
$to = $phase->endDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$from, $to];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{DateTimeImmutable, DateTimeImmutable}
|
* @return array{DateTimeImmutable, DateTimeImmutable}
|
||||||
*/
|
*/
|
||||||
private function resolveLeavePeriodBounds(int $leaveYear): array
|
private function resolveLeavePeriodBounds(int $leaveYear, ContractPhase $phase): array
|
||||||
{
|
{
|
||||||
// Exercice CP "2026" = du 1er juin 2025 au 31 mai 2026.
|
// Exercice CP "2026" = du 1er juin 2025 au 31 mai 2026.
|
||||||
$from = new DateTimeImmutable(sprintf('%d-06-01 00:00:00', $leaveYear - 1));
|
$from = new DateTimeImmutable(sprintf('%d-06-01 00:00:00', $leaveYear - 1));
|
||||||
@@ -839,24 +968,29 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
/**
|
/**
|
||||||
* @return array{DateTimeImmutable, DateTimeImmutable}
|
* @return array{DateTimeImmutable, DateTimeImmutable}
|
||||||
*/
|
*/
|
||||||
private function resolveForfaitYearBounds(Employee $employee, int $year): array
|
private function resolveForfaitYearBounds(Employee $employee, int $year, ContractPhase $phase): array
|
||||||
{
|
{
|
||||||
$from = new DateTimeImmutable(sprintf('%d-01-01 00:00:00', $year));
|
$from = new DateTimeImmutable(sprintf('%d-01-01 00:00:00', $year));
|
||||||
$to = new DateTimeImmutable(sprintf('%d-12-31 00:00:00', $year));
|
$to = new DateTimeImmutable(sprintf('%d-12-31 00:00:00', $year));
|
||||||
|
|
||||||
$contractStartRaw = $employee->getCurrentContractStartDate();
|
// When viewing the current phase, prefer the live "current contract" dates
|
||||||
if (null !== $contractStartRaw && '' !== trim($contractStartRaw)) {
|
// for backward compat with existing tests/usage. Closed phases rely on the
|
||||||
$contractStart = $this->parseYmdDate($contractStartRaw);
|
// generic cap applied in resolvePeriodBounds().
|
||||||
if ($contractStart instanceof DateTimeImmutable && $contractStart > $from) {
|
if ($phase->isCurrent) {
|
||||||
$from = $contractStart;
|
$contractStartRaw = $employee->getCurrentContractStartDate();
|
||||||
|
if (null !== $contractStartRaw && '' !== trim($contractStartRaw)) {
|
||||||
|
$contractStart = $this->parseYmdDate($contractStartRaw);
|
||||||
|
if ($contractStart instanceof DateTimeImmutable && $contractStart > $from) {
|
||||||
|
$from = $contractStart;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
$contractEndRaw = $employee->getCurrentContractEndDate();
|
$contractEndRaw = $employee->getCurrentContractEndDate();
|
||||||
if (null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
if (null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
||||||
$contractEnd = $this->parseYmdDate($contractEndRaw);
|
$contractEnd = $this->parseYmdDate($contractEndRaw);
|
||||||
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $to) {
|
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $to) {
|
||||||
$to = $contractEnd;
|
$to = $contractEnd;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -878,16 +1012,23 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
return $month >= 6 ? $year + 1 : $year;
|
return $month >= 6 ? $year + 1 : $year;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveFirstComputationYear(Employee $employee): int
|
private function resolveFirstComputationYear(Employee $employee, ContractPhase $phase): int
|
||||||
{
|
{
|
||||||
$isForfait = ContractType::FORFAIT === $employee->getContract()?->getType();
|
$isForfait = ContractType::FORFAIT === $phase->contractType;
|
||||||
$fallbackYear = $isForfait
|
$fallbackYear = $isForfait
|
||||||
? (int) new DateTimeImmutable('today')->format('Y')
|
? (int) new DateTimeImmutable('today')->format('Y')
|
||||||
: $this->resolveCurrentLeaveYear(new DateTimeImmutable('today'));
|
: $this->resolveCurrentLeaveYear(new DateTimeImmutable('today'));
|
||||||
|
|
||||||
|
// Do not go before the exercice containing $phase->startDate.
|
||||||
|
$phaseFirstYear = $isForfait
|
||||||
|
? (int) $phase->startDate->format('Y')
|
||||||
|
: ((int) $phase->startDate->format('n') >= 6
|
||||||
|
? (int) $phase->startDate->format('Y') + 1
|
||||||
|
: (int) $phase->startDate->format('Y'));
|
||||||
|
|
||||||
$history = $employee->getContractHistory();
|
$history = $employee->getContractHistory();
|
||||||
if ([] === $history) {
|
if ([] === $history) {
|
||||||
return $fallbackYear;
|
return max($phaseFirstYear, $fallbackYear);
|
||||||
}
|
}
|
||||||
|
|
||||||
$oldestStartDate = null;
|
$oldestStartDate = null;
|
||||||
@@ -903,8 +1044,9 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
|
|
||||||
if (null === $oldestStartDate) {
|
if (null === $oldestStartDate) {
|
||||||
$oldestBalanceYear = $this->leaveBalanceRepository->findEarliestYearForEmployee($employee);
|
$oldestBalanceYear = $this->leaveBalanceRepository->findEarliestYearForEmployee($employee);
|
||||||
|
$candidate = null === $oldestBalanceYear ? $fallbackYear : min($fallbackYear, $oldestBalanceYear);
|
||||||
|
|
||||||
return null === $oldestBalanceYear ? $fallbackYear : min($fallbackYear, $oldestBalanceYear);
|
return max($phaseFirstYear, $candidate);
|
||||||
}
|
}
|
||||||
|
|
||||||
$firstYear = $isForfait
|
$firstYear = $isForfait
|
||||||
@@ -915,10 +1057,23 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
|
|
||||||
$oldestBalanceYear = $this->leaveBalanceRepository->findEarliestYearForEmployee($employee);
|
$oldestBalanceYear = $this->leaveBalanceRepository->findEarliestYearForEmployee($employee);
|
||||||
if (null !== $oldestBalanceYear && $oldestBalanceYear < $firstYear) {
|
if (null !== $oldestBalanceYear && $oldestBalanceYear < $firstYear) {
|
||||||
return $oldestBalanceYear;
|
$firstYear = $oldestBalanceYear;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $firstYear;
|
return max($phaseFirstYear, $firstYear);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveNatureForPhase(Employee $employee, ContractPhase $phase): ?ContractNature
|
||||||
|
{
|
||||||
|
// Find the period at the start of the phase to determine its nature.
|
||||||
|
foreach ($employee->getContractPeriods() as $period) {
|
||||||
|
if ((int) $period->getId() === $phase->id) {
|
||||||
|
return $period->getContractNatureEnum();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: nature of the current period (legacy behavior).
|
||||||
|
return ContractNature::tryFrom($employee->getCurrentContractNature());
|
||||||
}
|
}
|
||||||
|
|
||||||
private function parseYmdDate(string $value): ?DateTimeImmutable
|
private function parseYmdDate(string $value): ?DateTimeImmutable
|
||||||
|
|||||||
@@ -4,16 +4,32 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Tests\State;
|
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\EmployeeLeaveSummaryProvider;
|
use App\State\EmployeeLeaveSummaryProvider;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use ReflectionClass;
|
use ReflectionClass;
|
||||||
|
use ReflectionProperty;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final class EmployeeLeaveSummaryProviderTest extends TestCase
|
final class EmployeeLeaveSummaryProviderTest extends TestCase
|
||||||
{
|
{
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Existing tests (unchanged) — verify accrual prorating arithmetic.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
public function testComputeAccruedDaysFromStartProratesPartialFirstMonth(): void
|
public function testComputeAccruedDaysFromStartProratesPartialFirstMonth(): void
|
||||||
{
|
{
|
||||||
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
|
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
|
||||||
@@ -68,4 +84,253 @@ final class EmployeeLeaveSummaryProviderTest extends TestCase
|
|||||||
|
|
||||||
self::assertEqualsWithDelta(25.0 / 12.0, $result, 0.0001);
|
self::assertEqualsWithDelta(25.0 / 12.0, $result, 0.0001);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Phase resolution tests (Task 3 — 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::assertSame(ContractType::FORFAIT, $resolved->contractType);
|
||||||
|
self::assertTrue($resolved->isCurrent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPastH39PhaseAppliesNonForfaitRuleCodeEvenWhenCurrentIsForfait(): void
|
||||||
|
{
|
||||||
|
// Verifies resolveLeavePolicy uses the phase's contractType (not the current contract).
|
||||||
|
$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]);
|
||||||
|
$from = new DateTimeImmutable('2025-06-01');
|
||||||
|
$to = new DateTimeImmutable('2026-04-30');
|
||||||
|
$leavePolicy = $this->invokePrivate($provider, 'resolveLeavePolicy', $employee, $h39Phase, $from, $to);
|
||||||
|
|
||||||
|
self::assertNotNull($leavePolicy);
|
||||||
|
self::assertSame('CDI_CDD_NON_FORFAIT', $leavePolicy['ruleCode']);
|
||||||
|
self::assertSame(25.0, $leavePolicy['acquiredDays']);
|
||||||
|
self::assertEqualsWithDelta(25.0 / 12.0, $leavePolicy['accrualPerMonth'], 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResolvePeriodBoundsCapsAtPhaseEndDate(): void
|
||||||
|
{
|
||||||
|
// 39h phase (June 2020 → April 30 2026). Exercise 2026 spans June 2025 → May 31 2026.
|
||||||
|
// The phase cap should clip the upper bound to April 30 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' => '2026']);
|
||||||
|
[$from, $to] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2026, $h39Phase);
|
||||||
|
|
||||||
|
self::assertSame('2025-06-01', $from->format('Y-m-d'));
|
||||||
|
self::assertSame('2026-04-30', $to->format('Y-m-d'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTransitionExerciseOnH39PhaseAccruesAround22Point9Days(): void
|
||||||
|
{
|
||||||
|
// 11 full months of accrual at 25/12 ≈ 22.917 days.
|
||||||
|
$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']);
|
||||||
|
$method = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->getMethod('computeAccruedDaysFromStart');
|
||||||
|
|
||||||
|
// Period bounds for exercise 2026 on H39 phase = June 1 2025 → April 30 2026.
|
||||||
|
[$from, $to] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2026, $h39Phase);
|
||||||
|
$acquired = $method->invoke($provider, 25.0, 25.0 / 12.0, $from, $to);
|
||||||
|
|
||||||
|
self::assertEqualsWithDelta(22.92, $acquired, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
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', $employee, $h39Phase);
|
||||||
|
|
||||||
|
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 (non-forfait) = 2021 (since month >=6 = year+1).
|
||||||
|
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2010']);
|
||||||
|
$year = $this->invokePrivate($provider, 'resolveYear', $employee, $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 → non-forfait 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', $employee, $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', $employee, $currentPhase);
|
||||||
|
|
||||||
|
// Today is 2026-05-19, FORFAIT phase → year is the current calendar year (2026).
|
||||||
|
self::assertSame(2026, $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, LeaveBalanceComputationService, 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`. Tests targeting heavier code paths exercise
|
||||||
|
* private methods directly (resolveTargetPhase, resolvePeriodBounds, etc.).
|
||||||
|
*
|
||||||
|
* @param array<string, string> $request query parameters (year, phaseId, ...)
|
||||||
|
*/
|
||||||
|
private function buildProvider(array $request = []): EmployeeLeaveSummaryProvider
|
||||||
|
{
|
||||||
|
$requestStack = new RequestStack();
|
||||||
|
$requestStack->push(new Request(query: $request));
|
||||||
|
|
||||||
|
$reflection = new ReflectionClass(EmployeeLeaveSummaryProvider::class);
|
||||||
|
$provider = $reflection->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
$this->setReadonlyProperty($provider, 'requestStack', $requestStack);
|
||||||
|
$this->setReadonlyProperty($provider, 'phaseResolver', new EmployeeContractPhaseResolver());
|
||||||
|
$this->setReadonlyProperty($provider, 'dataStartDate', 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