Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket | |------------------|-----------------| | | | ## Description de la PR ## Modification du .env ## Check list - [ ] Pas de régression - [ ] TU/TI/TF rédigée - [ ] TU/TI/TF OK - [ ] CHANGELOG modifié Reviewed-on: #13 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
974 lines
39 KiB
PHP
974 lines
39 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\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 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;
|
|
|
|
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,
|
|
) {}
|
|
|
|
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);
|
|
$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);
|
|
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);
|
|
$summary->presenceDaysByMonth = $this->computePresenceDaysByMonth($employee, $periodFrom, $periodTo);
|
|
|
|
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): ?array
|
|
{
|
|
$firstYear = max($this->resolveFirstComputationYear($employee), $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);
|
|
$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->suspensionDaysCalculator->applyFirstMonthGrace(
|
|
$this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to)
|
|
);
|
|
|
|
$longMaladiePeriods = [];
|
|
$longMaladieReductionFactor = 1.0;
|
|
if (LeaveRuleCode::CDI_CDD_NON_FORFAIT->value === $leavePolicy['ruleCode']
|
|
&& 4 !== $employee->getContract()?->getWeeklyHours()
|
|
&& 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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* @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
|
|
): ?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);
|
|
$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,
|
|
];
|
|
}
|
|
|
|
$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,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
}
|
|
|
|
/**
|
|
* 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): 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))
|
|
: [];
|
|
|
|
// Count absence days per month, iterating day by day to handle multi-day absences
|
|
// and properly distribute across months.
|
|
$absenceDaysByMonth = [];
|
|
foreach ($absences 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;
|
|
}
|
|
|
|
$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): 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 resolvePaidLeaveDays(Employee $employee, string $ruleCode, int $year): float
|
|
{
|
|
$balance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $year);
|
|
|
|
return null !== $balance ? $balance->getPaidLeaveDays() : 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];
|
|
}
|
|
|
|
/**
|
|
* @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];
|
|
}
|
|
}
|