From 9787231052a6991fb92a7daca112bcd9e6941782 Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 17 Mar 2026 13:27:51 +0100 Subject: [PATCH] =?UTF-8?q?fix=20:=20correction=20calcule=20prorata=20cong?= =?UTF-8?q?=C3=A9s=20avec=20un=20arr=C3=AAt=20maladie=20long?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/functional-rules.md | 5 + src/Repository/AbsenceRepository.php | 32 +++++ .../Leave/LeaveBalanceComputationService.php | 51 ++++++-- src/Service/Leave/LongMaladieService.php | 116 ++++++++++++++++++ src/State/EmployeeLeaveSummaryProvider.php | 55 +++++++-- 5 files changed, 245 insertions(+), 14 deletions(-) create mode 100644 src/Service/Leave/LongMaladieService.php diff --git a/doc/functional-rules.md b/doc/functional-rules.md index 3852532..b0b191d 100644 --- a/doc/functional-rules.md +++ b/doc/functional-rules.md @@ -213,6 +213,11 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer. - en cours d'acquisition samedis: `5/12 = 0,42` samedi/mois (non detaille en UI) - en cas de début/fin en cours de mois, l'acquisition est proratisée au nombre de jours calendaires couverts dans le mois - en cas de suspension en cours de mois, l'acquisition est proratisée en jours ouvrés (lun-ven hors fériés) travaillés / 22 (standard mensuel) + - arrêt maladie long (absences continues de type `M` > 1 mois): + - premier mois de maladie (date début + 1 mois calendaire): acquisition normale (`2,50`/mois) + - après le premier mois: acquisition réduite à `2,00`/mois (facteur `0,80` appliqué aux deux taux jours et samedis) + - en cas de mois partiellement couvert par la période réduite, le prorata est calculé en jours calendaires (jours normaux × taux normal + jours réduits × taux réduit) + - la détection est automatique à partir des absences MALADIE consécutives en base (tolérance de gap ≤ 3 jours) - samedis acquis affiches: uniquement `opening_saturdays` (report N-1) - contrat `4h`: - acquis annuel CP: `10` diff --git a/src/Repository/AbsenceRepository.php b/src/Repository/AbsenceRepository.php index fae53d0..428e167 100644 --- a/src/Repository/AbsenceRepository.php +++ b/src/Repository/AbsenceRepository.php @@ -100,6 +100,38 @@ final class AbsenceRepository extends ServiceEntityRepository implements Absence return $qb->getQuery()->getResult(); } + /** + * @return list sorted maladie dates + */ + public function findMaladieDatesByEmployee( + Employee $employee, + DateTimeImmutable $from, + DateTimeImmutable $to + ): array { + $results = $this->createQueryBuilder('a') + ->select('a.startDate') + ->join('a.type', 't') + ->andWhere('a.employee = :employee') + ->andWhere('t.code = :code') + ->andWhere('a.startDate >= :from') + ->andWhere('a.startDate <= :to') + ->setParameter('employee', $employee) + ->setParameter('code', 'M') + ->setParameter('from', $from) + ->setParameter('to', $to) + ->orderBy('a.startDate', 'ASC') + ->getQuery() + ->getArrayResult() + ; + + return array_map( + static fn (array $row): DateTimeImmutable => $row['startDate'] instanceof DateTimeImmutable + ? $row['startDate'] + : DateTimeImmutable::createFromInterface($row['startDate']), + $results + ); + } + /** * @return list */ diff --git a/src/Service/Leave/LeaveBalanceComputationService.php b/src/Service/Leave/LeaveBalanceComputationService.php index b62d745..58a1f3e 100644 --- a/src/Service/Leave/LeaveBalanceComputationService.php +++ b/src/Service/Leave/LeaveBalanceComputationService.php @@ -24,6 +24,7 @@ final readonly class LeaveBalanceComputationService 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, @@ -31,6 +32,7 @@ final readonly class LeaveBalanceComputationService private EmployeeLeaveBalanceRepository $leaveBalanceRepository, private PublicHolidayServiceInterface $publicHolidayService, private SuspensionDaysCalculator $suspensionDaysCalculator, + private LongMaladieService $longMaladieService, ) {} /** @@ -83,19 +85,34 @@ final readonly class LeaveBalanceComputationService $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 + $suspensions, + $longMaladiePeriods, + $longMaladieReductionFactor ); $generatedSaturdays = $this->computeAccruedDays( $this->resolveAnnualSaturdays($employee), $this->resolveSaturdayAccrualPerMonth($employee), $effectiveFrom, $to, - $suspensions + $suspensions, + $longMaladiePeriods, + $longMaladieReductionFactor ); $absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to); @@ -267,12 +284,18 @@ final readonly class LeaveBalanceComputationService : self::STANDARD_SATURDAY_ACCRUAL_PER_MONTH; } + /** + * @param list $suspensions + * @param list $longMaladiePeriods + */ private function computeAccruedDays( float $annualCap, float $accrualPerMonth, DateTimeImmutable $periodStart, DateTimeImmutable $periodEnd, - array $suspensions = [] + array $suspensions = [], + array $longMaladiePeriods = [], + float $longMaladieReductionFactor = 1.0 ): float { if ($accrualPerMonth <= 0.0 || $periodEnd < $periodStart) { return 0.0; @@ -281,7 +304,8 @@ final readonly class LeaveBalanceComputationService $periodStart = $this->normalizeDate($periodStart); $periodEnd = $this->normalizeDate($periodEnd); $publicHolidays = [] !== $suspensions ? $this->buildPublicHolidayMap($periodStart, $periodEnd) : []; - $coveredMonths = 0.0; + $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; @@ -295,7 +319,7 @@ final readonly class LeaveBalanceComputationService if ($suspendedDays > 0) { $businessDays = $this->countBusinessDaysInRange($monthStart, $monthEnd, $publicHolidays); $suspendedBusinessDays = $this->suspensionDaysCalculator->countSuspendedBusinessDays($monthStart, $monthEnd, $suspensions, $publicHolidays); - $coveredMonths += max(0, $businessDays - $suspendedBusinessDays) / 22.0; + $normalMonths += max(0, $businessDays - $suspendedBusinessDays) / 22.0; $cursor = $cursor->modify('first day of next month'); continue; @@ -304,12 +328,25 @@ final readonly class LeaveBalanceComputationService $coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1; $daysInMonth = (int) $cursor->format('t'); - $coveredMonths += $coveredDays / $daysInMonth; + + 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, $coveredMonths * $accrualPerMonth); + return min($annualCap, ($normalMonths + $reducedMonths * $longMaladieReductionFactor) * $accrualPerMonth); } private function parseYmdDate(string $value): ?DateTimeImmutable diff --git a/src/Service/Leave/LongMaladieService.php b/src/Service/Leave/LongMaladieService.php new file mode 100644 index 0000000..ac7fed9 --- /dev/null +++ b/src/Service/Leave/LongMaladieService.php @@ -0,0 +1,116 @@ + 1 month, the first month is excluded (grace period). + * + * @return list + */ + public function findReducedRatePeriods( + Employee $employee, + DateTimeImmutable $from, + DateTimeImmutable $to + ): array { + // Look back 13 months to catch maladie that started before the exercise period + $extendedFrom = $from->modify('-13 months'); + $dates = $this->absenceRepository->findMaladieDatesByEmployee($employee, $extendedFrom, $to); + if ([] === $dates) { + return []; + } + + $periods = $this->consolidateIntoPeriods($dates); + + return $this->applyFirstMonthGrace($periods); + } + + /** + * Count calendar days in [monthStart, monthEnd] that fall within reduced maladie periods. + * + * @param list $reducedPeriods + */ + public function countReducedDaysInMonth( + DateTimeImmutable $monthStart, + DateTimeImmutable $monthEnd, + array $reducedPeriods + ): int { + $total = 0; + foreach ($reducedPeriods as $period) { + $overlapStart = $period['start'] > $monthStart ? $period['start'] : $monthStart; + $overlapEnd = $period['end'] < $monthEnd ? $period['end'] : $monthEnd; + + if ($overlapStart > $overlapEnd) { + continue; + } + + $total += ((int) $overlapEnd->diff($overlapStart)->format('%a')) + 1; + } + + return $total; + } + + /** + * @param list $dates sorted chronologically + * + * @return list + */ + private function consolidateIntoPeriods(array $dates): array + { + $periods = []; + $start = $dates[0]; + $prev = $start; + + for ($i = 1, $count = count($dates); $i < $count; ++$i) { + $current = $dates[$i]; + $gap = (int) $prev->diff($current)->format('%a'); + if ($gap > self::MAX_GAP_DAYS) { + $periods[] = ['start' => $start, 'end' => $prev]; + $start = $current; + } + $prev = $current; + } + $periods[] = ['start' => $start, 'end' => $prev]; + + return $periods; + } + + /** + * @param list $periods + * + * @return list + */ + private function applyFirstMonthGrace(array $periods): array + { + $result = []; + foreach ($periods as $period) { + $gracedStart = $period['start']->modify('+1 month'); + if ($gracedStart > $period['end']) { + continue; + } + $result[] = ['start' => $gracedStart, 'end' => $period['end']]; + } + + return $result; + } +} diff --git a/src/State/EmployeeLeaveSummaryProvider.php b/src/State/EmployeeLeaveSummaryProvider.php index 0816844..35857f3 100644 --- a/src/State/EmployeeLeaveSummaryProvider.php +++ b/src/State/EmployeeLeaveSummaryProvider.php @@ -21,6 +21,7 @@ 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; @@ -42,6 +43,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface 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, @@ -52,6 +54,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface private EmployeeContractPeriodRepository $periodRepository, private EmployeeLeaveBalanceRepository $leaveBalanceRepository, private LeaveBalanceComputationService $leaveBalanceComputationService, + private LongMaladieService $longMaladieService, private PublicHolidayServiceInterface $publicHolidayService, private SuspensionDaysCalculator $suspensionDaysCalculator, private WorkHourRepository $workHourRepository, @@ -187,13 +190,29 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface $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 + $suspensions, + $longMaladiePeriods, + $longMaladieReductionFactor ) : 0.0; $generatedSaturdays = $leavePolicy['saturdayAccrualPerMonth'] > 0.0 @@ -202,7 +221,9 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface $leavePolicy['saturdayAccrualPerMonth'], $effectiveFrom, $accrualCalculationEnd, - $suspensions + $suspensions, + $longMaladiePeriods, + $longMaladieReductionFactor ) : 0.0; $absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to); @@ -375,12 +396,18 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface return $year; } + /** + * @param list $suspensions + * @param list $longMaladiePeriods + */ private function computeAccruedDaysFromStart( float $acquiredDays, float $accrualPerMonth, DateTimeImmutable $periodStart, ?DateTimeImmutable $periodEnd, - array $suspensions = [] + array $suspensions = [], + array $longMaladiePeriods = [], + float $longMaladieReductionFactor = 1.0 ): float { if ($accrualPerMonth <= 0.0) { return $acquiredDays; @@ -393,7 +420,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface $periodStart = $this->normalizeDate($periodStart); $periodEnd = $this->normalizeDate($periodEnd); $publicHolidays = [] !== $suspensions ? $this->buildPublicHolidayMap($periodStart, $periodEnd) : []; - $coveredMonths = 0.0; + $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; @@ -407,7 +435,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface if ($suspendedDays > 0) { $businessDays = $this->countBusinessDays($monthStart, $monthEnd, $publicHolidays); $suspendedBusinessDays = $this->suspensionDaysCalculator->countSuspendedBusinessDays($monthStart, $monthEnd, $suspensions, $publicHolidays); - $coveredMonths += max(0, $businessDays - $suspendedBusinessDays) / 22.0; + $normalMonths += max(0, $businessDays - $suspendedBusinessDays) / 22.0; $cursor = $cursor->modify('first day of next month'); continue; @@ -416,12 +444,25 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface $coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1; $daysInMonth = (int) $cursor->format('t'); - $coveredMonths += $coveredDays / $daysInMonth; + + 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, $coveredMonths * $accrualPerMonth); + return min($acquiredDays, ($normalMonths + $reducedMonths * $longMaladieReductionFactor) * $accrualPerMonth); } private function resolveAccrualCalculationEndDate(