Files
SIRH/src/State/EmployeeLeaveSummaryProvider.php
tristan 38f09914cb
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
feat : ajout des suspensions et des jours de présence
2026-03-12 16:46:06 +01:00

835 lines
32 KiB
PHP

<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\EmployeeLeaveSummary;
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\Leave\LeaveBalanceComputationService;
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 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;
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 PublicHolidayServiceInterface $publicHolidayService,
private SuspensionDaysCalculator $suspensionDaysCalculator,
private WorkHourRepository $workHourRepository,
) {}
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.');
}
$year = $this->resolveYear($employee);
$summary = new EmployeeLeaveSummary();
$summary->year = $year;
$summary->ruleCode = LeaveRuleCode::UNSUPPORTED->value;
$yearSummary = $this->computeYearSummary($employee, $year);
if (null === $yearSummary) {
return $summary;
}
$fractionedDays = $this->resolveFractionedDays($employee, $yearSummary['ruleCode'], $year);
$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'];
[$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year);
$summary->presenceDaysByMonth = $this->workHourRepository->countPresenceDaysByMonth($employee, $periodFrom, $periodTo);
return $summary;
}
/**
* @return null|array{
* ruleCode: string,
* acquiredDays: float,
* acquiredSaturdays: float,
* accruingDays: float,
* takenDays: float,
* takenSaturdays: float,
* remainingDays: float,
* remainingSaturdays: float
* }
*/
private function computeYearSummary(Employee $employee, int $targetYear): ?array
{
$firstYear = $this->resolveFirstComputationYear($employee);
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);
$leavePolicy = $this->resolveLeavePolicy($employee, $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;
}
$accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee);
$takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee);
$suspensions = $this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to);
$generatedDays = $leavePolicy['accrualPerMonth'] > 0.0
? $this->computeAccruedDaysFromStart(
$leavePolicy['acquiredDays'],
$leavePolicy['accrualPerMonth'],
$effectiveFrom,
$accrualCalculationEnd,
$suspensions
)
: 0.0;
$generatedSaturdays = $leavePolicy['saturdayAccrualPerMonth'] > 0.0
? $this->computeAccruedDaysFromStart(
$leavePolicy['acquiredSaturdays'],
$leavePolicy['saturdayAccrualPerMonth'],
$effectiveFrom,
$accrualCalculationEnd,
$suspensions
)
: 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();
}
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.
$acquiredDays = $carryDays + $leavePolicy['acquiredDays'];
$suspensions = $this->resolveSuspensionsForPeriod($employee, $from, $to);
if ([] !== $suspensions) {
$totalMonths = $this->countFractionalMonths($from, $to);
$suspendedMonths = $this->countSuspendedFractionalMonths($from, $to, $suspensions);
if ($totalMonths > 0) {
$ratio = max(0.0, ($totalMonths - $suspendedMonths) / $totalMonths);
$acquiredDays = $carryDays + $leavePolicy['acquiredDays'] * $ratio;
}
}
$accruingDays = 0.0;
$remainingDays = max(0.0, $acquiredDays - $takenDays);
$acquiredSaturdays = 0.0;
$remainingSaturdays = 0.0;
$previousRemainingDays = $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,
];
}
}
return $targetSummary;
}
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): int
{
$raw = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? '');
if ('' === $raw) {
$today = new DateTimeImmutable('today');
if (ContractType::FORFAIT === $employee->getContract()?->getType()) {
return (int) $today->format('Y');
}
return $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.');
}
return $year;
}
private function computeAccruedDaysFromStart(
float $acquiredDays,
float $accrualPerMonth,
DateTimeImmutable $periodStart,
?DateTimeImmutable $periodEnd,
array $suspensions = []
): float {
if ($accrualPerMonth <= 0.0) {
return $acquiredDays;
}
if (!$periodEnd instanceof DateTimeImmutable || $periodEnd < $periodStart) {
return 0.0;
}
$periodStart = $this->normalizeDate($periodStart);
$periodEnd = $this->normalizeDate($periodEnd);
$coveredMonths = 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;
}
$coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1;
if ([] !== $suspensions) {
$suspendedDays = $this->suspensionDaysCalculator->countSuspendedDaysInMonth($monthStart, $monthEnd, $suspensions);
$coveredDays = max(0, $coveredDays - $suspendedDays);
}
$daysInMonth = (int) $cursor->format('t');
$coveredMonths += $coveredDays / $daysInMonth;
$cursor = $cursor->modify('first day of next month');
}
return min($acquiredDays, $coveredMonths * $accrualPerMonth);
}
private function resolveAccrualCalculationEndDate(
string $ruleCode,
int $year,
DateTimeImmutable $periodEnd,
Employee $employee
): ?DateTimeImmutable {
$today = new DateTimeImmutable('today');
$currentYear = LeaveRuleCode::FORFAIT_218->value === $ruleCode
? (int) $today->format('Y')
: $this->resolveCurrentLeaveYear($today);
if ($year < $currentYear) {
$end = $periodEnd;
} elseif ($year > $currentYear) {
$end = null;
} else {
$lastDayPreviousMonth = $today
->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.
$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
): ?DateTimeImmutable {
$end = $periodEnd;
// Cap at contract end date if the employee has left.
$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, DateTimeImmutable $from, DateTimeImmutable $to): ?array
{
$type = $employee->getContract()?->getType();
if (ContractType::FORFAIT === $type) {
$businessDaysInPeriod = $this->countBusinessDays($from, $to);
return [
'ruleCode' => LeaveRuleCode::FORFAIT_218->value,
'acquiredDays' => (float) max(0, $businessDaysInPeriod - self::FORFAIT_TARGET_WORKED_DAYS),
'acquiredSaturdays' => 0.0,
'accrualPerMonth' => 0.0,
'saturdayAccrualPerMonth' => 0.0,
'countOnlyCp' => false,
'splitSaturdays' => false,
];
}
$nature = ContractNature::tryFrom($employee->getCurrentContractNature());
if (ContractNature::CDI !== $nature && ContractNature::CDD !== $nature) {
return null;
}
$weeklyHours = $employee->getContract()?->getWeeklyHours();
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,
];
}
private function countBusinessDays(DateTimeImmutable $from, DateTimeImmutable $to): 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{DateTimeImmutable, DateTimeImmutable}
*/
private function resolvePeriodBounds(Employee $employee, int $year): array
{
if (ContractType::FORFAIT === $employee->getContract()?->getType()) {
return $this->resolveForfaitYearBounds($employee, $year);
}
return $this->resolveLeavePeriodBounds($year);
}
/**
* @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): array
{
$from = new DateTimeImmutable(sprintf('%d-01-01 00:00:00', $year));
$to = new DateTimeImmutable(sprintf('%d-12-31 00:00:00', $year));
$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): int
{
$isForfait = ContractType::FORFAIT === $employee->getContract()?->getType();
$fallbackYear = $isForfait
? (int) new DateTimeImmutable('today')->format('Y')
: $this->resolveCurrentLeaveYear(new DateTimeImmutable('today'));
$history = $employee->getContractHistory();
if ([] === $history) {
return $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);
return null === $oldestBalanceYear ? $fallbackYear : min($fallbackYear, $oldestBalanceYear);
}
$firstYear = $isForfait
? (int) $oldestStartDate->format('Y')
: ((int) $oldestStartDate->format('n') >= 6
? (int) $oldestStartDate->format('Y') + 1
: (int) $oldestStartDate->format('Y'));
$oldestBalanceYear = $this->leaveBalanceRepository->findEarliestYearForEmployee($employee);
if (null !== $oldestBalanceYear && $oldestBalanceYear < $firstYear) {
return $oldestBalanceYear;
}
return $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];
}
private function countFractionalMonths(DateTimeImmutable $from, DateTimeImmutable $to): float
{
$from = $this->normalizeDate($from);
$to = $this->normalizeDate($to);
$months = 0.0;
$cursor = $from->modify('first day of this month')->setTime(0, 0);
while ($cursor <= $to) {
$monthStart = $cursor > $from ? $cursor : $from;
$monthEnd = $cursor->modify('last day of this month')->setTime(0, 0);
if ($monthEnd > $to) {
$monthEnd = $to;
}
$coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1;
$daysInMonth = (int) $cursor->format('t');
$months += $coveredDays / $daysInMonth;
$cursor = $cursor->modify('first day of next month');
}
return $months;
}
/**
* @param list<ContractSuspension> $suspensions
*/
private function countSuspendedFractionalMonths(DateTimeImmutable $from, DateTimeImmutable $to, array $suspensions): float
{
$from = $this->normalizeDate($from);
$to = $this->normalizeDate($to);
$months = 0.0;
$cursor = $from->modify('first day of this month')->setTime(0, 0);
while ($cursor <= $to) {
$monthStart = $cursor > $from ? $cursor : $from;
$monthEnd = $cursor->modify('last day of this month')->setTime(0, 0);
if ($monthEnd > $to) {
$monthEnd = $to;
}
$daysInMonth = (int) $cursor->format('t');
$suspendedDays = $this->suspensionDaysCalculator->countSuspendedDaysInMonth($monthStart, $monthEnd, $suspensions);
$months += $suspendedDays / $daysInMonth;
$cursor = $cursor->modify('first day of next month');
}
return $months;
}
/**
* @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];
}
}