1322 lines
55 KiB
PHP
1322 lines
55 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\State;
|
||
|
||
use ApiPlatform\Metadata\Operation;
|
||
use ApiPlatform\State\ProviderInterface;
|
||
use App\ApiResource\EmployeeLeaveSummary;
|
||
use App\Dto\Contracts\ContractPhase;
|
||
use App\Entity\Absence;
|
||
use App\Entity\ContractSuspension;
|
||
use App\Entity\Employee;
|
||
use App\Entity\User;
|
||
use App\Enum\ContractNature;
|
||
use App\Enum\ContractType;
|
||
use App\Enum\LeaveRuleCode;
|
||
use App\Repository\AbsenceRepository;
|
||
use App\Repository\EmployeeContractPeriodRepository;
|
||
use App\Repository\EmployeeLeaveBalanceRepository;
|
||
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;
|
||
use App\Service\PublicHolidayServiceInterface;
|
||
use DateTimeImmutable;
|
||
use Symfony\Bundle\SecurityBundle\Security;
|
||
use Symfony\Component\HttpFoundation\RequestStack;
|
||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||
use Throwable;
|
||
|
||
final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||
{
|
||
private const int FORFAIT_TARGET_WORKED_DAYS = 218;
|
||
private const int FORFAIT_STANDARD_CP_DAYS = 25;
|
||
private const float CDI_NON_FORFAIT_STANDARD_ACQUIRED_DAYS = 25.0;
|
||
private const float CDI_NON_FORFAIT_STANDARD_ACQUIRED_SATURDAYS = 5.0;
|
||
private const float CDI_NON_FORFAIT_STANDARD_ACCRUAL_PER_MONTH = self::CDI_NON_FORFAIT_STANDARD_ACQUIRED_DAYS / 12.0;
|
||
private const float CDI_NON_FORFAIT_STANDARD_SATURDAY_ACCRUAL_PER_MONTH = self::CDI_NON_FORFAIT_STANDARD_ACQUIRED_SATURDAYS / 12.0;
|
||
private const float CDI_NON_FORFAIT_4H_ACQUIRED_DAYS = 10.0;
|
||
private const float CDI_NON_FORFAIT_4H_ACQUIRED_SATURDAYS = 0.0;
|
||
private const float CDI_NON_FORFAIT_4H_ACCRUAL_PER_MONTH = 0.83;
|
||
private const float CDI_NON_FORFAIT_4H_SATURDAY_ACCRUAL_PER_MONTH = 0.0;
|
||
private const float LONG_MALADIE_MONTHLY_ACCRUAL = 2.0;
|
||
|
||
private ?string $dataStartDate;
|
||
|
||
public function __construct(
|
||
private Security $security,
|
||
private RequestStack $requestStack,
|
||
private EmployeeRepository $employeeRepository,
|
||
private EmployeeScopeService $employeeScopeService,
|
||
private AbsenceRepository $absenceRepository,
|
||
private EmployeeContractPeriodRepository $periodRepository,
|
||
private EmployeeLeaveBalanceRepository $leaveBalanceRepository,
|
||
private LeaveBalanceComputationService $leaveBalanceComputationService,
|
||
private LongMaladieService $longMaladieService,
|
||
private PublicHolidayServiceInterface $publicHolidayService,
|
||
private SuspensionDaysCalculator $suspensionDaysCalculator,
|
||
private WorkHourRepository $workHourRepository,
|
||
private EmployeeContractPhaseResolver $phaseResolver,
|
||
private ExerciseYearResolver $exerciseYearResolver,
|
||
string $dataStartDate = '',
|
||
) {
|
||
$this->dataStartDate = '' !== $dataStartDate ? $dataStartDate : null;
|
||
}
|
||
|
||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmployeeLeaveSummary
|
||
{
|
||
$user = $this->security->getUser();
|
||
if (!$user instanceof User) {
|
||
throw new AccessDeniedHttpException('Authentication required.');
|
||
}
|
||
|
||
$employeeId = (int) ($uriVariables['id'] ?? 0);
|
||
if ($employeeId <= 0) {
|
||
throw new UnprocessableEntityHttpException('id must be a positive integer.');
|
||
}
|
||
|
||
$employee = $this->employeeRepository->find($employeeId);
|
||
if (!$employee instanceof Employee) {
|
||
throw new NotFoundHttpException('Employee not found.');
|
||
}
|
||
|
||
if (!$this->employeeScopeService->canAccessEmployee($user, $employee)) {
|
||
throw new AccessDeniedHttpException('Employee outside your scope.');
|
||
}
|
||
|
||
$phase = $this->resolveTargetPhase($employee);
|
||
$year = $this->resolveYear($employee, $phase);
|
||
|
||
$summary = new EmployeeLeaveSummary();
|
||
$summary->year = $year;
|
||
$summary->ruleCode = LeaveRuleCode::UNSUPPORTED->value;
|
||
$summary->dataStartDate = $this->dataStartDate;
|
||
|
||
$yearSummary = $this->computeYearSummary($employee, $year, 0.0, null, $phase);
|
||
if (null === $yearSummary) {
|
||
return $summary;
|
||
}
|
||
|
||
$fractionedDays = $this->resolveFractionedDays($employee, $yearSummary['ruleCode'], $year);
|
||
$paidLeaveDays = $this->resolvePaidLeaveDays($employee, $yearSummary['ruleCode'], $year);
|
||
|
||
// 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.
|
||
if ($paidLeaveDays > 0.0) {
|
||
$yearSummary = $this->computeYearSummary($employee, $year, $paidLeaveDays, null, $phase);
|
||
if (null === $yearSummary) {
|
||
return $summary;
|
||
}
|
||
}
|
||
|
||
$summary->isSupported = true;
|
||
$summary->ruleCode = $yearSummary['ruleCode'];
|
||
$summary->acquiredDays = $yearSummary['acquiredDays'] + $fractionedDays;
|
||
$summary->acquiredSaturdays = $yearSummary['acquiredSaturdays'];
|
||
$summary->fractionedDays = $fractionedDays;
|
||
$summary->accruingDays = $yearSummary['accruingDays'];
|
||
$summary->takenDays = $yearSummary['takenDays'];
|
||
$summary->takenSaturdays = $yearSummary['takenSaturdays'];
|
||
$summary->remainingDays = $yearSummary['remainingDays'] + $fractionedDays;
|
||
$summary->remainingSaturdays = $yearSummary['remainingSaturdays'];
|
||
$summary->previousYearAcquiredDays = $yearSummary['previousYearAcquiredDays'];
|
||
$summary->previousYearTakenDays = $yearSummary['previousYearTakenDays'];
|
||
$summary->previousYearRemainingDays = $yearSummary['previousYearRemainingDays'];
|
||
$summary->previousYearPaidDays = $paidLeaveDays;
|
||
|
||
[$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year, $phase);
|
||
// 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.
|
||
$n1AbsencesBudget = $yearSummary['previousYearTakenDays'];
|
||
$summary->presenceDaysByMonth = $this->computePresenceDaysByMonth(
|
||
$employee,
|
||
$periodFrom,
|
||
$periodTo,
|
||
$n1AbsencesBudget
|
||
);
|
||
|
||
// Same logic as presenceDaysByMonth but bounded at today: number of presence days
|
||
// accumulated from leave year start up to today (inclusive).
|
||
$today = new DateTimeImmutable('today');
|
||
$cappedTo = $today < $periodTo ? $today : $periodTo;
|
||
$summary->presenceDaysToToday = $today < $periodFrom
|
||
? 0.0
|
||
: array_sum($this->computePresenceDaysByMonth(
|
||
$employee,
|
||
$periodFrom,
|
||
$cappedTo,
|
||
$n1AbsencesBudget
|
||
));
|
||
|
||
return $summary;
|
||
}
|
||
|
||
/**
|
||
* @return null|array{
|
||
* ruleCode: string,
|
||
* acquiredDays: float,
|
||
* acquiredSaturdays: float,
|
||
* accruingDays: float,
|
||
* takenDays: float,
|
||
* takenSaturdays: float,
|
||
* remainingDays: float,
|
||
* remainingSaturdays: float,
|
||
* previousYearAcquiredDays: float,
|
||
* previousYearTakenDays: float,
|
||
* previousYearRemainingDays: float
|
||
* }
|
||
*/
|
||
public function computeYearSummary(Employee $employee, int $targetYear, float $paidLeaveDays = 0.0, ?DateTimeImmutable $asOfDate = null, ?ContractPhase $phase = null): ?array
|
||
{
|
||
// Track whether a phase was provided explicitly. When the caller supplies $phase,
|
||
// we apply the phase-end cap on period bounds. When we fall back to resolveCurrentPhase
|
||
// (legacy callers without phase awareness, e.g. LeaveRecapRowBuilder), we preserve
|
||
// the pre-phase-cap behavior to avoid changing observable results for terminated
|
||
// employees (the resolved fallback phase would otherwise unduly cap `to`).
|
||
$applyPhaseEndCap = null !== $phase;
|
||
$phase ??= $this->resolveCurrentPhase($employee);
|
||
if (null === $phase) {
|
||
return null;
|
||
}
|
||
|
||
$firstYear = max($this->resolveFirstComputationYear($employee, $phase), $targetYear - 1);
|
||
if ($targetYear < $firstYear) {
|
||
$targetYear = $firstYear;
|
||
}
|
||
|
||
$previousRemainingDays = 0.0;
|
||
$previousRemainingSaturdays = 0.0;
|
||
$targetSummary = null;
|
||
|
||
for ($year = $firstYear; $year <= $targetYear; ++$year) {
|
||
[$from, $to] = $this->resolvePeriodBounds($employee, $year, $phase, $applyPhaseEndCap);
|
||
$leavePolicy = $this->resolveLeavePolicy($employee, $phase, $from, $to);
|
||
if (null === $leavePolicy) {
|
||
if ($year === $targetYear) {
|
||
return null;
|
||
}
|
||
|
||
continue;
|
||
}
|
||
|
||
$carryDays = 0.0;
|
||
$carrySaturdays = 0.0;
|
||
$openingBalance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear(
|
||
$employee,
|
||
$leavePolicy['ruleCode'],
|
||
$year
|
||
);
|
||
if (null !== $openingBalance) {
|
||
$carryDays = $openingBalance->getOpeningDays();
|
||
$carrySaturdays = $leavePolicy['splitSaturdays'] ? $openingBalance->getOpeningSaturdays() : 0.0;
|
||
} elseif ($year > $firstYear) {
|
||
$ruleCode = LeaveRuleCode::from($leavePolicy['ruleCode']);
|
||
[$carryDays, $carrySaturdays] = $this->leaveBalanceComputationService
|
||
->computeDynamicClosingForYear($employee, $ruleCode, $year - 1)
|
||
;
|
||
[$previousFrom, $previousTo] = $this->leaveBalanceComputationService->resolvePeriodBounds($ruleCode, $year - 1);
|
||
$hasSettlement = $this->leaveBalanceComputationService
|
||
->hasPaidLeaveSettledClosureBetween($employee, $previousFrom, $previousTo)
|
||
;
|
||
if ($hasSettlement) {
|
||
$carryDays = 0.0;
|
||
$carrySaturdays = 0.0;
|
||
} elseif (!$leavePolicy['splitSaturdays']) {
|
||
$carrySaturdays = 0.0;
|
||
}
|
||
}
|
||
|
||
$effectiveFrom = $this->resolveEffectivePeriodStart($employee, $from, $to);
|
||
$hasShiftedStart = $effectiveFrom > $from;
|
||
if ($hasShiftedStart && null === $openingBalance) {
|
||
$carryDays = 0.0;
|
||
$carrySaturdays = 0.0;
|
||
}
|
||
|
||
$effectiveAsOfDate = ($year === $targetYear) ? $asOfDate : null;
|
||
$accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee, $phase, $effectiveAsOfDate, $applyPhaseEndCap);
|
||
$takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee, $phase, $effectiveAsOfDate, $applyPhaseEndCap);
|
||
$suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace(
|
||
$this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to)
|
||
);
|
||
|
||
$longMaladiePeriods = [];
|
||
$longMaladieReductionFactor = 1.0;
|
||
if (LeaveRuleCode::CDI_CDD_NON_FORFAIT->value === $leavePolicy['ruleCode']
|
||
&& 4 !== $phase->weeklyHours
|
||
&& null !== $accrualCalculationEnd
|
||
) {
|
||
$longMaladiePeriods = $this->longMaladieService->findReducedRatePeriods($employee, $effectiveFrom, $accrualCalculationEnd);
|
||
if ([] !== $longMaladiePeriods) {
|
||
$totalNormalAccrual = $leavePolicy['accrualPerMonth'] + $leavePolicy['saturdayAccrualPerMonth'];
|
||
$longMaladieReductionFactor = self::LONG_MALADIE_MONTHLY_ACCRUAL / $totalNormalAccrual;
|
||
}
|
||
}
|
||
|
||
$generatedDays = $leavePolicy['accrualPerMonth'] > 0.0
|
||
? $this->computeAccruedDaysFromStart(
|
||
$leavePolicy['acquiredDays'],
|
||
$leavePolicy['accrualPerMonth'],
|
||
$effectiveFrom,
|
||
$accrualCalculationEnd,
|
||
$suspensions,
|
||
$longMaladiePeriods,
|
||
$longMaladieReductionFactor
|
||
)
|
||
: 0.0;
|
||
$generatedSaturdays = $leavePolicy['saturdayAccrualPerMonth'] > 0.0
|
||
? $this->computeAccruedDaysFromStart(
|
||
$leavePolicy['acquiredSaturdays'],
|
||
$leavePolicy['saturdayAccrualPerMonth'],
|
||
$effectiveFrom,
|
||
$accrualCalculationEnd,
|
||
$suspensions,
|
||
$longMaladiePeriods,
|
||
$longMaladieReductionFactor
|
||
)
|
||
: 0.0;
|
||
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
|
||
[$takenDays, $takenSaturdays] = $this->computeTakenAbsences(
|
||
$absences,
|
||
$effectiveFrom,
|
||
$takenCalculationEnd,
|
||
$leavePolicy['countOnlyCp'],
|
||
$leavePolicy['splitSaturdays']
|
||
);
|
||
|
||
// Bootstrap support: if the opening balance has pre-filled taken days
|
||
// (e.g. manual data entry for production bootstrap), add them as an offset.
|
||
if (null !== $openingBalance) {
|
||
$takenDays += $openingBalance->getTakenDays();
|
||
$takenSaturdays += $openingBalance->getTakenSaturdays();
|
||
}
|
||
$previousYearAcquired = 0.0;
|
||
$previousYearTaken = 0.0;
|
||
$previousYearRemaining = 0.0;
|
||
|
||
if (LeaveRuleCode::CDI_CDD_NON_FORFAIT->value === $leavePolicy['ruleCode']) {
|
||
$availableAcquired = max(0.0, $carryDays);
|
||
$takenFromAcquired = min($availableAcquired, $takenDays);
|
||
$remainingAcquired = $carryDays - $takenFromAcquired;
|
||
$remainingToImpute = max(0.0, $takenDays - $takenFromAcquired);
|
||
$remainingGenerated = $generatedDays - $remainingToImpute;
|
||
|
||
$availableAcquiredSaturdays = max(0.0, $carrySaturdays);
|
||
$takenFromAcquiredSaturdays = min($availableAcquiredSaturdays, $takenSaturdays);
|
||
$remainingAcquiredSaturdays = $carrySaturdays - $takenFromAcquiredSaturdays;
|
||
$remainingSaturdaysToImpute = max(0.0, $takenSaturdays - $takenFromAcquiredSaturdays);
|
||
$remainingGeneratedSaturdays = $generatedSaturdays - $remainingSaturdaysToImpute;
|
||
|
||
$acquiredDays = $carryDays;
|
||
$accruingDays = $remainingGenerated + $remainingGeneratedSaturdays;
|
||
$remainingDays = $remainingAcquired;
|
||
$acquiredSaturdays = $carrySaturdays;
|
||
$remainingSaturdays = max(0.0, $remainingAcquiredSaturdays);
|
||
|
||
$previousRemainingDays = $remainingAcquired + $remainingGenerated;
|
||
$previousRemainingSaturdays = $remainingAcquiredSaturdays + $remainingGeneratedSaturdays;
|
||
} else {
|
||
// Forfait: no "en cours d'acquisition" counter, all rights are in acquired.
|
||
// Suspensions do not impact forfait 218 leave calculation.
|
||
// Paid days reduce N-1 stock first, then taken days are attributed to what remains in N-1.
|
||
$previousYearAcquired = $carryDays;
|
||
$effectivePaidDays = ($year === $targetYear) ? $paidLeaveDays : 0.0;
|
||
$availableAfterPayment = max(0.0, $previousYearAcquired - $effectivePaidDays);
|
||
$takenFromPrevious = min($availableAfterPayment, $takenDays);
|
||
$previousYearTaken = $takenFromPrevious;
|
||
$takenFromCurrent = $takenDays - $takenFromPrevious;
|
||
|
||
$previousYearRemaining = max(0.0, $availableAfterPayment - $takenFromPrevious);
|
||
|
||
$acquiredDays = $leavePolicy['acquiredDays'];
|
||
$accruingDays = 0.0;
|
||
$remainingDays = max(0.0, $acquiredDays - $takenFromCurrent);
|
||
$acquiredSaturdays = 0.0;
|
||
$remainingSaturdays = 0.0;
|
||
|
||
$previousRemainingDays = $previousYearRemaining + $remainingDays;
|
||
$previousRemainingSaturdays = 0.0;
|
||
}
|
||
|
||
if ($year === $targetYear) {
|
||
$targetSummary = [
|
||
'ruleCode' => $leavePolicy['ruleCode'],
|
||
'acquiredDays' => $acquiredDays,
|
||
'acquiredSaturdays' => $acquiredSaturdays,
|
||
'accruingDays' => $accruingDays,
|
||
'takenDays' => $takenDays,
|
||
'takenSaturdays' => $takenSaturdays,
|
||
'remainingDays' => $remainingDays,
|
||
'remainingSaturdays' => $remainingSaturdays,
|
||
'previousYearAcquiredDays' => $previousYearAcquired,
|
||
'previousYearTakenDays' => $previousYearTaken,
|
||
'previousYearRemainingDays' => $previousYearRemaining,
|
||
];
|
||
}
|
||
}
|
||
|
||
return $targetSummary;
|
||
}
|
||
|
||
public function resolveLeaveYearForToday(Employee $employee): int
|
||
{
|
||
$today = new DateTimeImmutable('today');
|
||
if (ContractType::FORFAIT === $employee->getContract()?->getType()) {
|
||
return (int) $today->format('Y');
|
||
}
|
||
|
||
return $this->resolveCurrentLeaveYear($today);
|
||
}
|
||
|
||
public function resolvePaidLeaveDays(Employee $employee, string $ruleCode, int $year): float
|
||
{
|
||
$balance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $year);
|
||
|
||
return null !== $balance ? $balance->getPaidLeaveDays() : 0.0;
|
||
}
|
||
|
||
private function resolveEffectivePeriodStart(
|
||
Employee $employee,
|
||
DateTimeImmutable $from,
|
||
DateTimeImmutable $to
|
||
): DateTimeImmutable {
|
||
$latestSettledClosure = $this->periodRepository->findLatestPaidLeaveSettledClosureDateBetween($employee, $from, $to);
|
||
$start = $from;
|
||
if (null !== $latestSettledClosure) {
|
||
$nextDay = $latestSettledClosure->modify('+1 day');
|
||
if ($nextDay > $start) {
|
||
$start = $nextDay;
|
||
}
|
||
}
|
||
|
||
$earliestContractStart = $this->resolveEarliestContractStartWithinRange($employee, $from, $to);
|
||
if (null !== $earliestContractStart && $earliestContractStart > $start) {
|
||
$start = $earliestContractStart;
|
||
}
|
||
|
||
return $start;
|
||
}
|
||
|
||
private function resolveEarliestContractStartWithinRange(
|
||
Employee $employee,
|
||
DateTimeImmutable $from,
|
||
DateTimeImmutable $to
|
||
): ?DateTimeImmutable {
|
||
$earliest = null;
|
||
foreach ($employee->getContractHistory() as $period) {
|
||
$start = $this->parseYmdDate($period->startDate);
|
||
if (!$start instanceof DateTimeImmutable) {
|
||
continue;
|
||
}
|
||
|
||
$end = null;
|
||
if (null !== $period->endDate && '' !== trim($period->endDate)) {
|
||
$end = $this->parseYmdDate($period->endDate);
|
||
}
|
||
|
||
if ($start > $to) {
|
||
continue;
|
||
}
|
||
if ($end instanceof DateTimeImmutable && $end < $from) {
|
||
continue;
|
||
}
|
||
|
||
$candidate = $start < $from ? $from : $start;
|
||
if (null === $earliest || $candidate < $earliest) {
|
||
$earliest = $candidate;
|
||
}
|
||
}
|
||
|
||
return $earliest;
|
||
}
|
||
|
||
private function resolveYear(Employee $employee, ContractPhase $phase): int
|
||
{
|
||
$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) {
|
||
// When a phaseId is explicitly provided, default to the 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 $isForfait
|
||
? (int) $reference->format('Y')
|
||
: $this->resolveCurrentLeaveYear($reference);
|
||
}
|
||
|
||
$today = new DateTimeImmutable('today');
|
||
|
||
return $isForfait
|
||
? (int) $today->format('Y')
|
||
: $this->resolveCurrentLeaveYear($today);
|
||
}
|
||
|
||
if (!preg_match('/^\d{4}$/', $raw)) {
|
||
throw new UnprocessableEntityHttpException('year must use YYYY format.');
|
||
}
|
||
|
||
$year = (int) $raw;
|
||
if ($year < 2000 || $year > 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;
|
||
}
|
||
|
||
private function clampYearToPhase(int $year, ContractPhase $phase, bool $isForfait): int
|
||
{
|
||
$firstYear = $this->exerciseYearResolver->forDate($phase->startDate, $isForfait);
|
||
$lastYear = $phase->endDate instanceof DateTimeImmutable
|
||
? $this->exerciseYearResolver->forDate($phase->endDate, $isForfait)
|
||
: null;
|
||
|
||
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];
|
||
}
|
||
|
||
/**
|
||
* Phase dont la date de début est la plus proche en deçà de celle de $phase
|
||
* (la phase qui précède immédiatement). Null si $phase est la première.
|
||
*/
|
||
private function resolvePhaseImmediatelyBefore(Employee $employee, ContractPhase $phase): ?ContractPhase
|
||
{
|
||
$prior = null;
|
||
foreach ($this->phaseResolver->resolvePhases($employee) as $candidate) {
|
||
if ($candidate->startDate >= $phase->startDate) {
|
||
continue;
|
||
}
|
||
if (null === $prior || $candidate->startDate > $prior->startDate) {
|
||
$prior = $candidate;
|
||
}
|
||
}
|
||
|
||
return $prior;
|
||
}
|
||
|
||
/**
|
||
* CP nets encore disponibles (jours + samedis) hérités de la phase non-forfait
|
||
* précédant immédiatement une entrée en FORFAIT. 0 si aucune phase précédente
|
||
* ou si la précédente est elle-même un FORFAIT (nouvel embauché → cas 2).
|
||
*
|
||
* Le total disponible = remainingDays (acquis restant) + accruingDays (généré
|
||
* restant, samedis générés inclus) + remainingSaturdays (samedis acquis restant).
|
||
* Les congés déjà posés sous la phase précédente sont déjà déduits par
|
||
* computeYearSummary, donc on récupère bien le NET (ex. Grégory : 12 acquis − 5 pris ≈ 7).
|
||
*
|
||
* Les jours fractionnés (fractionedDays, ajustement manuel ajouté par provide() à
|
||
* l'affichage) sont volontairement EXCLUS : on ne reporte que le solde CP acquis/généré
|
||
* de la phase précédente, pas les bonus de fractionnement.
|
||
*/
|
||
private function resolveCarriedCpFromPriorPhase(Employee $employee, ContractPhase $forfaitPhase): float
|
||
{
|
||
$prior = $this->resolvePhaseImmediatelyBefore($employee, $forfaitPhase);
|
||
if (null === $prior || ContractType::FORFAIT === $prior->contractType) {
|
||
return 0.0;
|
||
}
|
||
|
||
$reference = $prior->endDate ?? new DateTimeImmutable('today');
|
||
$priorYear = $this->exerciseYearResolver->forDate($reference, false);
|
||
|
||
$summary = $this->computeYearSummary($employee, $priorYear, 0.0, null, $prior);
|
||
if (null === $summary) {
|
||
return 0.0;
|
||
}
|
||
|
||
return $summary['remainingDays'] + $summary['accruingDays'] + $summary['remainingSaturdays'];
|
||
}
|
||
|
||
/**
|
||
* @param list<ContractSuspension> $suspensions
|
||
* @param list<array{start: DateTimeImmutable, end: DateTimeImmutable}> $longMaladiePeriods
|
||
*/
|
||
private function computeAccruedDaysFromStart(
|
||
float $acquiredDays,
|
||
float $accrualPerMonth,
|
||
DateTimeImmutable $periodStart,
|
||
?DateTimeImmutable $periodEnd,
|
||
array $suspensions = [],
|
||
array $longMaladiePeriods = [],
|
||
float $longMaladieReductionFactor = 1.0
|
||
): float {
|
||
if ($accrualPerMonth <= 0.0) {
|
||
return $acquiredDays;
|
||
}
|
||
|
||
if (!$periodEnd instanceof DateTimeImmutable || $periodEnd < $periodStart) {
|
||
return 0.0;
|
||
}
|
||
|
||
$periodStart = $this->normalizeDate($periodStart);
|
||
$periodEnd = $this->normalizeDate($periodEnd);
|
||
$publicHolidays = [] !== $suspensions ? $this->buildPublicHolidayMap($periodStart, $periodEnd) : [];
|
||
$normalMonths = 0.0;
|
||
$reducedMonths = 0.0;
|
||
$cursor = $periodStart->modify('first day of this month')->setTime(0, 0);
|
||
while ($cursor <= $periodEnd) {
|
||
$monthStart = $cursor > $periodStart ? $cursor : $periodStart;
|
||
$monthEnd = $cursor->modify('last day of this month')->setTime(0, 0);
|
||
if ($monthEnd > $periodEnd) {
|
||
$monthEnd = $periodEnd;
|
||
}
|
||
|
||
if ([] !== $suspensions) {
|
||
$suspendedDays = $this->suspensionDaysCalculator->countSuspendedDaysInMonth($monthStart, $monthEnd, $suspensions);
|
||
if ($suspendedDays > 0) {
|
||
$businessDays = $this->countBusinessDays($monthStart, $monthEnd, $publicHolidays);
|
||
$suspendedBusinessDays = $this->suspensionDaysCalculator->countSuspendedBusinessDays($monthStart, $monthEnd, $suspensions, $publicHolidays);
|
||
$normalMonths += max(0, $businessDays - $suspendedBusinessDays) / 22.0;
|
||
$cursor = $cursor->modify('first day of next month');
|
||
|
||
continue;
|
||
}
|
||
}
|
||
|
||
$coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1;
|
||
$daysInMonth = (int) $cursor->format('t');
|
||
|
||
if ([] !== $longMaladiePeriods) {
|
||
$reducedDays = $this->longMaladieService->countReducedDaysInMonth($monthStart, $monthEnd, $longMaladiePeriods);
|
||
if ($reducedDays > 0) {
|
||
$normalDays = max(0, $coveredDays - $reducedDays);
|
||
$normalMonths += $normalDays / $daysInMonth;
|
||
$reducedMonths += min($coveredDays, $reducedDays) / $daysInMonth;
|
||
$cursor = $cursor->modify('first day of next month');
|
||
|
||
continue;
|
||
}
|
||
}
|
||
|
||
$normalMonths += $coveredDays / $daysInMonth;
|
||
|
||
$cursor = $cursor->modify('first day of next month');
|
||
}
|
||
|
||
return min($acquiredDays, ($normalMonths + $reducedMonths * $longMaladieReductionFactor) * $accrualPerMonth);
|
||
}
|
||
|
||
private function resolveAccrualCalculationEndDate(
|
||
string $ruleCode,
|
||
int $year,
|
||
DateTimeImmutable $periodEnd,
|
||
Employee $employee,
|
||
ContractPhase $phase,
|
||
?DateTimeImmutable $asOfDate = null,
|
||
bool $applyPhaseEndCap = true
|
||
): ?DateTimeImmutable {
|
||
$reference = $asOfDate ?? new DateTimeImmutable('today');
|
||
$currentYear = LeaveRuleCode::FORFAIT_218->value === $ruleCode
|
||
? (int) $reference->format('Y')
|
||
: $this->resolveCurrentLeaveYear($reference);
|
||
|
||
// When viewing a closed phase explicitly, treat its end date as the reference cutoff:
|
||
// accrual is bounded to the phase end, never running to "today".
|
||
// Legacy callers (no explicit phase) skip this cap to preserve pre-phase behavior.
|
||
if ($applyPhaseEndCap && !$phase->isCurrent && null !== $phase->endDate) {
|
||
$end = $phase->endDate < $periodEnd ? $phase->endDate : $periodEnd;
|
||
} elseif ($year < $currentYear) {
|
||
$end = $periodEnd;
|
||
} elseif ($year > $currentYear) {
|
||
$end = null;
|
||
} else {
|
||
$lastDayPreviousMonth = $reference
|
||
->modify('first day of this month')
|
||
->modify('-1 day')
|
||
->setTime(0, 0)
|
||
;
|
||
$end = $lastDayPreviousMonth < $periodEnd ? $lastDayPreviousMonth : $periodEnd;
|
||
}
|
||
|
||
// Cap at contract end date if the employee has left (only meaningful when
|
||
// viewing the current phase; closed phases are already capped above).
|
||
// Legacy callers (no explicit phase) always evaluate this branch to mimic
|
||
// the pre-phase behavior, which relied on getCurrentContractEndDate().
|
||
if (!$applyPhaseEndCap || $phase->isCurrent) {
|
||
$contractEndRaw = $employee->getCurrentContractEndDate();
|
||
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
||
$contractEnd = $this->parseYmdDate($contractEndRaw);
|
||
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
|
||
$end = $contractEnd;
|
||
}
|
||
}
|
||
}
|
||
|
||
return $end;
|
||
}
|
||
|
||
private function resolveTakenCalculationEndDate(
|
||
DateTimeImmutable $periodEnd,
|
||
Employee $employee,
|
||
ContractPhase $phase,
|
||
?DateTimeImmutable $asOfDate = null,
|
||
bool $applyPhaseEndCap = true
|
||
): ?DateTimeImmutable {
|
||
$end = $periodEnd;
|
||
|
||
if ($asOfDate instanceof DateTimeImmutable && $asOfDate < $end) {
|
||
$end = $asOfDate;
|
||
}
|
||
|
||
// Closed phase: cap taken-absence accounting at the phase end.
|
||
// Skip for legacy callers (no explicit phase) to preserve pre-phase behavior.
|
||
if ($applyPhaseEndCap && !$phase->isCurrent && null !== $phase->endDate && $phase->endDate < $end) {
|
||
$end = $phase->endDate;
|
||
}
|
||
|
||
// Legacy callers (no explicit phase) always use the live contract end date,
|
||
// mirroring the pre-phase implementation.
|
||
if (!$applyPhaseEndCap || $phase->isCurrent) {
|
||
$contractEndRaw = $employee->getCurrentContractEndDate();
|
||
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
||
$contractEnd = $this->parseYmdDate($contractEndRaw);
|
||
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
|
||
$end = $contractEnd;
|
||
}
|
||
}
|
||
}
|
||
|
||
return $end;
|
||
}
|
||
|
||
/**
|
||
* @return null|array{
|
||
* ruleCode: string,
|
||
* acquiredDays: float,
|
||
* acquiredSaturdays: float,
|
||
* accrualPerMonth: float,
|
||
* saturdayAccrualPerMonth: float,
|
||
* countOnlyCp: bool,
|
||
* splitSaturdays: bool
|
||
* }
|
||
*/
|
||
private function resolveLeavePolicy(Employee $employee, ContractPhase $phase, DateTimeImmutable $from, DateTimeImmutable $to): ?array
|
||
{
|
||
$type = $phase->contractType;
|
||
if (ContractType::FORFAIT === $type) {
|
||
$year = (int) $from->format('Y'); // période forfait = année civile
|
||
|
||
// Entrée en FORFAIT en cours d'année : repos proratisés + CP nets reportés de
|
||
// la phase précédente, au lieu de max(0, businessDays − 218) qui donnerait 0.
|
||
if ($this->isForfaitEntryYear($phase, $year)) {
|
||
$yearStart = new DateTimeImmutable(sprintf('%d-01-01 00:00:00', $year));
|
||
$yearEnd = new DateTimeImmutable(sprintf('%d-12-31 00:00:00', $year));
|
||
$rawYearHolidays = $this->buildRawPublicHolidayMap($yearStart, $yearEnd);
|
||
|
||
$businessDaysYear = $this->countBusinessDays($yearStart, $yearEnd, $rawYearHolidays);
|
||
$businessDaysPeriod = $this->countBusinessDays($from, $to, $rawYearHolidays);
|
||
|
||
$repoDays = $this->computeProratedForfaitRepoDays($businessDaysYear, $businessDaysPeriod);
|
||
$carriedCp = $this->resolveCarriedCpFromPriorPhase($employee, $phase);
|
||
|
||
// NB : le bonus week-end/férié travaillé (bonusDays du chemin année pleine)
|
||
// n'est volontairement PAS ajouté ici. L'acquis de l'année d'entrée = repos
|
||
// proratisés + CP reportés (règle comptable). À revoir si la RH veut créditer
|
||
// le travail week-end/férié posé pendant la période forfait partielle.
|
||
return [
|
||
'ruleCode' => LeaveRuleCode::FORFAIT_218->value,
|
||
'acquiredDays' => $repoDays + $carriedCp,
|
||
'acquiredSaturdays' => 0.0,
|
||
'accrualPerMonth' => 0.0,
|
||
'saturdayAccrualPerMonth' => 0.0,
|
||
'countOnlyCp' => false,
|
||
'splitSaturdays' => false,
|
||
];
|
||
}
|
||
|
||
// Année pleine : calcul 218 existant (INCHANGÉ).
|
||
// 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
|
||
// the 218-day legal target).
|
||
$businessDaysInPeriod = $this->countBusinessDays($from, $to, $this->buildRawPublicHolidayMap($from, $to));
|
||
$publicHolidays = $this->buildPublicHolidayMap($from, $to);
|
||
$weekdayHolidays = array_filter(
|
||
array_keys($publicHolidays),
|
||
static fn (string $date): bool => (int) new DateTimeImmutable($date)->format('N') <= 5
|
||
);
|
||
$bonusDays = $this->workHourRepository->countWeekendAndHolidayWorkedDays(
|
||
$employee,
|
||
$from,
|
||
$to,
|
||
array_values($weekdayHolidays)
|
||
);
|
||
|
||
return [
|
||
'ruleCode' => LeaveRuleCode::FORFAIT_218->value,
|
||
'acquiredDays' => (float) max(0, $businessDaysInPeriod - self::FORFAIT_TARGET_WORKED_DAYS) + $bonusDays,
|
||
'acquiredSaturdays' => 0.0,
|
||
'accrualPerMonth' => 0.0,
|
||
'saturdayAccrualPerMonth' => 0.0,
|
||
'countOnlyCp' => false,
|
||
'splitSaturdays' => false,
|
||
];
|
||
}
|
||
|
||
// Resolve nature directly from the phase DTO (populated by EmployeeContractPhaseResolver).
|
||
$nature = $phase->contractNature;
|
||
if (ContractNature::CDI !== $nature && ContractNature::CDD !== $nature) {
|
||
return null;
|
||
}
|
||
|
||
$weeklyHours = $phase->weeklyHours;
|
||
if (4 === $weeklyHours) {
|
||
return [
|
||
'ruleCode' => LeaveRuleCode::CDI_CDD_NON_FORFAIT->value,
|
||
'acquiredDays' => self::CDI_NON_FORFAIT_4H_ACQUIRED_DAYS,
|
||
'acquiredSaturdays' => self::CDI_NON_FORFAIT_4H_ACQUIRED_SATURDAYS,
|
||
'accrualPerMonth' => self::CDI_NON_FORFAIT_4H_ACCRUAL_PER_MONTH,
|
||
'saturdayAccrualPerMonth' => self::CDI_NON_FORFAIT_4H_SATURDAY_ACCRUAL_PER_MONTH,
|
||
'countOnlyCp' => true,
|
||
'splitSaturdays' => true,
|
||
];
|
||
}
|
||
|
||
return [
|
||
'ruleCode' => LeaveRuleCode::CDI_CDD_NON_FORFAIT->value,
|
||
'acquiredDays' => self::CDI_NON_FORFAIT_STANDARD_ACQUIRED_DAYS,
|
||
'acquiredSaturdays' => self::CDI_NON_FORFAIT_STANDARD_ACQUIRED_SATURDAYS,
|
||
'accrualPerMonth' => self::CDI_NON_FORFAIT_STANDARD_ACCRUAL_PER_MONTH,
|
||
'saturdayAccrualPerMonth' => self::CDI_NON_FORFAIT_STANDARD_SATURDAY_ACCRUAL_PER_MONTH,
|
||
'countOnlyCp' => true,
|
||
'splitSaturdays' => true,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Jours de repos forfait proratisés sur la fraction de jours ouvrés couverte.
|
||
*
|
||
* Repos année pleine = jours_ouvrés_année − 218 (cible travaillée) − 25 (CP standard).
|
||
* Pour 2026 : 252 − 218 − 25 = 9, proratisés au ratio jours_ouvrés_période / jours_ouvrés_année.
|
||
*/
|
||
private function computeProratedForfaitRepoDays(int $businessDaysYear, int $businessDaysPeriod): float
|
||
{
|
||
if ($businessDaysYear <= 0) {
|
||
return 0.0;
|
||
}
|
||
|
||
$repoDaysYear = max(0, $businessDaysYear - self::FORFAIT_TARGET_WORKED_DAYS - self::FORFAIT_STANDARD_CP_DAYS);
|
||
|
||
return $repoDaysYear * $businessDaysPeriod / $businessDaysYear;
|
||
}
|
||
|
||
/**
|
||
* Vrai si la phase FORFAIT démarre en cours de l'année civile consultée
|
||
* (donc avec une période partielle), faux pour une année pleine ou un démarrage le 1er janvier.
|
||
*/
|
||
private function isForfaitEntryYear(ContractPhase $phase, int $year): bool
|
||
{
|
||
if (ContractType::FORFAIT !== $phase->contractType) {
|
||
return false;
|
||
}
|
||
|
||
return (int) $phase->startDate->format('Y') === $year
|
||
&& '01-01' !== $phase->startDate->format('m-d');
|
||
}
|
||
|
||
/**
|
||
* @param null|array<string, string> $publicHolidays pre-built map (built if null)
|
||
*/
|
||
private function countBusinessDays(DateTimeImmutable $from, DateTimeImmutable $to, ?array $publicHolidays = null): int
|
||
{
|
||
$publicHolidays ??= $this->buildPublicHolidayMap($from, $to);
|
||
$count = 0;
|
||
for ($cursor = $from; $cursor <= $to; $cursor = $cursor->modify('+1 day')) {
|
||
$weekDay = (int) $cursor->format('N');
|
||
$dayKey = $cursor->format('Y-m-d');
|
||
if ($weekDay <= 5 && !isset($publicHolidays[$dayKey])) {
|
||
++$count;
|
||
}
|
||
}
|
||
|
||
return $count;
|
||
}
|
||
|
||
/**
|
||
* @return array<string, string>
|
||
*/
|
||
private function buildPublicHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
|
||
{
|
||
$map = [];
|
||
$startYear = (int) $from->format('Y');
|
||
$endYear = (int) $to->format('Y');
|
||
|
||
try {
|
||
for ($year = $startYear; $year <= $endYear; ++$year) {
|
||
$holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', (string) $year);
|
||
foreach ($holidays as $date => $label) {
|
||
$map[(string) $date] = (string) $label;
|
||
}
|
||
}
|
||
} catch (Throwable) {
|
||
return [];
|
||
}
|
||
|
||
return $map;
|
||
}
|
||
|
||
/**
|
||
* @return array<string, string>
|
||
*/
|
||
private function buildRawPublicHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
|
||
{
|
||
$map = [];
|
||
$startYear = (int) $from->format('Y');
|
||
$endYear = (int) $to->format('Y');
|
||
|
||
try {
|
||
for ($year = $startYear; $year <= $endYear; ++$year) {
|
||
$holidays = $this->publicHolidayService->getRawHolidaysDayByYears('metropole', (string) $year);
|
||
foreach ($holidays as $date => $label) {
|
||
$map[(string) $date] = (string) $label;
|
||
}
|
||
}
|
||
} catch (Throwable) {
|
||
return [];
|
||
}
|
||
|
||
return $map;
|
||
}
|
||
|
||
/**
|
||
* Presence days = business days (Mon-Fri) - public holidays + weekend worked days - absence days.
|
||
*
|
||
* @return array<string, float> YYYY-MM => presence day count
|
||
*/
|
||
private function computePresenceDaysByMonth(
|
||
Employee $employee,
|
||
DateTimeImmutable $from,
|
||
DateTimeImmutable $to,
|
||
float $n1AbsencesBudget = 0.0
|
||
): array {
|
||
$publicHolidays = $this->buildPublicHolidayMap($from, $to);
|
||
$weekendWorkedDays = $this->workHourRepository->countWeekendWorkedDaysByMonth($employee, $from, $to);
|
||
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
|
||
|
||
// Find which public holidays were actually worked (should count as presence).
|
||
$workedHolidays = [] !== $publicHolidays
|
||
? $this->workHourRepository->findWorkedDatesAmong($employee, array_keys($publicHolidays))
|
||
: [];
|
||
|
||
// Sort absences chronologically so N-1 budget (forfait only) is consumed in date order:
|
||
// earliest absences attribute to N-1 first, later ones overflow to N and reduce presence.
|
||
$sortedAbsences = $absences;
|
||
usort(
|
||
$sortedAbsences,
|
||
static fn ($a, $b): int => $a->getStartDate() <=> $b->getStartDate()
|
||
);
|
||
|
||
$remainingN1Budget = $n1AbsencesBudget;
|
||
|
||
// Count absence days per month, iterating day by day to handle multi-day absences
|
||
// and properly distribute across months.
|
||
$absenceDaysByMonth = [];
|
||
foreach ($sortedAbsences as $absence) {
|
||
$start = DateTimeImmutable::createFromInterface($absence->getStartDate())->setTime(0, 0);
|
||
$end = DateTimeImmutable::createFromInterface($absence->getEndDate())->setTime(0, 0);
|
||
|
||
for ($day = $start; $day <= $end; $day = $day->modify('+1 day')) {
|
||
$weekDay = (int) $day->format('N');
|
||
// Skip weekends
|
||
if ($weekDay >= 6) {
|
||
continue;
|
||
}
|
||
|
||
$monthKey = $day->format('Y-m');
|
||
[$am, $pm] = $this->resolveSegmentsForDate($absence, $day->format('Y-m-d'));
|
||
$dayAmount = ($am ? 0.5 : 0.0) + ($pm ? 0.5 : 0.0);
|
||
if ($dayAmount <= 0.0) {
|
||
continue;
|
||
}
|
||
|
||
// Forfait: leaves taken from N-1 stock do NOT decrement presence days.
|
||
// We chronologically consume the N-1 budget before counting any absence.
|
||
if ($remainingN1Budget > 0.0) {
|
||
$consumed = min($remainingN1Budget, $dayAmount);
|
||
$remainingN1Budget -= $consumed;
|
||
$dayAmount -= $consumed;
|
||
if ($dayAmount <= 0.0) {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
$absenceDaysByMonth[$monthKey] = ($absenceDaysByMonth[$monthKey] ?? 0.0) + $dayAmount;
|
||
}
|
||
}
|
||
|
||
// Count business days and public holidays per month.
|
||
$result = [];
|
||
$cursor = $from->modify('first day of this month')->setTime(0, 0);
|
||
while ($cursor <= $to) {
|
||
$monthKey = $cursor->format('Y-m');
|
||
$monthStart = $cursor < $from ? $from : $cursor;
|
||
$monthEnd = $cursor->modify('last day of this month')->setTime(0, 0);
|
||
if ($monthEnd > $to) {
|
||
$monthEnd = $to;
|
||
}
|
||
|
||
$businessDays = 0;
|
||
for (
|
||
$day = $monthStart;
|
||
$day <= $monthEnd;
|
||
$day = $day->modify('+1 day')
|
||
) {
|
||
$weekDay = (int) $day->format('N');
|
||
$dayKey = $day->format('Y-m-d');
|
||
if ($weekDay <= 5 && (!isset($publicHolidays[$dayKey]) || isset($workedHolidays[$dayKey]))) {
|
||
++$businessDays;
|
||
}
|
||
}
|
||
|
||
$weekend = $weekendWorkedDays[$monthKey] ?? 0.0;
|
||
$absenced = $absenceDaysByMonth[$monthKey] ?? 0.0;
|
||
|
||
$presence = max(0.0, (float) $businessDays + $weekend - $absenced);
|
||
if ($presence > 0.0) {
|
||
$result[$monthKey] = $presence;
|
||
}
|
||
|
||
$cursor = $cursor->modify('first day of next month');
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* @return array{DateTimeImmutable, DateTimeImmutable}
|
||
*/
|
||
private function resolvePeriodBounds(Employee $employee, int $year, ContractPhase $phase, bool $applyPhaseEndCap = true): array
|
||
{
|
||
if (ContractType::FORFAIT === $phase->contractType) {
|
||
[$from, $to] = $this->resolveForfaitYearBounds($employee, $year, $phase);
|
||
|
||
// For FORFAIT, cap from at phase.startDate: the 218-day FORFAIT accrual
|
||
// is calendar-year scoped and only counts the FORFAIT portion of the year.
|
||
if ($phase->startDate > $from) {
|
||
$from = $phase->startDate;
|
||
}
|
||
} else {
|
||
[$from, $to] = $this->resolveLeavePeriodBounds($year);
|
||
|
||
// For non-forfait, do NOT cap from at phase.startDate: CP accrual is
|
||
// annual (Juin→Mai) and continuous across signature changes within the
|
||
// same leave rule (e.g. 35h → 39h, driver flag flip, weeklyHours bump).
|
||
// The contract-entry-date cap is handled by resolveEffectivePeriodStart().
|
||
}
|
||
|
||
// End cap applies to both modes. Skipped when the phase was not explicitly
|
||
// provided (legacy callers) to preserve pre-phase-cap behavior for
|
||
// terminated employees.
|
||
if ($applyPhaseEndCap && null !== $phase->endDate && $phase->endDate < $to) {
|
||
$to = $phase->endDate;
|
||
}
|
||
|
||
return [$from, $to];
|
||
}
|
||
|
||
/**
|
||
* @return array{DateTimeImmutable, DateTimeImmutable}
|
||
*/
|
||
private function resolveLeavePeriodBounds(int $leaveYear): array
|
||
{
|
||
// Exercice CP "2026" = du 1er juin 2025 au 31 mai 2026.
|
||
$from = new DateTimeImmutable(sprintf('%d-06-01 00:00:00', $leaveYear - 1));
|
||
$to = new DateTimeImmutable(sprintf('%d-05-31 00:00:00', $leaveYear));
|
||
|
||
return [$from, $to];
|
||
}
|
||
|
||
/**
|
||
* @return array{DateTimeImmutable, DateTimeImmutable}
|
||
*/
|
||
private function resolveForfaitYearBounds(Employee $employee, int $year, ContractPhase $phase): array
|
||
{
|
||
$from = new DateTimeImmutable(sprintf('%d-01-01 00:00:00', $year));
|
||
$to = new DateTimeImmutable(sprintf('%d-12-31 00:00:00', $year));
|
||
|
||
// When viewing the current phase, prefer the live "current contract" dates
|
||
// for backward compat with existing tests/usage. Closed phases rely on the
|
||
// generic cap applied in resolvePeriodBounds().
|
||
if ($phase->isCurrent) {
|
||
$contractStartRaw = $employee->getCurrentContractStartDate();
|
||
if (null !== $contractStartRaw && '' !== trim($contractStartRaw)) {
|
||
$contractStart = $this->parseYmdDate($contractStartRaw);
|
||
if ($contractStart instanceof DateTimeImmutable && $contractStart > $from) {
|
||
$from = $contractStart;
|
||
}
|
||
}
|
||
|
||
$contractEndRaw = $employee->getCurrentContractEndDate();
|
||
if (null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
||
$contractEnd = $this->parseYmdDate($contractEndRaw);
|
||
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $to) {
|
||
$to = $contractEnd;
|
||
}
|
||
}
|
||
}
|
||
|
||
return [$from, $to];
|
||
}
|
||
|
||
private function resolveFractionedDays(Employee $employee, string $ruleCode, int $year): float
|
||
{
|
||
$balance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $year);
|
||
|
||
return null !== $balance ? $balance->getFractionedDays() : 0.0;
|
||
}
|
||
|
||
private function resolveCurrentLeaveYear(DateTimeImmutable $today): int
|
||
{
|
||
$year = (int) $today->format('Y');
|
||
$month = (int) $today->format('n');
|
||
|
||
return $month >= 6 ? $year + 1 : $year;
|
||
}
|
||
|
||
private function resolveFirstComputationYear(Employee $employee, ContractPhase $phase): int
|
||
{
|
||
$isForfait = ContractType::FORFAIT === $phase->contractType;
|
||
$fallbackYear = $isForfait
|
||
? (int) new DateTimeImmutable('today')->format('Y')
|
||
: $this->resolveCurrentLeaveYear(new DateTimeImmutable('today'));
|
||
|
||
// Do not go before the exercice containing $phase->startDate.
|
||
$phaseFirstYear = $this->exerciseYearResolver->forDate($phase->startDate, $isForfait);
|
||
|
||
$history = $employee->getContractHistory();
|
||
if ([] === $history) {
|
||
return max($phaseFirstYear, $fallbackYear);
|
||
}
|
||
|
||
$oldestStartDate = null;
|
||
foreach ($history as $item) {
|
||
$start = $this->parseYmdDate($item->startDate);
|
||
if (!$start) {
|
||
continue;
|
||
}
|
||
if (null === $oldestStartDate || $start < $oldestStartDate) {
|
||
$oldestStartDate = $start;
|
||
}
|
||
}
|
||
|
||
if (null === $oldestStartDate) {
|
||
$oldestBalanceYear = $this->leaveBalanceRepository->findEarliestYearForEmployee($employee);
|
||
$candidate = null === $oldestBalanceYear ? $fallbackYear : min($fallbackYear, $oldestBalanceYear);
|
||
|
||
return max($phaseFirstYear, $candidate);
|
||
}
|
||
|
||
$firstYear = $this->exerciseYearResolver->forDate($oldestStartDate, $isForfait);
|
||
|
||
$oldestBalanceYear = $this->leaveBalanceRepository->findEarliestYearForEmployee($employee);
|
||
if (null !== $oldestBalanceYear && $oldestBalanceYear < $firstYear) {
|
||
$firstYear = $oldestBalanceYear;
|
||
}
|
||
|
||
return max($phaseFirstYear, $firstYear);
|
||
}
|
||
|
||
private function parseYmdDate(string $value): ?DateTimeImmutable
|
||
{
|
||
$date = DateTimeImmutable::createFromFormat('!Y-m-d', trim($value));
|
||
|
||
return $date instanceof DateTimeImmutable ? $date : null;
|
||
}
|
||
|
||
private function normalizeDate(DateTimeImmutable $date): DateTimeImmutable
|
||
{
|
||
return $date->setTime(0, 0);
|
||
}
|
||
|
||
/**
|
||
* @param list<Absence> $absences
|
||
*
|
||
* @return array{float, float}
|
||
*/
|
||
private function computeTakenAbsences(
|
||
array $absences,
|
||
DateTimeImmutable $from,
|
||
?DateTimeImmutable $to,
|
||
bool $countOnlyCp,
|
||
bool $splitSaturdays
|
||
): array {
|
||
$takenDays = 0.0;
|
||
$takenSaturdays = 0.0;
|
||
|
||
if (!$to instanceof DateTimeImmutable || $to < $from) {
|
||
return [$takenDays, $takenSaturdays];
|
||
}
|
||
|
||
foreach ($absences as $absence) {
|
||
if ($countOnlyCp) {
|
||
$typeCode = strtoupper((string) $absence->getType()?->getCode());
|
||
if ('C' !== $typeCode) {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if (null === $absence->getType()) {
|
||
continue;
|
||
}
|
||
|
||
$start = DateTimeImmutable::createFromInterface($absence->getStartDate());
|
||
$end = DateTimeImmutable::createFromInterface($absence->getEndDate());
|
||
$rangeStart = $start < $from ? $from : $start;
|
||
$rangeEnd = $end > $to ? $to : $end;
|
||
if ($rangeEnd < $rangeStart) {
|
||
continue;
|
||
}
|
||
|
||
for ($cursor = $rangeStart; $cursor <= $rangeEnd; $cursor = $cursor->modify('+1 day')) {
|
||
$dayOfWeek = (int) $cursor->format('N');
|
||
|
||
if ($splitSaturdays) {
|
||
// Mode CDI/CDD : dimanche ignoré, samedi compté séparément.
|
||
if (7 === $dayOfWeek) {
|
||
continue;
|
||
}
|
||
} else {
|
||
// Mode forfait : seuls les jours ouvrés (lun-ven) comptent.
|
||
if ($dayOfWeek >= 6) {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
[$am, $pm] = $this->resolveSegmentsForDate($absence, $cursor->format('Y-m-d'));
|
||
$dayAmount = ($am ? 0.5 : 0.0) + ($pm ? 0.5 : 0.0);
|
||
if ($dayAmount <= 0.0) {
|
||
continue;
|
||
}
|
||
|
||
if ($splitSaturdays && 6 === $dayOfWeek) {
|
||
$takenSaturdays += $dayAmount;
|
||
} else {
|
||
$takenDays += $dayAmount;
|
||
}
|
||
}
|
||
}
|
||
|
||
return [$takenDays, $takenSaturdays];
|
||
}
|
||
|
||
/**
|
||
* @return list<ContractSuspension>
|
||
*/
|
||
private function resolveSuspensionsForPeriod(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array
|
||
{
|
||
$suspensions = [];
|
||
foreach ($employee->getContractPeriods() as $period) {
|
||
$periodStart = $period->getStartDate();
|
||
$periodEnd = $period->getEndDate();
|
||
|
||
if ($periodStart > $to) {
|
||
continue;
|
||
}
|
||
if ($periodEnd instanceof DateTimeImmutable && $periodEnd < $from) {
|
||
continue;
|
||
}
|
||
|
||
foreach ($period->getSuspensions() as $suspension) {
|
||
$suspensions[] = $suspension;
|
||
}
|
||
}
|
||
|
||
return $suspensions;
|
||
}
|
||
|
||
/**
|
||
* @return array{bool, bool}
|
||
*/
|
||
private function resolveSegmentsForDate(Absence $absence, string $date): array
|
||
{
|
||
$startDate = $absence->getStartDate()->format('Y-m-d');
|
||
$endDate = $absence->getEndDate()->format('Y-m-d');
|
||
$startHalf = $absence->getStartHalf()->value;
|
||
$endHalf = $absence->getEndHalf()->value;
|
||
|
||
$isStart = $date === $startDate;
|
||
$isEnd = $date === $endDate;
|
||
$isSingleDay = $startDate === $endDate;
|
||
|
||
if ($isSingleDay) {
|
||
return ['AM' === $startHalf, 'PM' === $endHalf];
|
||
}
|
||
if ($isStart) {
|
||
return ['AM' === $startHalf, true];
|
||
}
|
||
if ($isEnd) {
|
||
return [true, 'PM' === $endHalf];
|
||
}
|
||
|
||
return [true, true];
|
||
}
|
||
}
|