Compare commits

..

10 Commits

Author SHA1 Message Date
gitea-actions 4b22270c60 chore: bump version to v0.1.118
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 1m4s
2026-06-12 09:58:49 +00:00
tristan acbf1ccecb fix(rtt) : jour de solidarité sans déficit si le salarié ne travaille pas le lundi
Auto Tag Develop / tag (push) Successful in 11s
Un contrat CUSTOM < 35h qui ne travaille pas le lundi (jour de solidarité,
workDaysHours[lundi] absent → attendu = 0) ne portait à tort un déficit
forfaitaire ((0 − 0) − prorata = −prorata). Garde ajoutée : aucun déficit
quand expectedMinutes === 0. Ewa (Lun+Jeu) reste à −0h48 ; Nadia (Mar+Ven)
passe de −0h48 à 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:01:24 +02:00
gitea-actions 036399846b chore: bump version to v0.1.117
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 34s
2026-06-12 07:45:41 +00:00
tristan 0a9b26d31e feat(overtime-contingent) : heures supp structurelles (>35h) ajoutées au contingent
Auto Tag Develop / tag (push) Successful in 6s
Les heures contractuelles au-delà de 35h (ex. 39h → 17,33h décimales = 17h20/mois)
sont payées chaque mois sans transiter par les paiements RTT (référence 39h). Elles
manquaient au contingent. Ajout via StructuralOvertimeContingentCalculator :
(weeklyHours-35)×260 min/mois, généralisé aux contrats non-forfait/non-intérim >35h,
proratisé aux jours sous contrat. Branché sur l'encart fiche et l'export PDF.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 08:57:26 +02:00
gitea-actions 7dc73f37ac chore: bump version to v0.1.116
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 27s
2026-06-12 06:22:33 +00:00
tristan dc02316d8b Merge remote-tracking branch 'origin/develop' into develop
Auto Tag Develop / tag (push) Successful in 12s
2026-06-12 08:21:57 +02:00
gitea-actions e89a1fd7cf chore: bump version to v0.1.115
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 34s
2026-06-11 15:48:00 +00:00
tristan 327c10fda4 feat(overtime-contingent) : contingent d'heures supplémentaires payées (#29)
Auto Tag Develop / tag (push) Successful in 7s
## Résumé
Suivi par **année civile** (Janv–Déc) des heures supplémentaires payées des employés non-forfait (chauffeurs inclus) face au plafond légal (**350 h** chauffeurs / **220 h** autres).

- **Fiche employé** : encart header `Total H.payés {année} : X h / plafond h` (année civile courante, rouge si dépassement), via `GET /employees/{id}/overtime-contingent`.
- **Export PDF** `GET /overtime-contingent/print?year=&siteIds=` (ROLE_USER, périmètre `findScoped`) : groupé par site, colonnes Janv–Déc + colonne `Total payé / payable`. Drawer liste employés (année + sites).
- Heures payées = `base25 + base50` (hors majoration). Mapping exercice→civil : `mois ≥ 6 ? exercice−1 : exercice`.
- Cœur partagé pur `OvertimePaidContingentCalculator`.
- Ajout « Année civile » dans le titre des deux exports PDF (contingent H.supp. et heures de nuit).

## Tests
- 214 tests PHPUnit verts (calculateur : mapping civil, base-only, plafond ; builder : ventilation mensuelle, ligne à zéro).

## Hors périmètre (consigné)
- Bug latent `SalaryRecapPrintProvider` : rattachement des paiements RTT des mois Juin–Déc par année civile sur un stockage par exercice. À traiter séparément.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #29
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-11 15:47:19 +00:00
tristan 6ba70c36e9 docs(overtime-contingent) : plan d'implémentation contingent heures supp payées
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 16:55:00 +02:00
tristan ef15d96d2a docs(overtime-contingent) : spec contingent heures supp payées
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 16:49:02 +02:00
29 changed files with 2463 additions and 10 deletions
+28 -1
View File
@@ -68,7 +68,7 @@
- Contracts <= 35h: +25% from 35h to 43h, +50% beyond
- Contracts >= 39h: +25% from 39h to 43h, +50% beyond
- CUSTOM contracts (weeklyHours ≠ 35 and ≠ 39, not INTERIM/FORFAIT): reference = actual contractual hours, no 25%/50% bonuses (1h overtime = 1h recovery). **Le déficit (heures travaillées < heures contractuelles) réduit le cumul RTT 1:1** (peut devenir négatif, reporté à l'exercice suivant). Implémenté via `WeekRecoveryDetail::isFlatRecovery` / `EmployeeRttWeekSummary::isFlatRecovery` : ces semaines portent leur récup/déficit signé dans `totalMinutes` (`RttRecoveryComputationService::buildWeekRecoveryDetail`) et `EmployeeRttSummaryProvider::applyDeficitCascade` **ne draine pas** les tranches 25/50 pour elles (colonnes 25%/50% restent à 0). Le `RttClosingBalanceService::fold` reporte le déficit en N+1.
- **Jour de solidarité (Lundi de Pentecôte) — CUSTOM < 35h** : le jour est neutralisé et chargé d'un déficit forfaitaire `7/35 × weeklyHours` = **12 min par heure hebdo** (4h→48 min, 25h→5h, 28h→5h36), retranché du cumul RTT (signé, reporté N+1, ne draine pas les tranches 25/50 qui restent à 0). Net = exactement prorata quel que soit ce qui est posé ce jour-là (RTT, heures, vide) → pas de double comptage avec le RTT que la RH pose aussi sur ce jour. Hors périmètre : 35h/39h/Forfait/Intérim et CUSTOM ≥ 35h (inchangés ; la RH y pose un RTT qui draine ~7h). Date via `App\Service\Rtt\SolidarityDayResolver` (computus, indépendant d'`EXCLUDED_PUBLIC_HOLIDAYS`). Appliqué dans `RttRecoveryComputationService::{resolveSolidarityDatesInRange, computeSolidarityDeficitAdjustment}`.
- **Jour de solidarité (Lundi de Pentecôte) — CUSTOM < 35h** : le jour est neutralisé et chargé d'un déficit forfaitaire `7/35 × weeklyHours` = **12 min par heure hebdo** (4h→48 min, 25h→5h, 28h→5h36), retranché du cumul RTT (signé, reporté N+1, ne draine pas les tranches 25/50 qui restent à 0). Net = exactement prorata quel que soit ce qui est posé ce jour-là (RTT, heures, vide) → pas de double comptage avec le RTT que la RH pose aussi sur ce jour. **Garde : uniquement si le salarié travaille le lundi** (`workDaysHours[lundi] > 0`, i.e. `expectedMinutes > 0`) ; un temps partiel ne travaillant jamais le lundi (ex. Nadia, Mar+Ven) **ne porte aucun déficit** (sinon `(0 0) prorata` lui facturerait à tort le prorata). Hors périmètre : 35h/39h/Forfait/Intérim et CUSTOM ≥ 35h (inchangés ; la RH y pose un RTT qui draine ~7h). Date via `App\Service\Rtt\SolidarityDayResolver` (computus, indépendant d'`EXCLUDED_PUBLIC_HOLIDAYS`). Appliqué dans `RttRecoveryComputationService::{resolveSolidarityDatesInRange, computeSolidarityDeficitAdjustment}`.
- **Ancre de semaine (type de contrat)** : le type/nature de contrat d'une semaine RTT est résolu sur le **premier jour contracté** de la semaine, pas sur le lundi (`RttRecoveryComputationService::resolveWeekAnchorDate`). Sinon une semaine d'embauche en milieu de semaine (lundi hors contrat) serait classée CUSTOM → bonus 25%/50% désactivés à tort. Ex. CDD 39h embauché le jeudi : la semaine reste 39h, le seuil 25% est proraté aux jours contractés (`computeWeeklyOvertime25StartMinutes`), donc les heures au-delà ouvrent bien le +25%.
- **Plafond 25%/50% proraté (mi-semaine)** : le plafond séparant 25% et 50% n'est **pas** codé en dur à 43h mais vaut `seuil_départ_proraté + largeur_bande_25%` (`RttRecoveryComputationService::{resolveOvertime25BandWidthMinutes, computeOvertimeBaseMinutes}`). Largeur = 43h base (4h pour un 39h, 8h pour un 35h). Pour une semaine pleine le plafond redonne 43h (aucune régression) ; pour une embauche mi-semaine il se décale avec le départ, ouvrant la tranche 50%. Témoin Dylan (CDD 39h embauché jeudi, 22h) : 4h à 25% + 3h à 50%. **Hors périmètre** : l'écran Heures (`WorkHourWeeklySummaryProvider`) n'a pas cette proratisation (calcul dupliqué, laissé tel quel par décision métier).
- INTERIM: no overtime bonuses, no recovery time
@@ -108,6 +108,33 @@
- **Verrou** : si le report de l'exercice courant est `is_locked`, le paiement rétroactif est **refusé** (`assertReportNotLocked`) — la RH doit déverrouiller d'abord.
- Portée limitée à N-1 (chaîne de recalcul = 1 étape). Si la ligne courante n'existe pas encore, le fallback provider couvre l'affichage (cf. ci-dessus).
## Contingent heures supplémentaires payées
- Suivi par **année civile** (JanvDéc) des heures supp payées vs plafond légal (350 h
chauffeur / 220 h autres), non-forfait uniquement.
- **Heures payées** = `base25 + base50` (hors bonus) **+ heures structurelles**. **Mapping** :
paiements RTT stockés par exercice → `annéeCivile = mois ≥ 6 ? exercice 1 : exercice` ;
année civile Y = exercice Y (mois 15) + exercice Y+1 (mois 612). Cœur partagé pur
`OvertimePaidContingentCalculator`.
- **Heures structurelles** : les heures contractuelles au-delà de 35h (durée légale) sont des
heures supp payées chaque mois, hors paiements RTT (la référence d'un 39h est 39h). Ajoutées
au contingent : `(weeklyHours 35) × 52/12` h/mois = `(weeklyHours 35) × 260` min (39h →
1040 min = 17,33 h/mois). Généralisé à tout contrat non-forfait/non-intérim `weeklyHours > 35`
(custom 40h → 21,67 h/mois) ; **proratisé** aux jours sous contrat dans le mois (itère
`employee.contractPeriods`). Cœur partagé `StructuralOvertimeContingentCalculator`
(`monthlyStructuralMinutes`/`totalStructuralMinutes`), branché sur l'encart fiche
(`EmployeeOvertimeContingentProvider`) **et** l'export (`OvertimeContingentExportBuilder`).
- **Plafond** résolu sur `isDriver` du **contrat courant**.
- **Fiche employé** : encart header `Total H.payés {année} : X h / plafond h` (année civile
courante, rouge si dépassement), via `GET /employees/{id}/overtime-contingent`. Encart
volontairement indépendant de la phase sélectionnée (toujours l'année civile courante).
- **Export PDF** (`GET /overtime-contingent/print?year=&siteIds=`, `ROLE_USER`,
`findScoped`) : groupé par site (`displayOrder`), tri `displayOrder → nom → prénom`,
colonnes JanvDéc + `Total payé / payable`. Drawer liste employés : sélecteur année +
sites (vide = périmètre complet). Exclut les FORFAIT (contrat courant).
- ⚠️ Bug latent consigné : `SalaryRecapPrintProvider` rattache mal les paiements RTT des mois
JuinDéc (requête par année civile sur un stockage par exercice). Hors périmètre.
- Doc : `doc/overtime-contingent.md`.
## Vue contrat (sélecteur de phase)
- Picker `Vue contrat` en haut de la fiche employé (`pages/employees/[id].vue`). Caché si l'employé n'a qu'une phase.
- Phase = groupe d'`EmployeeContractPeriod` consécutifs partageant la signature `(contract.type, weeklyHours, isDriver)`. Résolu par `App\Service\Contracts\EmployeeContractPhaseResolver`.
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.114'
app.version: '0.1.118'
+4
View File
@@ -154,6 +154,10 @@ soit ce qui y est saisi) et applique un déficit forfaitaire `7/35 × heuresHebd
déficits/surplus de la semaine. Date calculée par computus (Pâques + 50 jours),
indépendante de la liste `EXCLUDED_PUBLIC_HOLIDAYS`.
Le déficit ne s'applique **que si le salarié travaille le lundi** (jour de solidarité
planifié au contrat, `workDaysHours[lundi] > 0`). Un temps partiel ne travaillant jamais
le lundi (ex. Mar+Ven) n'est pas concerné : aucun déficit n'est imputé.
- Nature `INTERIM`:
- pas de bonus 25%
- pas de bonus 50%
+49
View File
@@ -0,0 +1,49 @@
# Contingent d'heures supplémentaires payées
## Objectif
Suivre, par année civile (JanvDéc), les heures supplémentaires payées de chaque employé
non-forfait (chauffeurs inclus) face au plafond légal annuel.
## Règles
- **Heures payées** = `base25 + base50` (en minutes), hors majoration (bonus), **+ heures
structurelles** (voir ci-dessous).
- **Plafond** : 350 h pour les chauffeurs (contrat courant `isDriver`), 220 h sinon.
- **Périmètre** : non-forfait uniquement (FORFAIT exclus, ni RTT ni heures supp payées).
## Heures supplémentaires structurelles
Les heures contractuelles **au-delà de 35h** (durée légale) sont des heures supplémentaires
payées **chaque mois**, qui ne transitent pas par les paiements RTT (la référence d'un 39h est
39h, pas 35h) mais comptent dans le contingent légal.
- Montant mensuel plein = `(weeklyHours 35) × 52/12` h = `(weeklyHours 35) × 260` min.
Pour un 39h : `4 × 260 = 1040` min = **17,33 h/mois**.
- **Généralisé** à tout contrat non-forfait/non-intérim dont `weeklyHours > 35` (ex. custom
40h → 21,67 h/mois). Contrats ≤ 35h, FORFAIT, INTERIM → 0.
- **Proratisé** au nombre de jours réellement sous contrat dans le mois (entrée/sortie en cours
de mois). Itère les périodes de contrat (`employee.contractPeriods`), pas de requête jour/jour.
- Cœur partagé : `App\Service\WorkHours\StructuralOvertimeContingentCalculator`
(`monthlyStructuralMinutes` / `totalStructuralMinutes`). Ajouté au total des paiements RTT
côté provider (encart fiche) **et** export builder (PDF).
## Mapping exercice → année civile
Les paiements RTT (`EmployeeRttPayment`) sont stockés par **exercice** (`year` = Juin N-1 →
Mai N) + `month` (112). L'année civile d'un paiement :
annéeCivile = month >= 6 ? exerciseYear - 1 : exerciseYear
Donc l'année civile **Y** agrège : exercice `Y` (mois 15) + exercice `Y+1` (mois 612).
## Implémentation
- Cœur partagé : `App\Service\WorkHours\OvertimePaidContingentCalculator` (pur).
- Repo : `EmployeeRttPaymentRepository::findByEmployeesAndYears`.
- Fiche employé : `GET /employees/{id}/overtime-contingent?year=YYYY` → encart header
(`Total H.payés {année} : X h / plafond h`, rouge si dépassement, année civile courante).
- Export PDF : `GET /overtime-contingent/print?year=&siteIds=` (`ROLE_USER`, périmètre
`findScoped`), groupé par site (`displayOrder`), tri `displayOrder → nom → prénom`,
colonnes JanvDéc + colonne `Total payé / payable`. Builder
`OvertimeContingentExportBuilder`, template `overtime-contingent/print.html.twig`.
## Hors périmètre / connu
- Bug latent récap salaire : `SalaryRecapPrintProvider` requête `findByYearAndMonth` avec
l'année civile alors que le stockage est par exercice (mauvais rattachement des paiements
des mois JuinDéc sur le récap mensuel). À corriger séparément.
+4 -2
View File
@@ -32,8 +32,10 @@ Techniquement : `WeekRecoveryDetail::isFlatRecovery` marque ces semaines ;
Sur la semaine du Lundi de Pentecôte, un contrat CUSTOM < 35h porte un déficit
forfaitaire de `7/35 × heuresHebdo` (12 min/h hebdo, ex. 4h → 0h48) dans les colonnes
Heure / Total / Cumul (25 %/50 % restent à 0). Le montant est fixe et inconditionnel :
il ne dépend pas des heures saisies ni du RTT que la RH pose ce jour-là. Les contrats
35h/39h ne sont pas concernés ici (leur RTT posé draine le cumul normalement).
il ne dépend pas des heures saisies ni du RTT que la RH pose ce jour-là. Un salarié qui
ne travaille pas le lundi (lundi non planifié au contrat) n'est pas concerné : aucun
déficit. Les contrats 35h/39h ne sont pas concernés ici (leur RTT posé draine le cumul
normalement).
## Sélecteur d'année
File diff suppressed because it is too large Load Diff
@@ -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 (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.
```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 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 `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 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.
@@ -127,6 +127,7 @@ Puis `buildWeekRecoveryDetail(...)` est appelé tel quel : pour un CUSTOM,
| Jour de solidarité avant `rttStartDate` | Pas de déficit (semaine zéro-ée en amont). |
| Changement de contrat dans la semaine | Contrat lu **au jour de solidarité**, pas à l'ancre de semaine. |
| Salarié non contracté ce jour-là | `contractAtS = null` → pas de déficit. |
| Salarié CUSTOM < 35h ne travaillant pas le lundi (ex. Mar+Ven) | `expectedMinutes = workDaysHours[lundi] = 0` → pas de déficit (garde `0 === $expectedMinutes`). |
| CUSTOM ≥ 35h (3638h) | Hors périmètre → pas de déficit. |
| 35h/39h avec RTT posé | Inchangé (drainage ~7h via la cascade existante). |
| Autre déficit/surplus la même semaine | Le forfait s'y cumule. |
@@ -2,12 +2,14 @@ import type { Employee } from '~/services/dto/employee'
import { CONTRACT_TYPES } from '~/services/dto/contract'
import { getEmployee } from '~/services/employees'
import { useEmployeeContractPhase } from '~/composables/useEmployeeContractPhase'
import { getEmployeeOvertimeContingent, type OvertimeContingent } from '~/services/employee-overtime-contingent'
export const useEmployeeDetailPage = () => {
const route = useRoute()
const employee = ref<Employee | null>(null)
const isLoading = ref(false)
const activeTab = ref<'contract' | 'leave' | 'rtt' | 'mileage' | 'formation' | 'bonus' | 'observation'>('contract')
const overtimeContingent = ref<OvertimeContingent | null>(null)
const phase = useEmployeeContractPhase(employee)
@@ -28,6 +30,18 @@ export const useEmployeeDetailPage = () => {
return contract.name || '-'
})
const loadOvertimeContingent = async () => {
if (!employee.value || !showRttTab.value) {
overtimeContingent.value = null
return
}
try {
overtimeContingent.value = await getEmployeeOvertimeContingent(employee.value.id)
} catch {
overtimeContingent.value = null
}
}
const loadEmployee = async () => {
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
const employeeId = Number(idParam)
@@ -71,6 +85,7 @@ export const useEmployeeDetailPage = () => {
// qui proviennent du récap congés — nécessaire même quand on ouvre un autre onglet.
await leave.loadLeaveData()
}
await loadOvertimeContingent()
} finally {
isLoading.value = false
}
@@ -94,6 +109,18 @@ export const useEmployeeDetailPage = () => {
if (presence === undefined || presence === null) return ''
return ` (${formatDays(presence)} présence)`
})
const overtimeContingentLabel = computed(() => {
if (!showRttTab.value) return ''
const c = overtimeContingent.value
if (!c) return ''
const h = c.paidMinutes / 60
const hStr = Number.isInteger(h) ? String(h) : (Math.round(h * 10) / 10).toFixed(1).replace('.', ',')
return `Total H.payés ${c.year} : ${hStr} h / ${c.capHours} h`
})
const overtimeContingentExceeded = computed(() => {
const c = overtimeContingent.value
return c ? c.paidMinutes > c.capHours * 60 : false
})
const rtt = useEmployeeRtt(employee, loadEmployee, phase.selectedPhase)
const mileage = useEmployeeMileage(employee, loadEmployee)
const formation = useEmployeeFormation(employee, loadEmployee)
@@ -147,6 +174,8 @@ export const useEmployeeDetailPage = () => {
employeeContractWorkLabel,
forfaitRemainingDaysLabel,
nonForfaitPresenceLabel,
overtimeContingentLabel,
overtimeContingentExceeded,
...phase,
...contract,
...leave,
+14 -1
View File
@@ -537,7 +537,7 @@ export const documentationSections: DocSection[] = [
{ type: 'note', content: 'Au passage à l\'exercice suivant (1er juin), le « Report N-1 » du nouvel exercice reprend exactement le « Disponible » de fin d\'exercice précédent, c\'est-à-dire report précédent + acquis RTT payés. Le report déjà présent en début d\'année n\'est donc jamais perdu.' },
{ type: 'paragraph', content: 'La colonne "Cumul" affiche le solde RTT à la fin de chaque semaine : Report N-1 + somme des heures hebdomadaires jusqu\'à la semaine concernée paiements RTT des mois précédents. Un paiement enregistré sur le mois M n\'est déduit qu\'à partir des semaines du mois M+1. Permet la comparaison ligne à ligne avec un suivi RH externe (Excel).' },
{ type: 'note', content: 'Contrats CUSTOM (ex. 4h) : une semaine travaillée sous les heures contractuelles génère un déficit qui réduit le cumul RTT (1h manquante = -1h), sans tranches 25/50. Le cumul peut devenir négatif et est reporté à l\'exercice suivant.' },
{ type: 'paragraph', content: 'Jour de solidarité : pour un contrat de moins de 35h, le Lundi de Pentecôte applique un déficit fixe proportionnel (7/35 des heures hebdomadaires, soit 12 minutes par heure : 4h donne 48 min). Ce déficit réduit le cumul RTT, quel que soit ce qui est saisi ce jour-là.' },
{ type: 'paragraph', content: 'Jour de solidarité : pour un contrat de moins de 35h, le Lundi de Pentecôte applique un déficit fixe proportionnel (7/35 des heures hebdomadaires, soit 12 minutes par heure : 4h donne 48 min). Ce déficit réduit le cumul RTT, quel que soit ce qui est saisi ce jour-là. Un salarié qui ne travaille pas le lundi n\'est pas concerné : aucun déficit ne lui est imputé.' },
],
},
{
@@ -643,6 +643,19 @@ export const documentationSections: DocSection[] = [
{ type: 'note', content: 'Export « Contingent H.nuit » : depuis la liste des employés, bouton Export → « Contingent H.nuit » + année. Génère un PDF A4 paysage avec une ligne par employé (groupés par site) et une colonne par mois, chacune avec le total d\'heures de nuit (travail entre 21h et 6h) et le nombre de nuits (jours où au moins 4h ont été travaillées de nuit). Les conducteurs utilisent leurs heures de nuit saisies.' },
],
},
{
id: 'contingent-heures-supp',
title: 'Export Contingent H.supp.',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'L\'encart « Total H.payés {année} : X h / plafond h », affiché dans l\'en-tête de la fiche d\'un employé non-forfait, indique le total d\'heures supplémentaires payées sur l\'année civile en cours face au plafond légal. Il passe en rouge si ce plafond est dépassé.' },
{ type: 'list', content: 'Plafond chauffeur (contrat courant « conducteur ») : 350 h\nPlafond autres salariés non-forfait : 220 h\nSeuls les employés non-forfait disposent de cet encart (FORFAIT exclus)' },
{ type: 'paragraph', content: 'L\'export PDF « Contingent H.supp. » est accessible depuis la liste des employés, via le bouton Export → option « Contingent H.supp. ». Choisissez l\'année civile (par défaut l\'année courante) et éventuellement des sites ; sans sélection de site, tous les sites de votre périmètre sont inclus.' },
{ type: 'list', content: 'PDF A4 paysage, une ligne par employé non-forfait, groupé par site\nTri : ordre d\'affichage du site, puis nom, puis prénom\nColonnes : Janv à Déc (heures payées par mois) + colonne « Total payé / payable »\nLes employés FORFAIT n\'apparaissent pas dans cet export' },
{ type: 'note', content: 'Les heures prises en compte sont les bases payées (25 % et 50 % confondus), hors majorations. Le contingent est calculé sur l\'année civile (janvierdécembre), indépendamment de l\'exercice RTT (juinmai) : un paiement RTT saisi pour le mois de juin est rattaché à l\'année civile précédente.' },
{ type: 'note', content: 'Heures structurelles : les heures contractuelles au-delà de 35 h (ex. un contrat 39 h) sont des heures supplémentaires payées chaque mois, indépendamment des paiements RTT. Elles sont automatiquement ajoutées au contingent : (heures hebdo 35) × 52 / 12 par mois, soit 17,33 h/mois pour un 39 h (proratisé aux jours réellement sous contrat). Les contrats forfait, intérim et ≤ 35 h n\'en génèrent pas.' },
],
},
{
id: 'impression-absences',
title: 'Impression absences',
+7
View File
@@ -28,6 +28,11 @@
<div class="text-right">
<p class="font-bold text-[22px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}{{ forfaitRemainingDaysLabel }}{{ nonForfaitPresenceLabel }}</p>
<p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p>
<p
v-if="overtimeContingentLabel"
class="text-[16px] font-semibold"
:class="overtimeContingentExceeded ? 'text-red-600' : ''"
>{{ overtimeContingentLabel }}</p>
</div>
</div>
<div v-if="showPicker" class="mt-3 flex items-center gap-3">
@@ -300,6 +305,8 @@ const {
employeeContractWorkLabel,
forfaitRemainingDaysLabel,
nonForfaitPresenceLabel,
overtimeContingentLabel,
overtimeContingentExceeded,
contractForm,
createContractForm,
isContractDrawerOpen,
+28 -3
View File
@@ -240,6 +240,22 @@
/>
</div>
<div v-else-if="exportChoice === 'overtime-contingent'" class="flex flex-col gap-4">
<MalioSelect
:model-value="exportYear"
:options="exportYearOptions"
label="Année *"
min-width=""
@update:model-value="(v) => { if (v !== null) exportYear = Number(v) }"
/>
<MalioSelectCheckbox
v-model="exportSiteIds"
:options="siteOptions"
label="Sites"
min-width=""
/>
</div>
<div class="flex justify-center pt-2">
<MalioButton
label="Valider"
@@ -274,16 +290,18 @@ const isDrawerOpen = ref(false)
const isSubmitting = ref(false)
const isLoading = ref(false)
const isExportDrawerOpen = ref(false)
const exportChoice = ref<'leave-recap' | 'salary-recap' | 'yearly-hours' | 'night-contingent' | ''>('')
const exportChoice = ref<'leave-recap' | 'salary-recap' | 'yearly-hours' | 'night-contingent' | 'overtime-contingent' | ''>('')
const exportYear = ref<number>(new Date().getFullYear())
const exportMonth = ref<number | ''>(new Date().getMonth() + 1)
const exportSalaryMonth = ref<string>(`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`)
const exportSiteIds = ref<number[]>([])
const exportTypeOptions = [
{ label: 'Récap. congés', value: 'leave-recap' },
{ label: 'Récap. salaire', value: 'salary-recap' },
{ label: 'Heures annuelles', value: 'yearly-hours' },
{ label: 'Contingent H.nuit', value: 'night-contingent' }
{ label: 'Contingent H.nuit', value: 'night-contingent' },
{ label: 'Contingent H.supp.', value: 'overtime-contingent' }
]
const exportYearOptions = computed(() => {
const current = new Date().getFullYear()
@@ -315,11 +333,14 @@ const isExportValid = computed(() => {
if (exportChoice.value === 'night-contingent') {
return exportYear.value > 0
}
if (exportChoice.value === 'overtime-contingent') {
return exportYear.value > 0
}
return true
})
const onExportChoiceChange = (value: string | number | null) => {
exportChoice.value = (value === null ? '' : String(value)) as 'leave-recap' | 'salary-recap' | 'yearly-hours' | 'night-contingent' | ''
exportChoice.value = (value === null ? '' : String(value)) as 'leave-recap' | 'salary-recap' | 'yearly-hours' | 'night-contingent' | 'overtime-contingent' | ''
}
const { printPdf } = usePdfPrinter()
const sitesInitialized = ref(false)
@@ -619,6 +640,7 @@ const openExportDrawer = () => {
exportYear.value = now.getFullYear()
exportMonth.value = now.getMonth() + 1
exportSalaryMonth.value = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
exportSiteIds.value = []
isExportDrawerOpen.value = true
}
@@ -634,6 +656,9 @@ const handleExportValidate = async () => {
await printPdf(`/yearly-hours/print-all?year=${exportYear.value}&month=${exportMonth.value}`)
} else if (choice === 'night-contingent') {
await printPdf(`/night-hours-contingent/print?year=${exportYear.value}`)
} else if (choice === 'overtime-contingent') {
const siteParam = exportSiteIds.value.length > 0 ? `&siteIds=${exportSiteIds.value.join(',')}` : ''
await printPdf(`/overtime-contingent/print?year=${exportYear.value}${siteParam}`)
}
}
@@ -0,0 +1,13 @@
export interface OvertimeContingent {
year: number
paidMinutes: number
capHours: number
isDriver: boolean
}
export const getEmployeeOvertimeContingent = async (employeeId: number, year?: number) => {
const api = useApi()
const query: Record<string, number> = {}
if (year) query.year = year
return api.get<OvertimeContingent>(`/employees/${employeeId}/overtime-contingent`, query, { toast: false })
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\State\EmployeeOvertimeContingentProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/employees/{id}/overtime-contingent',
security: "is_granted('ROLE_ADMIN')",
provider: EmployeeOvertimeContingentProvider::class
),
],
paginationEnabled: false
)]
final class EmployeeOvertimeContingent
{
public int $year = 0;
public int $paidMinutes = 0;
public int $capHours = 0;
public bool $isDriver = false;
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\QueryParameter;
use App\State\OvertimeContingentPrintProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/overtime-contingent/print',
provider: OvertimeContingentPrintProvider::class,
parameters: [
new QueryParameter(key: 'year', required: true),
new QueryParameter(key: 'siteIds', required: false),
],
security: "is_granted('ROLE_USER')"
),
]
)]
final class OvertimeContingentPrint {}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Dto\WorkHours;
final class OvertimeContingentRow
{
/**
* @param array<int, int> $months clé 1..12 -> minutes base payées
*/
public function __construct(
public readonly int $employeeId,
public readonly string $employeeName,
public readonly array $months,
public readonly int $totalMinutes,
public readonly int $capHours,
) {}
}
@@ -12,7 +12,7 @@ use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<EmployeeRttPayment>
*/
final class EmployeeRttPaymentRepository extends ServiceEntityRepository
class EmployeeRttPaymentRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
@@ -60,4 +60,31 @@ final class EmployeeRttPaymentRepository extends ServiceEntityRepository
->getResult()
;
}
/**
* Paiements de plusieurs employés sur plusieurs exercices (fetch groupé,
* évite le N+1 sur l'export PDF). Jointure employé chargée.
*
* @param list<Employee> $employees
* @param list<int> $years années d'exercice
*
* @return EmployeeRttPayment[]
*/
public function findByEmployeesAndYears(array $employees, array $years): array
{
if ([] === $employees || [] === $years) {
return [];
}
return $this->createQueryBuilder('p')
->andWhere('p.employee IN (:employees)')
->andWhere('p.year IN (:years)')
->setParameter('employees', $employees)
->setParameter('years', $years)
->innerJoin('p.employee', 'e')
->addSelect('e')
->getQuery()
->getResult()
;
}
}
@@ -498,6 +498,14 @@ final readonly class RttRecoveryComputationService
return 0;
}
// Le salarié ne travaille pas le jour de solidarité (lundi non planifié au contrat,
// workDaysHours[lundi] absent → attendu = 0) : le jour ne le concerne pas, aucun
// déficit n'est imputé. Sans cette garde, (0 0) prorata facturerait à tort le prorata
// à un temps partiel qui ne travaille jamais le lundi (ex. Nadia, Mar+Ven).
if (0 === $expectedMinutes) {
return 0;
}
$prorata = (int) round($weeklyHours * 12);
return ($expectedMinutes - $workedMinutes) - $prorata;
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Service\WorkHours;
use App\Dto\WorkHours\OvertimeContingentRow;
use App\Entity\Employee;
use App\Repository\EmployeeRttPaymentRepository;
/**
* Construit, par employé, les heures supp payées (base, hors bonus) ventilées
* par mois civil pour l'année civile demandée, le total et le plafond légal.
*/
final readonly class OvertimeContingentExportBuilder
{
public function __construct(
private EmployeeRttPaymentRepository $rttPaymentRepository,
private OvertimePaidContingentCalculator $calculator,
private StructuralOvertimeContingentCalculator $structuralCalculator,
) {}
/**
* @param list<Employee> $employees
*
* @return list<OvertimeContingentRow>
*/
public function buildRows(array $employees, int $civilYear): array
{
// Année civile Y = exercice Y (mois 1-5) + exercice Y+1 (mois 6-12).
$payments = $this->rttPaymentRepository->findByEmployeesAndYears(
$employees,
[$civilYear, $civilYear + 1],
);
$byEmployee = [];
foreach ($payments as $payment) {
$employeeId = $payment->getEmployee()?->getId();
if (null === $employeeId) {
continue;
}
$byEmployee[$employeeId][] = $payment;
}
$rows = [];
foreach ($employees as $employee) {
$employeeId = $employee->getId();
if (null === $employeeId) {
continue;
}
$employeePayments = $byEmployee[$employeeId] ?? [];
$paidMonths = $this->calculator->monthlyBaseMinutes($employeePayments, $civilYear);
$structuralMonths = $this->structuralCalculator->monthlyStructuralMinutes($employee, $civilYear);
$months = [];
for ($m = 1; $m <= 12; ++$m) {
$months[$m] = $paidMonths[$m] + $structuralMonths[$m];
}
$rows[] = new OvertimeContingentRow(
employeeId: $employeeId,
employeeName: trim($employee->getLastName().' '.$employee->getFirstName()),
months: $months,
totalMinutes: array_sum($months),
capHours: $this->calculator->capHours($employee->getIsDriver()),
);
}
return $rows;
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Service\WorkHours;
use App\Entity\EmployeeRttPayment;
/**
* Convertit les paiements RTT (stockés par exercice Juin N-1 -> Mai N + mois)
* en agrégats par ANNEE CIVILE (Janv-Déc). Heures payées = base25 + base50,
* hors majoration (bonus). Plafond : 350 h chauffeur, 220 h autres.
*/
final readonly class OvertimePaidContingentCalculator
{
public const int CAP_HOURS_DRIVER = 350;
public const int CAP_HOURS_DEFAULT = 220;
/**
* @param iterable<EmployeeRttPayment> $payments paiements d'un employé
* (typiquement exercices civilYear et civilYear+1)
*
* @return array<int, int> clé 1..12 -> minutes base payées (base25+base50)
*/
public function monthlyBaseMinutes(iterable $payments, int $civilYear): array
{
$months = array_fill(1, 12, 0);
foreach ($payments as $payment) {
$month = $payment->getMonth();
$paymentCivilYear = $month >= 6 ? $payment->getYear() - 1 : $payment->getYear();
if ($paymentCivilYear !== $civilYear) {
continue;
}
$months[$month] += $payment->getBase25Minutes() + $payment->getBase50Minutes();
}
return $months;
}
/**
* @param iterable<EmployeeRttPayment> $payments
*/
public function totalBaseMinutes(iterable $payments, int $civilYear): int
{
return array_sum($this->monthlyBaseMinutes($payments, $civilYear));
}
public function capHours(bool $isDriver): int
{
return $isDriver ? self::CAP_HOURS_DRIVER : self::CAP_HOURS_DEFAULT;
}
}
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Service\WorkHours;
use App\Entity\Employee;
use App\Enum\ContractType;
use DateTimeImmutable;
/**
* Heures supplémentaires « structurelles » payées chaque mois pour les contrats
* au-dessus de 35h (hors forfait/intérim) : les (weeklyHours 35) h/semaine
* au-delà de la durée légale sont payées chaque mois, lissées sur l'année :
* (weeklyHours 35) × 52/12 h/mois = (weeklyHours 35) × 260 min/mois.
*
* Ces heures ne transitent pas par les paiements RTT (la référence d'un 39h est
* 39h, pas 35h) mais comptent dans le contingent légal d'heures supplémentaires.
* Elles sont proratisées aux jours réellement sous contrat dans chaque mois.
*/
final readonly class StructuralOvertimeContingentCalculator
{
/** 60 min × 52 semaines / 12 mois = minutes mensuelles par heure hebdo au-delà de 35h. */
private const int MINUTES_PER_WEEKLY_HOUR_PER_MONTH = 260;
/**
* @return array<int, int> clé 1..12 -> minutes structurelles payées (proratisées)
*/
public function monthlyStructuralMinutes(Employee $employee, int $civilYear): array
{
$accumulated = array_fill(1, 12, 0.0);
foreach ($employee->getContractPeriods() as $period) {
$contract = $period->getContract();
if (null === $contract) {
continue;
}
$type = $contract->getType();
if (ContractType::FORFAIT === $type || ContractType::INTERIM === $type) {
continue;
}
$weeklyHours = $contract->getWeeklyHours();
if (null === $weeklyHours || $weeklyHours <= 35) {
continue;
}
$fullMonthlyMinutes = ($weeklyHours - 35) * self::MINUTES_PER_WEEKLY_HOUR_PER_MONTH;
$periodStart = $period->getStartDate();
$periodEnd = $period->getEndDate();
for ($month = 1; $month <= 12; ++$month) {
$monthStart = new DateTimeImmutable(sprintf('%04d-%02d-01', $civilYear, $month));
$monthEnd = $monthStart->modify('last day of this month');
$daysInMonth = (int) $monthEnd->format('d');
$overlapStart = $periodStart > $monthStart ? $periodStart : $monthStart;
$overlapEnd = (null !== $periodEnd && $periodEnd < $monthEnd) ? $periodEnd : $monthEnd;
if ($overlapStart > $overlapEnd) {
continue;
}
$overlapDays = $overlapStart->diff($overlapEnd)->days + 1;
$accumulated[$month] += $fullMonthlyMinutes * $overlapDays / $daysInMonth;
}
}
$months = [];
for ($month = 1; $month <= 12; ++$month) {
$months[$month] = (int) round($accumulated[$month]);
}
return $months;
}
public function totalStructuralMinutes(Employee $employee, int $civilYear): int
{
return array_sum($this->monthlyStructuralMinutes($employee, $civilYear));
}
}
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\EmployeeOvertimeContingent;
use App\Entity\Employee;
use App\Repository\EmployeeRepository;
use App\Repository\EmployeeRttPaymentRepository;
use App\Service\WorkHours\OvertimePaidContingentCalculator;
use App\Service\WorkHours\StructuralOvertimeContingentCalculator;
use DateTimeImmutable;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
final readonly class EmployeeOvertimeContingentProvider implements ProviderInterface
{
public function __construct(
private RequestStack $requestStack,
private EmployeeRttPaymentRepository $rttPaymentRepository,
private OvertimePaidContingentCalculator $calculator,
private StructuralOvertimeContingentCalculator $structuralCalculator,
private EmployeeRepository $employeeRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmployeeOvertimeContingent
{
$employeeId = (int) ($uriVariables['id'] ?? 0);
if ($employeeId <= 0) {
throw new UnprocessableEntityHttpException('id must be a positive integer.');
}
$employee = $this->employeeRepository->find($employeeId);
if (!$employee instanceof Employee) {
throw new NotFoundHttpException('Employee not found.');
}
$request = $this->requestStack->getCurrentRequest();
$year = (int) $request?->query->get('year', (string) (int) new DateTimeImmutable('now')->format('Y'));
if ($year < 2000 || $year > 2100) {
throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.');
}
// Année civile Y = exercice Y (mois 1-5) + exercice Y+1 (mois 6-12).
$payments = array_merge(
$this->rttPaymentRepository->findByEmployeeAndYear($employee, $year),
$this->rttPaymentRepository->findByEmployeeAndYear($employee, $year + 1),
);
$output = new EmployeeOvertimeContingent();
$output->year = $year;
$output->paidMinutes = $this->calculator->totalBaseMinutes($payments, $year)
+ $this->structuralCalculator->totalStructuralMinutes($employee, $year);
$output->isDriver = $employee->getIsDriver();
$output->capHours = $this->calculator->capHours($output->isDriver);
return $output;
}
}
@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Employee;
use App\Entity\User;
use App\Enum\ContractType;
use App\Repository\EmployeeRepository;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\OvertimeContingentExportBuilder;
use DateTimeImmutable;
use Dompdf\Dompdf;
use Dompdf\Options;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Twig\Environment;
final class OvertimeContingentPrintProvider implements ProviderInterface
{
public function __construct(
private Environment $twig,
private readonly RequestStack $requestStack,
private EmployeeRepository $employeeRepository,
private OvertimeContingentExportBuilder $exportBuilder,
private EmployeeContractResolver $contractResolver,
private readonly Security $security,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
{
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('Authentication required.');
}
$request = $this->requestStack->getCurrentRequest();
if (!$request) {
return new Response('Missing request.', Response::HTTP_BAD_REQUEST);
}
$year = (int) $request->query->get('year', (string) (int) new DateTimeImmutable('now')->format('Y'));
if ($year < 2000 || $year > 2100) {
throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.');
}
$from = new DateTimeImmutable(sprintf('%d-01-01', $year));
$to = new DateTimeImmutable(sprintf('%d-12-31', $year));
// Filtre sites optionnel (vide = tout le perimetre).
$rawSiteIds = (string) $request->query->get('siteIds', '');
$siteIds = array_values(array_filter(array_map('intval', array_filter(explode(',', $rawSiteIds), 'strlen'))));
// Perimetre selon le profil : admin -> tous, chef de site -> ses sites.
$employees = $this->employeeRepository->findScoped($user);
$today = new DateTimeImmutable('today');
$bySite = [];
$siteMeta = [];
foreach ($employees as $employee) {
if (!$this->hasContractInRange($employee, $from, $to)) {
continue;
}
// Exclure les forfait (contrat courant).
$currentContract = $this->contractResolver->resolveForEmployeeAndDate($employee, $today);
if (null !== $currentContract && ContractType::FORFAIT === $currentContract->getType()) {
continue;
}
$site = $employee->getSite();
if (null === $site) {
continue;
}
$siteId = $site->getId();
if ([] !== $siteIds && !in_array($siteId, $siteIds, true)) {
continue;
}
$bySite[$siteId][] = $employee;
$siteMeta[$siteId] ??= [
'name' => $site->getName(),
'order' => $site->getDisplayOrder(),
'color' => $site->getColor(),
];
}
uasort($siteMeta, static function (array $a, array $b): int {
return [$a['order'], $a['name']] <=> [$b['order'], $b['name']];
});
$groups = [];
foreach ($siteMeta as $siteId => $meta) {
$siteEmployees = $bySite[$siteId];
// Meme tri que le calendrier : displayOrder, puis nom, puis prenom.
usort($siteEmployees, static function (Employee $a, Employee $b): int {
return [$a->getDisplayOrder(), $a->getLastName(), $a->getFirstName()]
<=> [$b->getDisplayOrder(), $b->getLastName(), $b->getFirstName()];
});
$rows = $this->exportBuilder->buildRows($siteEmployees, $year);
$renderRows = [];
foreach ($rows as $row) {
$cells = [];
for ($m = 1; $m <= 12; ++$m) {
$cells[] = $row->months[$m] > 0 ? $this->formatMinutes($row->months[$m]) : '—';
}
$renderRows[] = [
'employeeName' => $row->employeeName,
'cells' => $cells,
'totalHours' => $this->formatMinutes($row->totalMinutes),
'capHours' => $row->capHours,
'exceeded' => $row->totalMinutes > $row->capHours * 60,
];
}
$groups[] = ['siteName' => $meta['name'], 'siteColor' => $meta['color'], 'rows' => $renderRows];
}
$options = new Options();
$options->set('isRemoteEnabled', true);
$dompdf = new Dompdf($options);
$html = $this->twig->render('overtime-contingent/print.html.twig', [
'groups' => $groups,
'year' => $year,
'exportedAt' => new DateTimeImmutable('now')->format('d/m/Y H:i'),
]);
$dompdf->loadHtml($html);
$dompdf->setPaper('A4', 'landscape');
$dompdf->render();
$filename = sprintf('contingent_heures_supp_%d.pdf', $year);
return new Response($dompdf->output(), Response::HTTP_OK, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => sprintf('attachment; filename="%s"', $filename),
]);
}
private function hasContractInRange(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): bool
{
$fromDay = $from->format('Y-m-d');
$toDay = $to->format('Y-m-d');
foreach ($employee->getContractPeriods() as $period) {
$start = $period->getStartDate()->format('Y-m-d');
$end = $period->getEndDate()?->format('Y-m-d');
if ($start <= $toDay && (null === $end || $end >= $fromDay)) {
return true;
}
}
return false;
}
private function formatMinutes(int $minutes): string
{
$h = intdiv($minutes, 60);
$m = $minutes % 60;
return sprintf('%dh%02d', $h, $m);
}
}
@@ -19,7 +19,7 @@
</style>
</head>
<body>
<h1>Contingent heures de nuit — {{ year }}</h1>
<h1>Contingent heures de nuit — Année civile {{ year }}</h1>
<div class="meta">Édité le {{ exportedAt }}</div>
{% set months = ['Janv', 'Févr', 'Mars', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sept', 'Oct', 'Nov', 'Déc'] %}
@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<style>
@page { margin: 16px; }
body { font-family: DejaVu Sans, sans-serif; font-size: 10px; color: #000; }
h1 { font-size: 15px; margin: 0 0 2px; }
.meta { font-size: 9px; color: #555; margin-bottom: 8px; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #999; padding: 2px 3px; text-align: center; }
th { background: #d9d9d9; }
td.name, th.name { text-align: left; width: 150px; padding-left: 4px; padding-right: 6px; }
td.data, th.data { width: 44px; font-size: 9px; }
td.total, th.total { width: 90px; font-weight: bold; white-space: nowrap; }
td.exceeded { color: #c00; }
tr.site-title td { text-align: left; font-weight: bold; }
</style>
</head>
<body>
<h1>Contingent heures supplémentaires payées — Année civile {{ year }}</h1>
<div class="meta">Édité le {{ exportedAt }}</div>
{% set months = ['Janv', 'Févr', 'Mars', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sept', 'Oct', 'Nov', 'Déc'] %}
<table>
<thead>
<tr>
<th class="name">Nom</th>
{% for m in months %}
<th class="data">{{ m }}</th>
{% endfor %}
<th class="total">Total payé / payable</th>
</tr>
</thead>
<tbody>
{% for group in groups %}
<tr class="site-title">
<td colspan="14" style="background: {{ group.siteColor|default('#eee') }}">{{ group.siteName }}</td>
</tr>
{% for row in group.rows %}
<tr>
<td class="name">{{ row.employeeName }}</td>
{% for cell in row.cells %}
<td class="data">{{ cell }}</td>
{% endfor %}
<td class="total{{ row.exceeded ? ' exceeded' : '' }}">{{ row.totalHours }} / {{ row.capHours }} h</td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
</body>
</html>
@@ -207,6 +207,20 @@ final class RttRecoveryComputationServiceTest extends TestCase
self::assertSame(-168, $delta);
}
/**
* CUSTOM 4h NE travaillant PAS le jour de solidarité (lundi non planifié, ex. Nadia Mar+Ven) :
* workDaysHours[lundi] absent expected = 0. Le jour de solidarité ne la concerne pas delta 0,
* aucun déficit imputé. C'est la correction du bug : (0 0) 48 ne doit PAS donner 48.
*/
public function testSolidarityAdjustmentCustomNotScheduledThatDayIsZero(): void
{
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', self::customContract(4), 0, 0);
self::assertSame(0, $delta);
}
/**
* CUSTOM 28h : prorata = round(28×12) = 336 (5h36). worked 0, expected 336 delta 0.
* Le delta est nul ici par coïncidence du fallback uniforme (expected = prorata) ; avec un vrai
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\WorkHours;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use App\Entity\EmployeeRttPayment;
use App\Enum\TrackingMode;
use App\Repository\EmployeeRttPaymentRepository;
use App\Service\WorkHours\OvertimeContingentExportBuilder;
use App\Service\WorkHours\OvertimePaidContingentCalculator;
use App\Service\WorkHours\StructuralOvertimeContingentCalculator;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use ReflectionProperty;
/**
* @internal
*/
final class OvertimeContingentExportBuilderTest extends TestCase
{
public function testBuildsRowsWithMonthlyTotalsAndCap(): void
{
// isDriver est résolu via le contrat courant : on le force par une
// sous-classe anonyme pour rester en test unitaire (sans BDD).
$driverEmp = new class extends Employee {
public function getIsDriver(): bool
{
return true;
}
};
$driverEmp->setLastName('Martin')->setFirstName('Luc');
$idRef = new ReflectionProperty(Employee::class, 'id');
$idRef->setValue($driverEmp, 7);
// Paiement : exercice 2027, mois 9 -> civil 2026, mois 9 ; base 100+20.
$payment = new EmployeeRttPayment()
->setEmployee($driverEmp)
->setYear(2027)->setMonth(9)
->setBase25Minutes(100)->setBase50Minutes(20)
;
$repo = $this->createStub(EmployeeRttPaymentRepository::class);
$repo->method('findByEmployeesAndYears')->willReturn([$payment]);
$builder = new OvertimeContingentExportBuilder($repo, new OvertimePaidContingentCalculator(), new StructuralOvertimeContingentCalculator());
$rows = $builder->buildRows([$driverEmp], 2026);
self::assertCount(1, $rows);
self::assertSame(7, $rows[0]->employeeId);
self::assertSame('Martin Luc', $rows[0]->employeeName);
self::assertSame(120, $rows[0]->months[9]);
self::assertSame(0, $rows[0]->months[1]);
self::assertSame(120, $rows[0]->totalMinutes);
self::assertSame(350, $rows[0]->capHours); // chauffeur
}
public function testEmployeeWithNoPaymentsYieldsZeroRow(): void
{
$emp = new Employee();
$emp->setLastName('Durand')->setFirstName('Alice');
$idRef = new ReflectionProperty(Employee::class, 'id');
$idRef->setValue($emp, 99);
$repo = $this->createStub(EmployeeRttPaymentRepository::class);
$repo->method('findByEmployeesAndYears')->willReturn([]);
$builder = new OvertimeContingentExportBuilder($repo, new OvertimePaidContingentCalculator(), new StructuralOvertimeContingentCalculator());
$rows = $builder->buildRows([$emp], 2026);
self::assertCount(1, $rows);
self::assertSame(0, $rows[0]->totalMinutes);
self::assertSame(0, $rows[0]->months[6]);
self::assertSame(220, $rows[0]->capHours); // non-driver
}
public function testStructuralHoursOf39hAreAddedToPaidBase(): void
{
$contract = new Contract()
->setName('CDI')
->setTrackingMode(TrackingMode::TIME)
->setWeeklyHours(39)
;
$period = new EmployeeContractPeriod()
->setContract($contract)
->setStartDate(new DateTimeImmutable('2020-01-01'))
;
$emp = new Employee();
$emp->setLastName('Petit')->setFirstName('Marc');
$emp->getContractPeriods()->add($period);
$idRef = new ReflectionProperty(Employee::class, 'id');
$idRef->setValue($emp, 11);
$repo = $this->createStub(EmployeeRttPaymentRepository::class);
$repo->method('findByEmployeesAndYears')->willReturn([]);
$builder = new OvertimeContingentExportBuilder($repo, new OvertimePaidContingentCalculator(), new StructuralOvertimeContingentCalculator());
$rows = $builder->buildRows([$emp], 2026);
// Aucun paiement RTT, mais 12 × 1040 min de structurel (39h plein sur l'année).
self::assertSame(1040, $rows[0]->months[1]);
self::assertSame(12 * 1040, $rows[0]->totalMinutes);
}
}
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\WorkHours;
use App\Entity\EmployeeRttPayment;
use App\Service\WorkHours\OvertimePaidContingentCalculator;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class OvertimePaidContingentCalculatorTest extends TestCase
{
public function testMapsPaymentToCalendarYearAndSumsBaseOnly(): void
{
$calc = new OvertimePaidContingentCalculator();
// Septembre 2025 stocké en exercice 2026 (mois 9 >= 6 -> civil 2025).
// Mars 2026 stocké en exercice 2026 (mois 3 < 6 -> civil 2026).
// Septembre 2026 stocké en exercice 2027 (mois 9 >= 6 -> civil 2026).
// March 2026 payment has a large bonus (999 min) that must be excluded.
$payments = [
$this->payment(2026, 9, 120, 0), // civil 2025 -> exclu de 2026
$this->payment(2026, 3, 60, 30, 999), // civil 2026 -> mois 3, bonus ignoré
$this->payment(2027, 9, 100, 20), // civil 2026 -> mois 9
];
$months = $calc->monthlyBaseMinutes($payments, 2026);
self::assertSame(90, $months[3]); // 60 + 30 (bonus 999 excluded)
self::assertSame(120, $months[9]); // 100 + 20
self::assertSame(0, $months[1]);
self::assertSame(0, $months[8]);
self::assertSame(210, $calc->totalBaseMinutes($payments, 2026)); // bonus ignoré
}
public function testMonth5BelongsToExerciseYearAndMonth6ToPreviousCalendarYear(): void
{
$calc = new OvertimePaidContingentCalculator();
$payments = [
$this->payment(2026, 5, 50, 0), // mai -> civil 2026
$this->payment(2026, 6, 70, 0), // juin -> civil 2025
];
self::assertSame(50, $calc->totalBaseMinutes($payments, 2026));
self::assertSame(70, $calc->totalBaseMinutes($payments, 2025));
}
public function testCapHours(): void
{
$calc = new OvertimePaidContingentCalculator();
self::assertSame(350, $calc->capHours(true));
self::assertSame(220, $calc->capHours(false));
}
public function testEmptyPaymentsYieldsZeros(): void
{
$calc = new OvertimePaidContingentCalculator();
$months = $calc->monthlyBaseMinutes([], 2026);
self::assertSame(0, $months[1]);
self::assertSame(0, $months[12]);
self::assertSame(0, array_sum($months));
self::assertSame(0, $calc->totalBaseMinutes([], 2026));
}
private function payment(
int $exerciseYear,
int $month,
int $base25,
int $base50,
int $bonus25 = 0,
int $bonus50 = 0,
): EmployeeRttPayment {
return new EmployeeRttPayment()
->setYear($exerciseYear)
->setMonth($month)
->setBase25Minutes($base25)
->setBase50Minutes($base50)
->setBonus25Minutes($bonus25)
->setBonus50Minutes($bonus50)
;
}
}
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\WorkHours;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use App\Enum\TrackingMode;
use App\Service\WorkHours\StructuralOvertimeContingentCalculator;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class StructuralOvertimeContingentCalculatorTest extends TestCase
{
public function testFullYear39hCreditsConstantMonthlyBase(): void
{
$calc = new StructuralOvertimeContingentCalculator();
$employee = $this->employeeWithPeriod(39, '2020-01-01', null);
$months = $calc->monthlyStructuralMinutes($employee, 2026);
// (39 - 35) x 260 = 1040 minutes (17,33 h) chaque mois plein.
self::assertSame(1040, $months[1]);
self::assertSame(1040, $months[6]);
self::assertSame(1040, $months[12]);
self::assertSame(12 * 1040, $calc->totalStructuralMinutes($employee, 2026));
}
public function testCustomAbove35hUsesGeneralizedFormula(): void
{
$calc = new StructuralOvertimeContingentCalculator();
$employee = $this->employeeWithPeriod(40, '2020-01-01', null);
// (40 - 35) x 260 = 1300 minutes par mois.
self::assertSame(1300, $calc->monthlyStructuralMinutes($employee, 2026)[1]);
}
public function test35hAndBelowCreditNothing(): void
{
$calc = new StructuralOvertimeContingentCalculator();
self::assertSame(0, $calc->totalStructuralMinutes($this->employeeWithPeriod(35, '2020-01-01', null), 2026));
self::assertSame(0, $calc->totalStructuralMinutes($this->employeeWithPeriod(28, '2020-01-01', null), 2026));
}
public function testMidMonthEntryIsProratedByContractedDays(): void
{
$calc = new StructuralOvertimeContingentCalculator();
// Embauche le 16 janvier 2026 : 16 jours contractés sur 31.
$employee = $this->employeeWithPeriod(39, '2026-01-16', null);
$months = $calc->monthlyStructuralMinutes($employee, 2026);
self::assertSame((int) round(1040 * 16 / 31), $months[1]);
self::assertSame(1040, $months[2]);
}
public function testMonthsOutsidePeriodCreditNothing(): void
{
$calc = new StructuralOvertimeContingentCalculator();
// Contrat clos fin mars 2026.
$employee = $this->employeeWithPeriod(39, '2020-01-01', '2026-03-31');
$months = $calc->monthlyStructuralMinutes($employee, 2026);
self::assertSame(1040, $months[3]);
self::assertSame(0, $months[4]);
self::assertSame(0, $months[12]);
}
public function testForfaitPeriodCreditsNothing(): void
{
$calc = new StructuralOvertimeContingentCalculator();
$contract = new Contract()
->setName('Forfait')
->setTrackingMode(TrackingMode::PRESENCE)
->setWeeklyHours(null)
;
$period = new EmployeeContractPeriod()
->setContract($contract)
->setStartDate(new DateTimeImmutable('2020-01-01'))
;
$employee = new Employee();
$employee->getContractPeriods()->add($period);
self::assertSame(0, $calc->totalStructuralMinutes($employee, 2026));
}
public function testInterimAbove35hCreditsNothing(): void
{
$calc = new StructuralOvertimeContingentCalculator();
$contract = new Contract()
->setName('Interim')
->setTrackingMode(TrackingMode::TIME)
->setWeeklyHours(39)
;
$period = new EmployeeContractPeriod()
->setContract($contract)
->setStartDate(new DateTimeImmutable('2020-01-01'))
;
$employee = new Employee();
$employee->getContractPeriods()->add($period);
self::assertSame(0, $calc->totalStructuralMinutes($employee, 2026));
}
private function employeeWithPeriod(int $weeklyHours, string $start, ?string $end): Employee
{
$contract = new Contract()
->setName('CDI')
->setTrackingMode(TrackingMode::TIME)
->setWeeklyHours($weeklyHours)
;
$period = new EmployeeContractPeriod()
->setContract($contract)
->setStartDate(new DateTimeImmutable($start))
->setEndDate(null === $end ? null : new DateTimeImmutable($end))
;
$employee = new Employee();
$employee->getContractPeriods()->add($period);
return $employee;
}
}