feat(heures) : export Contingent heures de nuit (liste employés) (#28)
Auto Tag Develop / tag (push) Successful in 9s
Auto Tag Develop / tag (push) Successful in 9s
## 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>
This commit was merged in pull request #28.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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<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).
|
||||
Reference in New Issue
Block a user