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