From b5bd4db5f1377a9b811f767e7478fb2955fcabfa Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 11 Jun 2026 13:02:30 +0000 Subject: [PATCH] =?UTF-8?q?feat(heures)=20:=20export=20Contingent=20heures?= =?UTF-8?q?=20de=20nuit=20(liste=20employ=C3=A9s)=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Résumé Nouvel export PDF **Contingent heures de nuit** dans le drawer Export de la liste employés. - PDF **A4 paysage** : lignes = employés (groupés par site, triés displayOrder/nom/prénom), colonnes = 12 mois civils, chaque mois avec 2 sous-colonnes **H.nuit** et **N.jours**. - Heures de nuit = minutes dans la fenêtre **21h→6h** via un service partagé `NightHoursCalculator` (mutualisé avec `WorkHourWeeklySummaryProvider` et `YearlyHoursExportBuilder` — duplication supprimée, sans changement de comportement). - **Conducteurs inclus** via `WorkHour.nightHoursMinutes`. Statut conducteur résolu par date. - **N.jours** = nb de jours où les minutes de nuit ≥ 240 (4h). Aucun crédit absence/férié. - Périmètre via `EmployeeRepository::findScoped` (admin → tous, chef de site → ses sites), endpoint `GET /night-hours-contingent/print?year=YYYY` (`ROLE_USER`). - Sélecteur d'année (année civile). Colonne Nom calibrée, séparateurs de mois épais. ## Composants - Service `NightHoursCalculator`, builder `NightContingentExportBuilder`, DTO `NightContingentRow` - Provider `NightHoursContingentPrintProvider` + opération API `NightHoursContingentPrint` - Gabarit `templates/night-hours-contingent/print.html.twig` - Option frontend dans `frontend/pages/employees/index.vue` - Docs : `doc/functional-rules.md`, `CLAUDE.md`, `frontend/data/documentation-content.ts` ## Tests - Nouveaux tests unitaires : `NightHoursCalculatorTest` (fenêtre 21h→6h, passage minuit, bornes), `NightContingentExportBuilderTest` (agrégation mensuelle, règle ≥4h=1j, conducteur, cas sans heures) - Suite complète : **208 tests OK** - Rendu PDF validé visuellement (Twig→Dompdf) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://gitea.malio.fr/MALIO-DEV/SIRH/pulls/28 Co-authored-by: tristan Co-committed-by: tristan --- CLAUDE.md | 1 + doc/functional-rules.md | 17 + ...026-06-11-night-hours-contingent-export.md | 1124 +++++++++++++++++ ...11-night-hours-contingent-export-design.md | 132 ++ frontend/data/documentation-content.ts | 1 + frontend/pages/employees/index.vue | 22 +- src/ApiResource/NightHoursContingentPrint.php | 24 + src/Dto/WorkHours/NightContingentRow.php | 17 + .../Rtt/RttRecoveryComputationService.php | 36 +- .../NightContingentExportBuilder.php | 95 ++ .../WorkHours/NightHoursCalculator.php | 102 ++ .../WorkHours/YearlyHoursExportBuilder.php | 36 +- .../NightHoursContingentPrintProvider.php | 154 +++ src/State/SalaryRecapPrintProvider.php | 46 +- src/State/WorkHourWeeklySummaryProvider.php | 39 +- .../night-hours-contingent/print.html.twig | 60 + .../NightContingentExportBuilderTest.php | 123 ++ .../WorkHours/NightHoursCalculatorTest.php | 80 ++ .../WorkHours/YearlyHoursDayRowsTest.php | 2 + .../WorkHourWeeklySummaryProviderTest.php | 3 + 20 files changed, 1974 insertions(+), 140 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-11-night-hours-contingent-export.md create mode 100644 docs/superpowers/specs/2026-06-11-night-hours-contingent-export-design.md create mode 100644 src/ApiResource/NightHoursContingentPrint.php create mode 100644 src/Dto/WorkHours/NightContingentRow.php create mode 100644 src/Service/WorkHours/NightContingentExportBuilder.php create mode 100644 src/Service/WorkHours/NightHoursCalculator.php create mode 100644 src/State/NightHoursContingentPrintProvider.php create mode 100644 templates/night-hours-contingent/print.html.twig create mode 100644 tests/Service/WorkHours/NightContingentExportBuilderTest.php create mode 100644 tests/Service/WorkHours/NightHoursCalculatorTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 07f119d..9a5b5a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,6 +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` (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/doc/functional-rules.md b/doc/functional-rules.md index c6c649e..aaeb11e 100644 --- a/doc/functional-rules.md +++ b/doc/functional-rules.md @@ -382,6 +382,23 @@ Seuls les employés dont au moins une période de contrat intersecte la période - Colonnes identiques au PDF (voir §10) - Détails techniques : voir `doc/leave-recap-screen.md` +## Export Contingent heures de nuit + +- Accès : drawer « Export » de la liste employés, type « Contingent H.nuit ». + Endpoint `GET /night-hours-contingent/print?year=YYYY`, `ROLE_USER`. +- Périmètre : `EmployeeRepository::findScoped($user)` (admin → tous, chef de + site → ses sites). Employés ayant ≥ 1 contrat sur l'année civile uniquement. +- PDF A4 **paysage** : lignes = employés (groupés par site, triés displayOrder + puis nom/prénom), colonnes = 12 mois (Janv→Déc), chaque mois avec 2 sous- + colonnes « H.nuit » et « N.jours ». +- Heures de nuit : minutes travaillées dans la fenêtre **21h→6h** + (`NightHoursCalculator`, identique au reste de l'app). Conducteurs inclus : + champ manuel `WorkHour.nightHoursMinutes`. +- « N.jours » : un jour compte 1 dès que ses minutes de nuit ≥ 240 (4h). +- Aucun crédit absence/férié : seules les heures réellement travaillées comptent. +- Services : `App\State\NightHoursContingentPrintProvider` + + `App\Service\WorkHours\NightContingentExportBuilder`. + ## 11) Récapitulatif Salaire (PDF mensuel) - Accessible depuis la page Employés via le bouton "Récap. Salaire" (réservé `ROLE_ADMIN`) diff --git a/docs/superpowers/plans/2026-06-11-night-hours-contingent-export.md b/docs/superpowers/plans/2026-06-11-night-hours-contingent-export.md new file mode 100644 index 0000000..35a311a --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-night-hours-contingent-export.md @@ -0,0 +1,1124 @@ +# Export « Contingent H.nuit » — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ajouter un export PDF (A4 paysage) sur la liste des employés : tableau du contingent d'heures de nuit, employés en lignes, 12 mois en colonnes, chaque mois portant « Total H.nuit » et « Total N.jours ». + +**Architecture:** Une opération API Platform read-only `GET /night-hours-contingent/print?year=YYYY` rend un PDF. Un `NightHoursContingentPrintProvider` résout le périmètre (`findScoped`), groupe/trie par site (comme le day-export), et délègue le calcul à un `NightContingentExportBuilder`. Le calcul des minutes de nuit (fenêtre 21h→6h, déjà en place) est extrait dans un service partagé `NightHoursCalculator` réutilisé par les deux providers existants. + +**Tech Stack:** Symfony, API Platform (Provider), Doctrine, Dompdf, Twig, PHPUnit ; frontend Nuxt 4 / Vue 3. + +--- + +## File Structure + +- Create: `src/Service/WorkHours/NightHoursCalculator.php` — calcul des minutes de nuit (fenêtre 21h→6h) pour un `WorkHour`. +- Create: `tests/Service/WorkHours/NightHoursCalculatorTest.php` +- Modify: `src/Service/WorkHours/YearlyHoursExportBuilder.php` — délègue la nuit au calculateur. +- Modify: `src/State/WorkHourWeeklySummaryProvider.php` — délègue la nuit au calculateur. +- Modify: `tests/Service/WorkHours/YearlyHoursDayRowsTest.php` — ajoute l'argument constructeur. +- Create: `src/Dto/WorkHours/NightContingentRow.php` — DTO d'une ligne employé (12 mois). +- Create: `src/Service/WorkHours/NightContingentExportBuilder.php` — agrégation mensuelle. +- Create: `tests/Service/WorkHours/NightContingentExportBuilderTest.php` +- Create: `src/State/NightHoursContingentPrintProvider.php` — provider PDF. +- Create: `src/ApiResource/NightHoursContingentPrint.php` — opération API. +- Create: `templates/night-hours-contingent/print.html.twig` — gabarit paysage. +- Modify: `frontend/pages/employees/index.vue` — option de drawer + sélecteur d'année. +- Modify: `doc/functional-rules.md`, `CLAUDE.md`, `frontend/data/documentation-content.ts` — documentation. + +--- + +## Task 1: Service partagé `NightHoursCalculator` + +**Files:** +- Create: `src/Service/WorkHours/NightHoursCalculator.php` +- Test: `tests/Service/WorkHours/NightHoursCalculatorTest.php` + +- [ ] **Step 1: Write the failing test** + +```php +nightIntervalMinutes(null, null)); + self::assertSame(0, $calc->nightIntervalMinutes('08:00', null)); + } + + public function testPureDayRangeHasNoNight(): void + { + $calc = new NightHoursCalculator(); + // 08:00 → 17:00 : entièrement hors fenêtres nuit (00:00-06:00, 21:00-24:00). + self::assertSame(0, $calc->nightIntervalMinutes('08:00', '17:00')); + } + + public function testEveningWindowCounts(): void + { + $calc = new NightHoursCalculator(); + // 21:00 → 24:00 = 180 min de nuit. + self::assertSame(180, $calc->nightIntervalMinutes('21:00', '00:00')); + } + + public function testShiftCrossingMidnightCountsBothWindows(): void + { + $calc = new NightHoursCalculator(); + // 21:00 → 05:00 : 21-24 (180) + 00-05 (300) = 480 min. + self::assertSame(480, $calc->nightIntervalMinutes('21:00', '05:00')); + } + + public function testNightMinutesForWorkHourDriverUsesManualField(): void + { + $calc = new NightHoursCalculator(); + $wh = new WorkHour(); + $wh->setWorkDate(new DateTimeImmutable('2026-01-15')) + ->setDayHoursMinutes(300) + ->setNightHoursMinutes(250) + ->setMorningFrom('08:00')->setMorningTo('12:00'); + + // Driver → champ manuel nightHoursMinutes, plages ignorées. + self::assertSame(250, $calc->nightMinutesForWorkHour($wh, true)); + } + + public function testNightMinutesForWorkHourNonDriverSumsRanges(): void + { + $calc = new NightHoursCalculator(); + $wh = new WorkHour(); + $wh->setWorkDate(new DateTimeImmutable('2026-01-15')) + ->setMorningFrom('22:00')->setMorningTo('00:00') // 120 min nuit + ->setEveningFrom('04:00')->setEveningTo('06:00'); // 120 min nuit + + self::assertSame(240, $calc->nightMinutesForWorkHour($wh, false)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `make test FILES=tests/Service/WorkHours/NightHoursCalculatorTest.php` +Expected: FAIL — class `App\Service\WorkHours\NightHoursCalculator` not found. + +- [ ] **Step 3: Write minimal implementation** + +```php +getNightHoursMinutes() ?? 0; + } + + return $this->nightMinutesFromRanges($workHour); + } + + public function nightMinutesFromRanges(WorkHour $workHour): int + { + $ranges = [ + [$workHour->getMorningFrom(), $workHour->getMorningTo()], + [$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()], + [$workHour->getEveningFrom(), $workHour->getEveningTo()], + ]; + + $total = 0; + foreach ($ranges as [$from, $to]) { + $total += $this->nightIntervalMinutes($from, $to); + } + + return $total; + } + + public 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; + } + + /** + * @return null|array{int, int} + */ + private function resolveInterval(?string $from, ?string $to): ?array + { + $fromMinutes = $this->toMinutes($from); + $toMinutes = $this->toMinutes($to); + if (null === $fromMinutes || null === $toMinutes) { + return null; + } + + $end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes; + + return [$fromMinutes, $end]; + } + + private function toMinutes(?string $time): ?int + { + if (null === $time || '' === $time) { + return null; + } + + [$hours, $minutes] = array_map('intval', explode(':', $time)); + + return ($hours * 60) + $minutes; + } + + 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); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `make test FILES=tests/Service/WorkHours/NightHoursCalculatorTest.php` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/Service/WorkHours/NightHoursCalculator.php tests/Service/WorkHours/NightHoursCalculatorTest.php +git commit -m "feat(night-contingent) : service partagé NightHoursCalculator" +``` + +--- + +## Task 2: Déléguer le calcul de nuit dans les 2 providers existants + +**Files:** +- Modify: `src/Service/WorkHours/YearlyHoursExportBuilder.php` +- Modify: `src/State/WorkHourWeeklySummaryProvider.php` +- Modify: `tests/Service/WorkHours/YearlyHoursDayRowsTest.php:82-90` + +But: garantir une fenêtre 21h→6h identique en supprimant la duplication, sans changer aucun résultat. Les tests existants (`YearlyHoursDayRowsTest`, suite complète) sont le filet de non-régression. + +- [ ] **Step 1: Inject the calculator into `YearlyHoursExportBuilder`** + +Dans `src/Service/WorkHours/YearlyHoursExportBuilder.php`, ajouter l'import et l'argument constructeur : + +```php +// en haut, avec les autres use : +use App\Service\WorkHours\NightHoursCalculator; +``` + +```php + public function __construct( + private WorkHourReadRepositoryInterface $workHourRepository, + private AbsenceReadRepositoryInterface $absenceRepository, + private EmployeeContractResolver $contractResolver, + private AbsenceSegmentsResolver $absenceSegmentsResolver, + private WorkedHoursCreditPolicy $workedHoursCreditPolicy, + private PublicHolidayServiceInterface $publicHolidayService, + private HolidayVirtualHoursResolver $holidayVirtualHoursResolver, + private NightHoursCalculator $nightHoursCalculator, + ) {} +``` + +- [ ] **Step 2: Replace the night computation inside `computeMetrics`** + +Remplacer la méthode `computeMetrics` (lignes ~535-558) par une version qui délègue la nuit : + +```php + private function computeMetrics(WorkHour $workHour): WorkMetrics + { + $ranges = [ + [$workHour->getMorningFrom(), $workHour->getMorningTo()], + [$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()], + [$workHour->getEveningFrom(), $workHour->getEveningTo()], + ]; + + $totalMinutes = 0; + foreach ($ranges as [$from, $to]) { + $totalMinutes += $this->intervalMinutes($from, $to); + } + + $nightMinutes = $this->nightHoursCalculator->nightMinutesFromRanges($workHour); + $dayMinutes = max(0, $totalMinutes - $nightMinutes); + + return new WorkMetrics( + dayMinutes: $dayMinutes, + nightMinutes: $nightMinutes, + totalMinutes: $totalMinutes, + ); + } +``` + +Puis **supprimer** les méthodes privées désormais inutilisées dans ce fichier : `nightIntervalMinutes` (~599-618) et `overlap` (~620-626). Conserver `resolveInterval`, `toMinutes`, `intervalMinutes` (toujours utilisées par le total). + +- [ ] **Step 3: Update `YearlyHoursDayRowsTest` constructor call** + +Dans `tests/Service/WorkHours/YearlyHoursDayRowsTest.php`, ajouter l'import et l'argument : + +```php +use App\Service\WorkHours\NightHoursCalculator; +``` + +```php + $builder = new YearlyHoursExportBuilder( + $workHourRepo, + $absenceRepo, + $contractResolver, + new AbsenceSegmentsResolver(), + new WorkedHoursCreditPolicy($contractResolver, new DailyReferenceMinutesResolver()), + $holidayService, + $virtualResolver, + new NightHoursCalculator(), + ); +``` + +- [ ] **Step 4: Inject the calculator into `WorkHourWeeklySummaryProvider`** + +Dans `src/State/WorkHourWeeklySummaryProvider.php`, ajouter l'import : + +```php +use App\Service\WorkHours\NightHoursCalculator; +``` + +et l'argument constructeur (en dernière position) : + +```php + private EmployeeWeekCommentRepository $weekCommentRepository, + private NightHoursCalculator $nightHoursCalculator, + ) {} +``` + +- [ ] **Step 5: Replace the night computation inside its `computeMetrics`** + +Remplacer `computeMetrics` (lignes ~427-450) par : + +```php + private function computeMetrics(WorkHour $workHour): WorkMetrics + { + $ranges = [ + [$workHour->getMorningFrom(), $workHour->getMorningTo()], + [$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()], + [$workHour->getEveningFrom(), $workHour->getEveningTo()], + ]; + + $totalMinutes = 0; + foreach ($ranges as [$from, $to]) { + $totalMinutes += $this->intervalMinutes($from, $to); + } + + $nightMinutes = $this->nightHoursCalculator->nightMinutesFromRanges($workHour); + $dayMinutes = max(0, $totalMinutes - $nightMinutes); + + return new WorkMetrics( + dayMinutes: $dayMinutes, + nightMinutes: $nightMinutes, + totalMinutes: $totalMinutes, + ); + } +``` + +Puis **supprimer** les méthodes privées `nightIntervalMinutes` (~492-513) et `overlap` (~515+) de ce fichier. Conserver `resolveInterval`, `toMinutes`, `intervalMinutes`. + +> ⚠️ Vérifier que `overlap` n'est plus référencée ailleurs dans le fichier avant suppression : `grep -n "overlap\|nightIntervalMinutes" src/State/WorkHourWeeklySummaryProvider.php` ne doit plus rien renvoyer après l'édition. + +- [ ] **Step 6: Run the full suite (non-regression)** + +Run: `make test` +Expected: PASS — tous les tests (y compris `YearlyHoursDayRowsTest` et les tests de récap/heures) passent, prouvant que la fenêtre nuit est inchangée. + +- [ ] **Step 7: Commit** + +```bash +git add src/Service/WorkHours/YearlyHoursExportBuilder.php src/State/WorkHourWeeklySummaryProvider.php tests/Service/WorkHours/YearlyHoursDayRowsTest.php +git commit -m "refactor(night) : mutualiser le calcul de nuit via NightHoursCalculator" +``` + +--- + +## Task 3: DTO `NightContingentRow` + +**Files:** +- Create: `src/Dto/WorkHours/NightContingentRow.php` + +(Pas de test dédié : structure de données pure, couverte par le test du builder en Task 4.) + +- [ ] **Step 1: Create the DTO** + +```php + $months clé 1..12 + */ + public function __construct( + public readonly int $employeeId, + public readonly string $employeeName, + public readonly array $months, + ) {} +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/Dto/WorkHours/NightContingentRow.php +git commit -m "feat(night-contingent) : DTO NightContingentRow" +``` + +--- + +## Task 4: Builder `NightContingentExportBuilder` + +**Files:** +- Create: `src/Service/WorkHours/NightContingentExportBuilder.php` +- Test: `tests/Service/WorkHours/NightContingentExportBuilderTest.php` + +- [ ] **Step 1: Write the failing test** + +```php +makeEmployee(1, 'Dupont', 'Jean'); + + // Janvier : un jour 4h de nuit (≥240 → 1 jour) + un jour 3h59 (<240 → 0 jour). + $whFull = (new WorkHour())->setEmployee($employee) + ->setWorkDate(new DateTimeImmutable('2026-01-10')) + ->setEveningFrom('21:00')->setEveningTo('01:00'); // 240 min nuit + $whShort = (new WorkHour())->setEmployee($employee) + ->setWorkDate(new DateTimeImmutable('2026-01-11')) + ->setEveningFrom('21:00')->setEveningTo('00:59'); // 239 min nuit + + $workHourRepo = $this->createStub(WorkHourReadRepositoryInterface::class); + $workHourRepo->method('findByDateRangeAndEmployees')->willReturn([$whFull, $whShort]); + + $contractResolver = $this->createStub(EmployeeContractResolver::class); + $contractResolver->method('resolveIsDriverForEmployeeAndDate')->willReturn(false); + + $builder = new NightContingentExportBuilder( + $workHourRepo, + $contractResolver, + new NightHoursCalculator(), + ); + + $rows = $builder->buildRows([$employee], 2026); + + self::assertCount(1, $rows); + self::assertSame(479, $rows[0]->months[1]['nightMinutes']); // 240 + 239 + self::assertSame(1, $rows[0]->months[1]['nightDays']); // seul le jour ≥240 + self::assertSame(0, $rows[0]->months[2]['nightMinutes']); // février vide + self::assertSame(0, $rows[0]->months[2]['nightDays']); + } + + public function testDriverUsesManualNightMinutes(): void + { + $employee = $this->makeEmployee(2, 'Martin', 'Paul'); + + $wh = (new WorkHour())->setEmployee($employee) + ->setWorkDate(new DateTimeImmutable('2026-03-05')) + ->setNightHoursMinutes(300) + ->setMorningFrom('08:00')->setMorningTo('12:00'); // ignoré (driver) + + $workHourRepo = $this->createStub(WorkHourReadRepositoryInterface::class); + $workHourRepo->method('findByDateRangeAndEmployees')->willReturn([$wh]); + + $contractResolver = $this->createStub(EmployeeContractResolver::class); + $contractResolver->method('resolveIsDriverForEmployeeAndDate')->willReturn(true); + + $builder = new NightContingentExportBuilder( + $workHourRepo, + $contractResolver, + new NightHoursCalculator(), + ); + + $rows = $builder->buildRows([$employee], 2026); + + self::assertSame(300, $rows[0]->months[3]['nightMinutes']); + self::assertSame(1, $rows[0]->months[3]['nightDays']); // 300 ≥ 240 + } + + private function makeEmployee(int $id, string $last, string $first): Employee + { + $employee = new Employee(); + $employee->setLastName($last)->setFirstName($first); + $ref = new ReflectionProperty(Employee::class, 'id'); + $ref->setValue($employee, $id); + + return $employee; + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `make test FILES=tests/Service/WorkHours/NightContingentExportBuilderTest.php` +Expected: FAIL — class `NightContingentExportBuilder` not found. + +- [ ] **Step 3: Write minimal implementation** + +```php + $employees + * + * @return list + */ + public function buildRows(array $employees, int $year): array + { + $from = new DateTimeImmutable(sprintf('%d-01-01', $year)); + $to = new DateTimeImmutable(sprintf('%d-12-31', $year)); + + $workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, $employees); + + $byEmployee = []; + foreach ($workHours as $wh) { + $employeeId = $wh->getEmployee()?->getId(); + if (null === $employeeId) { + continue; + } + $byEmployee[$employeeId][] = $wh; + } + + $rows = []; + foreach ($employees as $employee) { + $employeeId = $employee->getId(); + if (null === $employeeId) { + continue; + } + + $months = []; + for ($m = 1; $m <= 12; ++$m) { + $months[$m] = ['nightMinutes' => 0, 'nightDays' => 0]; + } + + foreach ($byEmployee[$employeeId] ?? [] as $wh) { + $date = DateTimeImmutable::createFromInterface($wh->getWorkDate()); + $isDriver = $this->contractResolver->resolveIsDriverForEmployeeAndDate($employee, $date); + $nightMin = $this->nightHoursCalculator->nightMinutesForWorkHour($wh, $isDriver); + if ($nightMin <= 0) { + continue; + } + + $month = (int) $date->format('n'); + $months[$month]['nightMinutes'] += $nightMin; + if ($nightMin >= self::NIGHT_DAY_THRESHOLD_MINUTES) { + ++$months[$month]['nightDays']; + } + } + + $rows[] = new NightContingentRow( + employeeId: $employeeId, + employeeName: trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? '')), + months: $months, + ); + } + + return $rows; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `make test FILES=tests/Service/WorkHours/NightContingentExportBuilderTest.php` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/Service/WorkHours/NightContingentExportBuilder.php tests/Service/WorkHours/NightContingentExportBuilderTest.php +git commit -m "feat(night-contingent) : builder agrégation mensuelle des heures de nuit" +``` + +--- + +## Task 5: Provider PDF `NightHoursContingentPrintProvider` + +**Files:** +- Create: `src/State/NightHoursContingentPrintProvider.php` + +(Le rendu PDF est validé manuellement en Task 9 ; pas de test unitaire sur le provider HTTP, cohérent avec `WorkHourDayExportProvider`.) + +- [ ] **Step 1: Create the provider** + +```php +security->getUser(); + if (!$user instanceof User) { + throw new AccessDeniedHttpException('Authentication required.'); + } + + $request = $this->requestStack->getCurrentRequest(); + if (!$request) { + return new Response('Missing request.', Response::HTTP_BAD_REQUEST); + } + + $year = (int) $request->query->get('year', (string) (int) new DateTimeImmutable('now')->format('Y')); + if ($year < 2000 || $year > 2100) { + throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.'); + } + + $from = new DateTimeImmutable(sprintf('%d-01-01', $year)); + $to = new DateTimeImmutable(sprintf('%d-12-31', $year)); + + // Périmètre selon le profil : admin → tous, chef de site → ses sites. + $employees = $this->employeeRepository->findScoped($user); + + // Regroupement par site (ordre displayOrder), employés avec contrat sur l'année. + $bySite = []; + $siteMeta = []; + foreach ($employees as $employee) { + if (!$this->hasContractInRange($employee, $from, $to)) { + continue; + } + $site = $employee->getSite(); + if (null === $site) { + continue; + } + $siteId = $site->getId(); + $bySite[$siteId][] = $employee; + $siteMeta[$siteId] ??= [ + 'name' => $site->getName(), + 'order' => $site->getDisplayOrder(), + 'color' => $site->getColor(), + ]; + } + + uasort($siteMeta, static function (array $a, array $b): int { + return [$a['order'], $a['name']] <=> [$b['order'], $b['name']]; + }); + + $groups = []; + foreach ($siteMeta as $siteId => $meta) { + $siteEmployees = $bySite[$siteId]; + // Même tri que le calendrier : displayOrder, puis nom, puis prénom. + usort($siteEmployees, static function (Employee $a, Employee $b): int { + return [$a->getDisplayOrder(), $a->getLastName(), $a->getFirstName()] + <=> [$b->getDisplayOrder(), $b->getLastName(), $b->getFirstName()]; + }); + + $rows = $this->exportBuilder->buildRows($siteEmployees, $year); + if ([] === $rows) { + continue; + } + + $renderRows = []; + foreach ($rows as $row) { + $cells = []; + for ($m = 1; $m <= 12; ++$m) { + $cells[] = [ + 'hours' => $this->formatMinutes($row->months[$m]['nightMinutes']), + 'days' => $row->months[$m]['nightDays'], + ]; + } + $renderRows[] = [ + 'employeeName' => $row->employeeName, + 'cells' => $cells, + ]; + } + + $groups[] = ['siteName' => $meta['name'], 'siteColor' => $meta['color'], 'rows' => $renderRows]; + } + + $options = new Options(); + $options->set('isRemoteEnabled', true); + $dompdf = new Dompdf($options); + + $html = $this->twig->render('night-hours-contingent/print.html.twig', [ + 'groups' => $groups, + 'year' => $year, + 'exportedAt' => new DateTimeImmutable('now')->format('d/m/Y H:i'), + ]); + + $dompdf->loadHtml($html); + $dompdf->setPaper('A4', 'landscape'); + $dompdf->render(); + + $filename = sprintf('contingent_heures_nuit_%d.pdf', $year); + + return new Response($dompdf->output(), Response::HTTP_OK, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => sprintf('attachment; filename="%s"', $filename), + ]); + } + + 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; + } + + private function formatMinutes(int $minutes): string + { + $h = intdiv($minutes, 60); + $m = $minutes % 60; + + return sprintf('%dh%02d', $h, $m); + } +} +``` + +- [ ] **Step 2: Sanity check (lint/cache)** + +Run: `make test` (la suite charge le conteneur ; aucune nouvelle assertion mais le code doit se charger sans erreur de syntaxe). +Expected: PASS — pas d'erreur de compilation/autowire. + +- [ ] **Step 3: Commit** + +```bash +git add src/State/NightHoursContingentPrintProvider.php +git commit -m "feat(night-contingent) : provider PDF contingent heures de nuit" +``` + +--- + +## Task 6: Opération API `NightHoursContingentPrint` + +**Files:** +- Create: `src/ApiResource/NightHoursContingentPrint.php` + +- [ ] **Step 1: Create the API resource** + +```php + + + + + + + +

Contingent heures de nuit — {{ year }}

+
Édité le {{ exportedAt }}
+ + {% set months = ['Janv', 'Févr', 'Mars', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sept', 'Oct', 'Nov', 'Déc'] %} + + + + + + {% for m in months %} + + {% endfor %} + + + {% for m in months %} + + + {% endfor %} + + + + {% for group in groups %} + + + + {% for row in group.rows %} + + + {% for cell in row.cells %} + + + {% endfor %} + + {% endfor %} + {% endfor %} + +
Nom{{ m }}
H.nuitN.jours
{{ group.siteName }}
{{ row.employeeName }}{{ cell.hours }}{{ cell.days }}
+ + +``` + +- [ ] **Step 2: Commit** + +```bash +git add templates/night-hours-contingent/print.html.twig +git commit -m "feat(night-contingent) : gabarit PDF paysage" +``` + +--- + +## Task 8: Frontend — option de drawer + sélecteur d'année + +**Files:** +- Modify: `frontend/pages/employees/index.vue` + +- [ ] **Step 1: Add the option, the year selector, validation, and handler** + +Dans `frontend/pages/employees/index.vue` : + +1. Élargir le type de `exportChoice` (ligne 267) et le cast dans `onExportChoiceChange` (ligne 308) pour inclure `'night-contingent'` : + +```ts +const exportChoice = ref<'leave-recap' | 'salary-recap' | 'yearly-hours' | 'night-contingent' | ''>('') +``` + +```ts +const onExportChoiceChange = (value: string | number | null) => { + exportChoice.value = (value === null ? '' : String(value)) as 'leave-recap' | 'salary-recap' | 'yearly-hours' | 'night-contingent' | '' +} +``` + +2. Ajouter l'option dans `exportTypeOptions` (ligne 272-276) : + +```ts +const exportTypeOptions = [ + { label: 'Récap. congés', value: 'leave-recap' }, + { label: 'Récap. salaire', value: 'salary-recap' }, + { label: 'Heures annuelles', value: 'yearly-hours' }, + { label: 'Contingent H.nuit', value: 'night-contingent' } +] +``` + +3. Ajouter le bloc de sélection d'année dans le template, juste après le `` du bloc `yearly-hours` (après la ligne 231) : + +```vue +
+ +
+``` + +4. Étendre `isExportValid` (ligne 296-305) : + +```ts +const isExportValid = computed(() => { + if (!exportChoice.value) return false + if (exportChoice.value === 'salary-recap') { + return exportSalaryMonth.value.trim() !== '' + } + if (exportChoice.value === 'yearly-hours') { + return exportYear.value > 0 && exportMonth.value !== '' + } + if (exportChoice.value === 'night-contingent') { + return exportYear.value > 0 + } + return true +}) +``` + +5. Étendre `handleExportValidate` (autour de la ligne 612-622) en ajoutant une branche : + +```ts + } else if (choice === 'night-contingent') { + await printPdf(`/night-hours-contingent/print?year=${exportYear.value}`) + } +``` + +- [ ] **Step 2: Type-check the frontend** + +Run: `cd frontend && npx vue-tsc --noEmit` (ou `npm run typecheck` si défini) +Expected: pas d'erreur de type sur `index.vue`. + +> NB : ne PAS lancer `npm run build` (préférence utilisateur). + +- [ ] **Step 3: Commit** + +```bash +git add frontend/pages/employees/index.vue +git commit -m "feat(night-contingent) : option export Contingent H.nuit (liste employés)" +``` + +--- + +## Task 9: Documentation (obligatoire — même intervention) + +**Files:** +- Modify: `doc/functional-rules.md` +- Modify: `CLAUDE.md` +- Modify: `frontend/data/documentation-content.ts` + +- [ ] **Step 1: Add a section to `doc/functional-rules.md`** + +Ajouter (près des autres exports) une section : + +```markdown +## Export Contingent heures de nuit + +- Accès : drawer « Export » de la liste employés, type « Contingent H.nuit ». + Endpoint `GET /night-hours-contingent/print?year=YYYY`, `ROLE_USER`. +- Périmètre : `EmployeeRepository::findScoped($user)` (admin → tous, chef de + site → ses sites). Employés ayant ≥ 1 contrat sur l'année civile uniquement. +- PDF A4 **paysage** : lignes = employés (groupés par site, triés displayOrder + puis nom/prénom), colonnes = 12 mois (Janv→Déc), chaque mois avec 2 sous- + colonnes « H.nuit » et « N.jours ». +- Heures de nuit : minutes travaillées dans la fenêtre **21h→6h** + (`NightHoursCalculator`, identique au reste de l'app). Conducteurs : champ + manuel `WorkHour.nightHoursMinutes`. +- « N.jours » : un jour compte 1 dès que ses minutes de nuit ≥ 240 (4h). +- Aucun crédit absence/férié : seules les heures réellement travaillées comptent. +- Services : `App\State\NightHoursContingentPrintProvider` + + `App\Service\WorkHours\NightContingentExportBuilder`. +``` + +- [ ] **Step 2: Add an entry to `CLAUDE.md`** + +Ajouter sous une rubrique export (par ex. juste après la règle « Export heures vue Jour ») : + +```markdown +- **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`. +``` + +- [ ] **Step 3: Add a user-facing article to `frontend/data/documentation-content.ts`** + +Repérer la section décrivant les exports de la liste employés (où figurent « Récap. congés », « Heures annuelles ») et ajouter un article/bloc de niveau `site_manager` : + +```ts +{ type: 'note', content: 'Export « Contingent H.nuit » : depuis la liste des employés, bouton Export → « Contingent H.nuit » + année. Génère un PDF A4 paysage avec une ligne par employé (groupés par site) et une colonne par mois, chacune avec le total d\'heures de nuit (travail entre 21h et 6h) et le nombre de nuits (jours où au moins 4h ont été travaillées de nuit). Les conducteurs utilisent leurs heures de nuit saisies.' }, +``` + +> Respecter la structure existante (`DocBlock` dans un `DocArticle`). Placer le bloc dans l'article des exports, niveau d'accès `site_manager` (visible chefs de site + admin). + +- [ ] **Step 4: Commit** + +```bash +git add doc/functional-rules.md CLAUDE.md frontend/data/documentation-content.ts +git commit -m "docs(night-contingent) : documentation export contingent heures de nuit" +``` + +--- + +## Task 10: Vérification finale + +- [ ] **Step 1: Run the full backend suite** + +Run: `make test` +Expected: PASS — toute la suite, incluant `NightHoursCalculatorTest` et `NightContingentExportBuilderTest`. + +- [ ] **Step 2: Manual smoke test (PDF)** + +- `make start` si nécessaire. +- Se connecter en admin, aller sur la liste des employés, bouton **Export** → **Contingent H.nuit** → choisir une année avec des heures saisies → **Valider**. +- Vérifier le PDF : paysage, groupé par site, 12 mois × (H.nuit / N.jours), totaux cohérents avec quelques jours connus (un jour 21h-01h = 4h → +1 N.jours). +- Vérifier en chef de site : seuls ses sites apparaissent. + +- [ ] **Step 3: Final commit (if any doc tweak)** + +```bash +git status # doit être propre ; sinon committer les ajustements +``` + +--- + +## Self-Review + +**Spec coverage :** +- Drawer « Contingent H.nuit » sur la liste employés → Task 8. ✅ +- PDF A4 paysage → Task 5 (`setPaper('A4','landscape')`) + Task 7. ✅ +- Lignes = employés, colonnes = mois, 2 sous-colonnes H.nuit / N.jours → Task 7. ✅ +- Heures de nuit 21h→6h (réutilise l'existant) → Task 1 + Task 2. ✅ +- ≥4h = 1 jour → Task 4 (`NIGHT_DAY_THRESHOLD_MINUTES = 240`). ✅ +- Conducteurs via nightHoursMinutes → Task 1/4. ✅ +- Statut driver résolu par date → Task 4 (`resolveIsDriverForEmployeeAndDate` par WorkHour). ✅ +- Groupé par site, trié ordre BDD → Task 5 (`displayOrder`/nom/prénom, sites par `displayOrder`). ✅ +- Pas de total annuel → aucune colonne total dans Task 7. ✅ +- Service partagé → Task 1 + Task 2. ✅ +- Docs → Task 9. ✅ + +**Type consistency :** `NightContingentRow.months` est `array` keyé 1..12, produit en Task 4 et lu en Task 5/test Task 4. `nightMinutesForWorkHour(WorkHour,bool)` cohérent entre Task 1, 2 et 4. Constructeurs mis à jour partout où ils changent (Task 2 met à jour `YearlyHoursDayRowsTest`). ✅ + +**Placeholders :** aucun — chaque étape contient le code complet. ✅ diff --git a/docs/superpowers/specs/2026-06-11-night-hours-contingent-export-design.md b/docs/superpowers/specs/2026-06-11-night-hours-contingent-export-design.md new file mode 100644 index 0000000..ff3d8f1 --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-night-hours-contingent-export-design.md @@ -0,0 +1,132 @@ +# Export « Contingent H.nuit » + +Date : 2026-06-11 +Statut : validé (design) + +## Objectif + +Ajouter un nouvel export PDF sur la **liste des employés** : un tableau du +contingent d'heures de nuit, employés en lignes, mois en colonnes. Chaque mois +porte deux sous-colonnes : **Total H.nuit** (heures travaillées de nuit) et +**Total N.jours** (nombre de nuits où ≥ 4h ont été travaillées de nuit). + +## Décisions cadrées + +- **Période** : année civile Janvier → Décembre, choisie via un sélecteur d'année + dans le drawer (réutilise `exportYearOptions`). +- **Fenêtre de nuit** : 21h → 6h — on **réutilise le calcul existant** de l'app + (constante `[0,360]` + `[1260,1440]` minutes, projection J+1 pour les shifts + traversant minuit). NB : la demande initiale mentionnait 21h-5h ; arbitré sur + 21h→6h pour rester cohérent avec le reste de l'application. +- **Règle « 1 jour »** : un jour compte 1 dans « N.jours » dès que les minutes de + nuit du jour ≥ 240 (4h). +- **Conducteurs** : inclus. Leurs minutes de nuit = champ manuel + `WorkHour.nightHoursMinutes` (pas de fenêtre horaire — total saisi). La règle + ≥ 240 min = 1 jour s'applique aussi. +- **Non-conducteurs** : minutes de nuit calculées depuis les plages + matin/après-midi/soir via la fenêtre 21h→6h. +- **Pas de crédit** absence/férié : on ne compte que les heures de nuit + réellement travaillées (pas de crédit virtuel férié ni crédit absence). +- **Pas de colonne « Total annuel »** (hors périmètre pour l'instant). +- **Mise en page** : A4 **paysage**. +- **Groupement / tri** : par site, identique au day-export et au calendrier — + sites triés par `displayOrder` puis nom ; employés triés par `displayOrder`, + puis nom, puis prénom. + +## Architecture + +### Frontend + +`frontend/pages/employees/index.vue` (drawer Export existant) : +- Ajouter une option `{ label: 'Contingent H.nuit', value: 'night-contingent' }` + dans `exportTypeOptions`. +- Quand `exportChoice === 'night-contingent'` : afficher le sélecteur d'année + (réutiliser le `MalioSelect` année déjà utilisé par `yearly-hours`). +- `isExportValid` : valide si une année est choisie. +- `handleExportValidate` : `await printPdf('/night-hours-contingent/print?year=' + exportYear)`. + +Aucun sélecteur de site (cohérent avec les autres exports de ce drawer — le +périmètre vient du back via `findScoped`). + +### Backend + +**Endpoint** : `GET /night-hours-contingent/print?year=YYYY` +- Operation API Platform custom (Provider, output PDF `Response`), `ROLE_USER`. +- `year` : entier validé 2000-2100, défaut = année courante. + +**Provider** `App\State\NightHoursContingentPrintProvider` +1. Auth + parse `year`. +2. `employees = employeeRepository->findScoped($user)` (périmètre admin / chef de site). +3. Garder les employés ayant ≥ 1 période de contrat intersectant + `[YYYY-01-01 ; YYYY-12-31]` (helper `hasContractInRange`, même esprit que + `AbsencePrintProvider`/`SalaryRecapPrintProvider`). +4. Grouper par site ; trier sites par `displayOrder` puis nom ; trier employés + intra-site par `displayOrder`, nom, prénom. +5. Construire les lignes via `NightContingentExportBuilder`. +6. Rendre `templates/night-hours-contingent/print.html.twig` → Dompdf (paysage). + +**Builder** `App\Service\WorkHours\NightContingentExportBuilder` +- `buildRows(list $employees, int $year): list` +- Pour chaque employé : charge ses `WorkHour` de l'année (1 requête par lot ou + par employé selon le repo existant), répartit par mois (1..12). +- Par jour : `nightMinutes = NightHoursCalculator::nightMinutesForWorkHour($wh, $isDriverThatDay)`. + - Driver ce jour-là → `nightHoursMinutes ?? 0`. + - Non-driver → somme des `nightIntervalMinutes` sur les 3 plages. +- Agrégats par mois : `nightMinutesTotal += nightMinutes` ; + `if (nightMinutes >= 240) nightDays += 1`. +- Retour DTO : `{ employeeId, employeeName, isDriver, months: [{nightMinutes, nightDays} × 12] }`. + +> Le statut driver est résolu **par date** (un employé peut changer de nature de +> contrat dans l'année), via le mécanisme existant (`EmployeeContractResolver` / +> période de contrat couvrant la date du `WorkHour`). On suit l'approche déjà en +> place dans les providers heures pour résoudre `isDriver` à la date. + +**Service partagé** `App\Service\WorkHours\NightHoursCalculator` +- Extrait la logique 21h→6h aujourd'hui **dupliquée** dans + `WorkHourWeeklySummaryProvider::nightIntervalMinutes/computeMetrics` et + `YearlyHoursExportBuilder::nightIntervalMinutes/computeMetrics`. +- Méthodes : + - `nightIntervalMinutes(?string $from, ?string $to): int` (fenêtres + `[0,360]` + `[1260,1440]`, projection J+1). + - `nightMinutesFromRanges(WorkHour $wh): int` (somme sur les 3 plages). + - `nightMinutesForWorkHour(WorkHour $wh, bool $isDriver): int` + (driver → `nightHoursMinutes ?? 0`, sinon `nightMinutesFromRanges`). +- `WorkHourWeeklySummaryProvider` et `YearlyHoursExportBuilder` délèguent à ce + service pour la partie nuit (résultats identiques garantis ; les tests + existants couvrent la non-régression). + +### Template + +`templates/night-hours-contingent/print.html.twig` +- A4 paysage. +- En-tête : « Contingent heures de nuit — {{ year }} », date d'export. +- Colonnes : `Nom` + 12 groupes de mois, chacun deux sous-colonnes + `H.nuit` / `N.jours`. +- Lignes d'en-tête de site colorées (couleur site), comme le day-export. +- `H.nuit` formaté `12h30` (helper minutes → HH h MM), `N.jours` entier. +- Mois sans données → `0h00` / `0` (ou vide selon rendu — défaut 0). + +## Tests + +- `NightHoursCalculatorTest` : fenêtre 21h→6h, shift traversant minuit + (21:00→05:00 = 8h nuit), plage de jour pur (0 nuit), plages nulles. +- `NightContingentExportBuilderTest` : agrégation mensuelle, règle ≥4h=1 jour + (3h59 → 0 jour, 4h → 1 jour), conducteur via `nightHoursMinutes`, employé + multi-mois. +- Non-régression : `make test` (les tests existants de + `WorkHourWeeklySummaryProvider` / `YearlyHoursExportBuilder` valident le + refactor du service partagé). + +## Documentation à mettre à jour (même intervention) + +- `doc/functional-rules.md` : nouvelle section export contingent nuit. +- `CLAUDE.md` : entrée sous une rubrique export. +- `frontend/data/documentation-content.ts` : article utilisateur (niveau admin/ + chef de site). + +## Hors périmètre + +- Colonne total annuel. +- Sélecteur de site dans le drawer. +- Export depuis un autre écran que la liste employés. +- Changement de la fenêtre de nuit ailleurs dans l'app (reste 21h→6h partout). diff --git a/frontend/data/documentation-content.ts b/frontend/data/documentation-content.ts index f425efa..5bf87b2 100644 --- a/frontend/data/documentation-content.ts +++ b/frontend/data/documentation-content.ts @@ -640,6 +640,7 @@ export const documentationSections: DocSection[] = [ { 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.' }, + { type: 'note', content: 'Export « Contingent H.nuit » : depuis la liste des employés, bouton Export → « Contingent H.nuit » + année. Génère un PDF A4 paysage avec une ligne par employé (groupés par site) et une colonne par mois, chacune avec le total d\'heures de nuit (travail entre 21h et 6h) et le nombre de nuits (jours où au moins 4h ont été travaillées de nuit). Les conducteurs utilisent leurs heures de nuit saisies.' }, ], }, { diff --git a/frontend/pages/employees/index.vue b/frontend/pages/employees/index.vue index 861f546..d79eb47 100644 --- a/frontend/pages/employees/index.vue +++ b/frontend/pages/employees/index.vue @@ -230,6 +230,16 @@ /> +
+ +
+
('') +const exportChoice = ref<'leave-recap' | 'salary-recap' | 'yearly-hours' | 'night-contingent' | ''>('') const exportYear = ref(new Date().getFullYear()) const exportMonth = ref(new Date().getMonth() + 1) const exportSalaryMonth = ref(`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`) @@ -272,7 +282,8 @@ const exportSalaryMonth = ref(`${new Date().getFullYear()}-${String(new const exportTypeOptions = [ { label: 'Récap. congés', value: 'leave-recap' }, { label: 'Récap. salaire', value: 'salary-recap' }, - { label: 'Heures annuelles', value: 'yearly-hours' } + { label: 'Heures annuelles', value: 'yearly-hours' }, + { label: 'Contingent H.nuit', value: 'night-contingent' } ] const exportYearOptions = computed(() => { const current = new Date().getFullYear() @@ -301,11 +312,14 @@ const isExportValid = computed(() => { if (exportChoice.value === 'yearly-hours') { return exportYear.value > 0 && exportMonth.value !== '' } + if (exportChoice.value === 'night-contingent') { + return exportYear.value > 0 + } return true }) const onExportChoiceChange = (value: string | number | null) => { - exportChoice.value = (value === null ? '' : String(value)) as 'leave-recap' | 'salary-recap' | 'yearly-hours' | '' + exportChoice.value = (value === null ? '' : String(value)) as 'leave-recap' | 'salary-recap' | 'yearly-hours' | 'night-contingent' | '' } const { printPdf } = usePdfPrinter() const sitesInitialized = ref(false) @@ -618,6 +632,8 @@ const handleExportValidate = async () => { await printPdf(`/salary-recap/print?month=${exportSalaryMonth.value}`) } else if (choice === 'yearly-hours') { await printPdf(`/yearly-hours/print-all?year=${exportYear.value}&month=${exportMonth.value}`) + } else if (choice === 'night-contingent') { + await printPdf(`/night-hours-contingent/print?year=${exportYear.value}`) } } diff --git a/src/ApiResource/NightHoursContingentPrint.php b/src/ApiResource/NightHoursContingentPrint.php new file mode 100644 index 0000000..8bd46ca --- /dev/null +++ b/src/ApiResource/NightHoursContingentPrint.php @@ -0,0 +1,24 @@ + $months clé 1..12 + */ + public function __construct( + public readonly int $employeeId, + public readonly string $employeeName, + public readonly array $months, + ) {} +} 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/Service/WorkHours/NightContingentExportBuilder.php b/src/Service/WorkHours/NightContingentExportBuilder.php new file mode 100644 index 0000000..e5ab2af --- /dev/null +++ b/src/Service/WorkHours/NightContingentExportBuilder.php @@ -0,0 +1,95 @@ += 4h de nuit dans la journee). Fenetre 21h->6h via + * NightHoursCalculator. Conducteurs : minutes saisies (nightHoursMinutes). + * Aucun credit absence/ferie : seules les heures reellement travaillees comptent. + */ +final readonly class NightContingentExportBuilder +{ + private const int NIGHT_DAY_THRESHOLD_MINUTES = 240; + + public function __construct( + private WorkHourReadRepositoryInterface $workHourRepository, + private EmployeeContractResolver $contractResolver, + private NightHoursCalculator $nightHoursCalculator, + ) {} + + /** + * @param list $employees + * + * @return list + */ + public function buildRows(array $employees, int $year): array + { + $from = new DateTimeImmutable(sprintf('%d-01-01', $year)); + $to = new DateTimeImmutable(sprintf('%d-12-31', $year)); + + $workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, $employees); + + $byEmployee = []; + foreach ($workHours as $wh) { + $employeeId = $wh->getEmployee()?->getId(); + if (null === $employeeId) { + continue; + } + $byEmployee[$employeeId][] = $wh; + } + + $days = []; + foreach ($workHours as $wh) { + $days[$wh->getWorkDate()->format('Y-m-d')] = true; + } + $days = array_keys($days); + + $driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days); + + $rows = []; + foreach ($employees as $employee) { + $employeeId = $employee->getId(); + if (null === $employeeId) { + continue; + } + + $months = []; + for ($m = 1; $m <= 12; ++$m) { + $months[$m] = ['nightMinutes' => 0, 'nightDays' => 0]; + } + + foreach ($byEmployee[$employeeId] ?? [] as $wh) { + $date = DateTimeImmutable::createFromInterface($wh->getWorkDate()); + $ymd = $date->format('Y-m-d'); + $isDriver = $driverMap[$employeeId][$ymd] ?? false; + $nightMin = $this->nightHoursCalculator->nightMinutesForWorkHour($wh, $isDriver); + if ($nightMin <= 0) { + continue; + } + + $month = (int) $date->format('n'); + $months[$month]['nightMinutes'] += $nightMin; + if ($nightMin >= self::NIGHT_DAY_THRESHOLD_MINUTES) { + ++$months[$month]['nightDays']; + } + } + + $rows[] = new NightContingentRow( + employeeId: $employeeId, + employeeName: trim($employee->getLastName().' '.$employee->getFirstName()), + months: $months, + ); + } + + return $rows; + } +} diff --git a/src/Service/WorkHours/NightHoursCalculator.php b/src/Service/WorkHours/NightHoursCalculator.php new file mode 100644 index 0000000..fe53ef6 --- /dev/null +++ b/src/Service/WorkHours/NightHoursCalculator.php @@ -0,0 +1,102 @@ +6h). + * + * Fenetres en minutes depuis 00:00 : [0,360] (00:00-06:00) et [1260,1440] + * (21:00-24:00). On projette sur J+1 pour les shifts qui traversent minuit. + * Source de verite unique partagee par les ecrans Heures et les exports. + */ +final readonly class NightHoursCalculator +{ + /** + * Minutes de nuit d'un WorkHour. Conducteurs : champ manuel nightHoursMinutes. + * Non-conducteurs : somme calculee depuis les plages matin/apres-midi/soir. + */ + public function nightMinutesForWorkHour(WorkHour $workHour, bool $isDriver): int + { + if ($isDriver) { + return $workHour->getNightHoursMinutes() ?? 0; + } + + return $this->nightMinutesFromRanges($workHour); + } + + public function nightMinutesFromRanges(WorkHour $workHour): int + { + $ranges = [ + [$workHour->getMorningFrom(), $workHour->getMorningTo()], + [$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()], + [$workHour->getEveningFrom(), $workHour->getEveningTo()], + ]; + + $total = 0; + foreach ($ranges as [$from, $to]) { + $total += $this->nightIntervalMinutes($from, $to); + } + + return $total; + } + + public 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; + } + + /** + * @return null|array{int, int} + */ + private function resolveInterval(?string $from, ?string $to): ?array + { + $fromMinutes = $this->toMinutes($from); + $toMinutes = $this->toMinutes($to); + if (null === $fromMinutes || null === $toMinutes) { + return null; + } + + $end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes; + + return [$fromMinutes, $end]; + } + + private function toMinutes(?string $time): ?int + { + if (null === $time || '' === $time) { + return null; + } + + [$hours, $minutes] = array_map('intval', explode(':', $time)); + + return ($hours * 60) + $minutes; + } + + 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); + } +} diff --git a/src/Service/WorkHours/YearlyHoursExportBuilder.php b/src/Service/WorkHours/YearlyHoursExportBuilder.php index 2d9f3ae..c1faaa2 100644 --- a/src/Service/WorkHours/YearlyHoursExportBuilder.php +++ b/src/Service/WorkHours/YearlyHoursExportBuilder.php @@ -29,6 +29,7 @@ class YearlyHoursExportBuilder private WorkedHoursCreditPolicy $workedHoursCreditPolicy, private PublicHolidayServiceInterface $publicHolidayService, private HolidayVirtualHoursResolver $holidayVirtualHoursResolver, + private NightHoursCalculator $nightHoursCalculator, ) {} /** @@ -541,14 +542,12 @@ class YearlyHoursExportBuilder ]; $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, @@ -596,35 +595,6 @@ class YearlyHoursExportBuilder 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); - } - private function formatMinutes(int $minutes): string { if (0 === $minutes) { diff --git a/src/State/NightHoursContingentPrintProvider.php b/src/State/NightHoursContingentPrintProvider.php new file mode 100644 index 0000000..1288a15 --- /dev/null +++ b/src/State/NightHoursContingentPrintProvider.php @@ -0,0 +1,154 @@ +security->getUser(); + if (!$user instanceof User) { + throw new AccessDeniedHttpException('Authentication required.'); + } + + $request = $this->requestStack->getCurrentRequest(); + if (!$request) { + return new Response('Missing request.', Response::HTTP_BAD_REQUEST); + } + + $year = (int) $request->query->get('year', (string) (int) new DateTimeImmutable('now')->format('Y')); + if ($year < 2000 || $year > 2100) { + throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.'); + } + + $from = new DateTimeImmutable(sprintf('%d-01-01', $year)); + $to = new DateTimeImmutable(sprintf('%d-12-31', $year)); + + // Perimetre selon le profil : admin -> tous, chef de site -> ses sites. + $employees = $this->employeeRepository->findScoped($user); + + // Regroupement par site (ordre displayOrder), employes avec contrat sur l'annee. + $bySite = []; + $siteMeta = []; + foreach ($employees as $employee) { + if (!$this->hasContractInRange($employee, $from, $to)) { + continue; + } + $site = $employee->getSite(); + if (null === $site) { + continue; + } + $siteId = $site->getId(); + $bySite[$siteId][] = $employee; + $siteMeta[$siteId] ??= [ + 'name' => $site->getName(), + 'order' => $site->getDisplayOrder(), + 'color' => $site->getColor(), + ]; + } + + uasort($siteMeta, static function (array $a, array $b): int { + return [$a['order'], $a['name']] <=> [$b['order'], $b['name']]; + }); + + $groups = []; + foreach ($siteMeta as $siteId => $meta) { + $siteEmployees = $bySite[$siteId]; + // Meme tri que le calendrier : displayOrder, puis nom, puis prenom. + usort($siteEmployees, static function (Employee $a, Employee $b): int { + return [$a->getDisplayOrder(), $a->getLastName(), $a->getFirstName()] + <=> [$b->getDisplayOrder(), $b->getLastName(), $b->getFirstName()]; + }); + + $rows = $this->exportBuilder->buildRows($siteEmployees, $year); + + $renderRows = []; + foreach ($rows as $row) { + $cells = []; + for ($m = 1; $m <= 12; ++$m) { + $cells[] = [ + 'hours' => $this->formatMinutes($row->months[$m]['nightMinutes']), + 'days' => $row->months[$m]['nightDays'], + ]; + } + $renderRows[] = [ + 'employeeName' => $row->employeeName, + 'cells' => $cells, + ]; + } + + $groups[] = ['siteName' => $meta['name'], 'siteColor' => $meta['color'], 'rows' => $renderRows]; + } + + $options = new Options(); + $options->set('isRemoteEnabled', true); + $dompdf = new Dompdf($options); + + $html = $this->twig->render('night-hours-contingent/print.html.twig', [ + 'groups' => $groups, + 'year' => $year, + 'exportedAt' => new DateTimeImmutable('now')->format('d/m/Y H:i'), + ]); + + $dompdf->loadHtml($html); + $dompdf->setPaper('A4', 'landscape'); + $dompdf->render(); + + $filename = sprintf('contingent_heures_nuit_%d.pdf', $year); + + return new Response($dompdf->output(), Response::HTTP_OK, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => sprintf('attachment; filename="%s"', $filename), + ]); + } + + 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; + } + + private function formatMinutes(int $minutes): string + { + $h = intdiv($minutes, 60); + $m = $minutes % 60; + + return sprintf('%dh%02d', $h, $m); + } +} 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; diff --git a/src/State/WorkHourWeeklySummaryProvider.php b/src/State/WorkHourWeeklySummaryProvider.php index 5885c05..ec8cee7 100644 --- a/src/State/WorkHourWeeklySummaryProvider.php +++ b/src/State/WorkHourWeeklySummaryProvider.php @@ -28,6 +28,7 @@ use App\Service\PublicHolidayServiceInterface; 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; use Symfony\Bundle\SecurityBundle\Security; @@ -51,6 +52,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface private HolidayVirtualHoursResolver $holidayVirtualHoursResolver, private PublicHolidayServiceInterface $publicHolidayService, private EmployeeWeekCommentRepository $weekCommentRepository, + private NightHoursCalculator $nightHoursCalculator, ) {} public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary @@ -433,14 +435,12 @@ final readonly class WorkHourWeeklySummaryProvider 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 new WorkMetrics( dayMinutes: $dayMinutes, @@ -489,37 +489,6 @@ final readonly class WorkHourWeeklySummaryProvider 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; - // Fenêtres de nuit: 00:00-06:00 et 21:00-24:00. - $windows = [[0, 360], [1260, 1440]]; - $total = 0; - - // On projette aussi sur J+1 pour couvrir les shifts qui traversent minuit. - 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 array $contractsByDate */ diff --git a/templates/night-hours-contingent/print.html.twig b/templates/night-hours-contingent/print.html.twig new file mode 100644 index 0000000..0bd98a6 --- /dev/null +++ b/templates/night-hours-contingent/print.html.twig @@ -0,0 +1,60 @@ + + + + + + + +

Contingent heures de nuit — {{ year }}

+
Édité le {{ exportedAt }}
+ + {% set months = ['Janv', 'Févr', 'Mars', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sept', 'Oct', 'Nov', 'Déc'] %} + + + + + + {% for m in months %} + + {% endfor %} + + + {% for m in months %} + + + {% endfor %} + + + + {% for group in groups %} + + + + {% for row in group.rows %} + + + {% for cell in row.cells %} + + + {% endfor %} + + {% endfor %} + {% endfor %} + +
Nom{{ m }}
H.nuitN.jours
{{ group.siteName }}
{{ row.employeeName }}{{ cell.hours }}{{ cell.days }}
+ + diff --git a/tests/Service/WorkHours/NightContingentExportBuilderTest.php b/tests/Service/WorkHours/NightContingentExportBuilderTest.php new file mode 100644 index 0000000..b29887d --- /dev/null +++ b/tests/Service/WorkHours/NightContingentExportBuilderTest.php @@ -0,0 +1,123 @@ +makeEmployee(1, 'Dupont', 'Jean'); + + // Janvier : un jour 4h de nuit (>=240 -> 1 jour) + un jour 3h59 (<240 -> 0 jour). + $whFull = new WorkHour()->setEmployee($employee) + ->setWorkDate(new DateTimeImmutable('2026-01-10')) + ->setEveningFrom('21:00')->setEveningTo('01:00') // 240 min nuit + ; + $whShort = new WorkHour()->setEmployee($employee) + ->setWorkDate(new DateTimeImmutable('2026-01-11')) + ->setEveningFrom('21:00')->setEveningTo('00:59') // 239 min nuit + ; + + $workHourRepo = $this->createStub(WorkHourReadRepositoryInterface::class); + $workHourRepo->method('findByDateRangeAndEmployees')->willReturn([$whFull, $whShort]); + + $contractResolver = $this->createStub(EmployeeContractResolver::class); + $contractResolver->method('resolveIsDriverForEmployeesAndDays')->willReturn([ + 1 => ['2026-01-10' => false, '2026-01-11' => false], + ]); + + $builder = new NightContingentExportBuilder( + $workHourRepo, + $contractResolver, + new NightHoursCalculator(), + ); + + $rows = $builder->buildRows([$employee], 2026); + + self::assertCount(1, $rows); + self::assertSame(479, $rows[0]->months[1]['nightMinutes']); // 240 + 239 + self::assertSame(1, $rows[0]->months[1]['nightDays']); // seul le jour >=240 + self::assertSame(0, $rows[0]->months[2]['nightMinutes']); // fevrier vide + self::assertSame(0, $rows[0]->months[2]['nightDays']); + } + + public function testDriverUsesManualNightMinutes(): void + { + $employee = $this->makeEmployee(2, 'Martin', 'Paul'); + + $wh = new WorkHour()->setEmployee($employee) + ->setWorkDate(new DateTimeImmutable('2026-03-05')) + ->setNightHoursMinutes(300) + ->setMorningFrom('08:00')->setMorningTo('12:00') // ignore (driver) + ; + + $workHourRepo = $this->createStub(WorkHourReadRepositoryInterface::class); + $workHourRepo->method('findByDateRangeAndEmployees')->willReturn([$wh]); + + $contractResolver = $this->createStub(EmployeeContractResolver::class); + $contractResolver->method('resolveIsDriverForEmployeesAndDays')->willReturn([ + 2 => ['2026-03-05' => true], + ]); + + $builder = new NightContingentExportBuilder( + $workHourRepo, + $contractResolver, + new NightHoursCalculator(), + ); + + $rows = $builder->buildRows([$employee], 2026); + + self::assertSame(300, $rows[0]->months[3]['nightMinutes']); + self::assertSame(1, $rows[0]->months[3]['nightDays']); // 300 >= 240 + } + + public function testEmployeeWithoutWorkHoursYieldsAllZeroMonths(): void + { + $employee = $this->makeEmployee(3, 'Durand', 'Marie'); + + $workHourRepo = $this->createStub(WorkHourReadRepositoryInterface::class); + $workHourRepo->method('findByDateRangeAndEmployees')->willReturn([]); + + $contractResolver = $this->createStub(EmployeeContractResolver::class); + $contractResolver->method('resolveIsDriverForEmployeesAndDays')->willReturn([]); + + $builder = new NightContingentExportBuilder( + $workHourRepo, + $contractResolver, + new NightHoursCalculator(), + ); + + $rows = $builder->buildRows([$employee], 2026); + + self::assertCount(1, $rows); + for ($m = 1; $m <= 12; ++$m) { + self::assertSame(0, $rows[0]->months[$m]['nightMinutes']); + self::assertSame(0, $rows[0]->months[$m]['nightDays']); + } + } + + private function makeEmployee(int $id, string $last, string $first): Employee + { + $employee = new Employee(); + $employee->setLastName($last)->setFirstName($first); + $ref = new ReflectionProperty(Employee::class, 'id'); + $ref->setValue($employee, $id); + + return $employee; + } +} diff --git a/tests/Service/WorkHours/NightHoursCalculatorTest.php b/tests/Service/WorkHours/NightHoursCalculatorTest.php new file mode 100644 index 0000000..08d6191 --- /dev/null +++ b/tests/Service/WorkHours/NightHoursCalculatorTest.php @@ -0,0 +1,80 @@ +nightIntervalMinutes(null, null)); + self::assertSame(0, $calc->nightIntervalMinutes('08:00', null)); + self::assertSame(0, $calc->nightIntervalMinutes(null, '17:00')); + } + + public function testPureDayRangeHasNoNight(): void + { + $calc = new NightHoursCalculator(); + // 08:00 -> 17:00 : entierement hors fenetres nuit (00:00-06:00, 21:00-24:00). + self::assertSame(0, $calc->nightIntervalMinutes('08:00', '17:00')); + } + + public function testWindowBoundariesAreRightExclusive(): void + { + $calc = new NightHoursCalculator(); + // 06:00 -> 21:00 : pile entre les deux fenetres de nuit, 0 min. + self::assertSame(0, $calc->nightIntervalMinutes('06:00', '21:00')); + // 22:00 -> 06:00 : 22-24 (120) + 00-06 (360) = 480, borne 06:00 exclue. + self::assertSame(480, $calc->nightIntervalMinutes('22:00', '06:00')); + } + + public function testEveningWindowCounts(): void + { + $calc = new NightHoursCalculator(); + // 21:00 -> 24:00 = 180 min de nuit. + self::assertSame(180, $calc->nightIntervalMinutes('21:00', '00:00')); + } + + public function testShiftCrossingMidnightCountsBothWindows(): void + { + $calc = new NightHoursCalculator(); + // 21:00 -> 05:00 : 21-24 (180) + 00-05 (300) = 480 min. + self::assertSame(480, $calc->nightIntervalMinutes('21:00', '05:00')); + } + + public function testNightMinutesForWorkHourDriverUsesManualField(): void + { + $calc = new NightHoursCalculator(); + $wh = new WorkHour(); + $wh->setWorkDate(new DateTimeImmutable('2026-01-15')) + ->setDayHoursMinutes(300) + ->setNightHoursMinutes(250) + ->setMorningFrom('08:00')->setMorningTo('12:00') + ; + + // Driver -> champ manuel nightHoursMinutes, plages ignorees. + self::assertSame(250, $calc->nightMinutesForWorkHour($wh, true)); + } + + public function testNightMinutesForWorkHourNonDriverSumsRanges(): void + { + $calc = new NightHoursCalculator(); + $wh = new WorkHour(); + $wh->setWorkDate(new DateTimeImmutable('2026-01-15')) + ->setMorningFrom('22:00')->setMorningTo('00:00') // 120 min nuit + ->setEveningFrom('04:00')->setEveningTo('06:00') // 120 min nuit + ; + + self::assertSame(240, $calc->nightMinutesForWorkHour($wh, false)); + } +} diff --git a/tests/Service/WorkHours/YearlyHoursDayRowsTest.php b/tests/Service/WorkHours/YearlyHoursDayRowsTest.php index 8138be8..212526c 100644 --- a/tests/Service/WorkHours/YearlyHoursDayRowsTest.php +++ b/tests/Service/WorkHours/YearlyHoursDayRowsTest.php @@ -14,6 +14,7 @@ use App\Service\PublicHolidayServiceInterface; 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 App\Service\WorkHours\YearlyHoursExportBuilder; use DateTimeImmutable; @@ -87,6 +88,7 @@ final class YearlyHoursDayRowsTest extends TestCase new WorkedHoursCreditPolicy($contractResolver, new DailyReferenceMinutesResolver()), $holidayService, $virtualResolver, + new NightHoursCalculator(), ); $rows = $builder->buildDayRowsForEmployees([$withContract, $noContract], $date); diff --git a/tests/State/WorkHourWeeklySummaryProviderTest.php b/tests/State/WorkHourWeeklySummaryProviderTest.php index 63ba1de..e5281c2 100644 --- a/tests/State/WorkHourWeeklySummaryProviderTest.php +++ b/tests/State/WorkHourWeeklySummaryProviderTest.php @@ -21,6 +21,7 @@ use App\Service\PublicHolidayServiceInterface; 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 App\State\WorkHourWeeklySummaryProvider; use DateTime; @@ -69,6 +70,7 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase $this->buildHolidayResolver(), $this->buildHolidayService(), $this->buildWeekCommentRepoStub(), + new NightHoursCalculator(), ); $this->expectException(AccessDeniedHttpException::class); @@ -133,6 +135,7 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase $this->buildHolidayResolver(), $this->buildHolidayService(), $this->buildWeekCommentRepoStub(), + new NightHoursCalculator(), ); $result = $provider->provide(new Get());