Files
SIRH/src/Service/Leave/LeaveBalanceComputationService.php
tristan 9787231052
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
fix : correction calcule prorata congés avec un arrêt maladie long
2026-03-17 13:27:51 +01:00

526 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) {
$totalBusinessDays = $this->countBusinessDays($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;
}
/**
* @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];
}
}