676 lines
26 KiB
PHP
676 lines
26 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\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\Security\EmployeeScopeService;
|
|
use App\Service\Leave\LeaveBalanceComputationService;
|
|
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,
|
|
) {}
|
|
|
|
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'];
|
|
|
|
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;
|
|
}
|
|
|
|
$calculationEnd = $this->resolveCalculationEndDate($to, $employee);
|
|
$generatedDays = $leavePolicy['accrualPerMonth'] > 0.0
|
|
? $this->computeAccruedDaysFromStart(
|
|
$leavePolicy['acquiredDays'],
|
|
$leavePolicy['accrualPerMonth'],
|
|
$effectiveFrom,
|
|
$calculationEnd
|
|
)
|
|
: 0.0;
|
|
$generatedSaturdays = $leavePolicy['saturdayAccrualPerMonth'] > 0.0
|
|
? $this->computeAccruedDaysFromStart(
|
|
$leavePolicy['acquiredSaturdays'],
|
|
$leavePolicy['saturdayAccrualPerMonth'],
|
|
$effectiveFrom,
|
|
$calculationEnd
|
|
)
|
|
: 0.0;
|
|
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
|
|
[$takenDays, $takenSaturdays] = $this->computeTakenAbsences(
|
|
$absences,
|
|
$effectiveFrom,
|
|
$calculationEnd,
|
|
$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'];
|
|
$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 = DateTimeImmutable::createFromFormat('Y-m-d', $period->startDate);
|
|
if (!$start instanceof DateTimeImmutable) {
|
|
continue;
|
|
}
|
|
|
|
$end = null;
|
|
if (null !== $period->endDate && '' !== trim($period->endDate)) {
|
|
$end = DateTimeImmutable::createFromFormat('Y-m-d', $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
|
|
): float {
|
|
if ($accrualPerMonth <= 0.0) {
|
|
return $acquiredDays;
|
|
}
|
|
|
|
if (!$periodEnd instanceof DateTimeImmutable || $periodEnd < $periodStart) {
|
|
return 0.0;
|
|
}
|
|
|
|
$monthsElapsed = ((int) $periodEnd->format('Y') - (int) $periodStart->format('Y')) * 12
|
|
+ ((int) $periodEnd->format('n') - (int) $periodStart->format('n'))
|
|
+ 1;
|
|
if ($monthsElapsed < 0) {
|
|
return 0.0;
|
|
}
|
|
|
|
return min($acquiredDays, $monthsElapsed * $accrualPerMonth);
|
|
}
|
|
|
|
private function resolveCalculationEndDate(
|
|
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 = DateTimeImmutable::createFromFormat('Y-m-d', $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', $leaveYear - 1));
|
|
$to = new DateTimeImmutable(sprintf('%d-05-31', $leaveYear));
|
|
|
|
return [$from, $to];
|
|
}
|
|
|
|
/**
|
|
* @return array{DateTimeImmutable, DateTimeImmutable}
|
|
*/
|
|
private function resolveForfaitYearBounds(Employee $employee, int $year): array
|
|
{
|
|
$from = new DateTimeImmutable(sprintf('%d-01-01', $year));
|
|
$to = new DateTimeImmutable(sprintf('%d-12-31', $year));
|
|
|
|
$contractStartRaw = $employee->getCurrentContractStartDate();
|
|
if (null !== $contractStartRaw && '' !== trim($contractStartRaw)) {
|
|
$contractStart = DateTimeImmutable::createFromFormat('Y-m-d', $contractStartRaw);
|
|
if ($contractStart instanceof DateTimeImmutable && $contractStart > $from) {
|
|
$from = $contractStart;
|
|
}
|
|
}
|
|
|
|
$contractEndRaw = $employee->getCurrentContractEndDate();
|
|
if (null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
|
$contractEnd = DateTimeImmutable::createFromFormat('Y-m-d', $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 = DateTimeImmutable::createFromFormat('Y-m-d', $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;
|
|
}
|
|
|
|
/**
|
|
* @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 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];
|
|
}
|
|
}
|