Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7.3 KiB
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 :
- Fiche employé — un encart dans le header affichant
Contingent {année} : X h / plafond h. - É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 queRttTab.vue:392:annéeCivile = month >= 6 ? exerciseYear - 1 : exerciseYearDonc 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).
- exercice
-
Plafond :
isDriverdu 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.
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 :
- Récupérer les paiements des exercices
civilYearetcivilYear+1(fetch groupé). - Pour chaque paiement, calculer son année civile via la formule ci-dessus ; ne garder que
ceux dont l'année civile ==
civilYear. - Bucketiser par
month, sommerbase25Minutes + 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 :
// 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érationGET /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 depages/employees/[id].vue), au format :Contingent 2026 : 142 h / 220 hPasse en rouge (
text-m-danger/ classe danger) sipaidMinutes > 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).siteIdshors périmètre ignoré. - Exclut les FORFAIT (contrat courant) en plus du filtre
hasContractInRangesur l'année. - Groupe par site (
displayOrder), tri intra-sitedisplayOrder → nom → prénom(identique au calendrier / aux autres exports).
- Périmètre via
- Builder
OvertimeContingentExportBuilder::buildRows($employees, $year):- utilise
OvertimePaidContingentCalculator(fetch groupé viafindByEmployeesAndYears), - retourne par employé :
months[1..12](minutes base payées),totalMinutes,capHours.
- utilise
- 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
XhYYou—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+base50hors 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:86requêtefindByYearAndMonth(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.