docs(overtime-contingent) : spec contingent heures supp payées

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 16:49:02 +02:00
parent ceba1121f0
commit ef15d96d2a
@@ -0,0 +1,169 @@
# Contingent d'heures supplémentaires payées — Design
Date : 2026-06-11
Statut : validé (brainstorming)
## Objectif
La RH a besoin de suivre, **par année civile (Janvier→Décembre)**, le volume d'heures
supplémentaires payées à chaque employé non-forfait (chauffeurs inclus), rapporté au
plafond réglementaire annuel (le « contingent ») :
- **350 h** pour les chauffeurs (conducteurs),
- **220 h** pour les autres non-forfait.
Deux livrables :
1. **Fiche employé** — un encart dans le header affichant `Contingent {année} : X h / plafond h`.
2. **Écran liste employés** — un export PDF supplémentaire : par employé, les heures payées
de chaque mois + une colonne finale « Total payé / Total payable », groupé par site.
## Règles métier (validées)
- **Heures payées** = `base25Minutes + base50Minutes` (en minutes), **hors majoration
(bonus)**. Cohérent avec la colonne « Heures payés » du récap salaire, déjà définie hors
bonus.
- **Période = vraie année civile (JanvDéc).** Les paiements RTT (`EmployeeRttPayment`)
sont stockés par **exercice** (`year` = année d'exercice Juin N-1 → Mai N) + `month`
(112). L'année civile d'un paiement se reconstitue avec la même formule que
`RttTab.vue:392` :
```
annéeCivile = month >= 6 ? exerciseYear - 1 : exerciseYear
```
Donc l'année civile **Y** agrège :
- exercice `Y`, mois 15 (JanvMai Y),
- exercice `Y+1`, mois 612 (JuinDéc Y).
- **Plafond** : `isDriver` du **contrat courant** → 350 h, sinon → 220 h.
- **Périmètre** : non-forfait uniquement. Les FORFAIT sont exclus (pas d'heures supp
payées ; onglet RTT déjà masqué pour eux).
## Architecture
### Cœur partagé — `App\Service\WorkHours\OvertimePaidContingentCalculator`
Source de vérité unique, consommée par l'endpoint fiche employé ET le builder PDF.
```php
final readonly class OvertimePaidContingentCalculator
{
public const int CAP_HOURS_DRIVER = 350;
public const int CAP_HOURS_DEFAULT = 220;
// Heures payées (base25+base50) ventilées par mois civil 1..12 pour l'année civile.
public function monthlyBaseMinutes(Employee $employee, int $civilYear): array; // <int,int> 1..12
// Somme des 12 mois.
public function totalBaseMinutes(Employee $employee, int $civilYear): int;
// 350 si conducteur (contrat courant isDriver), sinon 220.
public function capHours(Employee $employee): int;
}
```
Calcul de `monthlyBaseMinutes` :
1. Récupérer les paiements des exercices `civilYear` et `civilYear+1` (fetch groupé).
2. Pour chaque paiement, calculer son année civile via la formule ci-dessus ; ne garder que
ceux dont l'année civile == `civilYear`.
3. Bucketiser par `month`, sommer `base25Minutes + base50Minutes`.
Statut conducteur : résolu via le contrat courant de l'employé (cohérent avec le choix
« contrat courant » pour le plafond). Réutiliser le mécanisme existant
(`employee.currentContract` / `EmployeeContractResolver`).
### Repository
Ajout à `EmployeeRttPaymentRepository` :
```php
// Fetch groupé pour le PDF (évite N+1 sur N employés).
public function findByEmployeesAndYears(array $employees, array $years): array;
```
Le calculator pour un seul employé peut réutiliser `findByEmployeeAndYear()` (existant) deux
fois (exercices `civilYear` et `civilYear+1`).
## Partie A — Encart fiche employé (header)
### Backend
- ApiResource `EmployeeOvertimeContingentOutput` + opération
`GET /employees/{id}/overtime-contingent?year=YYYY` (`ROLE_ADMIN`).
- Défaut `year` = année civile courante. Validation 20002100.
- Provider : retourne `{ year, paidMinutes, capHours, isDriver }`.
### Frontend
- Service + composable : fetch sur la fiche employé **uniquement pour les non-forfait**
(même condition que l'affichage de l'onglet RTT).
- Affichage : ligne texte dans le header, sous le libellé contrat
(`useEmployeeDetailPage` / header de `pages/employees/[id].vue`), au format :
```
Contingent 2026 : 142 h / 220 h
```
Passe en **rouge** (`text-m-danger` / classe danger) si `paidMinutes > capHours*60`.
- **Année civile courante uniquement, pas de sélecteur** dans le header. L'historique se
consulte via le PDF.
## Partie B — Export PDF (écran liste employés)
Calque exact de l'export contingent heures de nuit (`night-hours-contingent`).
### Backend
- ApiResource `OvertimeContingentPrint` → `GET /overtime-contingent/print?year=&siteIds=`
(`ROLE_USER`).
- Provider `OvertimeContingentPrintProvider` :
- Périmètre via `EmployeeRepository::findScoped($user)` (admin → tous, chef de site → ses
sites). `siteIds` hors périmètre ignoré.
- **Exclut les FORFAIT** (contrat courant) en plus du filtre `hasContractInRange` sur
l'année.
- Groupe par site (`displayOrder`), tri intra-site `displayOrder → nom → prénom`
(identique au calendrier / aux autres exports).
- Builder `OvertimeContingentExportBuilder::buildRows($employees, $year)` :
- utilise `OvertimePaidContingentCalculator` (fetch groupé via `findByEmployeesAndYears`),
- retourne par employé : `months[1..12]` (minutes base payées), `totalMinutes`, `capHours`.
- DTO `App\Dto\WorkHours\OvertimeContingentRow`.
### Template
- `templates/overtime-contingent/print.html.twig` — **A4 paysage**.
- Colonnes : Nom employé · Janv … Déc (heures payées du mois, format `XhYY` ou `` si 0) ·
**Total : `total payé h / plafond h`** (ex. `142 h / 220 h`).
- Total en gras ; cellule total en rouge si dépassement.
- En-têtes de site colorées (comme night-contingent).
### Frontend (drawer existant `pages/employees/index.vue`)
- Ajouter le choix `overtime-contingent` à `exportTypeOptions`
(libellé ex. « Contingent H.supp. »).
- Bloc de formulaire dédié : sélecteur **Année** (`exportYearOptions`) + sélecteur **Sites**
multi-sélection (tags, calqué sur le drawer d'export jour ; valeurs = sites visibles).
- `isExportValid` : `exportYear > 0` (sites optionnels — vide = tous les sites du périmètre).
- `handleExportValidate` : `printPdf('/overtime-contingent/print?year=${exportYear}${siteIdsParam}')`.
## Tests
- `OvertimePaidContingentCalculatorTest` :
- mapping année civile (paiement exercice 2027 mois 9 → compté en 2026),
- frontière mois 5/6 (mai = exercice, juin = exercice-1),
- somme `base25+base50` hors bonus,
- plafond 350 (driver) vs 220.
- `OvertimeContingentExportBuilderTest` : ventilation mensuelle + total + plafond par
employé, fetch groupé.
- Test provider : exclusion forfait, périmètre `findScoped`, tri/groupement par site.
## Documentation à mettre à jour (règle projet obligatoire)
- `doc/overtime-contingent.md` (nouveau) — règles + mapping civil/exercice.
- `CLAUDE.md` — section dédiée (cœur partagé, mapping, plafonds, périmètre).
- `frontend/data/documentation-content.ts` — section utilisateur (admin) décrivant l'encart
et l'export.
## Hors périmètre (consigné pour plus tard)
- **Bug latent du récap salaire** : `SalaryRecapPrintProvider:86` requête
`findByYearAndMonth(annéeCivile, mois)` alors que les paiements sont stockés par exercice.
Pour les mois JuinDéc, un paiement RTT est donc probablement mal rattaché sur le récap
mensuel. À corriger dans une intervention séparée.
- Plafonds 350/220 en constantes nommées dans le calculator ; passage en config/env
envisageable ultérieurement.