Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c298f66993 | |||
| 7187989003 | |||
| 4b22270c60 | |||
| acbf1ccecb | |||
| 036399846b | |||
| 0a9b26d31e | |||
| 7dc73f37ac | |||
| dc02316d8b | |||
| e89a1fd7cf | |||
| 327c10fda4 | |||
| 6ba70c36e9 | |||
| ef15d96d2a | |||
| ceba1121f0 | |||
| b5bd4db5f1 | |||
| 49ad6306ea | |||
| 9d2e70f81e |
@@ -33,9 +33,10 @@
|
||||
- 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.
|
||||
- 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.
|
||||
- **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`.
|
||||
- **Écran Calendrier** : un employé est affiché uniquement si au moins une de ses périodes de contrat (`employee.contractHistory`) intersecte le mois affiché (`[1er ; dernier jour]`). Filtre côté frontend dans `visibleEmployees` (`pages/calendar.vue`). **L'impression PDF des absences applique le même filtre** côté backend (`AbsencePrintProvider::hasContractInRange` sur la période `[from, to]`) : un salarié parti en avril n'apparaît pas sur une impression de mai. **Le récap salaire applique le même filtre** (`SalaryRecapPrintProvider::hasContractInRange` sur le mois imprimé) : un salarié sans contrat sur le mois (ex. parti en février) n'apparaît pas sur le récap de juin.
|
||||
- **Planning jours travaillés** (`EmployeeContractPeriod.workDaysHours` : JSON `{iso_day: minutes}`) : obligatoire pour tout contrat TIME **hors 35h/39h/INTERIM** (ex. 4h, 25h, 28h). Somme = `weeklyHours × 60`. Utilisé par `HolidayVirtualHoursResolver` (crédit férié) et `WorkedHoursCreditPolicy` (crédit absence) pour ne créditer que les jours effectivement travaillés. Validation : `EmployeeContractPeriodValidator::assertWorkDaysHours`.
|
||||
- Absences: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots
|
||||
@@ -67,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
|
||||
@@ -107,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** (Janv–Dé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 1–5) + exercice Y+1 (mois 6–12). 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 Janv–Dé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
|
||||
Juin–Dé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`.
|
||||
@@ -126,7 +154,7 @@
|
||||
## Récap. congés (écran)
|
||||
- Accès via sidebar `Récap. congés`, conditionné au flag `User.hasLeaveRecapAccess` (défaut `false`) — activé au create/edit user. Le flag s'applique à tous les profils, y compris admin.
|
||||
- Scope : `ROLE_ADMIN` → tous les employés, `ROLE_USER` (chef de site) → employés de ses sites, `ROLE_SELF` → sa ligne
|
||||
- Cutoff temporel : fin de la semaine S-2 (dimanche 23:59:59). Formule : `dimanche(lundi_semaine_courante − 14j)`. Pas de gate `isValid`.
|
||||
- Cutoff temporel : fin de la semaine S-1 (dimanche 23:59:59). Formule : `dimanche(lundi_semaine_courante − 7j)`. Pas de gate `isValid`.
|
||||
- Helper : `App\Util\LeaveRecapCutoff::resolveCutoff()`
|
||||
- Colonnes : Nom, Prénom, Contrat, CP N-1 restant, CP N, Samedis, RTT — identiques au PDF
|
||||
- Service partagé : `LeaveRecapRowBuilder` consommé par `LeaveRecapPrintProvider` (as-of today) et `EmployeeLeaveRecapProvider` (as-of cutoff)
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.112'
|
||||
app.version: '0.1.119'
|
||||
|
||||
+25
-3
@@ -61,6 +61,7 @@ Documents complementaires:
|
||||
- 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
|
||||
- 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.
|
||||
- **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.
|
||||
@@ -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),
|
||||
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%
|
||||
@@ -374,14 +379,31 @@ Seuls les employés dont au moins une période de contrat intersecte la période
|
||||
- `ROLE_ADMIN` : tous les employés
|
||||
- `ROLE_USER` (chef de site) : employés des sites autorisés (`UserSiteRole`)
|
||||
- `ROLE_SELF` : uniquement son employé lié
|
||||
- **Cutoff temporel** : le récap est figé à la fin de la semaine S-2 (dimanche 23:59:59)
|
||||
- Formule : `cutoffDate = dimanche(lundi_semaine_courante − 14 jours)`
|
||||
- Exemple : mardi 14/04/2026 (S16) → dimanche 05/04/2026 (fin S14)
|
||||
- **Cutoff temporel** : le récap est figé à la fin de la semaine S-1 (dimanche 23:59:59)
|
||||
- Formule : `cutoffDate = dimanche(lundi_semaine_courante − 7 jours)`
|
||||
- Exemple : mardi 14/04/2026 (S16) → dimanche 12/04/2026 (fin S15)
|
||||
- `isValid` n'entre PAS en compte : cutoff purement temporel
|
||||
- Les heures et absences postérieures au cutoff sont ignorées dans les calculs
|
||||
- Colonnes identiques au PDF (voir §10)
|
||||
- Détails techniques : voir `doc/leave-recap-screen.md`
|
||||
|
||||
## Export Contingent heures de nuit
|
||||
|
||||
- Accès : drawer « Export » de la liste employés, type « Contingent H.nuit ».
|
||||
Endpoint `GET /night-hours-contingent/print?year=YYYY`, `ROLE_USER`.
|
||||
- Périmètre : `EmployeeRepository::findScoped($user)` (admin → tous, chef de
|
||||
site → ses sites). Employés ayant ≥ 1 contrat sur l'année civile uniquement.
|
||||
- PDF A4 **paysage** : lignes = employés (groupés par site, triés displayOrder
|
||||
puis nom/prénom), colonnes = 12 mois (Janv→Déc), chaque mois avec 2 sous-
|
||||
colonnes « H.nuit » et « N.jours ».
|
||||
- Heures de nuit : minutes travaillées dans la fenêtre **21h→6h**
|
||||
(`NightHoursCalculator`, identique au reste de l'app). Conducteurs inclus :
|
||||
champ manuel `WorkHour.nightHoursMinutes`.
|
||||
- « N.jours » : un jour compte 1 dès que ses minutes de nuit ≥ 240 (4h).
|
||||
- Aucun crédit absence/férié : seules les heures réellement travaillées comptent.
|
||||
- Services : `App\State\NightHoursContingentPrintProvider` +
|
||||
`App\Service\WorkHours\NightContingentExportBuilder`.
|
||||
|
||||
## 11) Récapitulatif Salaire (PDF mensuel)
|
||||
|
||||
- Accessible depuis la page Employés via le bouton "Récap. Salaire" (réservé `ROLE_ADMIN`)
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
## Objet
|
||||
|
||||
Vue tableau des soldes de congés par employé, figée à un cutoff temporel (fin de semaine S-2).
|
||||
Vue tableau des soldes de congés par employé, figée à un cutoff temporel (fin de semaine S-1).
|
||||
Complémentaire à l'export PDF admin : mêmes colonnes, accès étendu aux employés et chefs de site.
|
||||
|
||||
## Cutoff
|
||||
|
||||
La formule est : `cutoffDate = dimanche de (lundi de la semaine courante − 14 jours)`.
|
||||
La formule est : `cutoffDate = dimanche de (lundi de la semaine courante − 7 jours)`.
|
||||
|
||||
Exemple : mardi 14/04/2026 (S16) → **dimanche 05/04/2026 23:59:59** (fin S14).
|
||||
Exemple : mardi 14/04/2026 (S16) → **dimanche 12/04/2026 23:59:59** (fin S15).
|
||||
|
||||
Le cutoff est purement temporel : l'état `isValid` des heures n'entre pas en compte. Les heures
|
||||
et absences postérieures au cutoff sont ignorées dans le calcul des soldes.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Contingent d'heures supplémentaires payées
|
||||
|
||||
## Objectif
|
||||
Suivre, par année civile (Janv–Dé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` (1–12). 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 1–5) + exercice `Y+1` (mois 6–12).
|
||||
|
||||
## 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 Janv–Dé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 Juin–Déc sur le récap mensuel). À corriger séparément.
|
||||
+4
-2
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,132 @@
|
||||
# Export « Contingent H.nuit »
|
||||
|
||||
Date : 2026-06-11
|
||||
Statut : validé (design)
|
||||
|
||||
## Objectif
|
||||
|
||||
Ajouter un nouvel export PDF sur la **liste des employés** : un tableau du
|
||||
contingent d'heures de nuit, employés en lignes, mois en colonnes. Chaque mois
|
||||
porte deux sous-colonnes : **Total H.nuit** (heures travaillées de nuit) et
|
||||
**Total N.jours** (nombre de nuits où ≥ 4h ont été travaillées de nuit).
|
||||
|
||||
## Décisions cadrées
|
||||
|
||||
- **Période** : année civile Janvier → Décembre, choisie via un sélecteur d'année
|
||||
dans le drawer (réutilise `exportYearOptions`).
|
||||
- **Fenêtre de nuit** : 21h → 6h — on **réutilise le calcul existant** de l'app
|
||||
(constante `[0,360]` + `[1260,1440]` minutes, projection J+1 pour les shifts
|
||||
traversant minuit). NB : la demande initiale mentionnait 21h-5h ; arbitré sur
|
||||
21h→6h pour rester cohérent avec le reste de l'application.
|
||||
- **Règle « 1 jour »** : un jour compte 1 dans « N.jours » dès que les minutes de
|
||||
nuit du jour ≥ 240 (4h).
|
||||
- **Conducteurs** : inclus. Leurs minutes de nuit = champ manuel
|
||||
`WorkHour.nightHoursMinutes` (pas de fenêtre horaire — total saisi). La règle
|
||||
≥ 240 min = 1 jour s'applique aussi.
|
||||
- **Non-conducteurs** : minutes de nuit calculées depuis les plages
|
||||
matin/après-midi/soir via la fenêtre 21h→6h.
|
||||
- **Pas de crédit** absence/férié : on ne compte que les heures de nuit
|
||||
réellement travaillées (pas de crédit virtuel férié ni crédit absence).
|
||||
- **Pas de colonne « Total annuel »** (hors périmètre pour l'instant).
|
||||
- **Mise en page** : A4 **paysage**.
|
||||
- **Groupement / tri** : par site, identique au day-export et au calendrier —
|
||||
sites triés par `displayOrder` puis nom ; employés triés par `displayOrder`,
|
||||
puis nom, puis prénom.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Frontend
|
||||
|
||||
`frontend/pages/employees/index.vue` (drawer Export existant) :
|
||||
- Ajouter une option `{ label: 'Contingent H.nuit', value: 'night-contingent' }`
|
||||
dans `exportTypeOptions`.
|
||||
- Quand `exportChoice === 'night-contingent'` : afficher le sélecteur d'année
|
||||
(réutiliser le `MalioSelect` année déjà utilisé par `yearly-hours`).
|
||||
- `isExportValid` : valide si une année est choisie.
|
||||
- `handleExportValidate` : `await printPdf('/night-hours-contingent/print?year=' + exportYear)`.
|
||||
|
||||
Aucun sélecteur de site (cohérent avec les autres exports de ce drawer — le
|
||||
périmètre vient du back via `findScoped`).
|
||||
|
||||
### Backend
|
||||
|
||||
**Endpoint** : `GET /night-hours-contingent/print?year=YYYY`
|
||||
- Operation API Platform custom (Provider, output PDF `Response`), `ROLE_USER`.
|
||||
- `year` : entier validé 2000-2100, défaut = année courante.
|
||||
|
||||
**Provider** `App\State\NightHoursContingentPrintProvider`
|
||||
1. Auth + parse `year`.
|
||||
2. `employees = employeeRepository->findScoped($user)` (périmètre admin / chef de site).
|
||||
3. Garder les employés ayant ≥ 1 période de contrat intersectant
|
||||
`[YYYY-01-01 ; YYYY-12-31]` (helper `hasContractInRange`, même esprit que
|
||||
`AbsencePrintProvider`/`SalaryRecapPrintProvider`).
|
||||
4. Grouper par site ; trier sites par `displayOrder` puis nom ; trier employés
|
||||
intra-site par `displayOrder`, nom, prénom.
|
||||
5. Construire les lignes via `NightContingentExportBuilder`.
|
||||
6. Rendre `templates/night-hours-contingent/print.html.twig` → Dompdf (paysage).
|
||||
|
||||
**Builder** `App\Service\WorkHours\NightContingentExportBuilder`
|
||||
- `buildRows(list<Employee> $employees, int $year): list<NightContingentRow>`
|
||||
- Pour chaque employé : charge ses `WorkHour` de l'année (1 requête par lot ou
|
||||
par employé selon le repo existant), répartit par mois (1..12).
|
||||
- Par jour : `nightMinutes = NightHoursCalculator::nightMinutesForWorkHour($wh, $isDriverThatDay)`.
|
||||
- Driver ce jour-là → `nightHoursMinutes ?? 0`.
|
||||
- Non-driver → somme des `nightIntervalMinutes` sur les 3 plages.
|
||||
- Agrégats par mois : `nightMinutesTotal += nightMinutes` ;
|
||||
`if (nightMinutes >= 240) nightDays += 1`.
|
||||
- Retour DTO : `{ employeeId, employeeName, isDriver, months: [{nightMinutes, nightDays} × 12] }`.
|
||||
|
||||
> Le statut driver est résolu **par date** (un employé peut changer de nature de
|
||||
> contrat dans l'année), via le mécanisme existant (`EmployeeContractResolver` /
|
||||
> période de contrat couvrant la date du `WorkHour`). On suit l'approche déjà en
|
||||
> place dans les providers heures pour résoudre `isDriver` à la date.
|
||||
|
||||
**Service partagé** `App\Service\WorkHours\NightHoursCalculator`
|
||||
- Extrait la logique 21h→6h aujourd'hui **dupliquée** dans
|
||||
`WorkHourWeeklySummaryProvider::nightIntervalMinutes/computeMetrics` et
|
||||
`YearlyHoursExportBuilder::nightIntervalMinutes/computeMetrics`.
|
||||
- Méthodes :
|
||||
- `nightIntervalMinutes(?string $from, ?string $to): int` (fenêtres
|
||||
`[0,360]` + `[1260,1440]`, projection J+1).
|
||||
- `nightMinutesFromRanges(WorkHour $wh): int` (somme sur les 3 plages).
|
||||
- `nightMinutesForWorkHour(WorkHour $wh, bool $isDriver): int`
|
||||
(driver → `nightHoursMinutes ?? 0`, sinon `nightMinutesFromRanges`).
|
||||
- `WorkHourWeeklySummaryProvider` et `YearlyHoursExportBuilder` délèguent à ce
|
||||
service pour la partie nuit (résultats identiques garantis ; les tests
|
||||
existants couvrent la non-régression).
|
||||
|
||||
### Template
|
||||
|
||||
`templates/night-hours-contingent/print.html.twig`
|
||||
- A4 paysage.
|
||||
- En-tête : « Contingent heures de nuit — {{ year }} », date d'export.
|
||||
- Colonnes : `Nom` + 12 groupes de mois, chacun deux sous-colonnes
|
||||
`H.nuit` / `N.jours`.
|
||||
- Lignes d'en-tête de site colorées (couleur site), comme le day-export.
|
||||
- `H.nuit` formaté `12h30` (helper minutes → HH h MM), `N.jours` entier.
|
||||
- Mois sans données → `0h00` / `0` (ou vide selon rendu — défaut 0).
|
||||
|
||||
## Tests
|
||||
|
||||
- `NightHoursCalculatorTest` : fenêtre 21h→6h, shift traversant minuit
|
||||
(21:00→05:00 = 8h nuit), plage de jour pur (0 nuit), plages nulles.
|
||||
- `NightContingentExportBuilderTest` : agrégation mensuelle, règle ≥4h=1 jour
|
||||
(3h59 → 0 jour, 4h → 1 jour), conducteur via `nightHoursMinutes`, employé
|
||||
multi-mois.
|
||||
- Non-régression : `make test` (les tests existants de
|
||||
`WorkHourWeeklySummaryProvider` / `YearlyHoursExportBuilder` valident le
|
||||
refactor du service partagé).
|
||||
|
||||
## Documentation à mettre à jour (même intervention)
|
||||
|
||||
- `doc/functional-rules.md` : nouvelle section export contingent nuit.
|
||||
- `CLAUDE.md` : entrée sous une rubrique export.
|
||||
- `frontend/data/documentation-content.ts` : article utilisateur (niveau admin/
|
||||
chef de site).
|
||||
|
||||
## Hors périmètre
|
||||
|
||||
- Colonne total annuel.
|
||||
- Sélecteur de site dans le drawer.
|
||||
- Export depuis un autre écran que la liste employés.
|
||||
- Changement de la fenêtre de nuit ailleurs dans l'app (reste 21h→6h partout).
|
||||
@@ -0,0 +1,169 @@
|
||||
# Contingent d'heures supplémentaires payées — Design
|
||||
|
||||
Date : 2026-06-11
|
||||
Statut : validé (brainstorming)
|
||||
|
||||
## Objectif
|
||||
|
||||
La RH a besoin de suivre, **par année civile (Janvier→Décembre)**, le volume d'heures
|
||||
supplémentaires payées à chaque employé non-forfait (chauffeurs inclus), rapporté au
|
||||
plafond réglementaire annuel (le « contingent ») :
|
||||
|
||||
- **350 h** pour les chauffeurs (conducteurs),
|
||||
- **220 h** pour les autres non-forfait.
|
||||
|
||||
Deux livrables :
|
||||
|
||||
1. **Fiche employé** — un encart dans le header affichant `Contingent {année} : X h / plafond h`.
|
||||
2. **Écran liste employés** — un export PDF supplémentaire : par employé, les heures payées
|
||||
de chaque mois + une colonne finale « Total payé / Total payable », groupé par site.
|
||||
|
||||
## Règles métier (validées)
|
||||
|
||||
- **Heures payées** = `base25Minutes + base50Minutes` (en minutes), **hors majoration
|
||||
(bonus)**. Cohérent avec la colonne « Heures payés » du récap salaire, déjà définie hors
|
||||
bonus.
|
||||
- **Période = vraie année civile (Janv–Déc).** Les paiements RTT (`EmployeeRttPayment`)
|
||||
sont stockés par **exercice** (`year` = année d'exercice Juin N-1 → Mai N) + `month`
|
||||
(1–12). L'année civile d'un paiement se reconstitue avec la même formule que
|
||||
`RttTab.vue:392` :
|
||||
|
||||
```
|
||||
annéeCivile = month >= 6 ? exerciseYear - 1 : exerciseYear
|
||||
```
|
||||
|
||||
Donc l'année civile **Y** agrège :
|
||||
- exercice `Y`, mois 1–5 (Janv–Mai Y),
|
||||
- exercice `Y+1`, mois 6–12 (Juin–Déc Y).
|
||||
|
||||
- **Plafond** : `isDriver` du **contrat courant** → 350 h, sinon → 220 h.
|
||||
- **Périmètre** : non-forfait uniquement. Les FORFAIT sont exclus (pas d'heures supp
|
||||
payées ; onglet RTT déjà masqué pour eux).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Cœur partagé — `App\Service\WorkHours\OvertimePaidContingentCalculator`
|
||||
|
||||
Source de vérité unique, consommée par l'endpoint fiche employé ET le builder PDF.
|
||||
|
||||
```php
|
||||
final readonly class OvertimePaidContingentCalculator
|
||||
{
|
||||
public const int CAP_HOURS_DRIVER = 350;
|
||||
public const int CAP_HOURS_DEFAULT = 220;
|
||||
|
||||
// Heures payées (base25+base50) ventilées par mois civil 1..12 pour l'année civile.
|
||||
public function monthlyBaseMinutes(Employee $employee, int $civilYear): array; // <int,int> 1..12
|
||||
|
||||
// Somme des 12 mois.
|
||||
public function totalBaseMinutes(Employee $employee, int $civilYear): int;
|
||||
|
||||
// 350 si conducteur (contrat courant isDriver), sinon 220.
|
||||
public function capHours(Employee $employee): int;
|
||||
}
|
||||
```
|
||||
|
||||
Calcul de `monthlyBaseMinutes` :
|
||||
1. Récupérer les paiements des exercices `civilYear` et `civilYear+1` (fetch groupé).
|
||||
2. Pour chaque paiement, calculer son année civile via la formule ci-dessus ; ne garder que
|
||||
ceux dont l'année civile == `civilYear`.
|
||||
3. Bucketiser par `month`, sommer `base25Minutes + base50Minutes`.
|
||||
|
||||
Statut conducteur : résolu via le contrat courant de l'employé (cohérent avec le choix
|
||||
« contrat courant » pour le plafond). Réutiliser le mécanisme existant
|
||||
(`employee.currentContract` / `EmployeeContractResolver`).
|
||||
|
||||
### Repository
|
||||
|
||||
Ajout à `EmployeeRttPaymentRepository` :
|
||||
|
||||
```php
|
||||
// Fetch groupé pour le PDF (évite N+1 sur N employés).
|
||||
public function findByEmployeesAndYears(array $employees, array $years): array;
|
||||
```
|
||||
|
||||
Le calculator pour un seul employé peut réutiliser `findByEmployeeAndYear()` (existant) deux
|
||||
fois (exercices `civilYear` et `civilYear+1`).
|
||||
|
||||
## Partie A — Encart fiche employé (header)
|
||||
|
||||
### Backend
|
||||
- ApiResource `EmployeeOvertimeContingentOutput` + opération
|
||||
`GET /employees/{id}/overtime-contingent?year=YYYY` (`ROLE_ADMIN`).
|
||||
- Défaut `year` = année civile courante. Validation 2000–2100.
|
||||
- Provider : retourne `{ year, paidMinutes, capHours, isDriver }`.
|
||||
|
||||
### Frontend
|
||||
- Service + composable : fetch sur la fiche employé **uniquement pour les non-forfait**
|
||||
(même condition que l'affichage de l'onglet RTT).
|
||||
- Affichage : ligne texte dans le header, sous le libellé contrat
|
||||
(`useEmployeeDetailPage` / header de `pages/employees/[id].vue`), au format :
|
||||
|
||||
```
|
||||
Contingent 2026 : 142 h / 220 h
|
||||
```
|
||||
|
||||
Passe en **rouge** (`text-m-danger` / classe danger) si `paidMinutes > capHours*60`.
|
||||
- **Année civile courante uniquement, pas de sélecteur** dans le header. L'historique se
|
||||
consulte via le PDF.
|
||||
|
||||
## Partie B — Export PDF (écran liste employés)
|
||||
|
||||
Calque exact de l'export contingent heures de nuit (`night-hours-contingent`).
|
||||
|
||||
### Backend
|
||||
- ApiResource `OvertimeContingentPrint` → `GET /overtime-contingent/print?year=&siteIds=`
|
||||
(`ROLE_USER`).
|
||||
- Provider `OvertimeContingentPrintProvider` :
|
||||
- Périmètre via `EmployeeRepository::findScoped($user)` (admin → tous, chef de site → ses
|
||||
sites). `siteIds` hors périmètre ignoré.
|
||||
- **Exclut les FORFAIT** (contrat courant) en plus du filtre `hasContractInRange` sur
|
||||
l'année.
|
||||
- Groupe par site (`displayOrder`), tri intra-site `displayOrder → nom → prénom`
|
||||
(identique au calendrier / aux autres exports).
|
||||
- Builder `OvertimeContingentExportBuilder::buildRows($employees, $year)` :
|
||||
- utilise `OvertimePaidContingentCalculator` (fetch groupé via `findByEmployeesAndYears`),
|
||||
- retourne par employé : `months[1..12]` (minutes base payées), `totalMinutes`, `capHours`.
|
||||
- DTO `App\Dto\WorkHours\OvertimeContingentRow`.
|
||||
|
||||
### Template
|
||||
- `templates/overtime-contingent/print.html.twig` — **A4 paysage**.
|
||||
- Colonnes : Nom employé · Janv … Déc (heures payées du mois, format `XhYY` ou `—` si 0) ·
|
||||
**Total : `total payé h / plafond h`** (ex. `142 h / 220 h`).
|
||||
- Total en gras ; cellule total en rouge si dépassement.
|
||||
- En-têtes de site colorées (comme night-contingent).
|
||||
|
||||
### Frontend (drawer existant `pages/employees/index.vue`)
|
||||
- Ajouter le choix `overtime-contingent` à `exportTypeOptions`
|
||||
(libellé ex. « Contingent H.supp. »).
|
||||
- Bloc de formulaire dédié : sélecteur **Année** (`exportYearOptions`) + sélecteur **Sites**
|
||||
multi-sélection (tags, calqué sur le drawer d'export jour ; valeurs = sites visibles).
|
||||
- `isExportValid` : `exportYear > 0` (sites optionnels — vide = tous les sites du périmètre).
|
||||
- `handleExportValidate` : `printPdf('/overtime-contingent/print?year=${exportYear}${siteIdsParam}')`.
|
||||
|
||||
## Tests
|
||||
|
||||
- `OvertimePaidContingentCalculatorTest` :
|
||||
- mapping année civile (paiement exercice 2027 mois 9 → compté en 2026),
|
||||
- frontière mois 5/6 (mai = exercice, juin = exercice-1),
|
||||
- somme `base25+base50` hors bonus,
|
||||
- plafond 350 (driver) vs 220.
|
||||
- `OvertimeContingentExportBuilderTest` : ventilation mensuelle + total + plafond par
|
||||
employé, fetch groupé.
|
||||
- Test provider : exclusion forfait, périmètre `findScoped`, tri/groupement par site.
|
||||
|
||||
## Documentation à mettre à jour (règle projet obligatoire)
|
||||
|
||||
- `doc/overtime-contingent.md` (nouveau) — règles + mapping civil/exercice.
|
||||
- `CLAUDE.md` — section dédiée (cœur partagé, mapping, plafonds, périmètre).
|
||||
- `frontend/data/documentation-content.ts` — section utilisateur (admin) décrivant l'encart
|
||||
et l'export.
|
||||
|
||||
## Hors périmètre (consigné pour plus tard)
|
||||
|
||||
- **Bug latent du récap salaire** : `SalaryRecapPrintProvider:86` requête
|
||||
`findByYearAndMonth(annéeCivile, mois)` alors que les paiements sont stockés par exercice.
|
||||
Pour les mois Juin–Déc, un paiement RTT est donc probablement mal rattaché sur le récap
|
||||
mensuel. À corriger dans une intervention séparée.
|
||||
- Plafonds 350/220 en constantes nommées dans le calculator ; passage en config/env
|
||||
envisageable ultérieurement.
|
||||
@@ -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 (36–38h) | 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. |
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<span class="font-normal text-neutral-600 text-sm">({{ contractLabel(employee) }})</span>
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -212,7 +212,7 @@
|
||||
</p>
|
||||
<p class="text-neutral-500 truncate inline-flex items-center gap-2">
|
||||
<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
|
||||
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
|
||||
@@ -406,6 +406,7 @@ const props = defineProps<{
|
||||
hasRowFormation: (employeeId: number) => boolean
|
||||
getRowFormationLabel: (employeeId: number) => string
|
||||
getRowContractNature: (employeeId: number) => 'CDI' | 'CDD' | 'INTERIM' | null
|
||||
getRowWorkedDaysLabel: (employeeId: number) => string | null
|
||||
getRowUpdatedAt: (employeeId: number) => string
|
||||
getPresenceDayValue: (employeeId: number) => string
|
||||
onAbsenceClick: (employeeId: number) => void
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -517,6 +517,11 @@ export const useHoursPage = () => {
|
||||
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 raw = rows.value[employeeId]?.updatedAt
|
||||
if (!raw) return ''
|
||||
@@ -1207,6 +1212,7 @@ export const useHoursPage = () => {
|
||||
hasRowFormation,
|
||||
getRowFormationLabel,
|
||||
getRowContractNature,
|
||||
getRowWorkedDaysLabel,
|
||||
getRowUpdatedAt,
|
||||
getPresenceDayValue,
|
||||
openAbsenceDrawer,
|
||||
|
||||
@@ -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: '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: '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.' },
|
||||
],
|
||||
},
|
||||
@@ -505,7 +506,7 @@ export const documentationSections: DocSection[] = [
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'L\'écran "Récap. congés" affiche un tableau figé des soldes de congés et RTT par employé. Il est accessible via la sidebar lorsque l\'accès a été activé sur le compte utilisateur.' },
|
||||
{ type: 'list', content: 'Employé : voit uniquement sa propre ligne\nChef de site : voit les employés des sites qui lui sont rattachés\nAdmin : voit tous les employés, groupés par site' },
|
||||
{ type: 'note', content: 'Le récap est arrêté à la fin de la semaine S-2 (dimanche). Exemple : un mardi en S16, les soldes sont calculés jusqu\'au dimanche de la S14 inclus. Les heures et absences postérieures ne sont pas comptées.' },
|
||||
{ type: 'note', content: 'Le récap est arrêté à la fin de la semaine S-1 (dimanche). Exemple : un mardi en S16, les soldes sont calculés jusqu\'au dimanche de la S15 inclus. Les heures et absences postérieures ne sont pas comptées.' },
|
||||
{ type: 'paragraph', content: 'Les colonnes affichées sont identiques à l\'export PDF admin (Nom, Prénom, Contrat, CP N-1 restant, CP N, Samedis, RTT). L\'accès à cet écran est géré par un flag sur l\'utilisateur, activé depuis le drawer de création/édition d\'un utilisateur par un admin.' },
|
||||
],
|
||||
},
|
||||
@@ -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: '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é.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -640,6 +641,20 @@ export const documentationSections: DocSection[] = [
|
||||
{ type: 'list', content: 'Sélecteur de mois (défaut = mois courant)\nDonnées groupées par site\nColonnes : nom, base contrat, jours de présence cadre, heures de nuit, panier de nuit, heures RTT payées (en-tête fusionné scindé en deux sous-colonnes 25 % et 50 %), congés (nombre + dates), maladie/AT (nombre + dates), primes conducteur (PDJ, repas, nuitée, samedi), observations\nColonne « Repas » chauffeur : somme déjeuner + dîner sur le mois (un jour avec les deux compte 2 repas)' },
|
||||
{ type: 'note', content: 'Seuls les salariés ayant un contrat couvrant tout ou partie du mois apparaissent : un salarié dont le contrat est terminé (ex. parti en février) n\'est pas listé sur le récap des mois suivants.' },
|
||||
{ type: 'note', content: 'Forfait : un congé imputé sur le stock de l\'année précédente (N-1) n\'apparaît pas dans la colonne congés et compte comme un jour de présence. Le budget N-1 est consommé dans l\'ordre chronologique depuis janvier, de façon cohérente avec la fiche employé (les jours payés réduisent le stock N-1 d\'abord). Au-delà du budget N-1, les congés s\'affichent normalement.' },
|
||||
{ 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 (janvier–décembre), indépendamment de l\'exercice RTT (juin–mai) : 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.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -230,6 +230,32 @@
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div v-else-if="exportChoice === 'night-contingent'">
|
||||
<MalioSelect
|
||||
:model-value="exportYear"
|
||||
:options="exportYearOptions"
|
||||
label="Année *"
|
||||
min-width=""
|
||||
@update:model-value="(v) => { if (v !== null) exportYear = Number(v) }"
|
||||
/>
|
||||
</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"
|
||||
@@ -264,15 +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' | ''>('')
|
||||
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: 'Heures annuelles', value: 'yearly-hours' },
|
||||
{ label: 'Contingent H.nuit', value: 'night-contingent' },
|
||||
{ label: 'Contingent H.supp.', value: 'overtime-contingent' }
|
||||
]
|
||||
const exportYearOptions = computed(() => {
|
||||
const current = new Date().getFullYear()
|
||||
@@ -301,11 +330,17 @@ const isExportValid = computed(() => {
|
||||
if (exportChoice.value === 'yearly-hours') {
|
||||
return exportYear.value > 0 && exportMonth.value !== ''
|
||||
}
|
||||
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' | ''
|
||||
exportChoice.value = (value === null ? '' : String(value)) as 'leave-recap' | 'salary-recap' | 'yearly-hours' | 'night-contingent' | 'overtime-contingent' | ''
|
||||
}
|
||||
const { printPdf } = usePdfPrinter()
|
||||
const sitesInitialized = ref(false)
|
||||
@@ -605,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
|
||||
}
|
||||
|
||||
@@ -618,6 +654,11 @@ const handleExportValidate = async () => {
|
||||
await printPdf(`/salary-recap/print?month=${exportSalaryMonth.value}`)
|
||||
} else if (choice === 'yearly-hours') {
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
:has-row-formation="hasRowFormation"
|
||||
:get-row-formation-label="getRowFormationLabel"
|
||||
:get-row-contract-nature="getRowContractNature"
|
||||
:get-row-worked-days-label="getRowWorkedDaysLabel"
|
||||
:get-row-updated-at="getRowUpdatedAt"
|
||||
:get-presence-day-value="getPresenceDayValue"
|
||||
:on-absence-click="openAbsenceDrawer"
|
||||
@@ -215,6 +216,7 @@ const {
|
||||
hasRowFormation,
|
||||
getRowFormationLabel,
|
||||
getRowContractNature,
|
||||
getRowWorkedDaysLabel,
|
||||
getRowUpdatedAt,
|
||||
getPresenceDayValue,
|
||||
openAbsenceDrawer,
|
||||
|
||||
@@ -119,6 +119,7 @@ export type WorkHourDayContextRow = {
|
||||
weeklyHours?: number | null
|
||||
contractType?: ContractType | null
|
||||
contractName?: string | null
|
||||
workDaysHours?: Record<number, number> | null
|
||||
}
|
||||
|
||||
export type WorkHourDayContext = {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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_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".
|
||||
* Returns null when the schedule is empty/unset.
|
||||
|
||||
@@ -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,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\QueryParameter;
|
||||
use App\State\NightHoursContingentPrintProvider;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/night-hours-contingent/print',
|
||||
provider: NightHoursContingentPrintProvider::class,
|
||||
parameters: [
|
||||
new QueryParameter(key: 'year', required: true),
|
||||
],
|
||||
security: "is_granted('ROLE_USER')"
|
||||
),
|
||||
]
|
||||
)]
|
||||
final class NightHoursContingentPrint {}
|
||||
@@ -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 {}
|
||||
@@ -41,7 +41,8 @@ final class WorkHourDayContext
|
||||
* trackingMode:?string,
|
||||
* weeklyHours:?int,
|
||||
* contractType:?string,
|
||||
* contractName:?string
|
||||
* contractName:?string,
|
||||
* workDaysHours:?array<int, int>
|
||||
* }>
|
||||
*/
|
||||
public array $rows = [];
|
||||
|
||||
@@ -25,6 +25,8 @@ final class DayContextRow
|
||||
public ?int $weeklyHours = null,
|
||||
public ?string $contractType = 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
|
||||
@@ -87,7 +89,8 @@ final class DayContextRow
|
||||
* trackingMode:?string,
|
||||
* weeklyHours:?int,
|
||||
* contractType:?string,
|
||||
* contractName:?string
|
||||
* contractName:?string,
|
||||
* workDaysHours:?array<int, int>
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
@@ -111,6 +114,7 @@ final class DayContextRow
|
||||
'weeklyHours' => $this->weeklyHours,
|
||||
'contractType' => $this->contractType,
|
||||
'contractName' => $this->contractName,
|
||||
'workDaysHours' => $this->workDaysHours,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Dto\WorkHours;
|
||||
|
||||
final class NightContingentRow
|
||||
{
|
||||
/**
|
||||
* @param array<int, array{nightMinutes: int, nightDays: int}> $months clé 1..12
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $employeeId,
|
||||
public readonly string $employeeName,
|
||||
public readonly array $months,
|
||||
) {}
|
||||
}
|
||||
@@ -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()
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ use App\Service\Contracts\EmployeeContractResolver;
|
||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||
use App\Service\WorkHours\DailyReferenceMinutesResolver;
|
||||
use App\Service\WorkHours\HolidayVirtualHoursResolver;
|
||||
use App\Service\WorkHours\NightHoursCalculator;
|
||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
||||
use DateTimeImmutable;
|
||||
|
||||
@@ -34,6 +35,7 @@ final readonly class RttRecoveryComputationService
|
||||
private DailyReferenceMinutesResolver $dailyReferenceResolver,
|
||||
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
|
||||
private SolidarityDayResolver $solidarityDayResolver,
|
||||
private NightHoursCalculator $nightHoursCalculator,
|
||||
string $rttStartDate = '',
|
||||
) {
|
||||
$this->rttStartDate = '' !== $rttStartDate ? new DateTimeImmutable($rttStartDate) : null;
|
||||
@@ -359,13 +361,12 @@ final readonly class RttRecoveryComputationService
|
||||
];
|
||||
|
||||
$totalMinutes = 0;
|
||||
$nightMinutes = 0;
|
||||
foreach ($ranges as [$from, $to]) {
|
||||
$totalMinutes += $this->intervalMinutes($from, $to);
|
||||
$nightMinutes += $this->nightIntervalMinutes($from, $to);
|
||||
}
|
||||
|
||||
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
|
||||
$nightMinutes = $this->nightHoursCalculator->nightMinutesFromRanges($workHour);
|
||||
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
|
||||
|
||||
return new WorkMetrics(
|
||||
dayMinutes: $dayMinutes,
|
||||
@@ -411,35 +412,6 @@ final readonly class RttRecoveryComputationService
|
||||
return max(0, $end - $start);
|
||||
}
|
||||
|
||||
private function nightIntervalMinutes(?string $from, ?string $to): int
|
||||
{
|
||||
$interval = $this->resolveInterval($from, $to);
|
||||
if (null === $interval) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
[$start, $end] = $interval;
|
||||
$windows = [[0, 360], [1260, 1440]];
|
||||
$total = 0;
|
||||
|
||||
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
|
||||
$shift = $dayOffset * 1440;
|
||||
foreach ($windows as [$windowStart, $windowEnd]) {
|
||||
$total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift);
|
||||
}
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
private function overlap(int $startA, int $endA, int $startB, int $endB): int
|
||||
{
|
||||
$start = max($startA, $startB);
|
||||
$end = min($endA, $endB);
|
||||
|
||||
return max(0, $end - $start);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $days
|
||||
* @param array<string, ?Contract> $contractsByDate
|
||||
@@ -526,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,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\WorkHours;
|
||||
|
||||
use App\Dto\WorkHours\NightContingentRow;
|
||||
use App\Entity\Employee;
|
||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||
use App\Service\Contracts\EmployeeContractResolver;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Construit, par employe, les totaux mensuels d'heures de nuit et le nombre de
|
||||
* nuits travaillees (>= 4h de nuit dans la journee). Fenetre 21h->6h via
|
||||
* NightHoursCalculator. Conducteurs : minutes saisies (nightHoursMinutes).
|
||||
* Aucun credit absence/ferie : seules les heures reellement travaillees comptent.
|
||||
*/
|
||||
final readonly class NightContingentExportBuilder
|
||||
{
|
||||
private const int NIGHT_DAY_THRESHOLD_MINUTES = 240;
|
||||
|
||||
public function __construct(
|
||||
private WorkHourReadRepositoryInterface $workHourRepository,
|
||||
private EmployeeContractResolver $contractResolver,
|
||||
private NightHoursCalculator $nightHoursCalculator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param list<Employee> $employees
|
||||
*
|
||||
* @return list<NightContingentRow>
|
||||
*/
|
||||
public function buildRows(array $employees, int $year): array
|
||||
{
|
||||
$from = new DateTimeImmutable(sprintf('%d-01-01', $year));
|
||||
$to = new DateTimeImmutable(sprintf('%d-12-31', $year));
|
||||
|
||||
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, $employees);
|
||||
|
||||
$byEmployee = [];
|
||||
foreach ($workHours as $wh) {
|
||||
$employeeId = $wh->getEmployee()?->getId();
|
||||
if (null === $employeeId) {
|
||||
continue;
|
||||
}
|
||||
$byEmployee[$employeeId][] = $wh;
|
||||
}
|
||||
|
||||
$days = [];
|
||||
foreach ($workHours as $wh) {
|
||||
$days[$wh->getWorkDate()->format('Y-m-d')] = true;
|
||||
}
|
||||
$days = array_keys($days);
|
||||
|
||||
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
|
||||
|
||||
$rows = [];
|
||||
foreach ($employees as $employee) {
|
||||
$employeeId = $employee->getId();
|
||||
if (null === $employeeId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$months = [];
|
||||
for ($m = 1; $m <= 12; ++$m) {
|
||||
$months[$m] = ['nightMinutes' => 0, 'nightDays' => 0];
|
||||
}
|
||||
|
||||
foreach ($byEmployee[$employeeId] ?? [] as $wh) {
|
||||
$date = DateTimeImmutable::createFromInterface($wh->getWorkDate());
|
||||
$ymd = $date->format('Y-m-d');
|
||||
$isDriver = $driverMap[$employeeId][$ymd] ?? false;
|
||||
$nightMin = $this->nightHoursCalculator->nightMinutesForWorkHour($wh, $isDriver);
|
||||
if ($nightMin <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$month = (int) $date->format('n');
|
||||
$months[$month]['nightMinutes'] += $nightMin;
|
||||
if ($nightMin >= self::NIGHT_DAY_THRESHOLD_MINUTES) {
|
||||
++$months[$month]['nightDays'];
|
||||
}
|
||||
}
|
||||
|
||||
$rows[] = new NightContingentRow(
|
||||
employeeId: $employeeId,
|
||||
employeeName: trim($employee->getLastName().' '.$employee->getFirstName()),
|
||||
months: $months,
|
||||
);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\WorkHours;
|
||||
|
||||
use App\Entity\WorkHour;
|
||||
|
||||
/**
|
||||
* Calcul des minutes travaillees de nuit (fenetre 21h->6h).
|
||||
*
|
||||
* Fenetres en minutes depuis 00:00 : [0,360] (00:00-06:00) et [1260,1440]
|
||||
* (21:00-24:00). On projette sur J+1 pour les shifts qui traversent minuit.
|
||||
* Source de verite unique partagee par les ecrans Heures et les exports.
|
||||
*/
|
||||
final readonly class NightHoursCalculator
|
||||
{
|
||||
/**
|
||||
* Minutes de nuit d'un WorkHour. Conducteurs : champ manuel nightHoursMinutes.
|
||||
* Non-conducteurs : somme calculee depuis les plages matin/apres-midi/soir.
|
||||
*/
|
||||
public function nightMinutesForWorkHour(WorkHour $workHour, bool $isDriver): int
|
||||
{
|
||||
if ($isDriver) {
|
||||
return $workHour->getNightHoursMinutes() ?? 0;
|
||||
}
|
||||
|
||||
return $this->nightMinutesFromRanges($workHour);
|
||||
}
|
||||
|
||||
public function nightMinutesFromRanges(WorkHour $workHour): int
|
||||
{
|
||||
$ranges = [
|
||||
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
|
||||
[$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
|
||||
[$workHour->getEveningFrom(), $workHour->getEveningTo()],
|
||||
];
|
||||
|
||||
$total = 0;
|
||||
foreach ($ranges as [$from, $to]) {
|
||||
$total += $this->nightIntervalMinutes($from, $to);
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
public function nightIntervalMinutes(?string $from, ?string $to): int
|
||||
{
|
||||
$interval = $this->resolveInterval($from, $to);
|
||||
if (null === $interval) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
[$start, $end] = $interval;
|
||||
$windows = [[0, 360], [1260, 1440]];
|
||||
$total = 0;
|
||||
|
||||
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
|
||||
$shift = $dayOffset * 1440;
|
||||
foreach ($windows as [$windowStart, $windowEnd]) {
|
||||
$total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift);
|
||||
}
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return null|array{int, int}
|
||||
*/
|
||||
private function resolveInterval(?string $from, ?string $to): ?array
|
||||
{
|
||||
$fromMinutes = $this->toMinutes($from);
|
||||
$toMinutes = $this->toMinutes($to);
|
||||
if (null === $fromMinutes || null === $toMinutes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes;
|
||||
|
||||
return [$fromMinutes, $end];
|
||||
}
|
||||
|
||||
private function toMinutes(?string $time): ?int
|
||||
{
|
||||
if (null === $time || '' === $time) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$hours, $minutes] = array_map('intval', explode(':', $time));
|
||||
|
||||
return ($hours * 60) + $minutes;
|
||||
}
|
||||
|
||||
private function overlap(int $startA, int $endA, int $startB, int $endB): int
|
||||
{
|
||||
$start = max($startA, $startB);
|
||||
$end = min($endA, $endB);
|
||||
|
||||
return max(0, $end - $start);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ class YearlyHoursExportBuilder
|
||||
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
|
||||
private PublicHolidayServiceInterface $publicHolidayService,
|
||||
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
|
||||
private NightHoursCalculator $nightHoursCalculator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -541,14 +542,12 @@ class YearlyHoursExportBuilder
|
||||
];
|
||||
|
||||
$totalMinutes = 0;
|
||||
$nightMinutes = 0;
|
||||
|
||||
foreach ($ranges as [$from, $to]) {
|
||||
$totalMinutes += $this->intervalMinutes($from, $to);
|
||||
$nightMinutes += $this->nightIntervalMinutes($from, $to);
|
||||
}
|
||||
|
||||
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
|
||||
$nightMinutes = $this->nightHoursCalculator->nightMinutesFromRanges($workHour);
|
||||
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
|
||||
|
||||
return new WorkMetrics(
|
||||
dayMinutes: $dayMinutes,
|
||||
@@ -596,35 +595,6 @@ class YearlyHoursExportBuilder
|
||||
return max(0, $end - $start);
|
||||
}
|
||||
|
||||
private function nightIntervalMinutes(?string $from, ?string $to): int
|
||||
{
|
||||
$interval = $this->resolveInterval($from, $to);
|
||||
if (null === $interval) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
[$start, $end] = $interval;
|
||||
$windows = [[0, 360], [1260, 1440]];
|
||||
$total = 0;
|
||||
|
||||
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
|
||||
$shift = $dayOffset * 1440;
|
||||
foreach ($windows as [$windowStart, $windowEnd]) {
|
||||
$total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift);
|
||||
}
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
private function overlap(int $startA, int $endA, int $startB, int $endB): int
|
||||
{
|
||||
$start = max($startA, $startB);
|
||||
$end = min($endA, $endB);
|
||||
|
||||
return max(0, $end - $start);
|
||||
}
|
||||
|
||||
private function formatMinutes(int $minutes): string
|
||||
{
|
||||
if (0 === $minutes) {
|
||||
|
||||
@@ -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,154 @@
|
||||
<?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\Repository\EmployeeRepository;
|
||||
use App\Service\WorkHours\NightContingentExportBuilder;
|
||||
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;
|
||||
|
||||
class NightHoursContingentPrintProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private Environment $twig,
|
||||
private readonly RequestStack $requestStack,
|
||||
private EmployeeRepository $employeeRepository,
|
||||
private NightContingentExportBuilder $exportBuilder,
|
||||
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));
|
||||
|
||||
// Perimetre selon le profil : admin -> tous, chef de site -> ses sites.
|
||||
$employees = $this->employeeRepository->findScoped($user);
|
||||
|
||||
// Regroupement par site (ordre displayOrder), employes avec contrat sur l'annee.
|
||||
$bySite = [];
|
||||
$siteMeta = [];
|
||||
foreach ($employees as $employee) {
|
||||
if (!$this->hasContractInRange($employee, $from, $to)) {
|
||||
continue;
|
||||
}
|
||||
$site = $employee->getSite();
|
||||
if (null === $site) {
|
||||
continue;
|
||||
}
|
||||
$siteId = $site->getId();
|
||||
$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[] = [
|
||||
'hours' => $this->formatMinutes($row->months[$m]['nightMinutes']),
|
||||
'days' => $row->months[$m]['nightDays'],
|
||||
];
|
||||
}
|
||||
$renderRows[] = [
|
||||
'employeeName' => $row->employeeName,
|
||||
'cells' => $cells,
|
||||
];
|
||||
}
|
||||
|
||||
$groups[] = ['siteName' => $meta['name'], 'siteColor' => $meta['color'], 'rows' => $renderRows];
|
||||
}
|
||||
|
||||
$options = new Options();
|
||||
$options->set('isRemoteEnabled', true);
|
||||
$dompdf = new Dompdf($options);
|
||||
|
||||
$html = $this->twig->render('night-hours-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_nuit_%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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ use App\Repository\WorkHourRepository;
|
||||
use App\Service\Contracts\EmployeeContractResolver;
|
||||
use App\Service\PublicHolidayServiceInterface;
|
||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||
use App\Service\WorkHours\NightHoursCalculator;
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use Dompdf\Dompdf;
|
||||
@@ -45,6 +46,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
private PublicHolidayServiceInterface $publicHolidayService,
|
||||
private EmployeeLeaveSummaryProvider $leaveSummaryProvider,
|
||||
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
||||
private NightHoursCalculator $nightHoursCalculator,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
||||
@@ -78,10 +80,10 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
// Congés depuis le début de l'exercice forfait (année civile) jusqu'à la fin du mois :
|
||||
// nécessaires pour consommer chronologiquement le budget N-1 d'un forfait (un congé
|
||||
// imputé N-1 ne doit ni s'afficher ni manquer en présence sur le récap).
|
||||
$yearStart = new DateTimeImmutable(sprintf('%d-01-01', $year));
|
||||
$ytdAbsences = $this->absenceRepository->findForPrint($yearStart, $to, $employees);
|
||||
$yearStart = new DateTimeImmutable(sprintf('%d-01-01', $year));
|
||||
$ytdAbsences = $this->absenceRepository->findForPrint($yearStart, $to, $employees);
|
||||
$ytdAbsenceMap = $this->buildAbsenceMap($ytdAbsences);
|
||||
$rttPayments = $this->rttPaymentRepository->findByYearAndMonth($year, $monthNumber);
|
||||
$rttPayments = $this->rttPaymentRepository->findByYearAndMonth($year, $monthNumber);
|
||||
|
||||
$bonuses = $this->bonusRepository->findByMonth($from, $to);
|
||||
$mileages = $this->mileageAllowanceRepository->findByMonth($from, $to);
|
||||
@@ -472,7 +474,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
$ytdAbsences,
|
||||
static fn (Absence $a): bool => 'C' === $a->getType()?->getCode()
|
||||
));
|
||||
$split = $this->splitForfaitCongesByN1($ytdConges, $n1Budget, $monthFrom, $monthTo);
|
||||
$split = $this->splitForfaitCongesByN1($ytdConges, $n1Budget, $monthFrom, $monthTo);
|
||||
$conges = ['count' => $split['count'], 'dates' => $split['dates']];
|
||||
$presenceDays += $split['n1PresenceDays'];
|
||||
} else {
|
||||
@@ -524,14 +526,13 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
];
|
||||
|
||||
$totalMinutes = 0;
|
||||
$nightMinutes = 0;
|
||||
|
||||
foreach ($ranges as [$from, $to]) {
|
||||
$totalMinutes += $this->intervalMinutes($from, $to);
|
||||
$nightMinutes += $this->nightIntervalMinutes($from, $to);
|
||||
}
|
||||
|
||||
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
|
||||
$nightMinutes = $this->nightHoursCalculator->nightMinutesFromRanges($workHour);
|
||||
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
|
||||
|
||||
return [
|
||||
'nightMinutes' => $nightMinutes,
|
||||
@@ -578,27 +579,6 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
return max(0, $end - $start);
|
||||
}
|
||||
|
||||
private function nightIntervalMinutes(?string $from, ?string $to): int
|
||||
{
|
||||
$interval = $this->resolveInterval($from, $to);
|
||||
if (null === $interval) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
[$start, $end] = $interval;
|
||||
$windows = [[0, 360], [1260, 1440]];
|
||||
$total = 0;
|
||||
|
||||
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
|
||||
$shift = $dayOffset * 1440;
|
||||
foreach ($windows as [$windowStart, $windowEnd]) {
|
||||
$total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift);
|
||||
}
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule les minutes qui débordent après minuit (> 1440) pour les créneaux d'un WorkHour.
|
||||
* Ex: créneau soir 21:00-05:00 → interval [1260, 1740] → overflow = 1740-1440 = 300 min (5h).
|
||||
@@ -630,14 +610,6 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
return $overflow;
|
||||
}
|
||||
|
||||
private function overlap(int $startA, int $endA, int $startB, int $endB): int
|
||||
{
|
||||
$start = max($startA, $startB);
|
||||
$end = min($endA, $endB);
|
||||
|
||||
return max(0, $end - $start);
|
||||
}
|
||||
|
||||
/**
|
||||
* Répartit les congés ('C') d'un forfait entre N-1 (budget consommé chronologiquement,
|
||||
* non affiché et compté en présence) et N (affiché en congé). Seuls les jours tombant
|
||||
@@ -677,7 +649,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
|
||||
$covered = 0.0;
|
||||
if ($remaining > 0.0) {
|
||||
$covered = min($remaining, $amount);
|
||||
$covered = min($remaining, $amount);
|
||||
$remaining -= $covered;
|
||||
}
|
||||
$displayed = $amount - $covered;
|
||||
|
||||
@@ -72,6 +72,7 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
|
||||
weeklyHours: $contract?->getWeeklyHours(),
|
||||
contractType: $contract?->getType()->value,
|
||||
contractName: $contract?->getName(),
|
||||
workDaysHours: $workDaysMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ use App\Service\PublicHolidayServiceInterface;
|
||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||
use App\Service\WorkHours\DailyReferenceMinutesResolver;
|
||||
use App\Service\WorkHours\HolidayVirtualHoursResolver;
|
||||
use App\Service\WorkHours\NightHoursCalculator;
|
||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
@@ -51,6 +52,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
||||
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
|
||||
private PublicHolidayServiceInterface $publicHolidayService,
|
||||
private EmployeeWeekCommentRepository $weekCommentRepository,
|
||||
private NightHoursCalculator $nightHoursCalculator,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary
|
||||
@@ -433,14 +435,12 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
||||
];
|
||||
|
||||
$totalMinutes = 0;
|
||||
$nightMinutes = 0;
|
||||
|
||||
foreach ($ranges as [$from, $to]) {
|
||||
$totalMinutes += $this->intervalMinutes($from, $to);
|
||||
$nightMinutes += $this->nightIntervalMinutes($from, $to);
|
||||
}
|
||||
|
||||
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
|
||||
$nightMinutes = $this->nightHoursCalculator->nightMinutesFromRanges($workHour);
|
||||
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
|
||||
|
||||
return new WorkMetrics(
|
||||
dayMinutes: $dayMinutes,
|
||||
@@ -489,37 +489,6 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
||||
return max(0, $end - $start);
|
||||
}
|
||||
|
||||
private function nightIntervalMinutes(?string $from, ?string $to): int
|
||||
{
|
||||
$interval = $this->resolveInterval($from, $to);
|
||||
if (null === $interval) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
[$start, $end] = $interval;
|
||||
// Fenêtres de nuit: 00:00-06:00 et 21:00-24:00.
|
||||
$windows = [[0, 360], [1260, 1440]];
|
||||
$total = 0;
|
||||
|
||||
// On projette aussi sur J+1 pour couvrir les shifts qui traversent minuit.
|
||||
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
|
||||
$shift = $dayOffset * 1440;
|
||||
foreach ($windows as [$windowStart, $windowEnd]) {
|
||||
$total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift);
|
||||
}
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
private function overlap(int $startA, int $endA, int $startB, int $endB): int
|
||||
{
|
||||
$start = max($startA, $startB);
|
||||
$end = min($endA, $endB);
|
||||
|
||||
return max(0, $end - $start);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, ?Contract> $contractsByDate
|
||||
*/
|
||||
|
||||
@@ -7,16 +7,16 @@ namespace App\Util;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Leave recap cutoff rule: as-of end of ISO week S-2 (Sunday 23:59:59).
|
||||
* Leave recap cutoff rule: as-of end of ISO week S-1 (Sunday 23:59:59).
|
||||
*
|
||||
* Example: Tuesday 2026-04-14 (S16) → Sunday 2026-04-05 23:59:59 (end of S14).
|
||||
* Example: Tuesday 2026-04-14 (S16) → Sunday 2026-04-12 23:59:59 (end of S15).
|
||||
*/
|
||||
final class LeaveRecapCutoff
|
||||
{
|
||||
public static function resolveCutoff(DateTimeImmutable $today): DateTimeImmutable
|
||||
{
|
||||
$currentWeekMonday = $today->modify('monday this week')->setTime(0, 0);
|
||||
$cutoffWeekMonday = $currentWeekMonday->modify('-14 days');
|
||||
$cutoffWeekMonday = $currentWeekMonday->modify('-7 days');
|
||||
|
||||
return $cutoffWeekMonday->modify('+6 days')->setTime(23, 59, 59);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<!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 1px; text-align: center; }
|
||||
th { background: #d9d9d9; }
|
||||
td.name, th.name { text-align: left; width: 145px; padding-left: 4px; padding-right: 6px; }
|
||||
.sub { font-size: 9px; }
|
||||
td.data, th.data { width: 34px; font-size: 9px; }
|
||||
tr.site-title td { text-align: left; font-weight: bold; }
|
||||
td.hours { white-space: nowrap; }
|
||||
td.month-start, th.month-start { border-left: 2.5px solid #333; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<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'] %}
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="name" rowspan="2">Nom</th>
|
||||
{% for m in months %}
|
||||
<th class="month-start" colspan="2">{{ m }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<tr>
|
||||
{% for m in months %}
|
||||
<th class="sub data month-start">H.nuit</th>
|
||||
<th class="sub data">N.jours</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for group in groups %}
|
||||
<tr class="site-title">
|
||||
<td colspan="25" 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="hours data month-start">{{ cell.hours }}</td>
|
||||
<td class="data">{{ cell.days }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\WorkHours;
|
||||
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\WorkHour;
|
||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||
use App\Service\Contracts\EmployeeContractResolver;
|
||||
use App\Service\WorkHours\NightContingentExportBuilder;
|
||||
use App\Service\WorkHours\NightHoursCalculator;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionProperty;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class NightContingentExportBuilderTest extends TestCase
|
||||
{
|
||||
public function testAggregatesNightMinutesAndDaysPerMonth(): void
|
||||
{
|
||||
$employee = $this->makeEmployee(1, 'Dupont', 'Jean');
|
||||
|
||||
// Janvier : un jour 4h de nuit (>=240 -> 1 jour) + un jour 3h59 (<240 -> 0 jour).
|
||||
$whFull = new WorkHour()->setEmployee($employee)
|
||||
->setWorkDate(new DateTimeImmutable('2026-01-10'))
|
||||
->setEveningFrom('21:00')->setEveningTo('01:00') // 240 min nuit
|
||||
;
|
||||
$whShort = new WorkHour()->setEmployee($employee)
|
||||
->setWorkDate(new DateTimeImmutable('2026-01-11'))
|
||||
->setEveningFrom('21:00')->setEveningTo('00:59') // 239 min nuit
|
||||
;
|
||||
|
||||
$workHourRepo = $this->createStub(WorkHourReadRepositoryInterface::class);
|
||||
$workHourRepo->method('findByDateRangeAndEmployees')->willReturn([$whFull, $whShort]);
|
||||
|
||||
$contractResolver = $this->createStub(EmployeeContractResolver::class);
|
||||
$contractResolver->method('resolveIsDriverForEmployeesAndDays')->willReturn([
|
||||
1 => ['2026-01-10' => false, '2026-01-11' => false],
|
||||
]);
|
||||
|
||||
$builder = new NightContingentExportBuilder(
|
||||
$workHourRepo,
|
||||
$contractResolver,
|
||||
new NightHoursCalculator(),
|
||||
);
|
||||
|
||||
$rows = $builder->buildRows([$employee], 2026);
|
||||
|
||||
self::assertCount(1, $rows);
|
||||
self::assertSame(479, $rows[0]->months[1]['nightMinutes']); // 240 + 239
|
||||
self::assertSame(1, $rows[0]->months[1]['nightDays']); // seul le jour >=240
|
||||
self::assertSame(0, $rows[0]->months[2]['nightMinutes']); // fevrier vide
|
||||
self::assertSame(0, $rows[0]->months[2]['nightDays']);
|
||||
}
|
||||
|
||||
public function testDriverUsesManualNightMinutes(): void
|
||||
{
|
||||
$employee = $this->makeEmployee(2, 'Martin', 'Paul');
|
||||
|
||||
$wh = new WorkHour()->setEmployee($employee)
|
||||
->setWorkDate(new DateTimeImmutable('2026-03-05'))
|
||||
->setNightHoursMinutes(300)
|
||||
->setMorningFrom('08:00')->setMorningTo('12:00') // ignore (driver)
|
||||
;
|
||||
|
||||
$workHourRepo = $this->createStub(WorkHourReadRepositoryInterface::class);
|
||||
$workHourRepo->method('findByDateRangeAndEmployees')->willReturn([$wh]);
|
||||
|
||||
$contractResolver = $this->createStub(EmployeeContractResolver::class);
|
||||
$contractResolver->method('resolveIsDriverForEmployeesAndDays')->willReturn([
|
||||
2 => ['2026-03-05' => true],
|
||||
]);
|
||||
|
||||
$builder = new NightContingentExportBuilder(
|
||||
$workHourRepo,
|
||||
$contractResolver,
|
||||
new NightHoursCalculator(),
|
||||
);
|
||||
|
||||
$rows = $builder->buildRows([$employee], 2026);
|
||||
|
||||
self::assertSame(300, $rows[0]->months[3]['nightMinutes']);
|
||||
self::assertSame(1, $rows[0]->months[3]['nightDays']); // 300 >= 240
|
||||
}
|
||||
|
||||
public function testEmployeeWithoutWorkHoursYieldsAllZeroMonths(): void
|
||||
{
|
||||
$employee = $this->makeEmployee(3, 'Durand', 'Marie');
|
||||
|
||||
$workHourRepo = $this->createStub(WorkHourReadRepositoryInterface::class);
|
||||
$workHourRepo->method('findByDateRangeAndEmployees')->willReturn([]);
|
||||
|
||||
$contractResolver = $this->createStub(EmployeeContractResolver::class);
|
||||
$contractResolver->method('resolveIsDriverForEmployeesAndDays')->willReturn([]);
|
||||
|
||||
$builder = new NightContingentExportBuilder(
|
||||
$workHourRepo,
|
||||
$contractResolver,
|
||||
new NightHoursCalculator(),
|
||||
);
|
||||
|
||||
$rows = $builder->buildRows([$employee], 2026);
|
||||
|
||||
self::assertCount(1, $rows);
|
||||
for ($m = 1; $m <= 12; ++$m) {
|
||||
self::assertSame(0, $rows[0]->months[$m]['nightMinutes']);
|
||||
self::assertSame(0, $rows[0]->months[$m]['nightDays']);
|
||||
}
|
||||
}
|
||||
|
||||
private function makeEmployee(int $id, string $last, string $first): Employee
|
||||
{
|
||||
$employee = new Employee();
|
||||
$employee->setLastName($last)->setFirstName($first);
|
||||
$ref = new ReflectionProperty(Employee::class, 'id');
|
||||
$ref->setValue($employee, $id);
|
||||
|
||||
return $employee;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\WorkHours;
|
||||
|
||||
use App\Entity\WorkHour;
|
||||
use App\Service\WorkHours\NightHoursCalculator;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class NightHoursCalculatorTest extends TestCase
|
||||
{
|
||||
public function testNullRangeReturnsZero(): void
|
||||
{
|
||||
$calc = new NightHoursCalculator();
|
||||
self::assertSame(0, $calc->nightIntervalMinutes(null, null));
|
||||
self::assertSame(0, $calc->nightIntervalMinutes('08:00', null));
|
||||
self::assertSame(0, $calc->nightIntervalMinutes(null, '17:00'));
|
||||
}
|
||||
|
||||
public function testPureDayRangeHasNoNight(): void
|
||||
{
|
||||
$calc = new NightHoursCalculator();
|
||||
// 08:00 -> 17:00 : entierement hors fenetres nuit (00:00-06:00, 21:00-24:00).
|
||||
self::assertSame(0, $calc->nightIntervalMinutes('08:00', '17:00'));
|
||||
}
|
||||
|
||||
public function testWindowBoundariesAreRightExclusive(): void
|
||||
{
|
||||
$calc = new NightHoursCalculator();
|
||||
// 06:00 -> 21:00 : pile entre les deux fenetres de nuit, 0 min.
|
||||
self::assertSame(0, $calc->nightIntervalMinutes('06:00', '21:00'));
|
||||
// 22:00 -> 06:00 : 22-24 (120) + 00-06 (360) = 480, borne 06:00 exclue.
|
||||
self::assertSame(480, $calc->nightIntervalMinutes('22:00', '06:00'));
|
||||
}
|
||||
|
||||
public function testEveningWindowCounts(): void
|
||||
{
|
||||
$calc = new NightHoursCalculator();
|
||||
// 21:00 -> 24:00 = 180 min de nuit.
|
||||
self::assertSame(180, $calc->nightIntervalMinutes('21:00', '00:00'));
|
||||
}
|
||||
|
||||
public function testShiftCrossingMidnightCountsBothWindows(): void
|
||||
{
|
||||
$calc = new NightHoursCalculator();
|
||||
// 21:00 -> 05:00 : 21-24 (180) + 00-05 (300) = 480 min.
|
||||
self::assertSame(480, $calc->nightIntervalMinutes('21:00', '05:00'));
|
||||
}
|
||||
|
||||
public function testNightMinutesForWorkHourDriverUsesManualField(): void
|
||||
{
|
||||
$calc = new NightHoursCalculator();
|
||||
$wh = new WorkHour();
|
||||
$wh->setWorkDate(new DateTimeImmutable('2026-01-15'))
|
||||
->setDayHoursMinutes(300)
|
||||
->setNightHoursMinutes(250)
|
||||
->setMorningFrom('08:00')->setMorningTo('12:00')
|
||||
;
|
||||
|
||||
// Driver -> champ manuel nightHoursMinutes, plages ignorees.
|
||||
self::assertSame(250, $calc->nightMinutesForWorkHour($wh, true));
|
||||
}
|
||||
|
||||
public function testNightMinutesForWorkHourNonDriverSumsRanges(): void
|
||||
{
|
||||
$calc = new NightHoursCalculator();
|
||||
$wh = new WorkHour();
|
||||
$wh->setWorkDate(new DateTimeImmutable('2026-01-15'))
|
||||
->setMorningFrom('22:00')->setMorningTo('00:00') // 120 min nuit
|
||||
->setEveningFrom('04:00')->setEveningTo('06:00') // 120 min nuit
|
||||
;
|
||||
|
||||
self::assertSame(240, $calc->nightMinutesForWorkHour($wh, false));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use App\Service\PublicHolidayServiceInterface;
|
||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||
use App\Service\WorkHours\DailyReferenceMinutesResolver;
|
||||
use App\Service\WorkHours\HolidayVirtualHoursResolver;
|
||||
use App\Service\WorkHours\NightHoursCalculator;
|
||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
||||
use App\Service\WorkHours\YearlyHoursExportBuilder;
|
||||
use DateTimeImmutable;
|
||||
@@ -87,6 +88,7 @@ final class YearlyHoursDayRowsTest extends TestCase
|
||||
new WorkedHoursCreditPolicy($contractResolver, new DailyReferenceMinutesResolver()),
|
||||
$holidayService,
|
||||
$virtualResolver,
|
||||
new NightHoursCalculator(),
|
||||
);
|
||||
|
||||
$rows = $builder->buildDayRowsForEmployees([$withContract, $noContract], $date);
|
||||
|
||||
@@ -23,6 +23,7 @@ use App\Service\WorkHours\HolidayVirtualHoursResolver;
|
||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
||||
use App\State\WorkHourDayContextProvider;
|
||||
use DateTime;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionObject;
|
||||
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 = $this->createStub(EmployeeContractResolver::class);
|
||||
$resolver->method('resolveForEmployeeAndDate')->willReturnCallback(
|
||||
static fn (Employee $e, \DateTimeImmutable $d): ?Contract =>
|
||||
$d < new \DateTimeImmutable('2026-03-01') ? $timeContract : $forfaitContract
|
||||
static fn (Employee $e, DateTimeImmutable $d): ?Contract => $d < new DateTimeImmutable('2026-03-01') ? $timeContract : $forfaitContract
|
||||
);
|
||||
$resolver->method('resolveNatureForEmployeeAndDate')->willReturn(ContractNature::CDI);
|
||||
|
||||
@@ -180,6 +180,67 @@ final class WorkHourDayContextProviderTest extends TestCase
|
||||
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
|
||||
{
|
||||
$contract = new Contract()
|
||||
|
||||
@@ -21,6 +21,7 @@ use App\Service\PublicHolidayServiceInterface;
|
||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||
use App\Service\WorkHours\DailyReferenceMinutesResolver;
|
||||
use App\Service\WorkHours\HolidayVirtualHoursResolver;
|
||||
use App\Service\WorkHours\NightHoursCalculator;
|
||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
||||
use App\State\WorkHourWeeklySummaryProvider;
|
||||
use DateTime;
|
||||
@@ -69,6 +70,7 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
|
||||
$this->buildHolidayResolver(),
|
||||
$this->buildHolidayService(),
|
||||
$this->buildWeekCommentRepoStub(),
|
||||
new NightHoursCalculator(),
|
||||
);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
@@ -133,6 +135,7 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
|
||||
$this->buildHolidayResolver(),
|
||||
$this->buildHolidayService(),
|
||||
$this->buildWeekCommentRepoStub(),
|
||||
new NightHoursCalculator(),
|
||||
);
|
||||
|
||||
$result = $provider->provide(new Get());
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Util;
|
||||
|
||||
use App\Util\LeaveRecapCutoff;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Cutoff rule: end of ISO week S-1 (previous week's Sunday 23:59:59).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class LeaveRecapCutoffTest extends TestCase
|
||||
{
|
||||
public function testCutoffIsPreviousWeekSunday(): void
|
||||
{
|
||||
// Tuesday 2026-04-14 (S16) → Sunday 2026-04-12 23:59:59 (end of S15).
|
||||
$cutoff = LeaveRecapCutoff::resolveCutoff(new DateTimeImmutable('2026-04-14'));
|
||||
|
||||
self::assertSame('2026-04-12 23:59:59', $cutoff->format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
public function testCutoffFromMondayPointsToPreviousSunday(): void
|
||||
{
|
||||
// Monday 2026-06-08 → previous Sunday 2026-06-07 23:59:59.
|
||||
$cutoff = LeaveRecapCutoff::resolveCutoff(new DateTimeImmutable('2026-06-08'));
|
||||
|
||||
self::assertSame('2026-06-07 23:59:59', $cutoff->format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
public function testCutoffFromSundayPointsToPreviousSunday(): void
|
||||
{
|
||||
// Sunday 2026-06-14 (still in current ISO week) → previous Sunday 2026-06-07.
|
||||
$cutoff = LeaveRecapCutoff::resolveCutoff(new DateTimeImmutable('2026-06-14'));
|
||||
|
||||
self::assertSame('2026-06-07 23:59:59', $cutoff->format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
public function testCutoffIsAlwaysASundayExactlyOneWeekBeforeCurrentWeek(): void
|
||||
{
|
||||
// Today 2026-06-11 (Thursday) → end of S-1 = Sunday 2026-06-07.
|
||||
$cutoff = LeaveRecapCutoff::resolveCutoff(new DateTimeImmutable('2026-06-11'));
|
||||
|
||||
self::assertSame('Sunday', $cutoff->format('l'));
|
||||
self::assertSame('2026-06-07 23:59:59', $cutoff->format('Y-m-d H:i:s'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user