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).