fix(calendar) : exclude departed employees from absence PDF print

The calendar view hides employees whose contract doesn't intersect the displayed
month, but the absence PDF print still listed them. Apply the same intersection
filter (hasContractInRange over [from, to]) in AbsencePrintProvider, and reject
invalid from/to dates. A employee who left in April no longer appears on a May print.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 08:26:20 +02:00
parent 480f2a4d4e
commit ddd1b8116e
2 changed files with 112 additions and 4 deletions

View File

@@ -6,6 +6,7 @@ namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Employee;
use App\Entity\Formation;
use App\Enum\ContractNature;
use App\Enum\HalfDay;
@@ -56,12 +57,15 @@ class AbsencePrintProvider implements ProviderInterface
$fromDate = DateTimeImmutable::createFromFormat('Y-m-d', $from);
$toDate = DateTimeImmutable::createFromFormat('Y-m-d', $to);
if (!$fromDate instanceof DateTimeImmutable || !$toDate instanceof DateTimeImmutable) {
return new Response('Invalid from/to date format.', Response::HTTP_BAD_REQUEST);
}
$siteIds = $this->parseIds($request->query->get('sites'));
$workContractIds = $this->parseIds($request->query->get('workContracts'));
$contractNatures = $this->parseContractNatures($request->query->get('contractNatures'));
$employees = $this->loadEmployees($siteIds, $contractNatures, $workContractIds);
$employees = $this->loadEmployees($siteIds, $contractNatures, $workContractIds, $fromDate, $toDate);
$absences = $this->loadAbsences($fromDate, $toDate, $employees);
$formations = $this->formationRepository->findByDateRangeAndEmployees($fromDate, $toDate, $employees);
@@ -117,21 +121,41 @@ class AbsencePrintProvider implements ProviderInterface
return array_values(array_unique($ids));
}
private function loadEmployees(array $siteIds, array $contractNatures, array $workContractIds): array
private function loadEmployees(array $siteIds, array $contractNatures, array $workContractIds, DateTimeImmutable $from, DateTimeImmutable $to): array
{
$employees = $this->employeeRepository->findForPrintBySiteIds($siteIds);
return array_values(array_filter($employees, static function ($employee) use ($contractNatures, $workContractIds): bool {
return array_values(array_filter($employees, function ($employee) use ($contractNatures, $workContractIds, $from, $to): bool {
$employeeNature = (string) $employee->getCurrentContractNature();
$employeeContractId = $employee->getContract()?->getId();
$natureMatches = [] === $contractNatures || in_array($employeeNature, $contractNatures, true);
$contractMatches = [] === $workContractIds || (null !== $employeeContractId && in_array($employeeContractId, $workContractIds, true));
return $natureMatches && $contractMatches;
// Exclut les employés dont aucune période de contrat n'intersecte la période imprimée
// (ex. un salarié parti en avril ne doit pas apparaître sur une impression de mai).
return $natureMatches && $contractMatches && $this->hasContractInRange($employee, $from, $to);
}));
}
/**
* 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.
*/
private function hasContractInRange(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): bool
{
foreach ($employee->getContractPeriods() as $period) {
$start = $period->getStartDate();
$end = $period->getEndDate();
if ($start <= $to && (null === $end || $end >= $from)) {
return true;
}
}
return false;
}
private function loadAbsences(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array
{
return $this->absenceRepository->findForPrint($from, $to, $employees);

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Tests\State;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use App\State\AbsencePrintProvider;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
/**
* The provider constructor takes final-class collaborators (Twig, repositories) that
* PHPUnit cannot double. The pure contract-range helper is exercised via
* newInstanceWithoutConstructor + reflection.
*
* @internal
*/
final class AbsencePrintProviderTest extends TestCase
{
public function testHasContractInRangeFalseWhenContractEndedBeforeRange(): void
{
$provider = new ReflectionClass(AbsencePrintProvider::class)->newInstanceWithoutConstructor();
$employee = $this->buildEmployeeWithPeriod('2025-01-01', '2026-04-30');
// Imprime mai : l'employé parti le 30/04 ne doit pas être inclus.
self::assertFalse($this->hasInRange($provider, $employee, '2026-05-01', '2026-05-31'));
}
public function testHasContractInRangeTrueWhenContractOverlapsRange(): void
{
$provider = new ReflectionClass(AbsencePrintProvider::class)->newInstanceWithoutConstructor();
$employee = $this->buildEmployeeWithPeriod('2025-01-01', '2026-05-15');
// Contrat finissant le 15/05 → chevauche le mois de mai → inclus.
self::assertTrue($this->hasInRange($provider, $employee, '2026-05-01', '2026-05-31'));
}
public function testHasContractInRangeTrueForOpenEndedContract(): void
{
$provider = new ReflectionClass(AbsencePrintProvider::class)->newInstanceWithoutConstructor();
$employee = $this->buildEmployeeWithPeriod('2020-01-01', null);
self::assertTrue($this->hasInRange($provider, $employee, '2026-05-01', '2026-05-31'));
}
public function testHasContractInRangeFalseWhenContractStartsAfterRange(): void
{
$provider = new ReflectionClass(AbsencePrintProvider::class)->newInstanceWithoutConstructor();
$employee = $this->buildEmployeeWithPeriod('2026-06-01', null);
self::assertFalse($this->hasInRange($provider, $employee, '2026-05-01', '2026-05-31'));
}
public function testHasContractInRangeFalseWhenNoPeriods(): void
{
$provider = new ReflectionClass(AbsencePrintProvider::class)->newInstanceWithoutConstructor();
$employee = new Employee();
self::assertFalse($this->hasInRange($provider, $employee, '2026-05-01', '2026-05-31'));
}
private function hasInRange(object $provider, Employee $employee, string $from, string $to): bool
{
return new ReflectionClass($provider::class)
->getMethod('hasContractInRange')
->invoke($provider, $employee, new DateTimeImmutable($from), new DateTimeImmutable($to))
;
}
private function buildEmployeeWithPeriod(string $start, ?string $end): Employee
{
$employee = new Employee();
$period = new EmployeeContractPeriod();
$period->setEmployee($employee);
$period->setStartDate(new DateTimeImmutable($start));
$period->setEndDate(null !== $end ? new DateTimeImmutable($end) : null);
$employee->getContractPeriods()->add($period);
return $employee;
}
}