From e56539f40c5609fea706fa5e0db9386771496741 Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 21 May 2026 08:35:23 +0200 Subject: [PATCH] fix(calendar) : date-only contract filter + eager-load periods for print Review follow-ups: (1) createFromFormat('Y-m-d') keeps the current time, so a raw DateTime comparison wrongly excluded an employee ending on the from-day (and dropped first-day absences); normalize from/to to day bounds and compare contract periods on date only (Y-m-d), mirroring the calendar view. (2) eager-load contractPeriods in findForPrintBySiteIds to avoid an N+1 during filtering. Added a boundary test. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Repository/EmployeeRepository.php | 4 ++++ src/State/AbsencePrintProvider.php | 17 +++++++++++++---- tests/State/AbsencePrintProviderTest.php | 15 +++++++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/Repository/EmployeeRepository.php b/src/Repository/EmployeeRepository.php index 343adc5..139e677 100644 --- a/src/Repository/EmployeeRepository.php +++ b/src/Repository/EmployeeRepository.php @@ -87,6 +87,10 @@ final class EmployeeRepository extends ServiceEntityRepository implements Employ ->addSelect('s') ->leftJoin('e.contract', 'c') ->addSelect('c') + // Eager-load des périodes pour le filtre d'intersection contrat/période (impression), + // évite un N+1 sur getContractPeriods() lors du filtrage des employés. + ->leftJoin('e.contractPeriods', 'cp') + ->addSelect('cp') ->orderBy('s.name', 'ASC') ->addOrderBy('e.displayOrder', 'ASC') ->addOrderBy('e.lastName', 'ASC') diff --git a/src/State/AbsencePrintProvider.php b/src/State/AbsencePrintProvider.php index a969910..912ce26 100644 --- a/src/State/AbsencePrintProvider.php +++ b/src/State/AbsencePrintProvider.php @@ -60,6 +60,11 @@ class AbsencePrintProvider implements ProviderInterface if (!$fromDate instanceof DateTimeImmutable || !$toDate instanceof DateTimeImmutable) { return new Response('Invalid from/to date format.', Response::HTTP_BAD_REQUEST); } + // createFromFormat('Y-m-d', ...) garde l'heure courante : on borne explicitement aux + // extrémités de journée, sinon les comparaisons de dates (présence d'un contrat/absence + // le jour de `from`) échouent contre les dates BDD à minuit. + $fromDate = $fromDate->setTime(0, 0, 0); + $toDate = $toDate->setTime(23, 59, 59); $siteIds = $this->parseIds($request->query->get('sites')); $workContractIds = $this->parseIds($request->query->get('workContracts')); @@ -141,14 +146,18 @@ class AbsencePrintProvider implements ProviderInterface /** * Vrai si au moins une période de contrat de l'employé intersecte [$from, $to]. * Une période sans date de fin (contrat en cours) est considérée ouverte jusqu'à l'infini. - * Aligné avec le filtre `hasContractInSelectedMonth` de la vue Calendrier. + * Comparaison sur la date seule (`Y-m-d`), insensible à l'heure des bornes — aligné avec + * le filtre `hasContractInSelectedMonth` de la vue Calendrier (comparaison de chaînes). */ private function hasContractInRange(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): bool { + $fromDay = $from->format('Y-m-d'); + $toDay = $to->format('Y-m-d'); + foreach ($employee->getContractPeriods() as $period) { - $start = $period->getStartDate(); - $end = $period->getEndDate(); - if ($start <= $to && (null === $end || $end >= $from)) { + $start = $period->getStartDate()->format('Y-m-d'); + $end = $period->getEndDate()?->format('Y-m-d'); + if ($start <= $toDay && (null === $end || $end >= $fromDay)) { return true; } } diff --git a/tests/State/AbsencePrintProviderTest.php b/tests/State/AbsencePrintProviderTest.php index 2665497..fbe4028 100644 --- a/tests/State/AbsencePrintProviderTest.php +++ b/tests/State/AbsencePrintProviderTest.php @@ -62,6 +62,21 @@ final class AbsencePrintProviderTest extends TestCase self::assertFalse($this->hasInRange($provider, $employee, '2026-05-01', '2026-05-31')); } + public function testHasContractInRangeIncludesEmployeeEndingOnFromDayDespiteTimeComponent(): void + { + // Garde-fou : `from` portant une heure (cf. createFromFormat) ne doit pas exclure + // un employé dont le contrat finit pile le jour de `from` (comparaison date seule). + $provider = new ReflectionClass(AbsencePrintProvider::class)->newInstanceWithoutConstructor(); + $employee = $this->buildEmployeeWithPeriod('2025-01-01', '2026-05-01'); + + $result = new ReflectionClass($provider::class) + ->getMethod('hasContractInRange') + ->invoke($provider, $employee, new DateTimeImmutable('2026-05-01 08:33:53'), new DateTimeImmutable('2026-05-31 23:59:59')) + ; + + self::assertTrue($result); + } + private function hasInRange(object $provider, Employee $employee, string $from, string $to): bool { return new ReflectionClass($provider::class)