From 1486b770b1811f8235a5719201410e81e42d86ac Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 1 Jun 2026 23:20:07 +0200 Subject: [PATCH] =?UTF-8?q?[#SIRH]=20R=C3=A9cap=20salaire:=20cong=C3=A9s?= =?UTF-8?q?=20N-1=20forfait=20non=20affich=C3=A9s=20et=20compt=C3=A9s=20en?= =?UTF-8?q?=20pr=C3=A9sence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'export récap salaire comptait tous les congés 'C' d'un forfait et ne créditait aucune présence sur les jours de congé. Or un congé imputé sur le stock N-1 ne doit pas s'afficher et doit compter comme jour de présence (règle déjà appliquée dans la fiche employé via EmployeeLeaveSummaryProvider). - Nouvelle méthode publique resolvePreviousYearTakenDays() (mutualise le budget N-1 avec la fiche: phase courante + recalcul jours payés). - SalaryRecapPrintProvider charge les congés depuis le 1er janvier et consomme le budget N-1 chronologiquement (splitForfaitCongesByN1): jours couverts N-1 retirés de l'affichage congés et ajoutés à la présence; au-delà = congés N. - Non-forfait / budget N-1 = 0: comportement inchangé. Vérifié end-to-end sur données prod (SARAZI mai: +1 présence, 4 congés affichés; LIOT/ODUNCU budget 0 après paiement N-1 -> congés affichés). Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 1 + doc/functional-rules.md | 1 + frontend/data/documentation-content.ts | 1 + src/State/EmployeeLeaveSummaryProvider.php | 29 +++++ src/State/SalaryRecapPrintProvider.php | 107 ++++++++++++++++++- tests/State/SalaryRecapPrintProviderTest.php | 95 ++++++++++++++++ 6 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 tests/State/SalaryRecapPrintProviderTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 76b5887..5759892 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,6 +70,7 @@ - Driver contracts: RTT uses `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` instead of morning/afternoon/evening time ranges - FORFAIT weekend/holiday bonus: each weekend or public holiday day worked gives bonus leave (full day if morning+afternoon, 0.5 if only one). Added to acquired days, no cap. PRESENCE mode only. - **FORFAIT — jours de présence et N-1** : les congés posés et imputés sur le stock N-1 ne décrémentent **pas** les jours de présence affichés (`presenceDaysByMonth` et `presenceDaysToToday`). Implémenté dans `EmployeeLeaveSummaryProvider::computePresenceDaysByMonth` via un budget N-1 (= `previousYearTakenDays`) consommé chronologiquement avant comptage des absences. Pour les non-forfait, ce budget vaut toujours 0 → comportement inchangé. + - **Récap salaire (export PDF mensuel)** : même règle appliquée dans `SalaryRecapPrintProvider` — un congé forfait imputé N-1 n'est ni affiché en colonne congés ni soustrait, et compte comme jour de présence. Le budget N-1 vient de `EmployeeLeaveSummaryProvider::resolvePreviousYearTakenDays()` (méthode publique mutualisée, qui reproduit `provide()` : phase courante + recalcul jours payés, donc **même budget que la fiche employé**). Comme l'export est mensuel, les congés sont chargés depuis le 1er janvier (`findForPrint(yearStart, to)`) et le budget consommé chronologiquement par `splitForfaitCongesByN1()`. Non-forfait ou budget N-1 = 0 → `countAbsencesByCode(['C'])` inchangé. - **Jours de présence — borne début de contrat** : `presenceDaysByMonth`/`presenceDaysToToday` sont calculés à partir de `resolveEarliestContractStartWithinRange` (début de contrat dans l'exercice), pas du début d'exercice brut. Évite de compter comme « présents » les jours ouvrés antérieurs à l'embauche pour une entrée en cours d'exercice (ex. CDD : Dylan passait de 43,5 à 246 sans la borne). Sans effet pour un employé présent depuis avant l'exercice ni pour le forfait (déjà capé au début de phase). ## Onglet Congés (fiche employé) diff --git a/doc/functional-rules.md b/doc/functional-rules.md index f902f3d..a8aa99b 100644 --- a/doc/functional-rules.md +++ b/doc/functional-rules.md @@ -267,6 +267,7 @@ Seuls les employés dont au moins une période de contrat intersecte la période - pris: basé sur toutes les absences (demi-journées incluses) - restants = acquis - pris (borné à 0) - paiement congés N-1: saisie RH via `PATCH /employees/{id}/paid-leave-days` (body: `paidLeaveDays`, `year`). Stocké dans `employee_leave_balances.paid_leave_days`. Les jours payés réduisent le stock N-1 **avant** l'attribution des jours pris : `disponible_N-1 = max(0, acquis_N-1 - payés)`, puis `pris_N-1 = min(disponible_N-1, total_pris)`, surplus pris basculé sur N. Reste à prendre N-1 = `max(0, disponible_N-1 - pris_N-1)`. Uniquement pour les contrats forfait. + - jours de présence et récap salaire: pour un forfait, les jours de congé imputés sur le stock N-1 (`previousYearTakenDays`) **ne réduisent pas** les jours de présence et **ne s'affichent pas** comme congés. Sur l'export Récap salaire (mensuel), le budget N-1 est consommé chronologiquement depuis le 1er janvier ; les jours couverts deviennent des jours de présence, les jours au-delà restent affichés en congés. Le budget est le même que la fiche employé (jours payés déduits du stock N-1 d'abord). - report annuel: - le reliquat (`restants`) de l'exercice précédent est reporté dans les acquis de l'exercice courant - pour `CDI`/`CDD` non forfait: report séparé jours + samedis diff --git a/frontend/data/documentation-content.ts b/frontend/data/documentation-content.ts index 853dc04..4edcdde 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, 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: '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/EmployeeLeaveSummaryProvider.php b/src/State/EmployeeLeaveSummaryProvider.php index 6195080..0a6a814 100644 --- a/src/State/EmployeeLeaveSummaryProvider.php +++ b/src/State/EmployeeLeaveSummaryProvider.php @@ -411,6 +411,35 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface return null !== $balance ? $balance->getPaidLeaveDays() : 0.0; } + /** + * Budget N-1 = nombre de jours de congé pris imputés sur le stock de l'année précédente, + * pour l'exercice de l'année donnée. Reproduit exactement la dérivation de provide() + * (phase courante + recalcul avec les jours payés) afin que les consommateurs externes + * (ex. récap salaire) voient le même budget que la fiche employé. 0 si non supporté. + */ + public function resolvePreviousYearTakenDays(Employee $employee, int $year): float + { + $phase = $this->resolveCurrentPhase($employee); + if (null === $phase) { + return 0.0; + } + + $summary = $this->computeYearSummary($employee, $year, 0.0, null, $phase); + if (null === $summary) { + return 0.0; + } + + $paidLeaveDays = $this->resolvePaidLeaveDays($employee, $summary['ruleCode'], $year); + if ($paidLeaveDays > 0.0) { + $summary = $this->computeYearSummary($employee, $year, $paidLeaveDays, null, $phase); + if (null === $summary) { + return 0.0; + } + } + + return (float) $summary['previousYearTakenDays']; + } + private function resolveEffectivePeriodStart( Employee $employee, DateTimeImmutable $from, diff --git a/src/State/SalaryRecapPrintProvider.php b/src/State/SalaryRecapPrintProvider.php index 0786e01..3a88cc9 100644 --- a/src/State/SalaryRecapPrintProvider.php +++ b/src/State/SalaryRecapPrintProvider.php @@ -19,6 +19,7 @@ use App\Repository\ObservationRepository; use App\Repository\WorkHourRepository; use App\Service\Contracts\EmployeeContractResolver; use App\Service\PublicHolidayServiceInterface; +use App\Service\WorkHours\AbsenceSegmentsResolver; use DateInterval; use DateTimeImmutable; use Dompdf\Dompdf; @@ -42,6 +43,8 @@ class SalaryRecapPrintProvider implements ProviderInterface private ObservationRepository $observationRepository, private EmployeeContractResolver $contractResolver, private PublicHolidayServiceInterface $publicHolidayService, + private EmployeeLeaveSummaryProvider $leaveSummaryProvider, + private AbsenceSegmentsResolver $absenceSegmentsResolver, ) {} public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response @@ -65,6 +68,13 @@ class SalaryRecapPrintProvider implements ProviderInterface $year = (int) $from->format('Y'); $monthNumber = (int) $from->format('n'); + + // Congés depuis le début de l'exercice forfait (année civile) jusqu'à la fin du mois : + // nécessaires pour consommer chronologiquement le budget N-1 d'un forfait (un congé + // imputé N-1 ne doit ni s'afficher ni manquer en présence sur le récap). + $yearStart = new DateTimeImmutable(sprintf('%d-01-01', $year)); + $ytdAbsences = $this->absenceRepository->findForPrint($yearStart, $to, $employees); + $ytdAbsenceMap = $this->buildAbsenceMap($ytdAbsences); $rttPayments = $this->rttPaymentRepository->findByYearAndMonth($year, $monthNumber); $bonuses = $this->bonusRepository->findByMonth($from, $to); @@ -83,7 +93,7 @@ class SalaryRecapPrintProvider implements ProviderInterface $mileageMap = $this->buildMileageMap($mileages); $observationMap = $this->buildObservationMap($observations); - $siteGroups = $this->aggregateBySite($employees, $days, $contractMap, $driverMap, $workHourMap, $absenceMap, $rttPaymentMap, $bonusMap, $mileageMap, $observationMap, $holidayMap); + $siteGroups = $this->aggregateBySite($employees, $days, $contractMap, $driverMap, $workHourMap, $absenceMap, $rttPaymentMap, $bonusMap, $mileageMap, $observationMap, $holidayMap, $ytdAbsenceMap, $year, $from, $to); $options = new Options(); $options->set('isRemoteEnabled', true); @@ -264,6 +274,10 @@ class SalaryRecapPrintProvider implements ProviderInterface array $mileageMap, array $observationMap, array $holidayMap, + array $ytdAbsenceMap, + int $year, + DateTimeImmutable $monthFrom, + DateTimeImmutable $monthTo, ): array { $siteGroups = []; @@ -286,6 +300,10 @@ class SalaryRecapPrintProvider implements ProviderInterface $mileageMap[$employeeId] ?? 0.0, $observationMap[$employeeId] ?? '', $holidayMap, + $ytdAbsenceMap[$employeeId] ?? [], + $year, + $monthFrom, + $monthTo, ); if (!isset($siteGroups[$siteId])) { @@ -315,6 +333,10 @@ class SalaryRecapPrintProvider implements ProviderInterface float $mileageKm, string $observation, array $holidayMap, + array $ytdAbsences, + int $year, + DateTimeImmutable $monthFrom, + DateTimeImmutable $monthTo, ): array { $contractName = null; $presenceDays = 0.0; @@ -415,7 +437,21 @@ class SalaryRecapPrintProvider implements ProviderInterface } } - $conges = $this->countAbsencesByCode($absences, ['C']); + // Forfait : un congé imputé sur le stock N-1 ne doit pas s'afficher dans le récap + // et doit compter comme jour de présence. On consomme le budget N-1 chronologiquement + // sur tous les congés de l'exercice (année civile) jusqu'à la fin du mois imprimé. + $n1Budget = $isForfait ? $this->leaveSummaryProvider->resolvePreviousYearTakenDays($employee, $year) : 0.0; + if ($isForfait && $n1Budget > 0.0) { + $ytdConges = array_values(array_filter( + $ytdAbsences, + static fn (Absence $a): bool => 'C' === $a->getType()?->getCode() + )); + $split = $this->splitForfaitCongesByN1($ytdConges, $n1Budget, $monthFrom, $monthTo); + $conges = ['count' => $split['count'], 'dates' => $split['dates']]; + $presenceDays += $split['n1PresenceDays']; + } else { + $conges = $this->countAbsencesByCode($absences, ['C']); + } $maladie = $this->countAbsencesByCode($absences, ['M', 'AT']); $nightHours = round($nightMinutesTotal / 60, 2); @@ -574,6 +610,73 @@ class SalaryRecapPrintProvider implements ProviderInterface return max(0, $end - $start); } + /** + * Répartit les congés ('C') d'un forfait entre N-1 (budget consommé chronologiquement, + * non affiché et compté en présence) et N (affiché en congé). Seuls les jours tombant + * dans le mois imprimé alimentent le retour ; les congés des mois antérieurs ne servent + * qu'à consommer le budget N-1. + * + * @param list $ytdConges congés depuis le début d'exercice jusqu'à la fin du mois + * + * @return array{count: float, dates: string, n1PresenceDays: float} + */ + private function splitForfaitCongesByN1( + array $ytdConges, + float $n1Budget, + DateTimeImmutable $monthFrom, + DateTimeImmutable $monthTo + ): array { + usort($ytdConges, static fn (Absence $a, Absence $b): int => $a->getStartDate() <=> $b->getStartDate()); + + $remaining = $n1Budget; + $count = 0.0; + $n1PresenceDays = 0.0; + $dayKeys = []; + + foreach ($ytdConges as $absence) { + $start = DateTimeImmutable::createFromInterface($absence->getStartDate())->setTime(0, 0); + $end = DateTimeImmutable::createFromInterface($absence->getEndDate())->setTime(0, 0); + + for ($day = $start; $day <= $end; $day = $day->modify('+1 day')) { + if ((int) $day->format('N') >= 6) { + continue; // week-ends ignorés + } + [$am, $pm] = $this->absenceSegmentsResolver->resolveForDate($absence, $day->format('Y-m-d')); + $amount = ($am ? 0.5 : 0.0) + ($pm ? 0.5 : 0.0); + if ($amount <= 0.0) { + continue; + } + + $covered = 0.0; + if ($remaining > 0.0) { + $covered = min($remaining, $amount); + $remaining -= $covered; + } + $displayed = $amount - $covered; + + // Seul le mois imprimé alimente le récap ; les mois antérieurs ne font que consommer. + if ($day < $monthFrom || $day > $monthTo) { + continue; + } + + $n1PresenceDays += $covered; + if ($displayed > 0.0) { + $count += $displayed; + $dayKeys[] = $day->format('Y-m-d'); + } + } + } + + sort($dayKeys); + $dayKeys = array_unique($dayKeys); + + return [ + 'count' => $count, + 'dates' => implode(', ', $this->mergeDaysIntoPeriods($dayKeys)), + 'n1PresenceDays' => $n1PresenceDays, + ]; + } + /** * @param list $absences * @param list $codes diff --git a/tests/State/SalaryRecapPrintProviderTest.php b/tests/State/SalaryRecapPrintProviderTest.php new file mode 100644 index 0000000..92b15d8 --- /dev/null +++ b/tests/State/SalaryRecapPrintProviderTest.php @@ -0,0 +1,95 @@ +buildConge('2026-01-05'), + $this->buildConge('2026-01-06'), + $this->buildConge('2026-01-07'), + ]; + + $result = $this->split($conges, 2.5, '2026-01-01', '2026-01-31'); + + self::assertSame(2.5, $result['n1PresenceDays']); + self::assertSame(0.5, $result['count']); + self::assertSame('07/01', $result['dates']); + } + + public function testN1BudgetConsumedInPriorMonthLeavesCurrentMonthFullyDisplayed(): void + { + // Budget 1 j, consommé par le congé de janvier. Récap de février → le congé de février + // est entièrement imputé N (affiché, 0 présence N-1 dans le mois). + $conges = [ + $this->buildConge('2026-01-12'), + $this->buildConge('2026-02-09'), + ]; + + $result = $this->split($conges, 1.0, '2026-02-01', '2026-02-28'); + + self::assertSame(0.0, $result['n1PresenceDays']); + self::assertSame(1.0, $result['count']); + self::assertSame('09/02', $result['dates']); + } + + public function testZeroBudgetDisplaysAllCongesInMonth(): void + { + $conges = [$this->buildConge('2026-03-03')]; + + $result = $this->split($conges, 0.0, '2026-03-01', '2026-03-31'); + + self::assertSame(0.0, $result['n1PresenceDays']); + self::assertSame(1.0, $result['count']); + self::assertSame('03/03', $result['dates']); + } + + /** + * @param list $conges + * + * @return array{count: float, dates: string, n1PresenceDays: float} + */ + private function split(array $conges, float $budget, string $from, string $to): array + { + $provider = new ReflectionClass(SalaryRecapPrintProvider::class)->newInstanceWithoutConstructor(); + new ReflectionProperty(SalaryRecapPrintProvider::class, 'absenceSegmentsResolver') + ->setValue($provider, new AbsenceSegmentsResolver()); + + return new ReflectionClass($provider::class) + ->getMethod('splitForfaitCongesByN1') + ->invoke($provider, $conges, $budget, new DateTimeImmutable($from), new DateTimeImmutable($to)); + } + + private function buildConge(string $date): Absence + { + return new Absence() + ->setStartDate(new DateTime($date)) + ->setEndDate(new DateTime($date)) + ->setStartHalf(HalfDay::AM) + ->setEndHalf(HalfDay::PM) + ; + } +}