diff --git a/doc/functional-rules.md b/doc/functional-rules.md index a4bb51d..97a4ee1 100644 --- a/doc/functional-rules.md +++ b/doc/functional-rules.md @@ -169,11 +169,13 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer. - acquis annuel samedi: `5` - en cours d'acquisition jours: `25/12 = 2,08` jours/mois - 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 - samedis acquis affiches: uniquement `opening_saturdays` (report N-1) - contrat `4h`: - acquis annuel CP: `10` - acquis annuel samedi: `0` - en cours d'acquisition: `0.83` jour/mois + - en cas de début/fin en cours de mois, l'acquisition est proratisée au nombre de jours calendaires couverts dans le mois - contrat `FORFAIT`: - base annuelle: `jours ouvrés de l'exercice (lundi-vendredi, hors jours fériés métropole) - 218` - prorata: en cas de démarrage/fin de contrat en cours d'année civile, le calcul ne couvre que l'intervalle actif du contrat dans l'année diff --git a/src/Service/Leave/LeaveBalanceComputationService.php b/src/Service/Leave/LeaveBalanceComputationService.php index 1d17b6d..3af9fb2 100644 --- a/src/Service/Leave/LeaveBalanceComputationService.php +++ b/src/Service/Leave/LeaveBalanceComputationService.php @@ -117,14 +117,14 @@ final readonly class LeaveBalanceComputationService { if (LeaveRuleCode::FORFAIT_218 === $ruleCode) { return [ - new DateTimeImmutable(sprintf('%d-01-01', $year)), - new DateTimeImmutable(sprintf('%d-12-31', $year)), + 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', $year - 1)), - new DateTimeImmutable(sprintf('%d-05-31', $year)), + new DateTimeImmutable(sprintf('%d-06-01 00:00:00', $year - 1)), + new DateTimeImmutable(sprintf('%d-05-31 00:00:00', $year)), ]; } @@ -145,7 +145,7 @@ final readonly class LeaveBalanceComputationService $oldestStartDate = null; foreach ($history as $item) { - $start = DateTimeImmutable::createFromFormat('Y-m-d', $item->startDate); + $start = $this->parseYmdDate($item->startDate); if (!$start) { continue; } @@ -197,14 +197,14 @@ final readonly class LeaveBalanceComputationService ): ?DateTimeImmutable { $earliest = null; foreach ($employee->getContractHistory() as $period) { - $start = DateTimeImmutable::createFromFormat('Y-m-d', $period->startDate); + $start = $this->parseYmdDate($period->startDate); if (!$start) { continue; } $end = null; if (null !== $period->endDate && '' !== trim($period->endDate)) { - $end = DateTimeImmutable::createFromFormat('Y-m-d', $period->endDate); + $end = $this->parseYmdDate($period->endDate); } if ($start > $to) { @@ -268,11 +268,37 @@ final readonly class LeaveBalanceComputationService return 0.0; } - $monthsElapsed = ((int) $periodEnd->format('Y') - (int) $periodStart->format('Y')) * 12 - + ((int) $periodEnd->format('n') - (int) $periodStart->format('n')) - + 1; + $periodStart = $this->normalizeDate($periodStart); + $periodEnd = $this->normalizeDate($periodEnd); + $coveredMonths = 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; + } - return min($annualCap, $monthsElapsed * $accrualPerMonth); + $coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1; + $daysInMonth = (int) $cursor->format('t'); + $coveredMonths += $coveredDays / $daysInMonth; + + $cursor = $cursor->modify('first day of next month'); + } + + return min($annualCap, $coveredMonths * $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 diff --git a/src/State/EmployeeLeaveSummaryProvider.php b/src/State/EmployeeLeaveSummaryProvider.php index 2bec81f..fbecb82 100644 --- a/src/State/EmployeeLeaveSummaryProvider.php +++ b/src/State/EmployeeLeaveSummaryProvider.php @@ -280,14 +280,14 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface ): ?DateTimeImmutable { $earliest = null; foreach ($employee->getContractHistory() as $period) { - $start = DateTimeImmutable::createFromFormat('Y-m-d', $period->startDate); + $start = $this->parseYmdDate($period->startDate); if (!$start instanceof DateTimeImmutable) { continue; } $end = null; if (null !== $period->endDate && '' !== trim($period->endDate)) { - $end = DateTimeImmutable::createFromFormat('Y-m-d', $period->endDate); + $end = $this->parseYmdDate($period->endDate); } if ($start > $to) { @@ -344,14 +344,25 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface 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; + $periodStart = $this->normalizeDate($periodStart); + $periodEnd = $this->normalizeDate($periodEnd); + $coveredMonths = 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; + } + + $coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1; + $daysInMonth = (int) $cursor->format('t'); + $coveredMonths += $coveredDays / $daysInMonth; + + $cursor = $cursor->modify('first day of next month'); } - return min($acquiredDays, $monthsElapsed * $accrualPerMonth); + return min($acquiredDays, $coveredMonths * $accrualPerMonth); } private function resolveAccrualCalculationEndDate( @@ -373,6 +384,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface $lastDayPreviousMonth = $today ->modify('first day of this month') ->modify('-1 day') + ->setTime(0, 0) ; $end = $lastDayPreviousMonth < $periodEnd ? $lastDayPreviousMonth : $periodEnd; } @@ -380,7 +392,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface // 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); + $contractEnd = $this->parseYmdDate($contractEndRaw); if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) { $end = $contractEnd; } @@ -398,7 +410,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface // 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); + $contractEnd = $this->parseYmdDate($contractEndRaw); if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) { $end = $contractEnd; } @@ -520,8 +532,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface 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)); + $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]; } @@ -531,12 +543,12 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface */ private function resolveForfaitYearBounds(Employee $employee, int $year): array { - $from = new DateTimeImmutable(sprintf('%d-01-01', $year)); - $to = new DateTimeImmutable(sprintf('%d-12-31', $year)); + $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 = DateTimeImmutable::createFromFormat('Y-m-d', $contractStartRaw); + $contractStart = $this->parseYmdDate($contractStartRaw); if ($contractStart instanceof DateTimeImmutable && $contractStart > $from) { $from = $contractStart; } @@ -544,7 +556,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface $contractEndRaw = $employee->getCurrentContractEndDate(); if (null !== $contractEndRaw && '' !== trim($contractEndRaw)) { - $contractEnd = DateTimeImmutable::createFromFormat('Y-m-d', $contractEndRaw); + $contractEnd = $this->parseYmdDate($contractEndRaw); if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $to) { $to = $contractEnd; } @@ -582,7 +594,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface $oldestStartDate = null; foreach ($history as $item) { - $start = DateTimeImmutable::createFromFormat('Y-m-d', $item->startDate); + $start = $this->parseYmdDate($item->startDate); if (!$start) { continue; } @@ -611,6 +623,18 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface 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 $absences * diff --git a/tests/Service/Leave/LeaveBalanceComputationServiceTest.php b/tests/Service/Leave/LeaveBalanceComputationServiceTest.php new file mode 100644 index 0000000..714055c --- /dev/null +++ b/tests/Service/Leave/LeaveBalanceComputationServiceTest.php @@ -0,0 +1,71 @@ +newInstanceWithoutConstructor(); + $method = new ReflectionClass(LeaveBalanceComputationService::class)->getMethod('computeAccruedDays'); + + $result = $method->invoke( + $service, + 25.0, + 25.0 / 12.0, + new DateTimeImmutable('2025-06-10'), + new DateTimeImmutable('2026-02-28') + ); + + self::assertEqualsWithDelta(18.125, $result, 0.0001); + } + + public function testComputeAccruedDaysTotalMatchesAlainCase(): void + { + $service = new ReflectionClass(LeaveBalanceComputationService::class)->newInstanceWithoutConstructor(); + $method = new ReflectionClass(LeaveBalanceComputationService::class)->getMethod('computeAccruedDays'); + + $days = $method->invoke( + $service, + 25.0, + 25.0 / 12.0, + new DateTimeImmutable('2025-06-10'), + new DateTimeImmutable('2026-02-28') + ); + $saturdays = $method->invoke( + $service, + 5.0, + 5.0 / 12.0, + new DateTimeImmutable('2025-06-10'), + new DateTimeImmutable('2026-02-28') + ); + + self::assertEqualsWithDelta(21.75, $days + $saturdays, 0.0001); + } + + public function testComputeAccruedDaysIncludesLastDayOfMonthDespiteTimeComponents(): void + { + $service = new ReflectionClass(LeaveBalanceComputationService::class)->newInstanceWithoutConstructor(); + $method = new ReflectionClass(LeaveBalanceComputationService::class)->getMethod('computeAccruedDays'); + + $result = $method->invoke( + $service, + 25.0, + 25.0 / 12.0, + new DateTimeImmutable('2026-02-01 12:50:18'), + new DateTimeImmutable('2026-02-28 00:00:00') + ); + + self::assertEqualsWithDelta(25.0 / 12.0, $result, 0.0001); + } +} diff --git a/tests/State/EmployeeLeaveSummaryProviderTest.php b/tests/State/EmployeeLeaveSummaryProviderTest.php new file mode 100644 index 0000000..34fc8d7 --- /dev/null +++ b/tests/State/EmployeeLeaveSummaryProviderTest.php @@ -0,0 +1,71 @@ +newInstanceWithoutConstructor(); + $method = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->getMethod('computeAccruedDaysFromStart'); + + $result = $method->invoke( + $provider, + 25.0, + 25.0 / 12.0, + new DateTimeImmutable('2025-06-10'), + new DateTimeImmutable('2026-02-28') + ); + + self::assertEqualsWithDelta(18.125, $result, 0.0001); + } + + public function testComputeAccruingDaysTotalMatchesAlainCase(): void + { + $provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor(); + $method = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->getMethod('computeAccruedDaysFromStart'); + + $days = $method->invoke( + $provider, + 25.0, + 25.0 / 12.0, + new DateTimeImmutable('2025-06-10'), + new DateTimeImmutable('2026-02-28') + ); + $saturdays = $method->invoke( + $provider, + 5.0, + 5.0 / 12.0, + new DateTimeImmutable('2025-06-10'), + new DateTimeImmutable('2026-02-28') + ); + + self::assertEqualsWithDelta(21.75, $days + $saturdays, 0.0001); + } + + public function testComputeAccruedDaysFromStartIncludesLastDayOfMonth(): void + { + $provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor(); + $method = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->getMethod('computeAccruedDaysFromStart'); + + $result = $method->invoke( + $provider, + 25.0, + 25.0 / 12.0, + new DateTimeImmutable('2026-02-01 12:50:18'), + new DateTimeImmutable('2026-02-28 00:00:00') + ); + + self::assertEqualsWithDelta(25.0 / 12.0, $result, 0.0001); + } +}