552 lines
20 KiB
PHP
552 lines
20 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Service\Leave;
|
|
|
|
use App\Entity\Absence;
|
|
use App\Entity\ContractSuspension;
|
|
use App\Entity\Employee;
|
|
use App\Enum\LeaveRuleCode;
|
|
use App\Repository\AbsenceRepository;
|
|
use App\Repository\EmployeeContractPeriodRepository;
|
|
use App\Repository\EmployeeLeaveBalanceRepository;
|
|
use App\Service\PublicHolidayServiceInterface;
|
|
use DateTimeImmutable;
|
|
use Throwable;
|
|
|
|
final readonly class LeaveBalanceComputationService
|
|
{
|
|
private const int FORFAIT_TARGET_WORKED_DAYS = 218;
|
|
private const float STANDARD_ANNUAL_DAYS = 25.0;
|
|
private const float STANDARD_ANNUAL_SATURDAYS = 5.0;
|
|
private const float STANDARD_ACCRUAL_PER_MONTH = self::STANDARD_ANNUAL_DAYS / 12.0;
|
|
private const float STANDARD_SATURDAY_ACCRUAL_PER_MONTH = self::STANDARD_ANNUAL_SATURDAYS / 12.0;
|
|
private const float FOUR_HOUR_ANNUAL_DAYS = 10.0;
|
|
private const float FOUR_HOUR_ACCRUAL_PER_MONTH = 0.83;
|
|
private const float LONG_MALADIE_MONTHLY_ACCRUAL = 2.0;
|
|
|
|
public function __construct(
|
|
private AbsenceRepository $absenceRepository,
|
|
private EmployeeContractPeriodRepository $periodRepository,
|
|
private EmployeeLeaveBalanceRepository $leaveBalanceRepository,
|
|
private PublicHolidayServiceInterface $publicHolidayService,
|
|
private SuspensionDaysCalculator $suspensionDaysCalculator,
|
|
private LongMaladieService $longMaladieService,
|
|
) {}
|
|
|
|
/**
|
|
* @return array{float, float}
|
|
*/
|
|
public function computeDynamicClosingForYear(Employee $employee, LeaveRuleCode $ruleCode, int $targetYear): array
|
|
{
|
|
$firstYear = $this->resolveFirstComputationYear($employee, $ruleCode, $targetYear);
|
|
if ($targetYear < $firstYear) {
|
|
return [0.0, 0.0];
|
|
}
|
|
|
|
$previousRemainingDays = 0.0;
|
|
$previousRemainingSaturdays = 0.0;
|
|
|
|
for ($year = $firstYear; $year <= $targetYear; ++$year) {
|
|
[$from, $to] = $this->resolvePeriodBounds($ruleCode, $year);
|
|
|
|
$carryDays = 0.0;
|
|
$carrySaturdays = 0.0;
|
|
if ($year > $firstYear) {
|
|
[$previousFrom, $previousTo] = $this->resolvePeriodBounds($ruleCode, $year - 1);
|
|
$hasSettlementOnPreviousYear = $this->periodRepository->hasPaidLeaveSettledClosureBetween($employee, $previousFrom, $previousTo);
|
|
if (!$hasSettlementOnPreviousYear) {
|
|
$carryDays = $previousRemainingDays;
|
|
$carrySaturdays = LeaveRuleCode::CDI_CDD_NON_FORFAIT === $ruleCode ? $previousRemainingSaturdays : 0.0;
|
|
}
|
|
}
|
|
|
|
$effectiveFrom = $this->resolveEffectivePeriodStart($employee, $from, $to);
|
|
if ($effectiveFrom > $from) {
|
|
$carryDays = 0.0;
|
|
$carrySaturdays = 0.0;
|
|
}
|
|
|
|
$fractionedDays = $this->resolveFractionedDays($employee, $ruleCode, $year);
|
|
|
|
if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
|
|
// 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).
|
|
$totalBusinessDays = $this->countBusinessDaysInRange($from, $to, $this->buildRawPublicHolidayMap($from, $to));
|
|
$baseAcquiredDays = (float) max(0, $totalBusinessDays - self::FORFAIT_TARGET_WORKED_DAYS);
|
|
$acquiredDays = $carryDays + $baseAcquiredDays + $fractionedDays;
|
|
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
|
|
[$takenDays] = $this->computeTakenAbsences($absences, $effectiveFrom, $to, false, false);
|
|
$previousRemainingDays = max(0.0, $acquiredDays - $takenDays);
|
|
$previousRemainingSaturdays = 0.0;
|
|
|
|
continue;
|
|
}
|
|
|
|
$suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace(
|
|
$this->resolveSuspensionsForEmployeePeriod($employee, $from, $to)
|
|
);
|
|
|
|
$longMaladiePeriods = [];
|
|
$longMaladieReductionFactor = 1.0;
|
|
if (4 !== $employee->getContract()?->getWeeklyHours()) {
|
|
$longMaladiePeriods = $this->longMaladieService->findReducedRatePeriods($employee, $effectiveFrom, $to);
|
|
if ([] !== $longMaladiePeriods) {
|
|
$totalNormalAccrual = $this->resolveDaysAccrualPerMonth($employee) + $this->resolveSaturdayAccrualPerMonth($employee);
|
|
$longMaladieReductionFactor = self::LONG_MALADIE_MONTHLY_ACCRUAL / $totalNormalAccrual;
|
|
}
|
|
}
|
|
|
|
$generatedDays = $this->computeAccruedDays(
|
|
$this->resolveAnnualDays($employee),
|
|
$this->resolveDaysAccrualPerMonth($employee),
|
|
$effectiveFrom,
|
|
$to,
|
|
$suspensions,
|
|
$longMaladiePeriods,
|
|
$longMaladieReductionFactor
|
|
);
|
|
$generatedSaturdays = $this->computeAccruedDays(
|
|
$this->resolveAnnualSaturdays($employee),
|
|
$this->resolveSaturdayAccrualPerMonth($employee),
|
|
$effectiveFrom,
|
|
$to,
|
|
$suspensions,
|
|
$longMaladiePeriods,
|
|
$longMaladieReductionFactor
|
|
);
|
|
|
|
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
|
|
[$takenDays, $takenSaturdays] = $this->computeTakenAbsences($absences, $effectiveFrom, $to, true, true);
|
|
|
|
$acquiredWithFractioned = $carryDays + $fractionedDays;
|
|
$takenFromAcquired = min(max(0.0, $acquiredWithFractioned), $takenDays);
|
|
$remainingAcquired = $acquiredWithFractioned - $takenFromAcquired;
|
|
$remainingToImpute = max(0.0, $takenDays - $takenFromAcquired);
|
|
$remainingGenerated = $generatedDays - $remainingToImpute;
|
|
|
|
$takenFromAcquiredSaturdays = min(max(0.0, $carrySaturdays), $takenSaturdays);
|
|
$remainingAcquiredSaturdays = $carrySaturdays - $takenFromAcquiredSaturdays;
|
|
$remainingSaturdaysToImpute = max(0.0, $takenSaturdays - $takenFromAcquiredSaturdays);
|
|
$remainingGeneratedSaturdays = $generatedSaturdays - $remainingSaturdaysToImpute;
|
|
|
|
$previousRemainingDays = $remainingAcquired + $remainingGenerated;
|
|
$previousRemainingSaturdays = $remainingAcquiredSaturdays + $remainingGeneratedSaturdays;
|
|
}
|
|
|
|
return [$previousRemainingDays, $previousRemainingSaturdays];
|
|
}
|
|
|
|
/**
|
|
* @return array{DateTimeImmutable, DateTimeImmutable}
|
|
*/
|
|
public function resolvePeriodBounds(LeaveRuleCode $ruleCode, int $year): array
|
|
{
|
|
if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
|
|
return [
|
|
new DateTimeImmutable(sprintf('%d-01-01 00:00:00', $year)),
|
|
new DateTimeImmutable(sprintf('%d-12-31 00:00:00', $year)),
|
|
];
|
|
}
|
|
|
|
return [
|
|
new DateTimeImmutable(sprintf('%d-06-01 00:00:00', $year - 1)),
|
|
new DateTimeImmutable(sprintf('%d-05-31 00:00:00', $year)),
|
|
];
|
|
}
|
|
|
|
public function hasPaidLeaveSettledClosureBetween(
|
|
Employee $employee,
|
|
DateTimeImmutable $from,
|
|
DateTimeImmutable $to
|
|
): bool {
|
|
return $this->periodRepository->hasPaidLeaveSettledClosureBetween($employee, $from, $to);
|
|
}
|
|
|
|
private function resolveFirstComputationYear(Employee $employee, LeaveRuleCode $ruleCode, int $fallbackYear): int
|
|
{
|
|
$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) {
|
|
return $fallbackYear;
|
|
}
|
|
|
|
if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
|
|
return (int) $oldestStartDate->format('Y');
|
|
}
|
|
|
|
$startYear = (int) $oldestStartDate->format('Y');
|
|
$startMonth = (int) $oldestStartDate->format('n');
|
|
|
|
return $startMonth >= 6 ? $startYear + 1 : $startYear;
|
|
}
|
|
|
|
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) {
|
|
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 resolveFractionedDays(Employee $employee, LeaveRuleCode $ruleCode, int $year): float
|
|
{
|
|
$balance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $year);
|
|
|
|
return null !== $balance ? $balance->getFractionedDays() : 0.0;
|
|
}
|
|
|
|
private function resolveAnnualDays(Employee $employee): float
|
|
{
|
|
return 4 === $employee->getContract()?->getWeeklyHours()
|
|
? self::FOUR_HOUR_ANNUAL_DAYS
|
|
: self::STANDARD_ANNUAL_DAYS;
|
|
}
|
|
|
|
private function resolveAnnualSaturdays(Employee $employee): float
|
|
{
|
|
return 4 === $employee->getContract()?->getWeeklyHours()
|
|
? 0.0
|
|
: self::STANDARD_ANNUAL_SATURDAYS;
|
|
}
|
|
|
|
private function resolveDaysAccrualPerMonth(Employee $employee): float
|
|
{
|
|
return 4 === $employee->getContract()?->getWeeklyHours()
|
|
? self::FOUR_HOUR_ACCRUAL_PER_MONTH
|
|
: self::STANDARD_ACCRUAL_PER_MONTH;
|
|
}
|
|
|
|
private function resolveSaturdayAccrualPerMonth(Employee $employee): float
|
|
{
|
|
return 4 === $employee->getContract()?->getWeeklyHours()
|
|
? 0.0
|
|
: self::STANDARD_SATURDAY_ACCRUAL_PER_MONTH;
|
|
}
|
|
|
|
/**
|
|
* @param list<ContractSuspension> $suspensions
|
|
* @param list<array{start: DateTimeImmutable, end: DateTimeImmutable}> $longMaladiePeriods
|
|
*/
|
|
private function computeAccruedDays(
|
|
float $annualCap,
|
|
float $accrualPerMonth,
|
|
DateTimeImmutable $periodStart,
|
|
DateTimeImmutable $periodEnd,
|
|
array $suspensions = [],
|
|
array $longMaladiePeriods = [],
|
|
float $longMaladieReductionFactor = 1.0
|
|
): float {
|
|
if ($accrualPerMonth <= 0.0 || $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->countBusinessDaysInRange($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($annualCap, ($normalMonths + $reducedMonths * $longMaladieReductionFactor) * $accrualPerMonth);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
private function countBusinessDays(DateTimeImmutable $from, DateTimeImmutable $to): int
|
|
{
|
|
return $this->countBusinessDaysInRange($from, $to, $this->buildPublicHolidayMap($from, $to));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string> $publicHolidays pre-built map
|
|
*/
|
|
private function countBusinessDaysInRange(DateTimeImmutable $from, DateTimeImmutable $to, array $publicHolidays): int
|
|
{
|
|
$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;
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
|
|
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) {
|
|
if (7 === $dayOfWeek) {
|
|
continue;
|
|
}
|
|
} else {
|
|
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 resolveSuspensionsForEmployeePeriod(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
|
|
{
|
|
$startYmd = DateTimeImmutable::createFromInterface($absence->getStartDate())->format('Y-m-d');
|
|
$endYmd = DateTimeImmutable::createFromInterface($absence->getEndDate())->format('Y-m-d');
|
|
$startHalf = $absence->getStartHalf()->value;
|
|
$endHalf = $absence->getEndHalf()->value;
|
|
|
|
$isSingleDay = $startYmd === $endYmd;
|
|
$isStartDay = $date === $startYmd;
|
|
$isEndDay = $date === $endYmd;
|
|
|
|
if ($isSingleDay) {
|
|
return ['AM' === $startHalf, 'PM' === $endHalf];
|
|
}
|
|
if ($isStartDay) {
|
|
return ['AM' === $startHalf, true];
|
|
}
|
|
if ($isEndDay) {
|
|
return [true, 'PM' === $endHalf];
|
|
}
|
|
|
|
return [true, true];
|
|
}
|
|
}
|