Compare commits

..

6 Commits

Author SHA1 Message Date
gitea-actions c298f66993 chore: bump version to v0.1.119
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 41s
2026-06-12 12:40:10 +00:00
tristan 7187989003 feat(heures) : affichage des jours travaillés (CUSTOM) sur la vue Jour
Auto Tag Develop / tag (push) Successful in 12s
Le libellé sous le nom de l'employé affiche en suffixe les jours du planning
workDaysHours au format court (ex. BUREAU — CDI — LU,JE) pour les contrats CUSTOM.
Résolu à la date filtrée et exposé via WorkHourDayContext.workDaysHours ; formaté
front par formatWorkedDaysShort. Limité aux CUSTOM (35h/39h/forfait/intérim sans
planning → rien affiché) et à l'écran Heures (pas Heures Conducteurs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:33:46 +02:00
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
23 changed files with 431 additions and 22 deletions
+14 -5
View File
@@ -33,7 +33,7 @@
- Contract nature (per period): CDI, CDD, INTERIM - Contract nature (per period): CDI, CDD, INTERIM
- **Agence d'intérim** (`InterimAgency` entity, table `interim_agencies`): optionnelle sur `EmployeeContractPeriod` quand nature = INTERIM. Pas de CRUD UI — gérée en BDD. API lecture seule `GET /interim_agencies`. Affichée "Intérim (NomAgence)" sur la liste employés et l'historique contrat. - **Agence d'intérim** (`InterimAgency` entity, table `interim_agencies`): optionnelle sur `EmployeeContractPeriod` quand nature = INTERIM. Pas de CRUD UI — gérée en BDD. API lecture seule `GET /interim_agencies`. Affichée "Intérim (NomAgence)" sur la liste employés et l'historique contrat.
- Employee contract history: `employee_contract_periods`, resolved by `EmployeeContractResolver` - Employee contract history: `employee_contract_periods`, resolved by `EmployeeContractResolver`
- **Écrans Heures / Heures Conducteurs (vue jour)** : le libellé nature (CDI/CDD/Intérim) sous le nom de l'employé est résolu **à la date filtrée** via `WorkHourDayContext.contractNature` (alimenté par `EmployeeContractResolver::resolveNatureForEmployeeAndDate`), pas via `Employee.currentContractNature` (qui est résolu à aujourd'hui). Idem pour le **mode de suivi (TIME/PRESENCE), les heures hebdo et le libellé de contrat** sur la vue Jour : résolus à la date filtrée via `WorkHourDayContext` (`trackingMode`/`weeklyHours`/`contractType`/`contractName`, peuplés depuis `EmployeeContractResolver::resolveForEmployeeAndDate`), pas via `employee.contract` (résolu à aujourd'hui). Côté front, `resolveDayContract()` (`useHoursPage.ts`) pilote l'affichage et `handleSave` (heures vs présence par date). - **Écrans Heures / Heures Conducteurs (vue jour)** : le libellé nature (CDI/CDD/Intérim) sous le nom de l'employé est résolu **à la date filtrée** via `WorkHourDayContext.contractNature` (alimenté par `EmployeeContractResolver::resolveNatureForEmployeeAndDate`), pas via `Employee.currentContractNature` (qui est résolu à aujourd'hui). Idem pour le **mode de suivi (TIME/PRESENCE), les heures hebdo et le libellé de contrat** sur la vue Jour : résolus à la date filtrée via `WorkHourDayContext` (`trackingMode`/`weeklyHours`/`contractType`/`contractName`, peuplés depuis `EmployeeContractResolver::resolveForEmployeeAndDate`), pas via `employee.contract` (résolu à aujourd'hui). Côté front, `resolveDayContract()` (`useHoursPage.ts`) pilote l'affichage et `handleSave` (heures vs présence par date). **Jours travaillés (CUSTOM)** : le libellé sous le nom affiche en suffixe les jours du planning `workDaysHours` au format court `LU,MA,ME,JE,VE` (ex. `BUREAU — CDI — LU,JE`). Exposé via `WorkHourDayContext.workDaysHours` (peuplé par `EmployeeContractResolver::resolveWorkDaysMinutesForEmployeeAndDate`, à la date filtrée), formaté front par `formatWorkedDaysShort` (`utils/contract.ts`) et accédé via `getRowWorkedDaysLabel` (`useHoursPage.ts`). Affiché **uniquement écran Heures** (`HoursDayView.vue`, mobile + desktop) ; naturellement limité aux CUSTOM (seuls eux ont `workDaysHours` → null sinon, rien affiché). Pas sur Heures Conducteurs (pas de planning workDaysHours).
- **Exports heures annuelles** (par salarié `EmployeeYearlyHoursPrintProvider` + tous `EmployeeYearlyHoursBulkPrintProvider`, via `YearlyHoursExportBuilder`) : **tous les jours sous contrat sont affichés**, même vides ou non saisis (jusqu'à aujourd'hui). Seuls les jours hors contrat sont omis (`buildSegments` : un seul filtre `!$hasData && null === $contract`). Ne pas réintroduire de saut des jours de semaine vides. Samedis/dimanches grisés (`#c0c0c0`) dans les templates `employee-yearly-hours/print*.html.twig`. NB : l'export *tous employés* sur l'année peut dépasser `memory_limit=256M` (Dompdf) — limitation pré-existante, voir avec l'infra si besoin. - **Exports heures annuelles** (par salarié `EmployeeYearlyHoursPrintProvider` + tous `EmployeeYearlyHoursBulkPrintProvider`, via `YearlyHoursExportBuilder`) : **tous les jours sous contrat sont affichés**, même vides ou non saisis (jusqu'à aujourd'hui). Seuls les jours hors contrat sont omis (`buildSegments` : un seul filtre `!$hasData && null === $contract`). Ne pas réintroduire de saut des jours de semaine vides. Samedis/dimanches grisés (`#c0c0c0`) dans les templates `employee-yearly-hours/print*.html.twig`. NB : l'export *tous employés* sur l'année peut dépasser `memory_limit=256M` (Dompdf) — limitation pré-existante, voir avec l'infra si besoin.
- **Export heures vue Jour** (`WorkHourDayExportProvider`, endpoint `GET /work-hours/day-export?workDate=&siteIds=`, `ROLE_USER`) : bouton « Exporter » à droite du titre « Heures », **visible uniquement en vue Jour** (`v-if="(isAdmin || isSiteManager) && viewMode === 'day'"`, masqué en vue Semaine et pour `ROLE_SELF`). **Accessible aux admins ET aux chefs de site** : le périmètre est résolu côté backend via `EmployeeRepository::findScoped($user)` (admin → tous les sites, chef de site → ses sites uniquement, cf. `EmployeeScopeService`), donc un `siteIds` hors périmètre est ignoré ; le drawer front ne propose que les sites visibles (`sites` dérivé des employés scopés). PDF A4 portrait d'**une seule journée**, **regroupé par site**, colonnes de la vue Jour **sans « Valider »** (colonne **Total en gras**). Mêmes employés que l'écran : non-conducteurs, sous contrat à la date, sites cochés et dans le périmètre (lignes vides incluses). **Tri intra-site identique au calendrier** : `displayOrder` (ordre manuel), puis nom, puis prénom (cf. `compareEmployeesInSite` front). Calcul des cellules mutualisé via `YearlyHoursExportBuilder::buildDayRowsForEmployees` (Jour/Nuit/Total incluent crédit absence + crédit virtuel férié). Colonne **Statut = code** du type d'absence (`AbsenceType::getCode`, ex. `AT`) sur sa couleur de fond ; férié sans absence → nom du férié sur `#b3e5fc`. Chaque row porte `statut` (code), `statutLabel` (libellé, pour la légende) et `statutColor`. **Légende** sous le tableau (carré coloré contenant le code + libellé à droite), construite côté provider à partir des codes présents (hors férié, dédupliquée par code, triée). Gabarit `templates/work-hour-day-export/print.html.twig`. - **Export heures vue Jour** (`WorkHourDayExportProvider`, endpoint `GET /work-hours/day-export?workDate=&siteIds=`, `ROLE_USER`) : bouton « Exporter » à droite du titre « Heures », **visible uniquement en vue Jour** (`v-if="(isAdmin || isSiteManager) && viewMode === 'day'"`, masqué en vue Semaine et pour `ROLE_SELF`). **Accessible aux admins ET aux chefs de site** : le périmètre est résolu côté backend via `EmployeeRepository::findScoped($user)` (admin → tous les sites, chef de site → ses sites uniquement, cf. `EmployeeScopeService`), donc un `siteIds` hors périmètre est ignoré ; le drawer front ne propose que les sites visibles (`sites` dérivé des employés scopés). PDF A4 portrait d'**une seule journée**, **regroupé par site**, colonnes de la vue Jour **sans « Valider »** (colonne **Total en gras**). Mêmes employés que l'écran : non-conducteurs, sous contrat à la date, sites cochés et dans le périmètre (lignes vides incluses). **Tri intra-site identique au calendrier** : `displayOrder` (ordre manuel), puis nom, puis prénom (cf. `compareEmployeesInSite` front). Calcul des cellules mutualisé via `YearlyHoursExportBuilder::buildDayRowsForEmployees` (Jour/Nuit/Total incluent crédit absence + crédit virtuel férié). Colonne **Statut = code** du type d'absence (`AbsenceType::getCode`, ex. `AT`) sur sa couleur de fond ; férié sans absence → nom du férié sur `#b3e5fc`. Chaque row porte `statut` (code), `statutLabel` (libellé, pour la légende) et `statutColor`. **Légende** sous le tableau (carré coloré contenant le code + libellé à droite), construite côté provider à partir des codes présents (hors férié, dédupliquée par code, triée). Gabarit `templates/work-hour-day-export/print.html.twig`.
- **Export Contingent heures de nuit** (`NightHoursContingentPrintProvider`, endpoint `GET /night-hours-contingent/print?year=YYYY`, `ROLE_USER`) : option « Contingent H.nuit » du drawer Export de la liste employés. PDF **A4 paysage**, lignes = employés **groupés par site** et triés `displayOrder`/nom/prénom (comme le day-export), colonnes = 12 mois civils, chacun avec 2 sous-colonnes **H.nuit** et **N.jours**. Heures de nuit = minutes dans la fenêtre **21h→6h** via le service partagé `App\Service\WorkHours\NightHoursCalculator` (source unique mutualisée avec `WorkHourWeeklySummaryProvider`, `YearlyHoursExportBuilder`, `RttRecoveryComputationService` et `SalaryRecapPrintProvider`). Conducteurs inclus via `WorkHour.nightHoursMinutes`. **N.jours** = nb de jours où minutes de nuit ≥ 240 (4h). **Aucun crédit** absence/férié. Agrégation : `App\Service\WorkHours\NightContingentExportBuilder`. Gabarit `templates/night-hours-contingent/print.html.twig`. - **Export Contingent heures de nuit** (`NightHoursContingentPrintProvider`, endpoint `GET /night-hours-contingent/print?year=YYYY`, `ROLE_USER`) : option « Contingent H.nuit » du drawer Export de la liste employés. PDF **A4 paysage**, lignes = employés **groupés par site** et triés `displayOrder`/nom/prénom (comme le day-export), colonnes = 12 mois civils, chacun avec 2 sous-colonnes **H.nuit** et **N.jours**. Heures de nuit = minutes dans la fenêtre **21h→6h** via le service partagé `App\Service\WorkHours\NightHoursCalculator` (source unique mutualisée avec `WorkHourWeeklySummaryProvider`, `YearlyHoursExportBuilder`, `RttRecoveryComputationService` et `SalaryRecapPrintProvider`). Conducteurs inclus via `WorkHour.nightHoursMinutes`. **N.jours** = nb de jours où minutes de nuit ≥ 240 (4h). **Aucun crédit** absence/férié. Agrégation : `App\Service\WorkHours\NightContingentExportBuilder`. Gabarit `templates/night-hours-contingent/print.html.twig`.
@@ -68,7 +68,7 @@
- Contracts <= 35h: +25% from 35h to 43h, +50% beyond - Contracts <= 35h: +25% from 35h to 43h, +50% beyond
- Contracts >= 39h: +25% from 39h 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. - 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%. - **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). - **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 - INTERIM: no overtime bonuses, no recovery time
@@ -111,9 +111,18 @@
## Contingent heures supplémentaires payées ## Contingent heures supplémentaires payées
- Suivi par **année civile** (JanvDéc) des heures supp payées vs plafond légal (350 h - 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. chauffeur / 220 h autres), non-forfait uniquement.
- **Heures payées** = `base25 + base50` (hors bonus). **Mapping** : paiements RTT stockés par - **Heures payées** = `base25 + base50` (hors bonus) **+ heures structurelles**. **Mapping** :
exercice → `annéeCivile = mois ≥ 6 ? exercice 1 : exercice` ; année civile Y = exercice Y paiements RTT stockés par exercice → `annéeCivile = mois ≥ 6 ? exercice 1 : exercice` ;
(mois 15) + exercice Y+1 (mois 612). Cœur partagé pur `OvertimePaidContingentCalculator`. 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**. - **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 - **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 courante, rouge si dépassement), via `GET /employees/{id}/overtime-contingent`. Encart
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.116' app.version: '0.1.119'
+5
View File
@@ -61,6 +61,7 @@ Documents complementaires:
- Libellé nature de contrat (CDI/CDD/Intérim) affiché sous le nom: - Libellé nature de contrat (CDI/CDD/Intérim) affiché sous le nom:
- résolu à la date filtrée (période de contrat couvrant ce jour), pas à aujourd'hui - résolu à la date filtrée (période de contrat couvrant ce jour), pas à aujourd'hui
- masqué si aucun contrat à cette date (cas rarissime en vue jour puisque l'employé est alors déjà filtré) - masqué si aucun contrat à cette date (cas rarissime en vue jour puisque l'employé est alors déjà filtré)
- **Jours travaillés (contrats CUSTOM)** : pour un contrat CUSTOM (planning `workDaysHours` renseigné), les jours effectivement travaillés sont affichés en suffixe du libellé `Site — Nature`, au format court `LU,MA,ME,JE,VE` (ex. `BUREAU — CDI — LU,JE` pour un temps partiel travaillant lundi et jeudi). Résolu à la date filtrée. Les contrats 35h/39h/Forfait/Intérim n'ont pas de planning → aucun suffixe. Écran Heures uniquement (pas Heures Conducteurs).
- **Vue Jour (Heures) — contrat à la date affichée** : le mode de suivi (saisie d'heures vs cases de présence), le libellé de contrat et la logique de sauvegarde sont résolus selon la période de contrat valable à la date filtrée (champs `trackingMode`/`weeklyHours`/`contractType`/`contractName` portés par `WorkHourDayContext`, alimentés par `EmployeeContractResolver::resolveForEmployeeAndDate`), et non selon le contrat courant de l'employé. Un salarié passé 39h/35h → Forfait conserve donc la saisie d'heures sur ses dates antérieures à la bascule, et bascule en cases de présence à partir de la date de passage en forfait. La vue Semaine était déjà résolue par date. - **Vue Jour (Heures) — contrat à la date affichée** : le mode de suivi (saisie d'heures vs cases de présence), le libellé de contrat et la logique de sauvegarde sont résolus selon la période de contrat valable à la date filtrée (champs `trackingMode`/`weeklyHours`/`contractType`/`contractName` portés par `WorkHourDayContext`, alimentés par `EmployeeContractResolver::resolveForEmployeeAndDate`), et non selon le contrat courant de l'employé. Un salarié passé 39h/35h → Forfait conserve donc la saisie d'heures sur ses dates antérieures à la bascule, et bascule en cases de présence à partir de la date de passage en forfait. La vue Semaine était déjà résolue par date.
- **Exports heures annuelles (par salarié et tous salariés)** : affichent **tous les jours sous contrat**, même vides ou non saisis, jusqu'à la date du jour ; seuls les jours hors contrat (avant embauche, après départ, suspension) sont omis. Les samedis et dimanches sont grisés (gris foncé), les jours fériés en bleu. - **Exports heures annuelles (par salarié et tous salariés)** : affichent **tous les jours sous contrat**, même vides ou non saisis, jusqu'à la date du jour ; seuls les jours hors contrat (avant embauche, après départ, suspension) sont omis. Les samedis et dimanches sont grisés (gris foncé), les jours fériés en bleu.
- **Récap salaire (export PDF mensuel)** : seuls les salariés ayant un contrat couvrant tout ou partie du mois imprimé apparaissent (filtre `hasContractInRange`). Un salarié dont le contrat est terminé avant le mois (ex. parti en février) n'est pas listé sur le récap des mois suivants. - **Récap salaire (export PDF mensuel)** : seuls les salariés ayant un contrat couvrant tout ou partie du mois imprimé apparaissent (filtre `hasContractInRange`). Un salarié dont le contrat est terminé avant le mois (ex. parti en février) n'est pas listé sur le récap des mois suivants.
@@ -154,6 +155,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), déficits/surplus de la semaine. Date calculée par computus (Pâques + 50 jours),
indépendante de la liste `EXCLUDED_PUBLIC_HOLIDAYS`. 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`: - Nature `INTERIM`:
- pas de bonus 25% - pas de bonus 25%
- pas de bonus 50% - pas de bonus 50%
+17 -1
View File
@@ -5,10 +5,26 @@ Suivre, par année civile (JanvDéc), les heures supplémentaires payées de
non-forfait (chauffeurs inclus) face au plafond légal annuel. non-forfait (chauffeurs inclus) face au plafond légal annuel.
## Règles ## Règles
- **Heures payées** = `base25 + base50` (en minutes), hors majoration (bonus). - **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. - **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). - **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 ## Mapping exercice → année civile
Les paiements RTT (`EmployeeRttPayment`) sont stockés par **exercice** (`year` = Juin N-1 → Les paiements RTT (`EmployeeRttPayment`) sont stockés par **exercice** (`year` = Juin N-1 →
Mai N) + `month` (112). L'année civile d'un paiement : Mai N) + `month` (112). L'année civile d'un paiement :
+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 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 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 : 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 il ne dépend pas des heures saisies ni du RTT que la RH pose ce jour-là. Un salarié qui
35h/39h ne sont pas concernés ici (leur RTT posé draine le cumul normalement). 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 ## Sélecteur d'année
@@ -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). | | 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. | | 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é 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. | | CUSTOM ≥ 35h (3638h) | Hors périmètre → pas de déficit. |
| 35h/39h avec RTT posé | Inchangé (drainage ~7h via la cascade existante). | | 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. | | Autre déficit/surplus la même semaine | Le forfait s'y cumule. |
+3 -2
View File
@@ -14,7 +14,7 @@
<span class="font-normal text-neutral-600 text-sm">({{ contractLabel(employee) }})</span> <span class="font-normal text-neutral-600 text-sm">({{ contractLabel(employee) }})</span>
</p> </p>
<p class="text-sm text-neutral-500 truncate"> <p class="text-sm text-neutral-500 truncate">
{{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span> {{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> — {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span><span v-if="getRowWorkedDaysLabel(employee.id)"> {{ getRowWorkedDaysLabel(employee.id) }}</span>
</p> </p>
</div> </div>
@@ -212,7 +212,7 @@
</p> </p>
<p class="text-neutral-500 truncate inline-flex items-center gap-2"> <p class="text-neutral-500 truncate inline-flex items-center gap-2">
<span> <span>
{{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span> {{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> — {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span><span v-if="getRowWorkedDaysLabel(employee.id)"> {{ getRowWorkedDaysLabel(employee.id) }}</span>
</span> </span>
<span <span
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)" v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
@@ -406,6 +406,7 @@ const props = defineProps<{
hasRowFormation: (employeeId: number) => boolean hasRowFormation: (employeeId: number) => boolean
getRowFormationLabel: (employeeId: number) => string getRowFormationLabel: (employeeId: number) => string
getRowContractNature: (employeeId: number) => 'CDI' | 'CDD' | 'INTERIM' | null getRowContractNature: (employeeId: number) => 'CDI' | 'CDD' | 'INTERIM' | null
getRowWorkedDaysLabel: (employeeId: number) => string | null
getRowUpdatedAt: (employeeId: number) => string getRowUpdatedAt: (employeeId: number) => string
getPresenceDayValue: (employeeId: number) => string getPresenceDayValue: (employeeId: number) => string
onAbsenceClick: (employeeId: number) => void onAbsenceClick: (employeeId: number) => void
+6
View File
@@ -517,6 +517,11 @@ export const useHoursPage = () => {
return dayContextByEmployeeId.value.get(employeeId)?.contractNature ?? null return dayContextByEmployeeId.value.get(employeeId)?.contractNature ?? null
} }
// Jours travaillés du planning (contrats CUSTOM uniquement), ex. "LU,VE". null sinon.
const getRowWorkedDaysLabel = (employeeId: number): string | null => {
return formatWorkedDaysShort(dayContextByEmployeeId.value.get(employeeId)?.workDaysHours)
}
const getRowUpdatedAt = (employeeId: number): string => { const getRowUpdatedAt = (employeeId: number): string => {
const raw = rows.value[employeeId]?.updatedAt const raw = rows.value[employeeId]?.updatedAt
if (!raw) return '' if (!raw) return ''
@@ -1207,6 +1212,7 @@ export const useHoursPage = () => {
hasRowFormation, hasRowFormation,
getRowFormationLabel, getRowFormationLabel,
getRowContractNature, getRowContractNature,
getRowWorkedDaysLabel,
getRowUpdatedAt, getRowUpdatedAt,
getPresenceDayValue, getPresenceDayValue,
openAbsenceDrawer, openAbsenceDrawer,
+3 -1
View File
@@ -29,6 +29,7 @@ export const documentationSections: DocSection[] = [
{ type: 'list', content: 'Boutons "Hier" / "Aujourd\'hui" / "Demain" pour naviguer rapidement\nSélecteur de date pour choisir une date spécifique\nFiltrage par site si vous avez accès à plusieurs sites' }, { type: 'list', content: 'Boutons "Hier" / "Aujourd\'hui" / "Demain" pour naviguer rapidement\nSélecteur de date pour choisir une date spécifique\nFiltrage par site si vous avez accès à plusieurs sites' },
{ type: 'paragraph', content: 'Seuls les employés ayant un contrat actif à la date sélectionnée sont affichés.' }, { type: 'paragraph', content: 'Seuls les employés ayant un contrat actif à la date sélectionnée sont affichés.' },
{ type: 'note', content: 'Sous le nom de l\'employé, la nature du contrat (CDI / CDD / Intérim) affichée correspond à la période couvrant la date filtrée, et non à aujourd\'hui.' }, { type: 'note', content: 'Sous le nom de l\'employé, la nature du contrat (CDI / CDD / Intérim) affichée correspond à la période couvrant la date filtrée, et non à aujourd\'hui.' },
{ type: 'paragraph', content: 'Pour un contrat à temps partiel avec un planning de jours travaillés (contrat « personnalisé »), les jours travaillés sont rappelés à la suite du libellé site et nature, en abrégé : par exemple « BUREAU — CDI — LU,JE » pour un salarié travaillant le lundi et le jeudi. Les contrats 35h, 39h, forfait et intérim n\'affichent pas ce rappel.' },
{ type: 'paragraph', content: 'Sur la vue Jour, l\'affichage (saisie d\'heures ou cases de présence) et le libellé de contrat correspondent au contrat de l\'employé à la date consultée. Si un salarié a changé de type de contrat (par exemple un passage en forfait), les jours antérieurs à ce changement restent affichés selon l\'ancien contrat.' }, { type: 'paragraph', content: 'Sur la vue Jour, l\'affichage (saisie d\'heures ou cases de présence) et le libellé de contrat correspondent au contrat de l\'employé à la date consultée. Si un salarié a changé de type de contrat (par exemple un passage en forfait), les jours antérieurs à ce changement restent affichés selon l\'ancien contrat.' },
], ],
}, },
@@ -537,7 +538,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: '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: '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: '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é.' },
], ],
}, },
{ {
@@ -653,6 +654,7 @@ export const documentationSections: DocSection[] = [
{ 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: '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: '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: '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.' },
], ],
}, },
{ {
+2
View File
@@ -87,6 +87,7 @@
:has-row-formation="hasRowFormation" :has-row-formation="hasRowFormation"
:get-row-formation-label="getRowFormationLabel" :get-row-formation-label="getRowFormationLabel"
:get-row-contract-nature="getRowContractNature" :get-row-contract-nature="getRowContractNature"
:get-row-worked-days-label="getRowWorkedDaysLabel"
:get-row-updated-at="getRowUpdatedAt" :get-row-updated-at="getRowUpdatedAt"
:get-presence-day-value="getPresenceDayValue" :get-presence-day-value="getPresenceDayValue"
:on-absence-click="openAbsenceDrawer" :on-absence-click="openAbsenceDrawer"
@@ -215,6 +216,7 @@ const {
hasRowFormation, hasRowFormation,
getRowFormationLabel, getRowFormationLabel,
getRowContractNature, getRowContractNature,
getRowWorkedDaysLabel,
getRowUpdatedAt, getRowUpdatedAt,
getPresenceDayValue, getPresenceDayValue,
openAbsenceDrawer, openAbsenceDrawer,
+1
View File
@@ -119,6 +119,7 @@ export type WorkHourDayContextRow = {
weeklyHours?: number | null weeklyHours?: number | null
contractType?: ContractType | null contractType?: ContractType | null
contractName?: string | null contractName?: string | null
workDaysHours?: Record<number, number> | null
} }
export type WorkHourDayContext = { export type WorkHourDayContext = {
+20
View File
@@ -37,6 +37,26 @@ export const requiresWorkDaysHours = (
const DAY_SHORT_LABELS: Record<number, string> = { 1: 'Lun', 2: 'Mar', 3: 'Mer', 4: 'Jeu', 5: 'Ven' } const DAY_SHORT_LABELS: Record<number, string> = { 1: 'Lun', 2: 'Mar', 3: 'Mer', 4: 'Jeu', 5: 'Ven' }
const DAY_TINY_LABELS: Record<number, string> = { 1: 'LU', 2: 'MA', 3: 'ME', 4: 'JE', 5: 'VE' }
/**
* Very compact worked-days summary for the day view header, e.g. "LU,VE".
* Lists the iso days actually worked (minutes > 0), uppercase 2-letter, comma-separated.
* Returns null when the schedule is empty/unset (non-CUSTOM contracts have no schedule).
*/
export const formatWorkedDaysShort = (
workDaysHours: Record<number, number> | null | undefined
): string | null => {
if (!workDaysHours) return null
const days = Object.entries(workDaysHours)
.map(([iso, minutes]) => [Number(iso), Number(minutes)] as const)
.filter(([iso, minutes]) => iso >= 1 && iso <= 5 && minutes > 0)
.sort(([a], [b]) => a - b)
.map(([iso]) => DAY_TINY_LABELS[iso])
if (days.length === 0) return null
return days.join(',')
}
/** /**
* Compact human-readable summary of a per-day schedule, e.g. "Lun 2h, Jeu 2h". * Compact human-readable summary of a per-day schedule, e.g. "Lun 2h, Jeu 2h".
* Returns null when the schedule is empty/unset. * Returns null when the schedule is empty/unset.
+2 -1
View File
@@ -41,7 +41,8 @@ final class WorkHourDayContext
* trackingMode:?string, * trackingMode:?string,
* weeklyHours:?int, * weeklyHours:?int,
* contractType:?string, * contractType:?string,
* contractName:?string * contractName:?string,
* workDaysHours:?array<int, int>
* }> * }>
*/ */
public array $rows = []; public array $rows = [];
+5 -1
View File
@@ -25,6 +25,8 @@ final class DayContextRow
public ?int $weeklyHours = null, public ?int $weeklyHours = null,
public ?string $contractType = null, public ?string $contractType = null,
public ?string $contractName = null, public ?string $contractName = null,
/** @var null|array<int, int> iso day (1=Mon..5=Fri) → minutes, planning des jours travaillés (CUSTOM uniquement) */
public ?array $workDaysHours = null,
) {} ) {}
public function setFormation(string $label): void public function setFormation(string $label): void
@@ -87,7 +89,8 @@ final class DayContextRow
* trackingMode:?string, * trackingMode:?string,
* weeklyHours:?int, * weeklyHours:?int,
* contractType:?string, * contractType:?string,
* contractName:?string * contractName:?string,
* workDaysHours:?array<int, int>
* } * }
*/ */
public function toArray(): array public function toArray(): array
@@ -111,6 +114,7 @@ final class DayContextRow
'weeklyHours' => $this->weeklyHours, 'weeklyHours' => $this->weeklyHours,
'contractType' => $this->contractType, 'contractType' => $this->contractType,
'contractName' => $this->contractName, 'contractName' => $this->contractName,
'workDaysHours' => $this->workDaysHours,
]; ];
} }
@@ -498,6 +498,14 @@ final readonly class RttRecoveryComputationService
return 0; 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); $prorata = (int) round($weeklyHours * 12);
return ($expectedMinutes - $workedMinutes) - $prorata; return ($expectedMinutes - $workedMinutes) - $prorata;
@@ -17,6 +17,7 @@ final readonly class OvertimeContingentExportBuilder
public function __construct( public function __construct(
private EmployeeRttPaymentRepository $rttPaymentRepository, private EmployeeRttPaymentRepository $rttPaymentRepository,
private OvertimePaidContingentCalculator $calculator, private OvertimePaidContingentCalculator $calculator,
private StructuralOvertimeContingentCalculator $structuralCalculator,
) {} ) {}
/** /**
@@ -49,7 +50,13 @@ final readonly class OvertimeContingentExportBuilder
} }
$employeePayments = $byEmployee[$employeeId] ?? []; $employeePayments = $byEmployee[$employeeId] ?? [];
$months = $this->calculator->monthlyBaseMinutes($employeePayments, $civilYear); $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( $rows[] = new OvertimeContingentRow(
employeeId: $employeeId, employeeId: $employeeId,
@@ -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));
}
}
@@ -11,6 +11,7 @@ use App\Entity\Employee;
use App\Repository\EmployeeRepository; use App\Repository\EmployeeRepository;
use App\Repository\EmployeeRttPaymentRepository; use App\Repository\EmployeeRttPaymentRepository;
use App\Service\WorkHours\OvertimePaidContingentCalculator; use App\Service\WorkHours\OvertimePaidContingentCalculator;
use App\Service\WorkHours\StructuralOvertimeContingentCalculator;
use DateTimeImmutable; use DateTimeImmutable;
use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -22,6 +23,7 @@ final readonly class EmployeeOvertimeContingentProvider implements ProviderInter
private RequestStack $requestStack, private RequestStack $requestStack,
private EmployeeRttPaymentRepository $rttPaymentRepository, private EmployeeRttPaymentRepository $rttPaymentRepository,
private OvertimePaidContingentCalculator $calculator, private OvertimePaidContingentCalculator $calculator,
private StructuralOvertimeContingentCalculator $structuralCalculator,
private EmployeeRepository $employeeRepository, private EmployeeRepository $employeeRepository,
) {} ) {}
@@ -51,9 +53,10 @@ final readonly class EmployeeOvertimeContingentProvider implements ProviderInter
$output = new EmployeeOvertimeContingent(); $output = new EmployeeOvertimeContingent();
$output->year = $year; $output->year = $year;
$output->paidMinutes = $this->calculator->totalBaseMinutes($payments, $year); $output->paidMinutes = $this->calculator->totalBaseMinutes($payments, $year)
$output->isDriver = $employee->getIsDriver(); + $this->structuralCalculator->totalStructuralMinutes($employee, $year);
$output->capHours = $this->calculator->capHours($output->isDriver); $output->isDriver = $employee->getIsDriver();
$output->capHours = $this->calculator->capHours($output->isDriver);
return $output; return $output;
} }
+1
View File
@@ -72,6 +72,7 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
weeklyHours: $contract?->getWeeklyHours(), weeklyHours: $contract?->getWeeklyHours(),
contractType: $contract?->getType()->value, contractType: $contract?->getType()->value,
contractName: $contract?->getName(), contractName: $contract?->getName(),
workDaysHours: $workDaysMinutes,
); );
} }
@@ -207,6 +207,20 @@ final class RttRecoveryComputationServiceTest extends TestCase
self::assertSame(-168, $delta); 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. * 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 * Le delta est nul ici par coïncidence du fallback uniforme (expected = prorata) ; avec un vrai
@@ -4,11 +4,16 @@ declare(strict_types=1);
namespace App\Tests\Service\WorkHours; namespace App\Tests\Service\WorkHours;
use App\Entity\Contract;
use App\Entity\Employee; use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use App\Entity\EmployeeRttPayment; use App\Entity\EmployeeRttPayment;
use App\Enum\TrackingMode;
use App\Repository\EmployeeRttPaymentRepository; use App\Repository\EmployeeRttPaymentRepository;
use App\Service\WorkHours\OvertimeContingentExportBuilder; use App\Service\WorkHours\OvertimeContingentExportBuilder;
use App\Service\WorkHours\OvertimePaidContingentCalculator; use App\Service\WorkHours\OvertimePaidContingentCalculator;
use App\Service\WorkHours\StructuralOvertimeContingentCalculator;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use ReflectionProperty; use ReflectionProperty;
@@ -41,7 +46,7 @@ final class OvertimeContingentExportBuilderTest extends TestCase
$repo = $this->createStub(EmployeeRttPaymentRepository::class); $repo = $this->createStub(EmployeeRttPaymentRepository::class);
$repo->method('findByEmployeesAndYears')->willReturn([$payment]); $repo->method('findByEmployeesAndYears')->willReturn([$payment]);
$builder = new OvertimeContingentExportBuilder($repo, new OvertimePaidContingentCalculator()); $builder = new OvertimeContingentExportBuilder($repo, new OvertimePaidContingentCalculator(), new StructuralOvertimeContingentCalculator());
$rows = $builder->buildRows([$driverEmp], 2026); $rows = $builder->buildRows([$driverEmp], 2026);
@@ -64,7 +69,7 @@ final class OvertimeContingentExportBuilderTest extends TestCase
$repo = $this->createStub(EmployeeRttPaymentRepository::class); $repo = $this->createStub(EmployeeRttPaymentRepository::class);
$repo->method('findByEmployeesAndYears')->willReturn([]); $repo->method('findByEmployeesAndYears')->willReturn([]);
$builder = new OvertimeContingentExportBuilder($repo, new OvertimePaidContingentCalculator()); $builder = new OvertimeContingentExportBuilder($repo, new OvertimePaidContingentCalculator(), new StructuralOvertimeContingentCalculator());
$rows = $builder->buildRows([$emp], 2026); $rows = $builder->buildRows([$emp], 2026);
self::assertCount(1, $rows); self::assertCount(1, $rows);
@@ -72,4 +77,32 @@ final class OvertimeContingentExportBuilderTest extends TestCase
self::assertSame(0, $rows[0]->months[6]); self::assertSame(0, $rows[0]->months[6]);
self::assertSame(220, $rows[0]->capHours); // non-driver 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,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;
}
}
+63 -2
View File
@@ -23,6 +23,7 @@ use App\Service\WorkHours\HolidayVirtualHoursResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy; use App\Service\WorkHours\WorkedHoursCreditPolicy;
use App\State\WorkHourDayContextProvider; use App\State\WorkHourDayContextProvider;
use DateTime; use DateTime;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use ReflectionObject; use ReflectionObject;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
@@ -150,8 +151,7 @@ final class WorkHourDayContextProviderTest extends TestCase
// Resolver renvoie le contrat 39h avant 2026-03-01, le forfait à partir de cette date. // Resolver renvoie le contrat 39h avant 2026-03-01, le forfait à partir de cette date.
$resolver = $this->createStub(EmployeeContractResolver::class); $resolver = $this->createStub(EmployeeContractResolver::class);
$resolver->method('resolveForEmployeeAndDate')->willReturnCallback( $resolver->method('resolveForEmployeeAndDate')->willReturnCallback(
static fn (Employee $e, \DateTimeImmutable $d): ?Contract => static fn (Employee $e, DateTimeImmutable $d): ?Contract => $d < new DateTimeImmutable('2026-03-01') ? $timeContract : $forfaitContract
$d < new \DateTimeImmutable('2026-03-01') ? $timeContract : $forfaitContract
); );
$resolver->method('resolveNatureForEmployeeAndDate')->willReturn(ContractNature::CDI); $resolver->method('resolveNatureForEmployeeAndDate')->willReturn(ContractNature::CDI);
@@ -180,6 +180,67 @@ final class WorkHourDayContextProviderTest extends TestCase
self::assertSame('Contrat', $row['contractName']); self::assertSame('Contrat', $row['contractName']);
} }
public function testRowCarriesWorkDaysHoursForCustomContract(): void
{
$user = new User();
$employee = $this->buildEmployee(1, Contract::TRACKING_TIME, 4);
$resolver = $this->createStub(EmployeeContractResolver::class);
$resolver->method('resolveForEmployeeAndDate')->willReturn($employee->getContract());
$resolver->method('resolveNatureForEmployeeAndDate')->willReturn(ContractNature::CDI);
// Contrat 4h travaillé le lundi et le vendredi (120 min chacun).
$resolver->method('resolveWorkDaysMinutesForEmployeeAndDate')->willReturn([1 => 120, 5 => 120]);
$this->requestStack->push(new Request(query: ['workDate' => '2026-02-16']));
$this->security->method('getUser')->willReturn($user);
$this->employeeRepository->method('findScoped')->with($user)->willReturn([$employee]);
$this->absenceRepository->method('findByDateAndEmployees')->willReturn([]);
$provider = new WorkHourDayContextProvider(
$this->security,
$this->requestStack,
$this->employeeRepository,
$this->absenceRepository,
$this->formationRepository,
$resolver,
new AbsenceSegmentsResolver(),
new WorkedHoursCreditPolicy($this->buildResolverStub(), new DailyReferenceMinutesResolver()),
$this->buildHolidayResolver(),
);
$row = $provider->provide(new Get())->rows[0];
self::assertSame([1 => 120, 5 => 120], $row['workDaysHours']);
}
public function testRowHasNullWorkDaysHoursForStandardContract(): void
{
$user = new User();
$employee = $this->buildEmployee(1, Contract::TRACKING_TIME, 35);
// buildResolverStub ne stube pas resolveWorkDaysMinutesForEmployeeAndDate → null (35h n'a pas de planning).
$this->requestStack->push(new Request(query: ['workDate' => '2026-02-16']));
$this->security->method('getUser')->willReturn($user);
$this->employeeRepository->method('findScoped')->with($user)->willReturn([$employee]);
$this->absenceRepository->method('findByDateAndEmployees')->willReturn([]);
$provider = new WorkHourDayContextProvider(
$this->security,
$this->requestStack,
$this->employeeRepository,
$this->absenceRepository,
$this->formationRepository,
$this->buildResolverStub(),
new AbsenceSegmentsResolver(),
new WorkedHoursCreditPolicy($this->buildResolverStub(), new DailyReferenceMinutesResolver()),
$this->buildHolidayResolver(),
);
$row = $provider->provide(new Get())->rows[0];
self::assertNull($row['workDaysHours']);
}
private function buildEmployee(int $id, string $trackingMode, ?int $weeklyHours): Employee private function buildEmployee(int $id, string $trackingMode, ?int $weeklyHours): Employee
{ {
$contract = new Contract() $contract = new Contract()