From 91cdf89de016bb66e41f3c38183ca8e86c3eb5a3 Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 11 Jun 2026 15:00:33 +0200 Subject: [PATCH] refactor(night) : RttRecoveryComputationService + SalaryRecapPrintProvider delegent a NightHoursCalculator --- CLAUDE.md | 2 +- .../Rtt/RttRecoveryComputationService.php | 36 ++------------- src/State/SalaryRecapPrintProvider.php | 46 ++++--------------- 3 files changed, 14 insertions(+), 70 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 30331f0..9a5b5a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ - **É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. - **Export heures vue Jour** (`WorkHourDayExportProvider`, endpoint `GET /work-hours/day-export?workDate=&siteIds=`, `ROLE_USER`) : bouton « Exporter » à droite du titre « Heures », **visible uniquement en vue Jour** (`v-if="(isAdmin || isSiteManager) && viewMode === 'day'"`, masqué en vue Semaine et pour `ROLE_SELF`). **Accessible aux admins ET aux chefs de site** : le périmètre est résolu côté backend via `EmployeeRepository::findScoped($user)` (admin → tous les sites, chef de site → ses sites uniquement, cf. `EmployeeScopeService`), donc un `siteIds` hors périmètre est ignoré ; le drawer front ne propose que les sites visibles (`sites` dérivé des employés scopés). PDF A4 portrait d'**une seule journée**, **regroupé par site**, colonnes de la vue Jour **sans « Valider »** (colonne **Total en gras**). Mêmes employés que l'écran : non-conducteurs, sous contrat à la date, sites cochés et dans le périmètre (lignes vides incluses). **Tri intra-site identique au calendrier** : `displayOrder` (ordre manuel), puis nom, puis prénom (cf. `compareEmployeesInSite` front). Calcul des cellules mutualisé via `YearlyHoursExportBuilder::buildDayRowsForEmployees` (Jour/Nuit/Total incluent crédit absence + crédit virtuel férié). Colonne **Statut = code** du type d'absence (`AbsenceType::getCode`, ex. `AT`) sur sa couleur de fond ; férié sans absence → nom du férié sur `#b3e5fc`. Chaque row porte `statut` (code), `statutLabel` (libellé, pour la légende) et `statutColor`. **Légende** sous le tableau (carré coloré contenant le code + libellé à droite), construite côté provider à partir des codes présents (hors férié, dédupliquée par code, triée). Gabarit `templates/work-hour-day-export/print.html.twig`. -- **Export Contingent heures de nuit** (`NightHoursContingentPrintProvider`, endpoint `GET /night-hours-contingent/print?year=YYYY`, `ROLE_USER`) : option « Contingent H.nuit » du drawer Export de la liste employés. PDF **A4 paysage**, lignes = employés **groupés par site** et triés `displayOrder`/nom/prénom (comme le day-export), colonnes = 12 mois civils, chacun avec 2 sous-colonnes **H.nuit** et **N.jours**. Heures de nuit = minutes dans la fenêtre **21h→6h** via le service partagé `App\Service\WorkHours\NightHoursCalculator` (mutualisé avec `WorkHourWeeklySummaryProvider` et `YearlyHoursExportBuilder`). Conducteurs inclus via `WorkHour.nightHoursMinutes`. **N.jours** = nb de jours où minutes de nuit ≥ 240 (4h). **Aucun crédit** absence/férié. Agrégation : `App\Service\WorkHours\NightContingentExportBuilder`. Gabarit `templates/night-hours-contingent/print.html.twig`. +- **Export Contingent heures de nuit** (`NightHoursContingentPrintProvider`, endpoint `GET /night-hours-contingent/print?year=YYYY`, `ROLE_USER`) : option « Contingent H.nuit » du drawer Export de la liste employés. PDF **A4 paysage**, lignes = employés **groupés par site** et triés `displayOrder`/nom/prénom (comme le day-export), colonnes = 12 mois civils, chacun avec 2 sous-colonnes **H.nuit** et **N.jours**. Heures de nuit = minutes dans la fenêtre **21h→6h** via le service partagé `App\Service\WorkHours\NightHoursCalculator` (source unique mutualisée avec `WorkHourWeeklySummaryProvider`, `YearlyHoursExportBuilder`, `RttRecoveryComputationService` et `SalaryRecapPrintProvider`). Conducteurs inclus via `WorkHour.nightHoursMinutes`. **N.jours** = nb de jours où minutes de nuit ≥ 240 (4h). **Aucun crédit** absence/férié. Agrégation : `App\Service\WorkHours\NightContingentExportBuilder`. Gabarit `templates/night-hours-contingent/print.html.twig`. - **É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 diff --git a/src/Service/Rtt/RttRecoveryComputationService.php b/src/Service/Rtt/RttRecoveryComputationService.php index 19b8c1d..539e0f9 100644 --- a/src/Service/Rtt/RttRecoveryComputationService.php +++ b/src/Service/Rtt/RttRecoveryComputationService.php @@ -18,6 +18,7 @@ use App\Service\Contracts\EmployeeContractResolver; use App\Service\WorkHours\AbsenceSegmentsResolver; use App\Service\WorkHours\DailyReferenceMinutesResolver; use App\Service\WorkHours\HolidayVirtualHoursResolver; +use App\Service\WorkHours\NightHoursCalculator; use App\Service\WorkHours\WorkedHoursCreditPolicy; use DateTimeImmutable; @@ -34,6 +35,7 @@ final readonly class RttRecoveryComputationService private DailyReferenceMinutesResolver $dailyReferenceResolver, private HolidayVirtualHoursResolver $holidayVirtualHoursResolver, private SolidarityDayResolver $solidarityDayResolver, + private NightHoursCalculator $nightHoursCalculator, string $rttStartDate = '', ) { $this->rttStartDate = '' !== $rttStartDate ? new DateTimeImmutable($rttStartDate) : null; @@ -359,13 +361,12 @@ final readonly class RttRecoveryComputationService ]; $totalMinutes = 0; - $nightMinutes = 0; foreach ($ranges as [$from, $to]) { $totalMinutes += $this->intervalMinutes($from, $to); - $nightMinutes += $this->nightIntervalMinutes($from, $to); } - $dayMinutes = max(0, $totalMinutes - $nightMinutes); + $nightMinutes = $this->nightHoursCalculator->nightMinutesFromRanges($workHour); + $dayMinutes = max(0, $totalMinutes - $nightMinutes); return new WorkMetrics( dayMinutes: $dayMinutes, @@ -411,35 +412,6 @@ final readonly class RttRecoveryComputationService return max(0, $end - $start); } - private function nightIntervalMinutes(?string $from, ?string $to): int - { - $interval = $this->resolveInterval($from, $to); - if (null === $interval) { - return 0; - } - - [$start, $end] = $interval; - $windows = [[0, 360], [1260, 1440]]; - $total = 0; - - for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) { - $shift = $dayOffset * 1440; - foreach ($windows as [$windowStart, $windowEnd]) { - $total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift); - } - } - - return $total; - } - - private function overlap(int $startA, int $endA, int $startB, int $endB): int - { - $start = max($startA, $startB); - $end = min($endA, $endB); - - return max(0, $end - $start); - } - /** * @param list $days * @param array $contractsByDate diff --git a/src/State/SalaryRecapPrintProvider.php b/src/State/SalaryRecapPrintProvider.php index ecd47c0..8a7ba22 100644 --- a/src/State/SalaryRecapPrintProvider.php +++ b/src/State/SalaryRecapPrintProvider.php @@ -20,6 +20,7 @@ use App\Repository\WorkHourRepository; use App\Service\Contracts\EmployeeContractResolver; use App\Service\PublicHolidayServiceInterface; use App\Service\WorkHours\AbsenceSegmentsResolver; +use App\Service\WorkHours\NightHoursCalculator; use DateInterval; use DateTimeImmutable; use Dompdf\Dompdf; @@ -45,6 +46,7 @@ class SalaryRecapPrintProvider implements ProviderInterface private PublicHolidayServiceInterface $publicHolidayService, private EmployeeLeaveSummaryProvider $leaveSummaryProvider, private AbsenceSegmentsResolver $absenceSegmentsResolver, + private NightHoursCalculator $nightHoursCalculator, ) {} public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response @@ -78,10 +80,10 @@ class SalaryRecapPrintProvider implements ProviderInterface // 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); + $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); + $rttPayments = $this->rttPaymentRepository->findByYearAndMonth($year, $monthNumber); $bonuses = $this->bonusRepository->findByMonth($from, $to); $mileages = $this->mileageAllowanceRepository->findByMonth($from, $to); @@ -472,7 +474,7 @@ class SalaryRecapPrintProvider implements ProviderInterface $ytdAbsences, static fn (Absence $a): bool => 'C' === $a->getType()?->getCode() )); - $split = $this->splitForfaitCongesByN1($ytdConges, $n1Budget, $monthFrom, $monthTo); + $split = $this->splitForfaitCongesByN1($ytdConges, $n1Budget, $monthFrom, $monthTo); $conges = ['count' => $split['count'], 'dates' => $split['dates']]; $presenceDays += $split['n1PresenceDays']; } else { @@ -524,14 +526,13 @@ class SalaryRecapPrintProvider implements ProviderInterface ]; $totalMinutes = 0; - $nightMinutes = 0; foreach ($ranges as [$from, $to]) { $totalMinutes += $this->intervalMinutes($from, $to); - $nightMinutes += $this->nightIntervalMinutes($from, $to); } - $dayMinutes = max(0, $totalMinutes - $nightMinutes); + $nightMinutes = $this->nightHoursCalculator->nightMinutesFromRanges($workHour); + $dayMinutes = max(0, $totalMinutes - $nightMinutes); return [ 'nightMinutes' => $nightMinutes, @@ -578,27 +579,6 @@ class SalaryRecapPrintProvider implements ProviderInterface return max(0, $end - $start); } - private function nightIntervalMinutes(?string $from, ?string $to): int - { - $interval = $this->resolveInterval($from, $to); - if (null === $interval) { - return 0; - } - - [$start, $end] = $interval; - $windows = [[0, 360], [1260, 1440]]; - $total = 0; - - for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) { - $shift = $dayOffset * 1440; - foreach ($windows as [$windowStart, $windowEnd]) { - $total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift); - } - } - - return $total; - } - /** * Calcule les minutes qui débordent après minuit (> 1440) pour les créneaux d'un WorkHour. * Ex: créneau soir 21:00-05:00 → interval [1260, 1740] → overflow = 1740-1440 = 300 min (5h). @@ -630,14 +610,6 @@ class SalaryRecapPrintProvider implements ProviderInterface return $overflow; } - private function overlap(int $startA, int $endA, int $startB, int $endB): int - { - $start = max($startA, $startB); - $end = min($endA, $endB); - - 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 @@ -677,7 +649,7 @@ class SalaryRecapPrintProvider implements ProviderInterface $covered = 0.0; if ($remaining > 0.0) { - $covered = min($remaining, $amount); + $covered = min($remaining, $amount); $remaining -= $covered; } $displayed = $amount - $covered;