Files
SIRH/docs/superpowers/specs/2026-06-11-overtime-paid-contingent-design.md
T
2026-06-11 16:49:02 +02:00

7.3 KiB
Raw Blame History

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.

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 :

// 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 OvertimeContingentPrintGET /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.twigA4 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.