Files
SIRH/docs/superpowers/specs/2026-06-11-night-hours-contingent-export-design.md
T
tristan b5bd4db5f1
Auto Tag Develop / tag (push) Successful in 9s
feat(heures) : export Contingent heures de nuit (liste employés) (#28)
## 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>
2026-06-11 13:02:30 +00:00

6.4 KiB
Raw Blame History

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<Employee> $employees, int $year): list<NightContingentRow>
  • 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).