Files
SIRH/src/State/EmployeeLeaveSummaryProvider.php
2026-05-20 16:36:53 +02:00

1336 lines
56 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 à reporter d'une phase non-forfait vers une entrée en FORFAIT : jours ouvrés
* NETS (acquis + en cours d'acquisition jours ouvrés posés) + samedis BRUTS (acquis,
* sans déduction des samedis posés). Retourne 0 si aucune phase précédente (nouvel
* embauché → cas 2) ou si la précédente est elle-même un FORFAIT (ré-embauche / double
* transition forfait — pas de report CP non-forfait à reprendre).
*
* Composition du retour (clés de computeYearSummary, branche CDI_CDD_NON_FORFAIT) :
* remainingDays : acquis (report N-1) restant après jours ouvrés posés
* accruingDays : généré de l'exercice restant, NET des jours posés en débordement
* (= remainingGenerated + remainingGeneratedSaturdays)
* remainingSaturdays : samedis acquis (report N-1) restants
* + takenSaturdays : ré-ajout des samedis posés (règle métier ci-dessous). Invariant :
* comme accruingDays a déjà déduit les samedis posés en débordement,
* ce ré-ajout laisse le solde samedi BRUT (généré + acquis), pas net.
*
* Règle (validée comptable) : seuls les congés en JOURS OUVRÉS déjà posés réduisent
* le report ; les SAMEDIS déjà posés ne le réduisent pas. computeYearSummary déduit
* tous les congés posés (samedis inclus), d'où le ré-ajout de takenSaturdays.
* Ex. Grégory : 12 acquis 5 jours ouvrés posés = 7 (le samedi posé reste crédité).
*
* 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']
+ $summary['takenSaturdays'];
}
/**
* @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];
}
}