diff --git a/docs/superpowers/specs/2026-06-11-overtime-paid-contingent-design.md b/docs/superpowers/specs/2026-06-11-overtime-paid-contingent-design.md new file mode 100644 index 0000000..2a6769e --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-overtime-paid-contingent-design.md @@ -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 (Janv–Déc).** Les paiements RTT (`EmployeeRttPayment`) + sont stockés par **exercice** (`year` = année d'exercice Juin N-1 → Mai N) + `month` + (1–12). 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 1–5 (Janv–Mai Y), + - exercice `Y+1`, mois 6–12 (Juin–Dé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; // 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 2000–2100. +- 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 Juin–Dé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.