## 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: #28 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
6.4 KiB
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
displayOrderpuis nom ; employés triés pardisplayOrder, 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' }dansexportTypeOptions. - Quand
exportChoice === 'night-contingent': afficher le sélecteur d'année (réutiliser leMalioSelectannée déjà utilisé paryearly-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
- Auth + parse
year. employees = employeeRepository->findScoped($user)(périmètre admin / chef de site).- Garder les employés ayant ≥ 1 période de contrat intersectant
[YYYY-01-01 ; YYYY-12-31](helperhasContractInRange, même esprit queAbsencePrintProvider/SalaryRecapPrintProvider). - Grouper par site ; trier sites par
displayOrderpuis nom ; trier employés intra-site pardisplayOrder, nom, prénom. - Construire les lignes via
NightContingentExportBuilder. - Rendre
templates/night-hours-contingent/print.html.twig→ Dompdf (paysage).
Builder App\Service\WorkHours\NightContingentExportBuilder
buildRows(list<Employee> $employees, int $year): list<NightContingentRow>- Pour chaque employé : charge ses
WorkHourde 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
nightIntervalMinutessur les 3 plages.
- Driver ce jour-là →
- 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 duWorkHour). On suit l'approche déjà en place dans les providers heures pour résoudreisDriverà la date.
Service partagé App\Service\WorkHours\NightHoursCalculator
- Extrait la logique 21h→6h aujourd'hui dupliquée dans
WorkHourWeeklySummaryProvider::nightIntervalMinutes/computeMetricsetYearlyHoursExportBuilder::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, sinonnightMinutesFromRanges).
WorkHourWeeklySummaryProvideretYearlyHoursExportBuilderdé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-colonnesH.nuit/N.jours. - Lignes d'en-tête de site colorées (couleur site), comme le day-export.
H.nuitformaté12h30(helper minutes → HH h MM),N.joursentier.- 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 vianightHoursMinutes, employé multi-mois.- Non-régression :
make test(les tests existants deWorkHourWeeklySummaryProvider/YearlyHoursExportBuildervalident 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).