From 94cf8eb7a9f5dc47de5f4df7603d82bd1fbc4482 Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 2 Jun 2026 08:10:54 +0200 Subject: [PATCH] =?UTF-8?q?[#SIRH]=20R=C3=A9cap=20salaire:=20exclure=20les?= =?UTF-8?q?=20salari=C3=A9s=20sans=20contrat=20sur=20le=20mois?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le récap listait tous les employés sans filtrer le contrat: un salarié au contrat terminé (ex. Marine, fin 26/02) apparaissait sur le récap de juin. Ajout du filtre hasContractInRange (même règle que l'impression absences) sur la période [from, to] du mois imprimé. 4 tests ajoutés. Vérifié sur données prod (Marine + 6 autres contrats terminés exclus du mois de juin, 39 salariés contractés conservés). Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- doc/functional-rules.md | 1 + frontend/data/documentation-content.ts | 1 + src/State/SalaryRecapPrintProvider.php | 24 +++++++++- tests/State/SalaryRecapPrintProviderTest.php | 50 ++++++++++++++++++++ 5 files changed, 76 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 296561a..787675d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ - Employee contract history: `employee_contract_periods`, resolved by `EmployeeContractResolver` - **Écrans Heures / Heures Conducteurs (vue jour)** : le libellé nature (CDI/CDD/Intérim) sous le nom de l'employé est résolu **à la date filtrée** via `WorkHourDayContext.contractNature` (alimenté par `EmployeeContractResolver::resolveNatureForEmployeeAndDate`), pas via `Employee.currentContractNature` (qui est résolu à aujourd'hui). Idem pour le **mode de suivi (TIME/PRESENCE), les heures hebdo et le libellé de contrat** sur la vue Jour : résolus à la date filtrée via `WorkHourDayContext` (`trackingMode`/`weeklyHours`/`contractType`/`contractName`, peuplés depuis `EmployeeContractResolver::resolveForEmployeeAndDate`), pas via `employee.contract` (résolu à aujourd'hui). Côté front, `resolveDayContract()` (`useHoursPage.ts`) pilote l'affichage et `handleSave` (heures vs présence par date). - **Exports heures annuelles** (par salarié `EmployeeYearlyHoursPrintProvider` + tous `EmployeeYearlyHoursBulkPrintProvider`, via `YearlyHoursExportBuilder`) : **tous les jours sous contrat sont affichés**, même vides ou non saisis (jusqu'à aujourd'hui). Seuls les jours hors contrat sont omis (`buildSegments` : un seul filtre `!$hasData && null === $contract`). Ne pas réintroduire de saut des jours de semaine vides. Samedis/dimanches grisés (`#c0c0c0`) dans les templates `employee-yearly-hours/print*.html.twig`. NB : l'export *tous employés* sur l'année peut dépasser `memory_limit=256M` (Dompdf) — limitation pré-existante, voir avec l'infra si besoin. -- **Écran Calendrier** : un employé est affiché uniquement si au moins une de ses périodes de contrat (`employee.contractHistory`) intersecte le mois affiché (`[1er ; dernier jour]`). Filtre côté frontend dans `visibleEmployees` (`pages/calendar.vue`). **L'impression PDF des absences applique le même filtre** côté backend (`AbsencePrintProvider::hasContractInRange` sur la période `[from, to]`) : un salarié parti en avril n'apparaît pas sur une impression de mai. +- **Écran Calendrier** : un employé est affiché uniquement si au moins une de ses périodes de contrat (`employee.contractHistory`) intersecte le mois affiché (`[1er ; dernier jour]`). Filtre côté frontend dans `visibleEmployees` (`pages/calendar.vue`). **L'impression PDF des absences applique le même filtre** côté backend (`AbsencePrintProvider::hasContractInRange` sur la période `[from, to]`) : un salarié parti en avril n'apparaît pas sur une impression de mai. **Le récap salaire applique le même filtre** (`SalaryRecapPrintProvider::hasContractInRange` sur le mois imprimé) : un salarié sans contrat sur le mois (ex. parti en février) n'apparaît pas sur le récap de juin. - **Planning jours travaillés** (`EmployeeContractPeriod.workDaysHours` : JSON `{iso_day: minutes}`) : obligatoire pour tout contrat TIME **hors 35h/39h/INTERIM** (ex. 4h, 25h, 28h). Somme = `weeklyHours × 60`. Utilisé par `HolidayVirtualHoursResolver` (crédit férié) et `WorkedHoursCreditPolicy` (crédit absence) pour ne créditer que les jours effectivement travaillés. Validation : `EmployeeContractPeriodValidator::assertWorkDaysHours`. - Absences: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots - Absences with `countAsWorkedHours=true`: credit minutes (TIME) or nothing (PRESENCE) diff --git a/doc/functional-rules.md b/doc/functional-rules.md index c0d7159..a156869 100644 --- a/doc/functional-rules.md +++ b/doc/functional-rules.md @@ -63,6 +63,7 @@ Documents complementaires: - masqué si aucun contrat à cette date (cas rarissime en vue jour puisque l'employé est alors déjà filtré) - **Vue Jour (Heures) — contrat à la date affichée** : le mode de suivi (saisie d'heures vs cases de présence), le libellé de contrat et la logique de sauvegarde sont résolus selon la période de contrat valable à la date filtrée (champs `trackingMode`/`weeklyHours`/`contractType`/`contractName` portés par `WorkHourDayContext`, alimentés par `EmployeeContractResolver::resolveForEmployeeAndDate`), et non selon le contrat courant de l'employé. Un salarié passé 39h/35h → Forfait conserve donc la saisie d'heures sur ses dates antérieures à la bascule, et bascule en cases de présence à partir de la date de passage en forfait. La vue Semaine était déjà résolue par date. - **Exports heures annuelles (par salarié et tous salariés)** : affichent **tous les jours sous contrat**, même vides ou non saisis, jusqu'à la date du jour ; seuls les jours hors contrat (avant embauche, après départ, suspension) sont omis. Les samedis et dimanches sont grisés (gris foncé), les jours fériés en bleu. +- **Récap salaire (export PDF mensuel)** : seuls les salariés ayant un contrat couvrant tout ou partie du mois imprimé apparaissent (filtre `hasContractInRange`). Un salarié dont le contrat est terminé avant le mois (ex. parti en février) n'est pas listé sur le récap des mois suivants. ## 4) Absences diff --git a/frontend/data/documentation-content.ts b/frontend/data/documentation-content.ts index 777496d..60dbb49 100644 --- a/frontend/data/documentation-content.ts +++ b/frontend/data/documentation-content.ts @@ -622,6 +622,7 @@ export const documentationSections: DocSection[] = [ blocks: [ { type: 'paragraph', content: 'Génère un PDF A4 paysage avec le détail mensuel pour la paie.' }, { type: 'list', content: 'Sélecteur de mois (défaut = mois courant)\nDonnées groupées par site\nColonnes : nom, base contrat, jours de présence cadre, heures de nuit, panier de nuit, heures RTT payées (en-tête fusionné scindé en deux sous-colonnes 25 % et 50 %), congés (nombre + dates), maladie/AT (nombre + dates), primes conducteur (PDJ, repas, nuitée, samedi), observations\nColonne « Repas » chauffeur : somme déjeuner + dîner sur le mois (un jour avec les deux compte 2 repas)' }, + { type: 'note', content: 'Seuls les salariés ayant un contrat couvrant tout ou partie du mois apparaissent : un salarié dont le contrat est terminé (ex. parti en février) n\'est pas listé sur le récap des mois suivants.' }, { type: 'note', content: 'Forfait : un congé imputé sur le stock de l\'année précédente (N-1) n\'apparaît pas dans la colonne congés et compte comme un jour de présence. Le budget N-1 est consommé dans l\'ordre chronologique depuis janvier, de façon cohérente avec la fiche employé (les jours payés réduisent le stock N-1 d\'abord). Au-delà du budget N-1, les congés s\'affichent normalement.' }, ], }, diff --git a/src/State/SalaryRecapPrintProvider.php b/src/State/SalaryRecapPrintProvider.php index 80f2e34..2251563 100644 --- a/src/State/SalaryRecapPrintProvider.php +++ b/src/State/SalaryRecapPrintProvider.php @@ -62,7 +62,13 @@ class SalaryRecapPrintProvider implements ProviderInterface $from = DateTimeImmutable::createFromFormat('Y-m-d', $month.'-01'); $to = $from->modify('last day of this month'); - $employees = $this->employeeRepository->findForPrintBySiteIds([]); + // N'inclure que les employés ayant un contrat couvrant tout ou partie du mois. + // Sans ce filtre, un salarié dont le contrat est terminé (ex. parti en février) + // apparaît à tort sur le récap des mois suivants. + $employees = array_values(array_filter( + $this->employeeRepository->findForPrintBySiteIds([]), + fn (Employee $employee): bool => $this->hasContractInRange($employee, $from, $to) + )); $workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, $employees); $absences = $this->absenceRepository->findForPrint($from, $to, $employees); @@ -120,6 +126,22 @@ class SalaryRecapPrintProvider implements ProviderInterface ]); } + 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()->format('Y-m-d'); + $end = $period->getEndDate()?->format('Y-m-d'); + if ($start <= $toDay && (null === $end || $end >= $fromDay)) { + return true; + } + } + + return false; + } + /** * @return list */ diff --git a/tests/State/SalaryRecapPrintProviderTest.php b/tests/State/SalaryRecapPrintProviderTest.php index 92b15d8..58d0230 100644 --- a/tests/State/SalaryRecapPrintProviderTest.php +++ b/tests/State/SalaryRecapPrintProviderTest.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace App\Tests\State; use App\Entity\Absence; +use App\Entity\Employee; +use App\Entity\EmployeeContractPeriod; use App\Enum\HalfDay; use App\Service\WorkHours\AbsenceSegmentsResolver; use App\State\SalaryRecapPrintProvider; @@ -67,6 +69,54 @@ final class SalaryRecapPrintProviderTest extends TestCase self::assertSame('03/03', $result['dates']); } + public function testTerminatedContractExcludedFromMonth(): void + { + // Marine : contrat terminé le 26/02 → absente du récap de juin. + $employee = $this->buildEmployeeWithPeriod('2025-02-10', '2026-02-26'); + + self::assertFalse($this->hasInRange($employee, '2026-06-01', '2026-06-30')); + } + + public function testOngoingContractIncluded(): void + { + $employee = $this->buildEmployeeWithPeriod('2025-01-01', null); + + self::assertTrue($this->hasInRange($employee, '2026-06-01', '2026-06-30')); + } + + public function testContractEndingOnFromDayIncluded(): void + { + $employee = $this->buildEmployeeWithPeriod('2025-01-01', '2026-06-01'); + + self::assertTrue($this->hasInRange($employee, '2026-06-01', '2026-06-30')); + } + + public function testNoPeriodsExcluded(): void + { + self::assertFalse($this->hasInRange(new Employee(), '2026-06-01', '2026-06-30')); + } + + private function hasInRange(Employee $employee, string $from, string $to): bool + { + $provider = new ReflectionClass(SalaryRecapPrintProvider::class)->newInstanceWithoutConstructor(); + + 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; + } + /** * @param list $conges *