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:
@@ -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; // <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 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.
|
||||
Reference in New Issue
Block a user