Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e89a1fd7cf | |||
| 327c10fda4 | |||
| ceba1121f0 | |||
| b5bd4db5f1 | |||
| 49ad6306ea | |||
| 9d2e70f81e | |||
| 370bbb491f | |||
| f0387233e4 | |||
| 081d92b9f4 | |||
| 143278a368 | |||
| 2802f9524c | |||
| 589018064b | |||
| 9cc5024e25 | |||
| b6c0dfb90b | |||
| 9dff25d61a | |||
| 6f9d19bda3 | |||
| 2745f4e476 | |||
| 1edb8d956f |
@@ -35,6 +35,8 @@
|
|||||||
- Employee contract history: `employee_contract_periods`, resolved by `EmployeeContractResolver`
|
- Employee contract history: `employee_contract_periods`, resolved by `EmployeeContractResolver`
|
||||||
- **Écrans Heures / Heures Conducteurs (vue jour)** : le libellé nature (CDI/CDD/Intérim) sous le nom de l'employé est résolu **à la date filtrée** via `WorkHourDayContext.contractNature` (alimenté par `EmployeeContractResolver::resolveNatureForEmployeeAndDate`), pas via `Employee.currentContractNature` (qui est résolu à aujourd'hui). Idem pour le **mode de suivi (TIME/PRESENCE), les heures hebdo et le libellé de contrat** sur la vue Jour : résolus à la date filtrée via `WorkHourDayContext` (`trackingMode`/`weeklyHours`/`contractType`/`contractName`, peuplés depuis `EmployeeContractResolver::resolveForEmployeeAndDate`), pas via `employee.contract` (résolu à aujourd'hui). Côté front, `resolveDayContract()` (`useHoursPage.ts`) pilote l'affichage et `handleSave` (heures vs présence par date).
|
- **Écrans Heures / Heures Conducteurs (vue jour)** : le libellé nature (CDI/CDD/Intérim) sous le nom de l'employé est résolu **à la date filtrée** via `WorkHourDayContext.contractNature` (alimenté par `EmployeeContractResolver::resolveNatureForEmployeeAndDate`), pas via `Employee.currentContractNature` (qui est résolu à aujourd'hui). Idem pour le **mode de suivi (TIME/PRESENCE), les heures hebdo et le libellé de contrat** sur la vue Jour : résolus à la date filtrée via `WorkHourDayContext` (`trackingMode`/`weeklyHours`/`contractType`/`contractName`, peuplés depuis `EmployeeContractResolver::resolveForEmployeeAndDate`), pas via `employee.contract` (résolu à aujourd'hui). Côté front, `resolveDayContract()` (`useHoursPage.ts`) pilote l'affichage et `handleSave` (heures vs présence par date).
|
||||||
- **Exports heures annuelles** (par salarié `EmployeeYearlyHoursPrintProvider` + tous `EmployeeYearlyHoursBulkPrintProvider`, via `YearlyHoursExportBuilder`) : **tous les jours sous contrat sont affichés**, même vides ou non saisis (jusqu'à aujourd'hui). Seuls les jours hors contrat sont omis (`buildSegments` : un seul filtre `!$hasData && null === $contract`). Ne pas réintroduire de saut des jours de semaine vides. Samedis/dimanches grisés (`#c0c0c0`) dans les templates `employee-yearly-hours/print*.html.twig`. NB : l'export *tous employés* sur l'année peut dépasser `memory_limit=256M` (Dompdf) — limitation pré-existante, voir avec l'infra si besoin.
|
- **Exports heures annuelles** (par salarié `EmployeeYearlyHoursPrintProvider` + tous `EmployeeYearlyHoursBulkPrintProvider`, via `YearlyHoursExportBuilder`) : **tous les jours sous contrat sont affichés**, même vides ou non saisis (jusqu'à aujourd'hui). Seuls les jours hors contrat sont omis (`buildSegments` : un seul filtre `!$hasData && null === $contract`). Ne pas réintroduire de saut des jours de semaine vides. Samedis/dimanches grisés (`#c0c0c0`) dans les templates `employee-yearly-hours/print*.html.twig`. NB : l'export *tous employés* sur l'année peut dépasser `memory_limit=256M` (Dompdf) — limitation pré-existante, voir avec l'infra si besoin.
|
||||||
|
- **Export heures vue Jour** (`WorkHourDayExportProvider`, endpoint `GET /work-hours/day-export?workDate=&siteIds=`, `ROLE_USER`) : bouton « Exporter » à droite du titre « Heures », **visible uniquement en vue Jour** (`v-if="(isAdmin || isSiteManager) && viewMode === 'day'"`, masqué en vue Semaine et pour `ROLE_SELF`). **Accessible aux admins ET aux chefs de site** : le périmètre est résolu côté backend via `EmployeeRepository::findScoped($user)` (admin → tous les sites, chef de site → ses sites uniquement, cf. `EmployeeScopeService`), donc un `siteIds` hors périmètre est ignoré ; le drawer front ne propose que les sites visibles (`sites` dérivé des employés scopés). PDF A4 portrait d'**une seule journée**, **regroupé par site**, colonnes de la vue Jour **sans « Valider »** (colonne **Total en gras**). Mêmes employés que l'écran : non-conducteurs, sous contrat à la date, sites cochés et dans le périmètre (lignes vides incluses). **Tri intra-site identique au calendrier** : `displayOrder` (ordre manuel), puis nom, puis prénom (cf. `compareEmployeesInSite` front). Calcul des cellules mutualisé via `YearlyHoursExportBuilder::buildDayRowsForEmployees` (Jour/Nuit/Total incluent crédit absence + crédit virtuel férié). Colonne **Statut = code** du type d'absence (`AbsenceType::getCode`, ex. `AT`) sur sa couleur de fond ; férié sans absence → nom du férié sur `#b3e5fc`. Chaque row porte `statut` (code), `statutLabel` (libellé, pour la légende) et `statutColor`. **Légende** sous le tableau (carré coloré contenant le code + libellé à droite), construite côté provider à partir des codes présents (hors férié, dédupliquée par code, triée). Gabarit `templates/work-hour-day-export/print.html.twig`.
|
||||||
|
- **Export 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.
|
- **É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`.
|
- **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
|
- Absences: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots
|
||||||
@@ -65,7 +67,8 @@
|
|||||||
## Overtime Rules
|
## Overtime Rules
|
||||||
- Contracts <= 35h: +25% from 35h to 43h, +50% beyond
|
- Contracts <= 35h: +25% from 35h to 43h, +50% beyond
|
||||||
- Contracts >= 39h: +25% from 39h to 43h, +50% beyond
|
- Contracts >= 39h: +25% from 39h to 43h, +50% beyond
|
||||||
- CUSTOM contracts (weeklyHours ≠ 35 and ≠ 39, not INTERIM/FORFAIT): reference = actual contractual hours, no 25%/50% bonuses (1h overtime = 1h recovery), deficit doesn't impact balance
|
- 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}`.
|
||||||
- **Ancre de semaine (type de contrat)** : le type/nature de contrat d'une semaine RTT est résolu sur le **premier jour contracté** de la semaine, pas sur le lundi (`RttRecoveryComputationService::resolveWeekAnchorDate`). Sinon une semaine d'embauche en milieu de semaine (lundi hors contrat) serait classée CUSTOM → bonus 25%/50% désactivés à tort. Ex. CDD 39h embauché le jeudi : la semaine reste 39h, le seuil 25% est proraté aux jours contractés (`computeWeeklyOvertime25StartMinutes`), donc les heures au-delà ouvrent bien le +25%.
|
- **Ancre de semaine (type de contrat)** : le type/nature de contrat d'une semaine RTT est résolu sur le **premier jour contracté** de la semaine, pas sur le lundi (`RttRecoveryComputationService::resolveWeekAnchorDate`). Sinon une semaine d'embauche en milieu de semaine (lundi hors contrat) serait classée CUSTOM → bonus 25%/50% désactivés à tort. Ex. CDD 39h embauché le jeudi : la semaine reste 39h, le seuil 25% est proraté aux jours contractés (`computeWeeklyOvertime25StartMinutes`), donc les heures au-delà ouvrent bien le +25%.
|
||||||
- **Plafond 25%/50% proraté (mi-semaine)** : le plafond séparant 25% et 50% n'est **pas** codé en dur à 43h mais vaut `seuil_départ_proraté + largeur_bande_25%` (`RttRecoveryComputationService::{resolveOvertime25BandWidthMinutes, computeOvertimeBaseMinutes}`). Largeur = 43h − base (4h pour un 39h, 8h pour un 35h). Pour une semaine pleine le plafond redonne 43h (aucune régression) ; pour une embauche mi-semaine il se décale avec le départ, ouvrant la tranche 50%. Témoin Dylan (CDD 39h embauché jeudi, 22h) : 4h à 25% + 3h à 50%. **Hors périmètre** : l'écran Heures (`WorkHourWeeklySummaryProvider`) n'a pas cette proratisation (calcul dupliqué, laissé tel quel par décision métier).
|
- **Plafond 25%/50% proraté (mi-semaine)** : le plafond séparant 25% et 50% n'est **pas** codé en dur à 43h mais vaut `seuil_départ_proraté + largeur_bande_25%` (`RttRecoveryComputationService::{resolveOvertime25BandWidthMinutes, computeOvertimeBaseMinutes}`). Largeur = 43h − base (4h pour un 39h, 8h pour un 35h). Pour une semaine pleine le plafond redonne 43h (aucune régression) ; pour une embauche mi-semaine il se décale avec le départ, ouvrant la tranche 50%. Témoin Dylan (CDD 39h embauché jeudi, 22h) : 4h à 25% + 3h à 50%. **Hors périmètre** : l'écran Heures (`WorkHourWeeklySummaryProvider`) n'a pas cette proratisation (calcul dupliqué, laissé tel quel par décision métier).
|
||||||
- INTERIM: no overtime bonuses, no recovery time
|
- INTERIM: no overtime bonuses, no recovery time
|
||||||
@@ -96,8 +99,33 @@
|
|||||||
- Service mutualisé : `App\Service\Rtt\RttClosingBalanceService` (méthode `computeClosingBalance` + `fold` pur testable). `fold` garantit `somme(tranches) = report + acquis − payés` ; la cascade des semaines déficitaires draine la tranche 50% avant la 25%, et la récup non bucketisée (CUSTOM 1h=1h, arrondis) atterrit en `base25` pour que la somme égale le total.
|
- Service mutualisé : `App\Service\Rtt\RttClosingBalanceService` (méthode `computeClosingBalance` + `fold` pur testable). `fold` garantit `somme(tranches) = report + acquis − payés` ; la cascade des semaines déficitaires draine la tranche 50% avant la 25%, et la récup non bucketisée (CUSTOM 1h=1h, arrondis) atterrit en `base25` pour que la somme égale le total.
|
||||||
- Options : `--force` (hors 01/06) ; `--recompute` (recalcule/écrase les lignes existantes au lieu de les sauter ; **ne touche jamais** une ligne verrouillée `is_locked`). Reprise d'une bascule erronée : `app:rtt:rollover --force --recompute`.
|
- Options : `--force` (hors 01/06) ; `--recompute` (recalcule/écrase les lignes existantes au lieu de les sauter ; **ne touche jamais** une ligne verrouillée `is_locked`). Reprise d'une bascule erronée : `app:rtt:rollover --force --recompute`.
|
||||||
- ⚠️ Bug historique : la 1ʳᵉ version ne reportait que `acquis(N-1)` (report d'ouverture perdu, paiements non déduits). Corrigé via `RttClosingBalanceService`.
|
- ⚠️ Bug historique : la 1ʳᵉ version ne reportait que `acquis(N-1)` (report d'ouverture perdu, paiements non déduits). Corrigé via `RttClosingBalanceService`.
|
||||||
|
- **Fallback provider** : quand aucune ligne `employee_rtt_balances` n'existe pour l'exercice affiché (avant la bascule), `EmployeeRttSummaryProvider::resolveCarry` calcule le report en direct via `RttClosingBalanceService::computeClosingBalance($year-1)` (et non plus `computeTotalRecoveryForExercise`) — le disponible reste donc correct (report d'ouverture + acquis − payés) même sans ligne stockée.
|
||||||
- Doc : `doc/rtt-rollover.md`.
|
- Doc : `doc/rtt-rollover.md`.
|
||||||
|
|
||||||
|
## Paiement RTT rétroactif (exercice précédent) — Option B
|
||||||
|
- Le paiement RTT est autorisé sur : l'**exercice courant**, l'**exercice immédiatement précédent** (N-1), ou le dernier exercice d'une phase clôturée. Garde back : `EmployeeRttPaymentProcessor::assertYearAllowedForPayment`. Garde front : `RttTab.vue` `isPayDisabled` (bouton actif sur `selectedYear === currentYear - 1`).
|
||||||
|
- **Cohérence du report** : un paiement sur N-1 modifie la clôture de N-1 = ouverture de N. Le processor **recalcule automatiquement** la ligne `employee_rtt_balances` de l'exercice courant (`computeClosingBalance(N-1)`) dans une **transaction** (le `flush` du paiement le rend visible au recalcul). Pas de double comptage.
|
||||||
|
- **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). **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`.
|
||||||
|
- **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)
|
## 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.
|
- 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`.
|
- Phase = groupe d'`EmployeeContractPeriod` consécutifs partageant la signature `(contract.type, weeklyHours, isDriver)`. Résolu par `App\Service\Contracts\EmployeeContractPhaseResolver`.
|
||||||
@@ -117,7 +145,7 @@
|
|||||||
## Récap. congés (écran)
|
## 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.
|
- 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
|
- 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()`
|
- Helper : `App\Util\LeaveRecapCutoff::resolveCutoff()`
|
||||||
- Colonnes : Nom, Prénom, Contrat, CP N-1 restant, CP N, Samedis, RTT — identiques au PDF
|
- 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)
|
- Service partagé : `LeaveRecapRowBuilder` consommé par `LeaveRecapPrintProvider` (as-of today) et `EmployeeLeaveRecapProvider` (as-of cutoff)
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.106'
|
app.version: '0.1.115'
|
||||||
|
|||||||
+34
-4
@@ -140,7 +140,20 @@ Documents complementaires:
|
|||||||
- Contrats CUSTOM (heures hebdo ≠ 35h et ≠ 39h, hors INTERIM/FORFAIT):
|
- Contrats CUSTOM (heures hebdo ≠ 35h et ≠ 39h, hors INTERIM/FORFAIT):
|
||||||
- référence heures sup = heures contractuelles réelles (ex: 4h → référence 4h)
|
- référence heures sup = heures contractuelles réelles (ex: 4h → référence 4h)
|
||||||
- pas de bonus 25% ni 50% : 1 heure sup = 1 heure de récupération
|
- pas de bonus 25% ni 50% : 1 heure sup = 1 heure de récupération
|
||||||
- le déficit (travail < contrat) ne génère pas de récup mais n'impacte pas le solde
|
- le déficit (travail < contrat) réduit le cumul RTT 1:1 (peut devenir négatif, reporté N+1)
|
||||||
|
|
||||||
|
### Jour de solidarité (contrats CUSTOM < 35h)
|
||||||
|
|
||||||
|
Le Lundi de Pentecôte (jour de solidarité) impose une contribution proratisée aux temps
|
||||||
|
partiels < 35h. La RH pose un RTT sur ce jour pour tous les salariés ; pour les contrats
|
||||||
|
standard (35h/39h) cela draine ~7h du cumul RTT (comportement inchangé). Pour les CUSTOM
|
||||||
|
< 35h, poser un RTT entier n'a pas de sens : le logiciel **neutralise** le jour (quel que
|
||||||
|
soit ce qui y est saisi) et applique un déficit forfaitaire `7/35 × heuresHebdo`
|
||||||
|
(= 12 min par heure hebdo : 4h → 48 min, 28h → 5h36). Ce déficit réduit le cumul RTT
|
||||||
|
(peut le rendre négatif, reporté à l'exercice suivant) et se cumule avec les autres
|
||||||
|
déficits/surplus de la semaine. Date calculée par computus (Pâques + 50 jours),
|
||||||
|
indépendante de la liste `EXCLUDED_PUBLIC_HOLIDAYS`.
|
||||||
|
|
||||||
- Nature `INTERIM`:
|
- Nature `INTERIM`:
|
||||||
- pas de bonus 25%
|
- pas de bonus 25%
|
||||||
- pas de bonus 50%
|
- pas de bonus 50%
|
||||||
@@ -361,14 +374,31 @@ Seuls les employés dont au moins une période de contrat intersecte la période
|
|||||||
- `ROLE_ADMIN` : tous les employés
|
- `ROLE_ADMIN` : tous les employés
|
||||||
- `ROLE_USER` (chef de site) : employés des sites autorisés (`UserSiteRole`)
|
- `ROLE_USER` (chef de site) : employés des sites autorisés (`UserSiteRole`)
|
||||||
- `ROLE_SELF` : uniquement son employé lié
|
- `ROLE_SELF` : uniquement son employé lié
|
||||||
- **Cutoff temporel** : le récap est figé à la fin de la semaine S-2 (dimanche 23:59:59)
|
- **Cutoff temporel** : le récap est figé à la fin de la semaine S-1 (dimanche 23:59:59)
|
||||||
- Formule : `cutoffDate = dimanche(lundi_semaine_courante − 14 jours)`
|
- Formule : `cutoffDate = dimanche(lundi_semaine_courante − 7 jours)`
|
||||||
- Exemple : mardi 14/04/2026 (S16) → dimanche 05/04/2026 (fin S14)
|
- Exemple : mardi 14/04/2026 (S16) → dimanche 12/04/2026 (fin S15)
|
||||||
- `isValid` n'entre PAS en compte : cutoff purement temporel
|
- `isValid` n'entre PAS en compte : cutoff purement temporel
|
||||||
- Les heures et absences postérieures au cutoff sont ignorées dans les calculs
|
- Les heures et absences postérieures au cutoff sont ignorées dans les calculs
|
||||||
- Colonnes identiques au PDF (voir §10)
|
- Colonnes identiques au PDF (voir §10)
|
||||||
- Détails techniques : voir `doc/leave-recap-screen.md`
|
- 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)
|
## 11) Récapitulatif Salaire (PDF mensuel)
|
||||||
|
|
||||||
- Accessible depuis la page Employés via le bouton "Récap. Salaire" (réservé `ROLE_ADMIN`)
|
- Accessible depuis la page Employés via le bouton "Récap. Salaire" (réservé `ROLE_ADMIN`)
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Export PDF des heures — vue Jour
|
||||||
|
|
||||||
|
Bouton **Exporter** à droite du titre « Heures », visible pour les **administrateurs**
|
||||||
|
(`ROLE_ADMIN`) **et les chefs de site** (`ROLE_USER`), **uniquement en vue Jour** (masqué
|
||||||
|
en vue Semaine, masqué pour les profils employé `ROLE_SELF`).
|
||||||
|
|
||||||
|
## Périmètre
|
||||||
|
- **Administrateur** : peut exporter tous les sites.
|
||||||
|
- **Chef de site** : ne voit dans le drawer que **ses sites** et ne peut exporter que
|
||||||
|
ceux-ci. Le périmètre est appliqué côté backend (`EmployeeRepository::findScoped`) — un
|
||||||
|
`siteIds` forcé hors de son périmètre est ignoré, aucune donnée d'un autre site ne fuit.
|
||||||
|
|
||||||
|
## Comportement
|
||||||
|
- Ouvre un drawer : un champ **date** (préremplit la date affichée) et des **cases à
|
||||||
|
cocher des sites** (limitées au périmètre de l'utilisateur).
|
||||||
|
- Génère un **PDF A4 portrait** d'une seule journée, **regroupé par site**.
|
||||||
|
|
||||||
|
## Données
|
||||||
|
- Mêmes employés que la vue Jour : **non-conducteurs**, **sous contrat** à la date
|
||||||
|
choisie, des sites cochés et **dans le périmètre de l'utilisateur**. Les employés sous
|
||||||
|
contrat sans saisie apparaissent (lignes vides).
|
||||||
|
- Colonnes : Nom · Statut · Début matin · Fin matin · Début après-midi · Fin après-midi ·
|
||||||
|
Début soir · Fin soir · Jour · Nuit · **Total** (en gras). **Pas de colonne « Valider ».**
|
||||||
|
- Colonne **Statut** : affiche le **code** du type d'absence (ex. `AT`), pas le libellé,
|
||||||
|
sur la couleur de fond du type. Un jour férié sans absence affiche le **nom du férié**
|
||||||
|
sur fond bleu clair (`#b3e5fc`).
|
||||||
|
- Jour / Nuit / Total : identiques à l'écran (crédit d'absence `countAsWorkedHours` et
|
||||||
|
crédit virtuel férié inclus).
|
||||||
|
- **Légende** sous le tableau : pour chaque code d'absence présent (hors férié), un carré
|
||||||
|
de couleur contenant le code et le libellé du type à droite. Triée par code, dédupliquée.
|
||||||
|
|
||||||
|
## Technique
|
||||||
|
- Endpoint : `GET /work-hours/day-export?workDate=YYYY-MM-DD&siteIds=1,2,3` (`ROLE_USER`).
|
||||||
|
- Provider : `App\State\WorkHourDayExportProvider` — résout le périmètre via
|
||||||
|
`EmployeeRepository::findScoped($user)` (admin → tous, chef de site → ses sites).
|
||||||
|
- Calcul des cellules : `YearlyHoursExportBuilder::buildDayRowsForEmployees` (source
|
||||||
|
unique de vérité, partagée avec les exports annuels).
|
||||||
|
- Gabarit : `templates/work-hour-day-export/print.html.twig`.
|
||||||
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
## Objet
|
## 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.
|
Complémentaire à l'export PDF admin : mêmes colonnes, accès étendu aux employés et chefs de site.
|
||||||
|
|
||||||
## Cutoff
|
## 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
|
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.
|
et absences postérieures au cutoff sont ignorées dans le calcul des soldes.
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# 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).
|
||||||
|
- **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).
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -96,6 +96,12 @@ Traitement par employe:
|
|||||||
|
|
||||||
> Regle clef : le report d'un exercice a l'autre reprend exactement le **disponible** affiche sur l'onglet RTT (cf. `EmployeeRttSummaryProvider`). Le report deja present au debut de l'exercice precedent n'est jamais perdu, et les heures deja payees ne sont pas re-creditees. Service mutualise : `App\Service\Rtt\RttClosingBalanceService`.
|
> Regle clef : le report d'un exercice a l'autre reprend exactement le **disponible** affiche sur l'onglet RTT (cf. `EmployeeRttSummaryProvider`). Le report deja present au debut de l'exercice precedent n'est jamais perdu, et les heures deja payees ne sont pas re-creditees. Service mutualise : `App\Service\Rtt\RttClosingBalanceService`.
|
||||||
|
|
||||||
|
> Contrats CUSTOM : le solde de clôture intègre désormais les **déficits** hebdomadaires
|
||||||
|
> (semaines travaillées sous les heures contractuelles), via `RttClosingBalanceService::fold`
|
||||||
|
> qui gère les totaux négatifs. La clôture (donc le report d'ouverture N+1) peut être négative.
|
||||||
|
> Après une mise à jour de cette règle, rejouer `app:rtt:rollover --force --recompute` pour
|
||||||
|
> recalculer les lignes `employee_rtt_balances` non verrouillées calculées avec l'ancienne règle.
|
||||||
|
|
||||||
> Bug historique corrige : la version initiale ne reportait que `acquis N-1` (ni report d'ouverture, ni deduction des paiements), ce qui faisait disparaitre le solde de depart. Pour corriger des lignes deja creees a tort, relancer avec `--force --recompute`.
|
> Bug historique corrige : la version initiale ne reportait que `acquis N-1` (ni report d'ouverture, ni deduction des paiements), ce qui faisait disparaitre le solde de depart. Pour corriger des lignes deja creees a tort, relancer avec `--force --recompute`.
|
||||||
|
|
||||||
## 7) Donnees a fournir au go-live
|
## 7) Donnees a fournir au go-live
|
||||||
|
|||||||
+30
-2
@@ -16,6 +16,25 @@ L'onglet est **masqué pour les contrats FORFAIT** (filtre `showRttTab` dans `us
|
|||||||
|
|
||||||
Toujours **Juin (Y-1) → Mai (Y)**. Le champ `EmployeeRttSummary.year` correspond à `Y` (année de fin d'exercice) ; ex. `year=2026` = `01/06/2025 → 31/05/2026`.
|
Toujours **Juin (Y-1) → Mai (Y)**. Le champ `EmployeeRttSummary.year` correspond à `Y` (année de fin d'exercice) ; ex. `year=2026` = `01/06/2025 → 31/05/2026`.
|
||||||
|
|
||||||
|
## Règle de calcul — contrats CUSTOM (4h, 25h…)
|
||||||
|
|
||||||
|
Pour un contrat CUSTOM, la récupération est **plate** (1h sup = 1h récup, sans bonus 25 %/50 %).
|
||||||
|
Depuis 2026-06, une semaine **travaillée sous les heures contractuelles** produit un **déficit
|
||||||
|
signé** dans la colonne « Heure » qui **réduit le « Total » et le « Cumul »** (1h manquante =
|
||||||
|
-1h). Les colonnes Base/25 %/50 % restent à **0** (pas de tranches pour ces contrats). Le cumul
|
||||||
|
peut devenir négatif ; il est reporté à l'exercice suivant.
|
||||||
|
|
||||||
|
Techniquement : `WeekRecoveryDetail::isFlatRecovery` marque ces semaines ;
|
||||||
|
`EmployeeRttSummaryProvider::applyDeficitCascade` les exclut du drainage des tranches 25/50.
|
||||||
|
|
||||||
|
#### Jour de solidarité (CUSTOM < 35h)
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
## Sélecteur d'année
|
## Sélecteur d'année
|
||||||
|
|
||||||
Position : sous la table, à l'intérieur de la zone scrollable, à gauche.
|
Position : sous la table, à l'intérieur de la zone scrollable, à gauche.
|
||||||
@@ -34,9 +53,18 @@ Comportement :
|
|||||||
|
|
||||||
## Verrouillage des éditions sur exercices passés
|
## Verrouillage des éditions sur exercices passés
|
||||||
|
|
||||||
Quand `selectedYear !== currentYear` (consultation d'un exercice antérieur), le bouton **+ Payer les RTT** est désactivé. Justification : un paiement rétroactif sur un exercice clos décalerait les soldes courants et le report N-1 calculé.
|
Quand `selectedYear !== currentYear` (consultation d'un exercice antérieur), le bouton **+ Payer les RTT** est désactivé — **sauf sur l'exercice immédiatement précédent** (`selectedYear === currentYear - 1`), où le paiement rétroactif est autorisé (Option B).
|
||||||
|
|
||||||
La consultation reste possible, l'édition non.
|
La consultation des exercices plus anciens reste possible, l'édition non.
|
||||||
|
|
||||||
|
### Paiement rétroactif sur l'exercice précédent (Option B)
|
||||||
|
|
||||||
|
Un paiement enregistré sur l'exercice N-1 modifie sa clôture, donc le **report d'ouverture de l'exercice courant N**. Pour éviter tout décalage / double comptage :
|
||||||
|
|
||||||
|
- garde back `EmployeeRttPaymentProcessor::assertYearAllowedForPayment` : accepte courant, **N-1**, ou dernier exercice d'une phase clôturée ;
|
||||||
|
- après enregistrement, le processor **recalcule automatiquement** la ligne `employee_rtt_balances` de l'exercice courant via `RttClosingBalanceService::computeClosingBalance(N-1)`, dans une **transaction** (le `flush` du paiement le rend visible au recalcul) ;
|
||||||
|
- si le report de l'exercice courant est **verrouillé** (`is_locked`), le paiement est **refusé** (`assertReportNotLocked`) : la RH doit déverrouiller d'abord ;
|
||||||
|
- portée volontairement limitée à N-1 (chaîne de recalcul = 1 étape). Si la ligne courante n'existe pas encore, l'affichage reste correct grâce au fallback de `EmployeeRttSummaryProvider::resolveCarry` (calcul dynamique de la clôture N-1).
|
||||||
|
|
||||||
## Sélecteur de phase de contrat
|
## Sélecteur de phase de contrat
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,854 @@
|
|||||||
|
# Export PDF des heures — vue Jour — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Ajouter un bouton « Exporter » (admin) sur l'écran Heures qui génère un PDF d'une journée (colonnes de la vue Jour, sans Valider) pour les employés des sites sélectionnés, regroupés par site.
|
||||||
|
|
||||||
|
**Architecture:** Réutilisation de `YearlyHoursExportBuilder` (nouvelle méthode `buildDayRowsForEmployees`) pour le calcul des cellules d'une journée — source unique de vérité. Une `ApiResource` GET `/work-hours/day-export` + provider rend un Twig A4 portrait via Dompdf. Côté front, un `AppDrawer` (date + checkboxes sites) déclenche le téléchargement via `usePdfPrinter`.
|
||||||
|
|
||||||
|
**Tech Stack:** Symfony + API Platform + Doctrine, Dompdf, Twig ; Nuxt 4 + Vue 3 + TypeScript + `@malio/layer-ui`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Backend**
|
||||||
|
- `src/Service/WorkHours/YearlyHoursExportBuilder.php` (modifier) — ajout méthode publique `buildDayRowsForEmployees`.
|
||||||
|
- `tests/Service/WorkHours/YearlyHoursDayRowsTest.php` (créer) — test unitaire de la nouvelle méthode.
|
||||||
|
- `src/ApiResource/WorkHourDayExport.php` (créer) — opération GET `/work-hours/day-export`.
|
||||||
|
- `src/State/WorkHourDayExportProvider.php` (créer) — parse params, scope/filtre/groupe, rend le PDF.
|
||||||
|
- `templates/work-hour-day-export/print.html.twig` (créer) — gabarit A4 portrait.
|
||||||
|
|
||||||
|
**Frontend**
|
||||||
|
- `frontend/components/hours/HoursDayExportDrawer.vue` (créer) — drawer date + sites.
|
||||||
|
- `frontend/pages/hours.vue` (modifier) — bouton « Exporter » + câblage drawer + appel export.
|
||||||
|
|
||||||
|
**Docs**
|
||||||
|
- `doc/hours-day-export.md` (créer).
|
||||||
|
- `frontend/data/documentation-content.ts` (modifier) — entrée admin.
|
||||||
|
- `CLAUDE.md` (modifier) — note sous la section exports heures.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1 : Méthode `buildDayRowsForEmployees` (backend, TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Test: `tests/Service/WorkHours/YearlyHoursDayRowsTest.php`
|
||||||
|
- Modify: `src/Service/WorkHours/YearlyHoursExportBuilder.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Écrire le test qui échoue**
|
||||||
|
|
||||||
|
Créer `tests/Service/WorkHours/YearlyHoursDayRowsTest.php` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service\WorkHours;
|
||||||
|
|
||||||
|
use App\Entity\Contract;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\WorkHour;
|
||||||
|
use App\Repository\AbsenceRepository;
|
||||||
|
use App\Repository\WorkHourRepository;
|
||||||
|
use App\Service\Contracts\EmployeeContractResolver;
|
||||||
|
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||||
|
use App\Service\WorkHours\DailyReferenceMinutesResolver;
|
||||||
|
use App\Service\WorkHours\HolidayVirtualHoursResolver;
|
||||||
|
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
||||||
|
use App\Service\WorkHours\YearlyHoursExportBuilder;
|
||||||
|
use App\Service\PublicHolidayServiceInterface;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class YearlyHoursDayRowsTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testTimeContractRowComputesHoursAndExcludesNoContract(): void
|
||||||
|
{
|
||||||
|
$date = new DateTimeImmutable('2026-06-08'); // lundi
|
||||||
|
|
||||||
|
$contract = new Contract();
|
||||||
|
$contract->setName('35h');
|
||||||
|
$contract->setTrackingMode(Contract::TRACKING_TIME);
|
||||||
|
$contract->setWeeklyHours(35);
|
||||||
|
|
||||||
|
$withContract = new Employee();
|
||||||
|
$withContract->setFirstName('Jean')->setLastName('Dupont');
|
||||||
|
$this->setEmployeeId($withContract, 1);
|
||||||
|
|
||||||
|
$noContract = new Employee();
|
||||||
|
$noContract->setFirstName('Paul')->setLastName('Martin');
|
||||||
|
$this->setEmployeeId($noContract, 2);
|
||||||
|
|
||||||
|
$workHour = new WorkHour();
|
||||||
|
$workHour->setEmployee($withContract)
|
||||||
|
->setWorkDate($date)
|
||||||
|
->setMorningFrom('08:00')->setMorningTo('12:00')
|
||||||
|
->setAfternoonFrom('13:00')->setAfternoonTo('17:00');
|
||||||
|
|
||||||
|
$workHourRepo = $this->createStub(WorkHourRepository::class);
|
||||||
|
$workHourRepo->method('findByDateRangeAndEmployees')->willReturn([$workHour]);
|
||||||
|
|
||||||
|
$absenceRepo = $this->createStub(AbsenceRepository::class);
|
||||||
|
$absenceRepo->method('findForPrint')->willReturn([]);
|
||||||
|
|
||||||
|
$contractResolver = $this->createStub(EmployeeContractResolver::class);
|
||||||
|
$contractResolver->method('resolveForEmployeesAndDays')->willReturn([
|
||||||
|
1 => ['2026-06-08' => $contract],
|
||||||
|
2 => ['2026-06-08' => null],
|
||||||
|
]);
|
||||||
|
$contractResolver->method('resolveIsDriverForEmployeesAndDays')->willReturn([
|
||||||
|
1 => ['2026-06-08' => false],
|
||||||
|
2 => ['2026-06-08' => false],
|
||||||
|
]);
|
||||||
|
$contractResolver->method('resolveWorkDaysMinutesForEmployeesAndDays')->willReturn([
|
||||||
|
1 => ['2026-06-08' => null],
|
||||||
|
2 => ['2026-06-08' => null],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$holidayService = $this->createStub(PublicHolidayServiceInterface::class);
|
||||||
|
$holidayService->method('getHolidaysDayByYears')->willReturn([]);
|
||||||
|
|
||||||
|
$virtualResolver = $this->createStub(HolidayVirtualHoursResolver::class);
|
||||||
|
$virtualResolver->method('resolveVirtualCredit')->willReturn(0);
|
||||||
|
|
||||||
|
$builder = new YearlyHoursExportBuilder(
|
||||||
|
$workHourRepo,
|
||||||
|
$absenceRepo,
|
||||||
|
$contractResolver,
|
||||||
|
new AbsenceSegmentsResolver(),
|
||||||
|
new WorkedHoursCreditPolicy($contractResolver, new DailyReferenceMinutesResolver()),
|
||||||
|
$holidayService,
|
||||||
|
$virtualResolver,
|
||||||
|
);
|
||||||
|
|
||||||
|
$rows = $builder->buildDayRowsForEmployees([$withContract, $noContract], $date);
|
||||||
|
|
||||||
|
self::assertCount(1, $rows);
|
||||||
|
self::assertSame(1, $rows[0]['employeeId']);
|
||||||
|
self::assertSame('Dupont Jean', $rows[0]['employeeName']);
|
||||||
|
self::assertSame('08:00', $rows[0]['morningFrom']);
|
||||||
|
self::assertSame('17:00', $rows[0]['afternoonTo']);
|
||||||
|
self::assertSame('8:00', $rows[0]['total']);
|
||||||
|
self::assertSame('8:00', $rows[0]['dayHours']);
|
||||||
|
self::assertSame('', $rows[0]['nightHours']);
|
||||||
|
self::assertNull($rows[0]['statut']);
|
||||||
|
self::assertFalse($rows[0]['isWeekend']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setEmployeeId(Employee $employee, int $id): void
|
||||||
|
{
|
||||||
|
$ref = new \ReflectionProperty(Employee::class, 'id');
|
||||||
|
$ref->setAccessible(true);
|
||||||
|
$ref->setValue($employee, $id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Lancer le test, vérifier l'échec**
|
||||||
|
|
||||||
|
Run: `make test` (ou `docker exec -t -u www-data php-sirh-fpm php vendor/bin/phpunit --filter YearlyHoursDayRowsTest`)
|
||||||
|
Expected: FAIL — `Call to undefined method ...::buildDayRowsForEmployees()`.
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Implémenter la méthode**
|
||||||
|
|
||||||
|
Dans `src/Service/WorkHours/YearlyHoursExportBuilder.php`, ajouter cette méthode publique (après `buildForEmployee`, avant `buildContractLabel`) :
|
||||||
|
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* Construit une ligne par employé pour une seule journée (vue Jour de l'écran Heures).
|
||||||
|
* Réutilise les helpers de calcul de cellule pour rester l'unique source de vérité.
|
||||||
|
* Les employés sans contrat ce jour sont exclus (comme l'écran).
|
||||||
|
*
|
||||||
|
* @param list<Employee> $employees
|
||||||
|
*
|
||||||
|
* @return list<array{employeeId:int, employeeName:string, statut:?string,
|
||||||
|
* morningFrom:string, morningTo:string, afternoonFrom:string, afternoonTo:string,
|
||||||
|
* eveningFrom:string, eveningTo:string, dayHours:string, nightHours:string,
|
||||||
|
* total:string, isWeekend:bool, isHoliday:bool}>
|
||||||
|
*/
|
||||||
|
public function buildDayRowsForEmployees(array $employees, DateTimeImmutable $date): array
|
||||||
|
{
|
||||||
|
$ymd = $date->format('Y-m-d');
|
||||||
|
$days = [$ymd];
|
||||||
|
|
||||||
|
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($date, $date, $employees);
|
||||||
|
$absences = $this->absenceRepository->findForPrint($date, $date, $employees);
|
||||||
|
$contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
|
||||||
|
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
|
||||||
|
$workDaysMap = $this->contractResolver->resolveWorkDaysMinutesForEmployeesAndDays($employees, $days);
|
||||||
|
$holidayMap = $this->buildHolidayMap($date, $date);
|
||||||
|
|
||||||
|
$workHourMap = $this->buildWorkHourMap($workHours);
|
||||||
|
$absenceMap = $this->buildAbsenceMap($absences, $days);
|
||||||
|
|
||||||
|
$isoDay = (int) $date->format('N');
|
||||||
|
$isWeekend = $isoDay >= 6;
|
||||||
|
$holidayLabel = $holidayMap[$ymd] ?? null;
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($employees as $employee) {
|
||||||
|
$employeeId = $employee->getId();
|
||||||
|
$contract = $contractMap[$employeeId][$ymd] ?? null;
|
||||||
|
|
||||||
|
// Hors contrat ce jour → exclu (avant embauche / après départ / suspension).
|
||||||
|
if (null === $contract) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$wh = $workHourMap[$employeeId][$ymd] ?? null;
|
||||||
|
$absenceData = $this->resolveAbsenceDataForEmployee($absenceMap[$employeeId] ?? [], $days, $employee);
|
||||||
|
$hasAbsence = $absenceData['hasDayAbsence'][$ymd] ?? false;
|
||||||
|
|
||||||
|
$isDriver = $driverMap[$employeeId][$ymd] ?? false;
|
||||||
|
$mode = $this->resolveSegmentMode($contract->getTrackingMode(), $isDriver);
|
||||||
|
$creditedMinutes = $absenceData['credited'][$ymd] ?? 0;
|
||||||
|
$virtualMinutes = $this->holidayVirtualHoursResolver->resolveVirtualCredit(
|
||||||
|
$contract,
|
||||||
|
$date,
|
||||||
|
$hasAbsence,
|
||||||
|
$workDaysMap[$employeeId][$ymd] ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$statut = $absenceData['labels'][$ymd] ?? null;
|
||||||
|
if (null === $statut && null !== $holidayLabel) {
|
||||||
|
$statut = $holidayLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = [
|
||||||
|
'employeeId' => $employeeId,
|
||||||
|
'employeeName' => trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? '')),
|
||||||
|
'statut' => $statut,
|
||||||
|
'morningFrom' => '',
|
||||||
|
'morningTo' => '',
|
||||||
|
'afternoonFrom' => '',
|
||||||
|
'afternoonTo' => '',
|
||||||
|
'eveningFrom' => '',
|
||||||
|
'eveningTo' => '',
|
||||||
|
'dayHours' => '',
|
||||||
|
'nightHours' => '',
|
||||||
|
'total' => '',
|
||||||
|
'isWeekend' => $isWeekend,
|
||||||
|
'isHoliday' => null !== $holidayLabel,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ('presence' === $mode) {
|
||||||
|
$absentMorning = $absenceData['absentMorning'][$ymd] ?? false;
|
||||||
|
$absentAfternoon = $absenceData['absentAfternoon'][$ymd] ?? false;
|
||||||
|
$morning = (($wh?->getIsPresentMorning() ?? false) && !$absentMorning) ? 0.5 : 0.0;
|
||||||
|
$afternoon = (($wh?->getIsPresentAfternoon() ?? false) && !$absentAfternoon) ? 0.5 : 0.0;
|
||||||
|
$total = $morning + $afternoon;
|
||||||
|
$row['total'] = $total > 0 ? (string) $total : '';
|
||||||
|
} elseif ('driver' === $mode) {
|
||||||
|
$dayMin = $wh?->getDayHoursMinutes() ?? 0;
|
||||||
|
$nightMin = $wh?->getNightHoursMinutes() ?? 0;
|
||||||
|
$workshop = $wh?->getWorkshopHoursMinutes() ?? 0;
|
||||||
|
$totalMin = $dayMin + $nightMin + $workshop + $creditedMinutes;
|
||||||
|
if ($virtualMinutes > $totalMin) {
|
||||||
|
$totalMin = $virtualMinutes;
|
||||||
|
}
|
||||||
|
$row['dayHours'] = $dayMin > 0 ? $this->formatMinutes($dayMin) : '';
|
||||||
|
$row['nightHours'] = $nightMin > 0 ? $this->formatMinutes($nightMin) : '';
|
||||||
|
$row['total'] = $totalMin > 0 ? $this->formatMinutes($totalMin) : '';
|
||||||
|
} else {
|
||||||
|
$metrics = null !== $wh ? $this->computeMetrics($wh) : new WorkMetrics();
|
||||||
|
$metrics->addCreditedMinutes($creditedMinutes);
|
||||||
|
$dayMin = $metrics->dayMinutes;
|
||||||
|
$nightMin = $metrics->nightMinutes;
|
||||||
|
$totalMin = $metrics->totalMinutes;
|
||||||
|
if ($virtualMinutes > $totalMin) {
|
||||||
|
$dayMin += $virtualMinutes - $totalMin;
|
||||||
|
$totalMin = $virtualMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row['morningFrom'] = $wh?->getMorningFrom() ?? '';
|
||||||
|
$row['morningTo'] = $wh?->getMorningTo() ?? '';
|
||||||
|
$row['afternoonFrom'] = $wh?->getAfternoonFrom() ?? '';
|
||||||
|
$row['afternoonTo'] = $wh?->getAfternoonTo() ?? '';
|
||||||
|
$row['eveningFrom'] = $wh?->getEveningFrom() ?? '';
|
||||||
|
$row['eveningTo'] = $wh?->getEveningTo() ?? '';
|
||||||
|
$row['dayHours'] = $dayMin > 0 ? $this->formatMinutes($dayMin) : '';
|
||||||
|
$row['nightHours'] = $nightMin > 0 ? $this->formatMinutes($nightMin) : '';
|
||||||
|
$row['total'] = $totalMin > 0 ? $this->formatMinutes($totalMin) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Relancer le test, vérifier le succès**
|
||||||
|
|
||||||
|
Run: `make test`
|
||||||
|
Expected: PASS (toute la suite verte).
|
||||||
|
|
||||||
|
- [ ] **Step 5 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/Service/WorkHours/YearlyHoursDayRowsTest.php src/Service/WorkHours/YearlyHoursExportBuilder.php
|
||||||
|
git commit -m "feat(heures) : calcul des lignes jour pour export PDF"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2 : Gabarit Twig
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `templates/work-hour-day-export/print.html.twig`
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Créer le gabarit**
|
||||||
|
|
||||||
|
```twig
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Heures - {{ dateLabel }}</title>
|
||||||
|
<style>
|
||||||
|
@page { size: A4 portrait; margin: 4mm; }
|
||||||
|
html, body { margin: 0; padding: 2mm; font-family: Helvetica, sans-serif; font-size: 8px; }
|
||||||
|
.title-bar { position: relative; margin: 0 0 3mm 0; }
|
||||||
|
h1 { text-align: center; font-size: 15px; margin: 0; }
|
||||||
|
.export-date { position: absolute; top: 0; right: 0; font-size: 8px; color: #333; padding-top: 4px; }
|
||||||
|
h2 { font-size: 11px; margin: 3mm 0 1mm 0; padding: 2px 6px; background: #e8e8e8; }
|
||||||
|
table { width: 100%; border-collapse: collapse; table-layout: auto; border: 2px solid #0a0a0a; }
|
||||||
|
th, td { border: 1px solid #0a0a0a; padding: 1px 3px; vertical-align: middle; white-space: nowrap; text-align: center; }
|
||||||
|
th { font-weight: 700; background: #f0f0f0; }
|
||||||
|
td.name { text-align: left; }
|
||||||
|
tr.weekend td { background: #c0c0c0; }
|
||||||
|
td.statut { background: #b3e5fc; }
|
||||||
|
.site-block { page-break-inside: auto; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="title-bar">
|
||||||
|
<h1>Heures du {{ dateLabel }}</h1>
|
||||||
|
<div class="export-date">Édité le {{ exportedAt }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for group in groups %}
|
||||||
|
<div class="site-block">
|
||||||
|
<h2>{{ group.siteName }}</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nom</th>
|
||||||
|
<th>Statut</th>
|
||||||
|
<th>Début matin</th>
|
||||||
|
<th>Fin matin</th>
|
||||||
|
<th>Début après-midi</th>
|
||||||
|
<th>Fin après-midi</th>
|
||||||
|
<th>Début soir</th>
|
||||||
|
<th>Fin soir</th>
|
||||||
|
<th>Jour</th>
|
||||||
|
<th>Nuit</th>
|
||||||
|
<th>Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in group.rows %}
|
||||||
|
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
|
||||||
|
<td class="name">{{ row.employeeName }}</td>
|
||||||
|
<td class="{{ row.statut ? 'statut' : '' }}">{{ row.statut }}</td>
|
||||||
|
<td>{{ row.morningFrom }}</td>
|
||||||
|
<td>{{ row.morningTo }}</td>
|
||||||
|
<td>{{ row.afternoonFrom }}</td>
|
||||||
|
<td>{{ row.afternoonTo }}</td>
|
||||||
|
<td>{{ row.eveningFrom }}</td>
|
||||||
|
<td>{{ row.eveningTo }}</td>
|
||||||
|
<td>{{ row.dayHours }}</td>
|
||||||
|
<td>{{ row.nightHours }}</td>
|
||||||
|
<td>{{ row.total }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add templates/work-hour-day-export/print.html.twig
|
||||||
|
git commit -m "feat(heures) : gabarit PDF export jour"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3 : ApiResource + Provider
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ApiResource/WorkHourDayExport.php`
|
||||||
|
- Create: `src/State/WorkHourDayExportProvider.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Créer l'ApiResource**
|
||||||
|
|
||||||
|
`src/ApiResource/WorkHourDayExport.php` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ApiResource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\QueryParameter;
|
||||||
|
use App\State\WorkHourDayExportProvider;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/work-hours/day-export',
|
||||||
|
provider: WorkHourDayExportProvider::class,
|
||||||
|
parameters: [
|
||||||
|
new QueryParameter(key: 'workDate', required: true),
|
||||||
|
new QueryParameter(key: 'siteIds', required: true),
|
||||||
|
],
|
||||||
|
security: "is_granted('ROLE_ADMIN')"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
final class WorkHourDayExport {}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Créer le Provider**
|
||||||
|
|
||||||
|
`src/State/WorkHourDayExportProvider.php` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\Repository\EmployeeRepository;
|
||||||
|
use App\Service\WorkHours\YearlyHoursExportBuilder;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Dompdf\Dompdf;
|
||||||
|
use Dompdf\Options;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||||
|
use Twig\Environment;
|
||||||
|
|
||||||
|
class WorkHourDayExportProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Environment $twig,
|
||||||
|
private readonly RequestStack $requestStack,
|
||||||
|
private EmployeeRepository $employeeRepository,
|
||||||
|
private YearlyHoursExportBuilder $exportBuilder,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
||||||
|
{
|
||||||
|
$request = $this->requestStack->getCurrentRequest();
|
||||||
|
if (!$request) {
|
||||||
|
return new Response('Missing request.', Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workDateRaw = (string) $request->query->get('workDate');
|
||||||
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $workDateRaw)) {
|
||||||
|
throw new UnprocessableEntityHttpException('workDate must use YYYY-MM-DD format.');
|
||||||
|
}
|
||||||
|
$date = new DateTimeImmutable($workDateRaw);
|
||||||
|
|
||||||
|
$siteIdsRaw = (string) $request->query->get('siteIds', '');
|
||||||
|
$siteIds = array_values(array_filter(array_map(
|
||||||
|
static fn (string $value): int => (int) trim($value),
|
||||||
|
explode(',', $siteIdsRaw),
|
||||||
|
), static fn (int $id): bool => $id > 0));
|
||||||
|
if ([] === $siteIds) {
|
||||||
|
throw new UnprocessableEntityHttpException('siteIds is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feature réservée admin : on charge tous les employés puis on filtre.
|
||||||
|
$employees = $this->employeeRepository->findAll();
|
||||||
|
|
||||||
|
// Regroupement par site (ordre displayOrder), non-conducteurs uniquement.
|
||||||
|
$bySite = [];
|
||||||
|
$siteMeta = [];
|
||||||
|
foreach ($employees as $employee) {
|
||||||
|
if (true === $employee->getIsDriver()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$site = $employee->getSite();
|
||||||
|
if (null === $site || !in_array($site->getId(), $siteIds, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$siteId = $site->getId();
|
||||||
|
$bySite[$siteId][] = $employee;
|
||||||
|
$siteMeta[$siteId] ??= [
|
||||||
|
'name' => $site->getName(),
|
||||||
|
'order' => $site->getDisplayOrder() ?? 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
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];
|
||||||
|
usort($siteEmployees, static fn ($a, $b) => ($a->getLastName() ?? '') <=> ($b->getLastName() ?? ''));
|
||||||
|
|
||||||
|
$rows = $this->exportBuilder->buildDayRowsForEmployees($siteEmployees, $date);
|
||||||
|
if ([] === $rows) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$groups[] = ['siteName' => $meta['name'], 'rows' => $rows];
|
||||||
|
}
|
||||||
|
|
||||||
|
$options = new Options();
|
||||||
|
$options->set('isRemoteEnabled', true);
|
||||||
|
$dompdf = new Dompdf($options);
|
||||||
|
|
||||||
|
$html = $this->twig->render('work-hour-day-export/print.html.twig', [
|
||||||
|
'groups' => $groups,
|
||||||
|
'dateLabel' => $date->format('d/m/Y'),
|
||||||
|
'exportedAt' => new DateTimeImmutable('now')->format('d/m/Y H:i'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$dompdf->loadHtml($html);
|
||||||
|
$dompdf->setPaper('A4', 'portrait');
|
||||||
|
$dompdf->render();
|
||||||
|
|
||||||
|
$filename = sprintf('heures_jour_%s.pdf', $date->format('Y-m-d'));
|
||||||
|
|
||||||
|
return new Response($dompdf->output(), Response::HTTP_OK, [
|
||||||
|
'Content-Type' => 'application/pdf',
|
||||||
|
'Content-Disposition' => sprintf('attachment; filename="%s"', $filename),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Vérifier les getters utilisés**
|
||||||
|
|
||||||
|
Run: `grep -n "function getIsDriver\|function getSite\b\|function getDisplayOrder\|function getName" src/Entity/Employee.php src/Entity/Site.php`
|
||||||
|
Expected: les méthodes `Employee::getIsDriver()`, `Employee::getSite()`, `Site::getDisplayOrder()`, `Site::getName()` existent. Si `getIsDriver` n'existe pas, utiliser le getter réel (ex. `isDriver()`), idem pour `getDisplayOrder`.
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Vider le cache et vérifier la route**
|
||||||
|
|
||||||
|
Run: `php bin/console cache:clear && php bin/console debug:router | grep day-export`
|
||||||
|
Expected: la route `/work-hours/day-export` apparaît.
|
||||||
|
|
||||||
|
- [ ] **Step 5 : Lancer la suite backend**
|
||||||
|
|
||||||
|
Run: `make test`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ApiResource/WorkHourDayExport.php src/State/WorkHourDayExportProvider.php
|
||||||
|
git commit -m "feat(heures) : endpoint export PDF heures jour par sites"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4 : Drawer frontend
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/components/hours/HoursDayExportDrawer.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Créer le composant**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<AppDrawer v-model="drawerOpen" title="Export des heures (par jour)">
|
||||||
|
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="hours-export-date">
|
||||||
|
Date <span class="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="hours-export-date"
|
||||||
|
v-model="selectedDate"
|
||||||
|
type="date"
|
||||||
|
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700">
|
||||||
|
Sites <span class="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
v-model="selectedSites"
|
||||||
|
:options="siteOptions"
|
||||||
|
groupClass="w-full mt-2"
|
||||||
|
label="Sites"
|
||||||
|
display-select-all
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
:disabled="isLoading || !selectedDate || selectedSites.length === 0"
|
||||||
|
>
|
||||||
|
<template v-if="isLoading">Génération en cours...</template>
|
||||||
|
<template v-else>Exporter</template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppDrawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import AppDrawer from '~/components/AppDrawer.vue'
|
||||||
|
import type { Site } from '~/services/dto/site'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
sites: Site[]
|
||||||
|
initialDate: string
|
||||||
|
initialSiteIds: number[]
|
||||||
|
isLoading?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:modelValue', value: boolean): void
|
||||||
|
(event: 'submit', payload: { date: string; siteIds: number[] }): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const drawerOpen = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value: boolean) => emit('update:modelValue', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedDate = ref(props.initialDate)
|
||||||
|
const selectedSites = ref<number[]>([...props.initialSiteIds])
|
||||||
|
|
||||||
|
const siteOptions = computed(() =>
|
||||||
|
props.sites.map((site) => ({ value: site.id, label: site.name }))
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!selectedDate.value || selectedSites.value.length === 0) return
|
||||||
|
emit('submit', { date: selectedDate.value, siteIds: [...selectedSites.value] })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Réinitialise sur l'état courant de l'écran à chaque ouverture.
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
selectedDate.value = props.initialDate
|
||||||
|
selectedSites.value = [...props.initialSiteIds]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Vérifier le type `Site` et l'option `MalioSelectCheckbox`**
|
||||||
|
|
||||||
|
Run: `grep -rn "export type Site\|export interface Site" frontend/services/dto/ ; grep -n "value\|label\|options" node_modules/@malio/layer-ui/COMPONENTS.md | grep -i "selectcheckbox" `
|
||||||
|
Expected: confirmer le chemin d'import `Site` (ajuster `~/services/dto/site` si nécessaire — cf. import existant dans `HoursToolbar.vue`) et la forme des `options` (`{ value, label }`) attendue par `MalioSelectCheckbox`. Aligner sur l'usage existant dans `HoursToolbar.vue`.
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/components/hours/HoursDayExportDrawer.vue
|
||||||
|
git commit -m "feat(heures) : drawer d'export PDF jour"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5 : Bouton + câblage dans `hours.vue`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/pages/hours.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Ajouter le bouton dans l'en-tête**
|
||||||
|
|
||||||
|
Remplacer le bloc titre (lignes ~3-5) :
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Heures</h1>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
par :
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Heures</h1>
|
||||||
|
<button
|
||||||
|
v-if="isAdmin"
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||||
|
@click="isExportDrawerOpen = true"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:file-export-outline" />
|
||||||
|
Exporter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HoursDayExportDrawer
|
||||||
|
v-model="isExportDrawerOpen"
|
||||||
|
:sites="sites"
|
||||||
|
:initial-date="selectedDate"
|
||||||
|
:initial-site-ids="selectedSiteIds"
|
||||||
|
:is-loading="isExporting"
|
||||||
|
@submit="handleExport"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note : si `Icon` n'est pas auto-importé dans ce projet, retirer la balise `<Icon>` et garder uniquement le texte « Exporter ». Vérifier l'usage d'`Icon` ailleurs dans `frontend/` avant.
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Ajouter l'état et le handler dans le `<script setup>`**
|
||||||
|
|
||||||
|
Dans le `<script setup lang="ts">` de `hours.vue`, ajouter les imports et l'état. Repérer la déstructuration existante de `useHoursPage()` pour confirmer que `isAdmin`, `sites`, `selectedSiteIds`, `selectedDate` en sont issus, puis ajouter :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import HoursDayExportDrawer from '~/components/hours/HoursDayExportDrawer.vue'
|
||||||
|
import { usePdfPrinter } from '~/composables/usePdfPrinter'
|
||||||
|
|
||||||
|
const { printPdf } = usePdfPrinter()
|
||||||
|
const isExportDrawerOpen = ref(false)
|
||||||
|
const isExporting = ref(false)
|
||||||
|
|
||||||
|
const handleExport = async (payload: { date: string; siteIds: number[] }) => {
|
||||||
|
isExporting.value = true
|
||||||
|
try {
|
||||||
|
const siteIdsParam = payload.siteIds.join(',')
|
||||||
|
await printPdf(`/work-hours/day-export?workDate=${payload.date}&siteIds=${siteIdsParam}`)
|
||||||
|
isExportDrawerOpen.value = false
|
||||||
|
} finally {
|
||||||
|
isExporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note : `selectedDate` côté `useHoursPage` est attendu au format `YYYY-MM-DD` (utilisé tel quel dans `getWorkHourDayContext(selectedDate.value)`). Le passer directement comme `initial-date`. Si son format diffère, normaliser en `YYYY-MM-DD` avant de le transmettre.
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Lancer le typecheck / lint frontend**
|
||||||
|
|
||||||
|
Run: `cd frontend && npx vue-tsc --noEmit` (ou la commande de typecheck du projet ; **ne pas** lancer `npm run build`).
|
||||||
|
Expected: pas d'erreur de type sur les fichiers modifiés.
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Vérification manuelle**
|
||||||
|
|
||||||
|
Démarrer la stack (`make start` + `make dev-nuxt` si besoin), se connecter en admin, écran Heures :
|
||||||
|
- Le bouton « Exporter » est visible (et absent pour un non-admin).
|
||||||
|
- Le drawer s'ouvre avec la date courante et les sites cochés.
|
||||||
|
- « Exporter » télécharge un PDF portrait, une section par site, colonnes attendues sans « Valider ».
|
||||||
|
|
||||||
|
- [ ] **Step 5 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/pages/hours.vue
|
||||||
|
git commit -m "feat(heures) : bouton export PDF jour (admin)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6 : Documentation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `doc/hours-day-export.md`
|
||||||
|
- Modify: `frontend/data/documentation-content.ts`
|
||||||
|
- Modify: `CLAUDE.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Créer `doc/hours-day-export.md`**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Export PDF des heures — vue Jour
|
||||||
|
|
||||||
|
Bouton **Exporter** à droite du titre « Heures », visible **uniquement pour les
|
||||||
|
administrateurs** (`ROLE_ADMIN`).
|
||||||
|
|
||||||
|
## Comportement
|
||||||
|
- Ouvre un drawer : un champ **date** (préremplit la date affichée) et des **cases à
|
||||||
|
cocher des sites** (présélectionnées sur le filtre courant).
|
||||||
|
- Génère un **PDF A4 portrait** d'une seule journée, **regroupé par site**.
|
||||||
|
|
||||||
|
## Données
|
||||||
|
- Mêmes employés que la vue Jour : **non-conducteurs**, **sous contrat** à la date
|
||||||
|
choisie, des sites cochés. Les employés sous contrat sans saisie apparaissent (lignes
|
||||||
|
vides).
|
||||||
|
- Colonnes : Nom · Statut · Début matin · Fin matin · Début après-midi · Fin après-midi ·
|
||||||
|
Début soir · Fin soir · Jour · Nuit · Total. **Pas de colonne « Valider ».**
|
||||||
|
- Jour / Nuit / Total : identiques à l'écran (crédit d'absence `countAsWorkedHours` et
|
||||||
|
crédit virtuel férié inclus).
|
||||||
|
|
||||||
|
## Technique
|
||||||
|
- Endpoint : `GET /work-hours/day-export?workDate=YYYY-MM-DD&siteIds=1,2,3` (`ROLE_ADMIN`).
|
||||||
|
- Provider : `App\State\WorkHourDayExportProvider`.
|
||||||
|
- Calcul des cellules : `YearlyHoursExportBuilder::buildDayRowsForEmployees` (source
|
||||||
|
unique de vérité, partagée avec les exports annuels).
|
||||||
|
- Gabarit : `templates/work-hour-day-export/print.html.twig`.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Ajouter une entrée admin dans `documentation-content.ts`**
|
||||||
|
|
||||||
|
Repérer la section « Heures » dans `frontend/data/documentation-content.ts` et ajouter, dans ses `articles` (ou un nouvel article `requiredLevel: 'admin'`), un bloc décrivant l'export. Exemple d'article à insérer (adapter `id`/structure aux types `DocArticle`/`DocBlock` existants dans le fichier) :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
id: 'hours-day-export',
|
||||||
|
title: 'Exporter les heures (PDF par jour)',
|
||||||
|
requiredLevel: 'admin',
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
text: "Le bouton « Exporter », à droite du titre « Heures », ouvre un panneau permettant de générer un PDF des heures d'une journée. Choisissez la date et les sites concernés.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
text: "Le PDF est organisé par site et reprend les colonnes de la vue Jour (nom, statut, horaires matin/après-midi/soir, jour, nuit, total), sans la colonne de validation. Les employés sous contrat ce jour-là apparaissent même sans saisie.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
> Avant d'écrire, lire le haut de `documentation-content.ts` et `frontend/types/documentation.ts` pour respecter exactement la forme des objets `DocArticle`/`DocBlock` (noms de champs, types de blocs autorisés).
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Mettre à jour `CLAUDE.md`**
|
||||||
|
|
||||||
|
Ajouter, sous la puce « Exports heures annuelles » de la section *Functional Rules*, une nouvelle puce :
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- **Export heures vue Jour** (`WorkHourDayExportProvider`, endpoint `GET /work-hours/day-export?workDate=&siteIds=`, `ROLE_ADMIN`) : bouton « Exporter » à droite du titre « Heures ». PDF A4 portrait d'**une seule journée**, **regroupé par site**, colonnes de la vue Jour **sans « Valider »**. Mêmes employés que l'écran : non-conducteurs, sous contrat à la date, sites cochés (lignes vides incluses). Calcul des cellules mutualisé via `YearlyHoursExportBuilder::buildDayRowsForEmployees` (Jour/Nuit/Total incluent crédit absence + crédit virtuel férié). Gabarit `templates/work-hour-day-export/print.html.twig`.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add doc/hours-day-export.md frontend/data/documentation-content.ts CLAUDE.md
|
||||||
|
git commit -m "docs(heures) : documenter l'export PDF jour"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes de mise en œuvre
|
||||||
|
|
||||||
|
- **Conducteurs exclus** : filtrés côté provider (`getIsDriver()`), cohérent avec l'écran.
|
||||||
|
- **PRESENCE** : géré dans le builder (cellules horaires vides, `Total` en demi-journées).
|
||||||
|
- **Validation des params** : `workDate` (`YYYY-MM-DD`) et `siteIds` (CSV d'entiers > 0)
|
||||||
|
rejetés en `422` si invalides.
|
||||||
|
- **Pas de `npm run build`** (règle projet) — utiliser typecheck/dev pour vérifier le front.
|
||||||
|
- **Format des commits** : le hook impose `<type>(<scope>) : <message>` (espace avant les
|
||||||
|
deux-points). Les messages ci-dessus le respectent.
|
||||||
@@ -0,0 +1,608 @@
|
|||||||
|
# RTT — Déficit pris en compte pour les contrats CUSTOM — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Pour les contrats CUSTOM (4h, 25h…), une semaine travaillée sous les heures contractuelles doit réduire le cumul RTT (déficit compté), avec un affichage propre (colonnes 25%/50% à 0).
|
||||||
|
|
||||||
|
**Architecture:** On retire l'écrêtage `max(0, …)` du total hebdo CUSTOM dans `RttRecoveryComputationService` (le déficit signé circule dans `totalMinutes`) et on marque ces semaines `isFlatRecovery = true`. Ce drapeau désactive la cascade de drainage 25/50 dans `EmployeeRttSummaryProvider`, de sorte que le déficit n'impacte que les colonnes Heure/Total/Cumul. Le `RttClosingBalanceService::fold` gère déjà les totaux négatifs (report N+1 cohérent).
|
||||||
|
|
||||||
|
**Tech Stack:** Symfony, API Platform, Doctrine ORM, PHPUnit. Frontend Nuxt/Vue (aucun changement de code, docs uniquement).
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-09-rtt-custom-deficit-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Ajouter le drapeau `isFlatRecovery` aux DTOs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Dto/Rtt/WeekRecoveryDetail.php`
|
||||||
|
- Modify: `src/Dto/Rtt/EmployeeRttWeekSummary.php`
|
||||||
|
|
||||||
|
Ces deux DTOs sont de simples porteurs de données ; ils sont couverts par les tests des tâches 3 et 4. Pas de test dédié.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Ajouter le champ à `WeekRecoveryDetail`**
|
||||||
|
|
||||||
|
Dans `src/Dto/Rtt/WeekRecoveryDetail.php`, ajouter un dernier paramètre au constructeur (après `$dailyMinutes`) :
|
||||||
|
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $dailyMinutes date (Y-m-d) => worked minutes
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public int $overtimeMinutes = 0,
|
||||||
|
public int $base25Minutes = 0,
|
||||||
|
public int $bonus25Minutes = 0,
|
||||||
|
public int $base50Minutes = 0,
|
||||||
|
public int $bonus50Minutes = 0,
|
||||||
|
public int $totalMinutes = 0,
|
||||||
|
public array $dailyMinutes = [],
|
||||||
|
public bool $isFlatRecovery = false,
|
||||||
|
) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Ajouter le champ à `EmployeeRttWeekSummary`**
|
||||||
|
|
||||||
|
Dans `src/Dto/Rtt/EmployeeRttWeekSummary.php`, ajouter un dernier paramètre au constructeur (après `$cumulativeBalanceMinutes`) :
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function __construct(
|
||||||
|
public int $month,
|
||||||
|
public int $weekNumber,
|
||||||
|
public string $weekStart,
|
||||||
|
public string $weekEnd,
|
||||||
|
public int $overtimeMinutes = 0,
|
||||||
|
public int $base25Minutes = 0,
|
||||||
|
public int $bonus25Minutes = 0,
|
||||||
|
public int $base50Minutes = 0,
|
||||||
|
public int $bonus50Minutes = 0,
|
||||||
|
public int $totalMinutes = 0,
|
||||||
|
public int $cumulativeBalanceMinutes = 0,
|
||||||
|
public bool $isFlatRecovery = false,
|
||||||
|
) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Vérifier que rien n'est cassé (DTO à valeur par défaut)**
|
||||||
|
|
||||||
|
Run: `make test`
|
||||||
|
Expected: PASS (aucun appel existant ne casse — le nouveau paramètre a une valeur par défaut).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Dto/Rtt/WeekRecoveryDetail.php src/Dto/Rtt/EmployeeRttWeekSummary.php
|
||||||
|
git commit -m "feat(rtt): add isFlatRecovery flag to recovery DTOs"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Test de clôture — déficit CUSTOM diminue le report (aucun code à changer)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Test: `tests/Service/Rtt/RttClosingBalanceServiceTest.php`
|
||||||
|
|
||||||
|
`RttClosingBalanceService::fold` gère déjà les `totalMinutes` négatifs. On ajoute un test explicite « déficit CUSTOM » pour verrouiller le comportement.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Écrire le test**
|
||||||
|
|
||||||
|
Ajouter cette méthode dans `tests/Service/Rtt/RttClosingBalanceServiceTest.php` (après `testCustomRecoveryWithoutBucketsStillCountsInTotal`) :
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function testCustomDeficitWeekReducesClosingBalance(): void
|
||||||
|
{
|
||||||
|
// CUSTOM (4h) : une semaine de récup +3h puis une semaine déficitaire -1h
|
||||||
|
// (toutes deux sans tranches 25/50). Le déficit doit réduire la clôture.
|
||||||
|
$recovery = new WeekRecoveryDetail(totalMinutes: 180, isFlatRecovery: true); // +3h
|
||||||
|
$deficit = new WeekRecoveryDetail(totalMinutes: -60, isFlatRecovery: true); // -1h
|
||||||
|
|
||||||
|
$closing = $this->service()->fold(new WeekRecoveryDetail(), [$recovery, $deficit], $this->payments());
|
||||||
|
|
||||||
|
// 3h - 1h = 2h reportées, et la somme des buckets égale toujours le total.
|
||||||
|
self::assertSame(120, $closing->totalMinutes);
|
||||||
|
self::assertSame(
|
||||||
|
120,
|
||||||
|
$closing->base25Minutes + $closing->bonus25Minutes + $closing->base50Minutes + $closing->bonus50Minutes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Lancer le test**
|
||||||
|
|
||||||
|
Run: `docker exec php-sirh-fpm sh -c 'cd /var/www/html && vendor/bin/phpunit tests/Service/Rtt/RttClosingBalanceServiceTest.php --filter testCustomDeficitWeekReducesClosingBalance'`
|
||||||
|
Expected: PASS (le `fold` gère déjà les totaux négatifs).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/Service/Rtt/RttClosingBalanceServiceTest.php
|
||||||
|
git commit -m "test(rtt): custom deficit week reduces closing balance"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: `RttRecoveryComputationService` — récup plate signée pour CUSTOM
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Service/Rtt/RttRecoveryComputationService.php` (`computeRecoveryByWeek` lignes ~243-270, + nouvelle méthode privée)
|
||||||
|
- Test: `tests/Service/Rtt/RttRecoveryComputationServiceTest.php`
|
||||||
|
|
||||||
|
On extrait la construction du `WeekRecoveryDetail` dans une méthode pure `buildWeekRecoveryDetail`, testable par réflexion (style existant du fichier de test), et on y applique le changement CUSTOM.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Écrire les tests (méthode pure)**
|
||||||
|
|
||||||
|
Ajouter dans `tests/Service/Rtt/RttRecoveryComputationServiceTest.php` (le fichier instancie déjà le service via `newInstanceWithoutConstructor` et possède le helper `invokePrivate`) :
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function testBuildWeekDetailCustomDeficitKeepsSignedTotalAndFlatFlag(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
// CUSTOM, semaine sous les heures : overtime -120 (worked 2h sur réf 4h).
|
||||||
|
$detail = $this->invokePrivate(
|
||||||
|
$service,
|
||||||
|
'buildWeekRecoveryDetail',
|
||||||
|
false, // isPresence
|
||||||
|
false, // disableBonuses
|
||||||
|
true, // isCustom
|
||||||
|
-120, // overtimeTotalMinutes
|
||||||
|
0, // rawBase25
|
||||||
|
0, // rawBase50
|
||||||
|
[], // dailyMinutes
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(-120, $detail->totalMinutes);
|
||||||
|
self::assertTrue($detail->isFlatRecovery);
|
||||||
|
self::assertSame(0, $detail->base25Minutes);
|
||||||
|
self::assertSame(0, $detail->base50Minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBuildWeekDetailCustomPositiveIsFlatOneToOne(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
$detail = $this->invokePrivate($service, 'buildWeekRecoveryDetail', false, false, true, 180, 0, 0, []);
|
||||||
|
|
||||||
|
self::assertSame(180, $detail->totalMinutes); // 1h = 1h
|
||||||
|
self::assertTrue($detail->isFlatRecovery);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBuildWeekDetailStandardKeepsBucketsAndBonuses(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
// 39h : overtime 300, base25 240, base50 60.
|
||||||
|
$detail = $this->invokePrivate($service, 'buildWeekRecoveryDetail', false, false, false, 300, 240, 60, []);
|
||||||
|
|
||||||
|
self::assertFalse($detail->isFlatRecovery);
|
||||||
|
self::assertSame(240, $detail->base25Minutes);
|
||||||
|
self::assertSame(60, $detail->bonus25Minutes); // round(240 * 0.25)
|
||||||
|
self::assertSame(60, $detail->base50Minutes);
|
||||||
|
self::assertSame(30, $detail->bonus50Minutes); // round(60 * 0.5)
|
||||||
|
self::assertSame(300 + 60 + 30, $detail->totalMinutes);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent**
|
||||||
|
|
||||||
|
Run: `docker exec php-sirh-fpm sh -c 'cd /var/www/html && vendor/bin/phpunit tests/Service/Rtt/RttRecoveryComputationServiceTest.php --filter testBuildWeekDetail'`
|
||||||
|
Expected: FAIL — `buildWeekRecoveryDetail` n'existe pas encore (ReflectionException / method does not exist).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Ajouter la méthode privée `buildWeekRecoveryDetail`**
|
||||||
|
|
||||||
|
Dans `src/Service/Rtt/RttRecoveryComputationService.php`, ajouter cette méthode (par ex. juste après `computeRecoveryByWeek`, avant `computeMetrics`) :
|
||||||
|
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* Assemble le détail de récupération d'une semaine à partir des drapeaux résolus et
|
||||||
|
* des bandes d'heures sup brutes.
|
||||||
|
*
|
||||||
|
* - PRESENCE / INTERIM (bonus désactivés) : aucune récupération.
|
||||||
|
* - CUSTOM : récupération plate 1h = 1h, sans tranches 25/50 ; l'heure sup signée EST
|
||||||
|
* le total, donc une semaine travaillée sous les heures contractuelles produit un
|
||||||
|
* total négatif (déficit qui réduit le solde). Marquée isFlatRecovery pour que le
|
||||||
|
* provider ne draine pas les tranches 25/50.
|
||||||
|
* - Standard 35h/39h : heures sup + bonus 25 %/50 %.
|
||||||
|
*
|
||||||
|
* @param array<string, int> $dailyMinutes
|
||||||
|
*/
|
||||||
|
private function buildWeekRecoveryDetail(
|
||||||
|
bool $isPresence,
|
||||||
|
bool $disableBonuses,
|
||||||
|
bool $isCustom,
|
||||||
|
int $overtimeTotalMinutes,
|
||||||
|
int $rawBase25,
|
||||||
|
int $rawBase50,
|
||||||
|
array $dailyMinutes,
|
||||||
|
): WeekRecoveryDetail {
|
||||||
|
$noBands = $isPresence || $disableBonuses || $isCustom;
|
||||||
|
|
||||||
|
$base25 = $noBands ? 0 : $rawBase25;
|
||||||
|
$bonus25 = $noBands ? 0 : (int) round($base25 * 0.25);
|
||||||
|
$base50 = $noBands ? 0 : $rawBase50;
|
||||||
|
$bonus50 = $noBands ? 0 : (int) round($base50 * 0.5);
|
||||||
|
|
||||||
|
if ($isPresence || $disableBonuses) {
|
||||||
|
$totalMinutes = 0;
|
||||||
|
} elseif ($isCustom) {
|
||||||
|
$totalMinutes = $overtimeTotalMinutes; // signé : le déficit réduit le solde
|
||||||
|
} else {
|
||||||
|
$totalMinutes = $overtimeTotalMinutes + $bonus25 + $bonus50;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WeekRecoveryDetail(
|
||||||
|
overtimeMinutes: $overtimeTotalMinutes,
|
||||||
|
base25Minutes: $base25,
|
||||||
|
bonus25Minutes: $bonus25,
|
||||||
|
base50Minutes: $base50,
|
||||||
|
bonus50Minutes: $bonus50,
|
||||||
|
totalMinutes: $totalMinutes,
|
||||||
|
dailyMinutes: $dailyMinutes,
|
||||||
|
isFlatRecovery: $isCustom,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Brancher l'appelant sur la nouvelle méthode**
|
||||||
|
|
||||||
|
Dans `computeRecoveryByWeek`, remplacer le bloc existant (depuis `[$rawBase25, $rawBase50] = …` jusqu'à la fin du `new WeekRecoveryDetail(...)` qui assigne `$results[$weekKey]`) par :
|
||||||
|
|
||||||
|
```php
|
||||||
|
[$rawBase25, $rawBase50] = $this->computeOvertimeBaseMinutes($weeklyTotalMinutes, $overtime25StartMinutes, $overtime50StartMinutes);
|
||||||
|
|
||||||
|
$results[$weekKey] = $this->buildWeekRecoveryDetail(
|
||||||
|
$isWeekPresenceTracking,
|
||||||
|
$disableOvertimeBonuses,
|
||||||
|
$isCustomContract,
|
||||||
|
$weeklyOvertimeTotalMinutes,
|
||||||
|
$rawBase25,
|
||||||
|
$rawBase50,
|
||||||
|
$dailyWorkedMinutes,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
(Conserver les lignes précédentes qui calculent `$weeklyOvertimeTotalMinutes`, `$overtime25StartMinutes`, `$overtime50StartMinutes`.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Lancer les tests pour vérifier qu'ils passent**
|
||||||
|
|
||||||
|
Run: `docker exec php-sirh-fpm sh -c 'cd /var/www/html && vendor/bin/phpunit tests/Service/Rtt/RttRecoveryComputationServiceTest.php'`
|
||||||
|
Expected: PASS (nouveaux tests + tests existants des helpers).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Service/Rtt/RttRecoveryComputationService.php tests/Service/Rtt/RttRecoveryComputationServiceTest.php
|
||||||
|
git commit -m "feat(rtt): custom contract deficit counts as signed recovery (1h=1h, no bands)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: `EmployeeRttSummaryProvider` — sauter la cascade pour les semaines plates
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/State/EmployeeRttSummaryProvider.php` (cascade lignes ~145-174 → méthode extraite ; `buildWeekSummaries` lignes ~385-396 et ~425-436)
|
||||||
|
- Test: `tests/State/EmployeeRttSummaryProviderTest.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Écrire les tests (méthode pure `applyDeficitCascade`)**
|
||||||
|
|
||||||
|
Ajouter dans `tests/State/EmployeeRttSummaryProviderTest.php`. Le fichier importe déjà `EmployeeRttSummaryProvider`, `ReflectionClass`, et possède `invokePrivate` / `buildProvider`. Ajouter d'abord ce petit helper de fabrication en bas de la classe (si un helper équivalent n'existe pas déjà) :
|
||||||
|
|
||||||
|
```php
|
||||||
|
private function weekSummary(int $totalMinutes, bool $isFlat, int $base25 = 0, int $base50 = 0): \App\Dto\Rtt\EmployeeRttWeekSummary
|
||||||
|
{
|
||||||
|
return new \App\Dto\Rtt\EmployeeRttWeekSummary(
|
||||||
|
month: 6,
|
||||||
|
weekNumber: 1,
|
||||||
|
weekStart: '2026-06-01',
|
||||||
|
weekEnd: '2026-06-07',
|
||||||
|
overtimeMinutes: $totalMinutes,
|
||||||
|
base25Minutes: $base25,
|
||||||
|
base50Minutes: $base50,
|
||||||
|
totalMinutes: $totalMinutes,
|
||||||
|
isFlatRecovery: $isFlat,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis les tests :
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function testFlatDeficitWeekIsNotDrainedFromTiers(): void
|
||||||
|
{
|
||||||
|
$provider = $this->buildProvider([]);
|
||||||
|
|
||||||
|
// Semaine CUSTOM déficitaire (-120), aucune tranche accumulée.
|
||||||
|
$weeks = [$this->weekSummary(-120, true)];
|
||||||
|
$result = $this->invokePrivate($provider, 'applyDeficitCascade', $weeks, 0, 0);
|
||||||
|
|
||||||
|
// Buckets restent à 0 ; le total négatif est conservé (le cumul est calculé ailleurs).
|
||||||
|
self::assertSame(0, $result[0]->base25Minutes);
|
||||||
|
self::assertSame(0, $result[0]->base50Minutes);
|
||||||
|
self::assertSame(-120, $result[0]->totalMinutes);
|
||||||
|
self::assertTrue($result[0]->isFlatRecovery);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStandardDeficitWeekDrainsFiftyThenTwentyFive(): void
|
||||||
|
{
|
||||||
|
$provider = $this->buildProvider([]);
|
||||||
|
|
||||||
|
// Semaine 35h/39h déficitaire (-100), avec 60 en 50% et 120 en 25% accumulés.
|
||||||
|
$weeks = [$this->weekSummary(-100, false)];
|
||||||
|
$result = $this->invokePrivate($provider, 'applyDeficitCascade', $weeks, 120, 60);
|
||||||
|
|
||||||
|
self::assertSame(-60, $result[0]->base50Minutes); // 60 drainés du 50%
|
||||||
|
self::assertSame(-40, $result[0]->base25Minutes); // 40 restants drainés du 25%
|
||||||
|
self::assertSame(-100, $result[0]->totalMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFlatPositiveWeekIsUntouched(): void
|
||||||
|
{
|
||||||
|
$provider = $this->buildProvider([]);
|
||||||
|
|
||||||
|
$weeks = [$this->weekSummary(180, true)];
|
||||||
|
$result = $this->invokePrivate($provider, 'applyDeficitCascade', $weeks, 0, 0);
|
||||||
|
|
||||||
|
self::assertSame(180, $result[0]->totalMinutes);
|
||||||
|
self::assertSame(0, $result[0]->base25Minutes);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
NB : si `invokePrivate` n'accepte pas d'arguments variadiques dans ce fichier, vérifier sa signature en haut du fichier de test et adapter (l'autre fichier de test du dépôt l'utilise déjà avec des arguments).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent**
|
||||||
|
|
||||||
|
Run: `docker exec php-sirh-fpm sh -c 'cd /var/www/html && vendor/bin/phpunit tests/State/EmployeeRttSummaryProviderTest.php --filter Deficit'`
|
||||||
|
Expected: FAIL — `applyDeficitCascade` n'existe pas encore.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Extraire la cascade en méthode privée**
|
||||||
|
|
||||||
|
Dans `src/State/EmployeeRttSummaryProvider.php`, ajouter cette méthode privée (par ex. juste avant `buildWeekSummaries`) :
|
||||||
|
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* Distribue les semaines déficitaires sur les tranches 25/50 accumulées (50 % d'abord,
|
||||||
|
* puis 25 %), en réécrivant les buckets affichés de chaque semaine déficitaire avec les
|
||||||
|
* montants négatifs drainés.
|
||||||
|
*
|
||||||
|
* Les semaines à récupération plate (CUSTOM 1h = 1h) sont ignorées : elles n'ont pas de
|
||||||
|
* tranches 25/50, donc leur déficit ne réduit que le cumul courant (calculé ensuite à
|
||||||
|
* partir de totalMinutes) et les colonnes 25/50 restent à 0.
|
||||||
|
*
|
||||||
|
* @param list<EmployeeRttWeekSummary> $weeks
|
||||||
|
*
|
||||||
|
* @return list<EmployeeRttWeekSummary>
|
||||||
|
*/
|
||||||
|
private function applyDeficitCascade(array $weeks, int $cumulative25, int $cumulative50): array
|
||||||
|
{
|
||||||
|
foreach ($weeks as $i => $week) {
|
||||||
|
if ($week->totalMinutes >= 0 || $week->isFlatRecovery) {
|
||||||
|
$cumulative50 += $week->base50Minutes + $week->bonus50Minutes;
|
||||||
|
$cumulative25 += $week->base25Minutes + $week->bonus25Minutes;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$deficit = -$week->totalMinutes;
|
||||||
|
$from50 = min($deficit, max(0, $cumulative50));
|
||||||
|
$from25 = $deficit - $from50;
|
||||||
|
|
||||||
|
$cumulative50 -= $from50;
|
||||||
|
$cumulative25 -= $from25;
|
||||||
|
|
||||||
|
$weeks[$i] = new EmployeeRttWeekSummary(
|
||||||
|
month: $week->month,
|
||||||
|
weekNumber: $week->weekNumber,
|
||||||
|
weekStart: $week->weekStart,
|
||||||
|
weekEnd: $week->weekEnd,
|
||||||
|
overtimeMinutes: $week->overtimeMinutes,
|
||||||
|
base25Minutes: $from25 > 0 ? -$from25 : 0,
|
||||||
|
bonus25Minutes: 0,
|
||||||
|
base50Minutes: $from50 > 0 ? -$from50 : 0,
|
||||||
|
bonus50Minutes: 0,
|
||||||
|
totalMinutes: $week->totalMinutes,
|
||||||
|
isFlatRecovery: $week->isFlatRecovery,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $weeks;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Brancher `provide()` sur la méthode extraite**
|
||||||
|
|
||||||
|
Dans `provide()`, remplacer le bloc commentaire + boucle (depuis `// Post-process: distribute deficit weeks across cumulative balance (50% first, then 25%)` et la déclaration de `$cumulative50`/`$cumulative25` jusqu'à la fin du `foreach ($summary->weeks as $i => $week) { … }`) par :
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Post-process: distribute deficit weeks across cumulative balance (50% first, then 25%).
|
||||||
|
// Flat-recovery (CUSTOM) weeks are skipped — their deficit only reduces the running cumul.
|
||||||
|
$summary->weeks = $this->applyDeficitCascade(
|
||||||
|
$summary->weeks,
|
||||||
|
$carry->base25Minutes + $carry->bonus25Minutes,
|
||||||
|
$carry->base50Minutes + $carry->bonus50Minutes,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Propager `isFlatRecovery` dans `buildWeekSummaries`**
|
||||||
|
|
||||||
|
Dans `buildWeekSummaries`, ajouter `isFlatRecovery: $detail->isFlatRecovery,` comme dernier argument des DEUX appels `new EmployeeRttWeekSummary(...)` :
|
||||||
|
- le cas mono-mois (`if ($startMonth === $endMonth)`, après `totalMinutes: $detail->totalMinutes,`)
|
||||||
|
- le cas semaine à cheval (boucle `foreach ([$startMonth, $endMonth] …`, après `totalMinutes: (int) round($detail->totalMinutes * $ratio),`)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Lancer les tests**
|
||||||
|
|
||||||
|
Run: `docker exec php-sirh-fpm sh -c 'cd /var/www/html && vendor/bin/phpunit tests/State/EmployeeRttSummaryProviderTest.php'`
|
||||||
|
Expected: PASS (nouveaux tests + tests existants du provider).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/State/EmployeeRttSummaryProvider.php tests/State/EmployeeRttSummaryProviderTest.php
|
||||||
|
git commit -m "feat(rtt): skip 25/50 deficit cascade for flat (custom) recovery weeks"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: `DumpVerificationSnapshotCommand` — refléter le drapeau
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Command/DumpVerificationSnapshotCommand.php` (`distributeDeficits` ligne ~689 ; build des week summaries lignes ~628-639 et ~664-675)
|
||||||
|
|
||||||
|
Ce command duplique la logique du provider pour produire les snapshots de vérification. Sans mise à jour, les snapshots « after » seraient faux pour les semaines CUSTOM.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Propager `isFlatRecovery` dans les week summaries dupliquées**
|
||||||
|
|
||||||
|
Ajouter `isFlatRecovery: $detail->isFlatRecovery,` comme dernier argument des deux appels `new EmployeeRttWeekSummary(...)` (cas mono-mois ligne ~638 après `totalMinutes: $detail->totalMinutes,`, et cas à cheval ligne ~674 après `totalMinutes: (int) round($detail->totalMinutes * $ratio),`).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Sauter la cascade pour les semaines plates dans `distributeDeficits`**
|
||||||
|
|
||||||
|
Modifier la condition de la boucle :
|
||||||
|
|
||||||
|
```php
|
||||||
|
if ($week->totalMinutes >= 0 || $week->isFlatRecovery) {
|
||||||
|
```
|
||||||
|
|
||||||
|
et ajouter `isFlatRecovery: $week->isFlatRecovery,` comme dernier argument du `new EmployeeRttWeekSummary(...)` de reconstruction (après `totalMinutes: $week->totalMinutes,`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Vérifier la compilation (lint)**
|
||||||
|
|
||||||
|
Run: `docker exec php-sirh-fpm sh -c 'cd /var/www/html && php -l src/Command/DumpVerificationSnapshotCommand.php'`
|
||||||
|
Expected: `No syntax errors detected`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Command/DumpVerificationSnapshotCommand.php
|
||||||
|
git commit -m "chore(rtt): mirror flat-recovery cascade skip in verification snapshot command"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Documentation (obligatoire — même intervention)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `CLAUDE.md` (ligne ~68)
|
||||||
|
- Modify: `frontend/data/documentation-content.ts` (lignes ~367-368 et section RTT ~520)
|
||||||
|
- Modify: `doc/rtt-tab.md`
|
||||||
|
- Modify: `doc/rtt-rollover.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: `CLAUDE.md`**
|
||||||
|
|
||||||
|
Remplacer la ligne :
|
||||||
|
|
||||||
|
```
|
||||||
|
- CUSTOM contracts (weeklyHours ≠ 35 and ≠ 39, not INTERIM/FORFAIT): reference = actual contractual hours, no 25%/50% bonuses (1h overtime = 1h recovery), deficit doesn't impact balance
|
||||||
|
```
|
||||||
|
|
||||||
|
par :
|
||||||
|
|
||||||
|
```
|
||||||
|
- 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.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: In-app docs — règles d'heures sup (ligne ~367)**
|
||||||
|
|
||||||
|
Dans `frontend/data/documentation-content.ts`, remplacer le contenu de la liste ligne ~367 :
|
||||||
|
|
||||||
|
```
|
||||||
|
Contrats CUSTOM (4h, 25h, etc.) : 1h supplémentaire = 1h de récupération, pas de bonus
|
||||||
|
```
|
||||||
|
|
||||||
|
par :
|
||||||
|
|
||||||
|
```
|
||||||
|
Contrats CUSTOM (4h, 25h, etc.) : 1h supplémentaire = 1h de récupération, pas de bonus. Une semaine sous les heures contractuelles réduit le cumul RTT (1h manquante = -1h), sans passer par les tranches 25/50
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: In-app docs — note déficit (ligne ~368)**
|
||||||
|
|
||||||
|
Remplacer le contenu de la note ligne ~368 :
|
||||||
|
|
||||||
|
```
|
||||||
|
En cas de déficit hebdomadaire (heures travaillées < heures contrat), le déficit est déduit du cumul RTT : d'abord des heures à 50%, puis des heures à 25%.
|
||||||
|
```
|
||||||
|
|
||||||
|
par :
|
||||||
|
|
||||||
|
```
|
||||||
|
En cas de déficit hebdomadaire (heures travaillées < heures contrat), le déficit est déduit du cumul RTT. Pour un 35h/39h, il est puisé d'abord dans les heures à 50%, puis à 25%. Pour un contrat CUSTOM (4h, etc.), il réduit directement le cumul (pas de tranches 25/50) ; le cumul peut devenir négatif et est reporté à l'exercice suivant.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: In-app docs — section RTT « Compteurs » (ajout d'une note)**
|
||||||
|
|
||||||
|
Dans l'article `rtt-compteurs` (id `'rtt-compteurs'`, vers la ligne ~520), ajouter une note à la fin du tableau `blocks` (après le dernier `paragraph`) :
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{ 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.' },
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: `doc/rtt-tab.md` — ajouter une sous-section règle CUSTOM**
|
||||||
|
|
||||||
|
Après la section « Période affichée » (avant « Sélecteur d'année »), insérer :
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Règle de calcul — contrats CUSTOM (4h, 25h…)
|
||||||
|
|
||||||
|
Pour un contrat CUSTOM, la récupération est **plate** (1h sup = 1h récup, sans bonus 25 %/50 %).
|
||||||
|
Depuis 2026-06, une semaine **travaillée sous les heures contractuelles** produit un **déficit
|
||||||
|
signé** dans la colonne « Heure » qui **réduit le « Total » et le « Cumul »** (1h manquante =
|
||||||
|
-1h). Les colonnes Base/25 %/50 % restent à **0** (pas de tranches pour ces contrats). Le cumul
|
||||||
|
peut devenir négatif ; il est reporté à l'exercice suivant.
|
||||||
|
|
||||||
|
Techniquement : `WeekRecoveryDetail::isFlatRecovery` marque ces semaines ;
|
||||||
|
`EmployeeRttSummaryProvider::applyDeficitCascade` les exclut du drainage des tranches 25/50.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: `doc/rtt-rollover.md` — préciser que le déficit CUSTOM est reporté**
|
||||||
|
|
||||||
|
Sous le point 3 (« calculer le solde de clôture… ») / la « Règle clef » (ligne ~97), ajouter :
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
> Contrats CUSTOM : le solde de clôture intègre désormais les **déficits** hebdomadaires
|
||||||
|
> (semaines travaillées sous les heures contractuelles), via `RttClosingBalanceService::fold`
|
||||||
|
> qui gère les totaux négatifs. La clôture (donc le report d'ouverture N+1) peut être négative.
|
||||||
|
> Après une mise à jour de cette règle, rejouer `app:rtt:rollover --force --recompute` pour
|
||||||
|
> recalculer les lignes `employee_rtt_balances` non verrouillées calculées avec l'ancienne règle.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Build frontend (vérifier que le TS compile)**
|
||||||
|
|
||||||
|
Run: `docker exec php-sirh-fpm true` (no-op) puis localement : ne PAS lancer `npm run build` (préférence utilisateur). Vérifier visuellement que les chaînes ajoutées échappent bien les apostrophes (`\'`).
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add CLAUDE.md frontend/data/documentation-content.ts doc/rtt-tab.md doc/rtt-rollover.md
|
||||||
|
git commit -m "docs(rtt): custom contract deficit now reduces the balance"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Vérification métier sur données prod + suite complète
|
||||||
|
|
||||||
|
**Files:** aucun (vérification).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Lancer toute la suite de tests**
|
||||||
|
|
||||||
|
Run: `make test`
|
||||||
|
Expected: PASS (aucune régression).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Vérifier Ewa / Nadia via le snapshot de vérification (ou requête API)**
|
||||||
|
|
||||||
|
Générer un snapshot « after » et confirmer que pour Ewa (id 31, exercice 2027) la semaine 23 affiche : Heure −2h, Total −2h, Cumul −2h, colonnes 25/50 = 0 ; et que Nadia (id 22) reste cohérente.
|
||||||
|
|
||||||
|
Run (exemple, adapter à la signature réelle du command) :
|
||||||
|
`docker exec php-sirh-fpm sh -c 'cd /var/www/html && php bin/console app:dump-verification-snapshot --help'`
|
||||||
|
|
||||||
|
Comparer `docs/verifications/` (before) et le nouveau snapshot.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Note de déploiement**
|
||||||
|
|
||||||
|
Consigner dans la PR : après déploiement, exécuter
|
||||||
|
`php bin/console app:rtt:rollover --force --recompute`
|
||||||
|
pour rafraîchir les reports stockés (lignes non verrouillées) calculés avec l'ancienne règle (déficit = 0).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
- **Spec coverage** : (1) déficit signé CUSTOM → Task 3 ; (2) cumul réduit + cascade non drainée → Task 4 ; (3) report N+1 → Task 2 (fold déjà OK) + note rollover Task 6/7 ; (4) affichage propre (frontend inchangé) → couvert par buckets 0 ; (5) command de vérification → Task 5 ; (6) docs → Task 6. ✓
|
||||||
|
- **Placeholders** : aucun — code complet à chaque étape.
|
||||||
|
- **Cohérence des types** : `isFlatRecovery` (bool) ajouté de façon identique aux 2 DTOs ; `buildWeekRecoveryDetail` et `applyDeficitCascade` ont des signatures fixes utilisées de manière cohérente entre tâches et tests. ✓
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,522 @@
|
|||||||
|
# RTT — Déficit jour de solidarité (CUSTOM < 35h) — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Sur le Lundi de Pentecôte, retrancher au cumul RTT des contrats CUSTOM < 35h un déficit forfaitaire de `7/35 × heuresHebdo` (= 12 min/heure hebdo), net et inconditionnel, sans rien changer aux autres contrats.
|
||||||
|
|
||||||
|
**Architecture :** Un service pur `SolidarityDayResolver` calcule le Lundi de Pentecôte par computus (Pâques + 50 j). `RttRecoveryComputationService::computeRecoveryByWeek` (calcul partagé : onglet RTT, clôture/rollover, commande de vérification) neutralise le jour de solidarité pour les CUSTOM < 35h et applique le prorata, en le faisant transiter par `totalMinutes` via le mécanisme `isFlatRecovery` existant (reporté en N+1, ne draine pas les tranches 25/50).
|
||||||
|
|
||||||
|
**Tech Stack :** PHP 8.4, Symfony, PHPUnit. Tests purs via `ReflectionClass::newInstanceWithoutConstructor` (pattern existant dans `RttRecoveryComputationServiceTest`).
|
||||||
|
|
||||||
|
**Spec :** `docs/superpowers/specs/2026-06-11-rtt-solidarity-day-deficit-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- **Create** `src/Service/Rtt/SolidarityDayResolver.php` — service pur, computus de Pâques + Lundi de Pentecôte. Responsabilité unique : donner la date du jour de solidarité d'une année.
|
||||||
|
- **Create** `tests/Service/Rtt/SolidarityDayResolverTest.php` — tests des dates 2024/2025/2026.
|
||||||
|
- **Modify** `src/Service/Rtt/RttRecoveryComputationService.php` — injecter `SolidarityDayResolver` ; ajouter `resolveSolidarityDatesInRange()` + `computeSolidarityDeficitAdjustment()` ; appliquer dans `computeRecoveryByWeek()`.
|
||||||
|
- **Modify** `tests/Service/Rtt/RttRecoveryComputationServiceTest.php` — tests réflexion de `computeSolidarityDeficitAdjustment()`.
|
||||||
|
- **Modify (docs)** `CLAUDE.md`, `frontend/data/documentation-content.ts`, `doc/rtt-tab.md`, `doc/functional-rules.md`.
|
||||||
|
- **Inchangé** : `config/services.yaml` (autowiring : `SolidarityDayResolver` est un service autowireable, et `RttRecoveryComputationService` n'override que `$rttStartDate` — les autres args s'autowirent), `DumpVerificationSnapshotCommand.php` (consomme `WeekRecoveryDetail.totalMinutes`, hérite du déficit), `RttTab.vue`, migrations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: SolidarityDayResolver (computus Pâques + Pentecôte)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Service/Rtt/SolidarityDayResolver.php`
|
||||||
|
- Test: `tests/Service/Rtt/SolidarityDayResolverTest.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `tests/Service/Rtt/SolidarityDayResolverTest.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service\Rtt;
|
||||||
|
|
||||||
|
use App\Service\Rtt\SolidarityDayResolver;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class SolidarityDayResolverTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Lundi de Pentecôte = dimanche de Pâques + 50 jours.
|
||||||
|
* 2024 : Pâques 31/03 → 20/05 ; 2025 : Pâques 20/04 → 09/06 ; 2026 : Pâques 05/04 → 25/05.
|
||||||
|
*
|
||||||
|
* @dataProvider pentecostCases
|
||||||
|
*/
|
||||||
|
public function testPentecostMonday(int $year, string $expected): void
|
||||||
|
{
|
||||||
|
$resolver = new SolidarityDayResolver();
|
||||||
|
|
||||||
|
self::assertSame($expected, $resolver->pentecostMonday($year)->format('Y-m-d'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return iterable<string, array{int, string}>
|
||||||
|
*/
|
||||||
|
public static function pentecostCases(): iterable
|
||||||
|
{
|
||||||
|
yield '2024' => [2024, '2024-05-20'];
|
||||||
|
yield '2025' => [2025, '2025-06-09'];
|
||||||
|
yield '2026' => [2026, '2026-05-25'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `make test` (or `docker exec php-sirh-fpm php bin/phpunit tests/Service/Rtt/SolidarityDayResolverTest.php`)
|
||||||
|
Expected: FAIL — `Class "App\Service\Rtt\SolidarityDayResolver" not found`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write the implementation**
|
||||||
|
|
||||||
|
Create `src/Service/Rtt/SolidarityDayResolver.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Rtt;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résout le jour de solidarité (Lundi de Pentecôte) d'une année.
|
||||||
|
*
|
||||||
|
* Pur et déterministe : Pâques via l'algorithme de Meeus/Jones/Butcher (calendrier
|
||||||
|
* grégorien), sans dépendance à l'extension calendar ni au réseau. Lundi de Pentecôte
|
||||||
|
* = dimanche de Pâques + 50 jours.
|
||||||
|
*/
|
||||||
|
final class SolidarityDayResolver
|
||||||
|
{
|
||||||
|
public function pentecostMonday(int $year): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->easterSunday($year)->modify('+50 days');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function easterSunday(int $year): DateTimeImmutable
|
||||||
|
{
|
||||||
|
$a = $year % 19;
|
||||||
|
$b = intdiv($year, 100);
|
||||||
|
$c = $year % 100;
|
||||||
|
$d = intdiv($b, 4);
|
||||||
|
$e = $b % 4;
|
||||||
|
$f = intdiv($b + 8, 25);
|
||||||
|
$g = intdiv($b - $f + 1, 3);
|
||||||
|
$h = (19 * $a + $b - $d - $g + 15) % 30;
|
||||||
|
$i = intdiv($c, 4);
|
||||||
|
$k = $c % 4;
|
||||||
|
$l = (32 + 2 * $e + 2 * $i - $h - $k) % 7;
|
||||||
|
$m = intdiv($a + 11 * $h + 22 * $l, 451);
|
||||||
|
|
||||||
|
$month = intdiv($h + $l - 7 * $m + 114, 31);
|
||||||
|
$day = (($h + $l - 7 * $m + 114) % 31) + 1;
|
||||||
|
|
||||||
|
return new DateTimeImmutable(sprintf('%04d-%02d-%02d', $year, $month, $day));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `make test`
|
||||||
|
Expected: PASS (3 nouveaux tests verts, le reste de la suite inchangé).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Service/Rtt/SolidarityDayResolver.php tests/Service/Rtt/SolidarityDayResolverTest.php
|
||||||
|
git commit -m "feat(rtt) : add SolidarityDayResolver (Pentecost Monday via computus)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Déficit solidarité dans RttRecoveryComputationService
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Service/Rtt/RttRecoveryComputationService.php`
|
||||||
|
- Test: `tests/Service/Rtt/RttRecoveryComputationServiceTest.php`
|
||||||
|
|
||||||
|
Le helper `computeSolidarityDeficitAdjustment()` est **pur** (n'utilise que `ContractType::resolve` et les getters de `Contract`) → testable via `newInstanceWithoutConstructor` comme les autres helpers du fichier. Il renvoie le **delta** à ajouter à `weeklyOvertimeTotalMinutes`.
|
||||||
|
|
||||||
|
Rappel arithmétique (Ewa, 4h, lundi, `expected = workDaysHours[lundi] = 120`, `prorata = round(4×12) = 48`) :
|
||||||
|
- RTT posé / jour vide (`worked = 0`) → delta `(120−0)−48 = +72` ; appliqué au naturel `−120` ⇒ semaine **−48 min**.
|
||||||
|
- travaillé normalement (`worked = 120`) → delta `(120−120)−48 = −48` ; naturel `0` ⇒ **−48 min**.
|
||||||
|
- travaillé en plus (`worked = 240`) → delta `(120−240)−48 = −168` ; naturel `+120` ⇒ **−48 min**.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test (pure helper)**
|
||||||
|
|
||||||
|
Add these methods to `tests/Service/Rtt/RttRecoveryComputationServiceTest.php` (before the `invokePrivate` helper). Note `use` additions at top: `use App\Enum\TrackingMode;` (already imports `App\Entity\Contract`).
|
||||||
|
|
||||||
|
```php
|
||||||
|
private static function customContract(int $weeklyHours): Contract
|
||||||
|
{
|
||||||
|
return new Contract()
|
||||||
|
->setName('Temps partiel')
|
||||||
|
->setTrackingMode(TrackingMode::TIME)
|
||||||
|
->setWeeklyHours($weeklyHours)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CUSTOM 4h, jour de solidarité non travaillé (RTT posé ou vide) : delta = (attendu − 0) − prorata.
|
||||||
|
* attendu lundi = workDaysHours = 120 ; prorata = round(4×12) = 48 ; delta = 120 − 48 = 72.
|
||||||
|
* (Combiné au naturel −120 de la semaine, donne −48 min.)
|
||||||
|
*/
|
||||||
|
public function testSolidarityAdjustmentCustomNotWorkedNeutralisesToProrata(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
$delta = $this->invokePrivate(
|
||||||
|
$service,
|
||||||
|
'computeSolidarityDeficitAdjustment',
|
||||||
|
self::customContract(4),
|
||||||
|
120, // expectedMinutes (workDaysHours du lundi)
|
||||||
|
0, // workedMinutes (RTT posé / vide)
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(72, $delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CUSTOM 4h, jour de solidarité travaillé normalement (120) : delta = (120 − 120) − 48 = −48.
|
||||||
|
*/
|
||||||
|
public function testSolidarityAdjustmentCustomWorkedNormallyChargesProrata(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', self::customContract(4), 120, 120);
|
||||||
|
|
||||||
|
self::assertSame(-48, $delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CUSTOM 4h, jour de solidarité travaillé en plus (240) : delta = (120 − 240) − 48 = −168.
|
||||||
|
* Le surplus du jour de solidarité n'est PAS crédité (jour neutralisé, net forcé à −prorata).
|
||||||
|
*/
|
||||||
|
public function testSolidarityAdjustmentCustomWorkedExtraStillNetsProrata(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', self::customContract(4), 120, 240);
|
||||||
|
|
||||||
|
self::assertSame(-168, $delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CUSTOM 28h : prorata = round(28×12) = 336 (5h36). worked 0, expected 336 → delta 0.
|
||||||
|
*/
|
||||||
|
public function testSolidarityAdjustmentCustom28hUsesProrata(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', self::customContract(28), 336, 0);
|
||||||
|
|
||||||
|
self::assertSame(0, $delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CUSTOM ≥ 35h (36h) : hors périmètre → delta 0.
|
||||||
|
*/
|
||||||
|
public function testSolidarityAdjustmentCustom36hOutOfScope(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', self::customContract(36), 999, 0);
|
||||||
|
|
||||||
|
self::assertSame(0, $delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 35h : type H35 (pas CUSTOM) → delta 0 (comportement inchangé, RTT posé fait foi).
|
||||||
|
*/
|
||||||
|
public function testSolidarityAdjustment35hOutOfScope(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
$contract = new Contract()->setName('35h')->setTrackingMode(TrackingMode::TIME)->setWeeklyHours(35);
|
||||||
|
|
||||||
|
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', $contract, 420, 0);
|
||||||
|
|
||||||
|
self::assertSame(0, $delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aucun contrat ce jour-là (salarié parti / pas encore embauché) → delta 0.
|
||||||
|
*/
|
||||||
|
public function testSolidarityAdjustmentNoContractIsZero(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', null, 0, 0);
|
||||||
|
|
||||||
|
self::assertSame(0, $delta);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `make test`
|
||||||
|
Expected: FAIL — `computeSolidarityDeficitAdjustment` n'existe pas (réflexion : `Method ... does not exist`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the constructor dependency**
|
||||||
|
|
||||||
|
In `src/Service/Rtt/RttRecoveryComputationService.php`, add `SolidarityDayResolver` to the constructor (BEFORE the defaulted `$rttStartDate`, sinon erreur « param non-défaut après défaut »). `SolidarityDayResolver` est dans le même namespace `App\Service\Rtt` → aucun `use` à ajouter.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function __construct(
|
||||||
|
private WorkHourRepository $workHourRepository,
|
||||||
|
private AbsenceRepository $absenceRepository,
|
||||||
|
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
||||||
|
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
|
||||||
|
private EmployeeContractResolver $contractResolver,
|
||||||
|
private DailyReferenceMinutesResolver $dailyReferenceResolver,
|
||||||
|
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
|
||||||
|
private SolidarityDayResolver $solidarityDayResolver,
|
||||||
|
string $rttStartDate = '',
|
||||||
|
) {
|
||||||
|
$this->rttStartDate = '' !== $rttStartDate ? new DateTimeImmutable($rttStartDate) : null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add the two private methods**
|
||||||
|
|
||||||
|
In the same file, add these methods (e.g. just after `resolveWeekAnchorDate`, alongside the other private helpers):
|
||||||
|
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* Lundi(s) de Pentecôte (jour de solidarité) inclus dans [from, to]. Un exercice
|
||||||
|
* Juin N-1 → Mai N couvre les années civiles N-1 et N ; on retient les dates dans la fenêtre.
|
||||||
|
*
|
||||||
|
* @return list<string> dates au format 'Y-m-d'
|
||||||
|
*/
|
||||||
|
private function resolveSolidarityDatesInRange(DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||||
|
{
|
||||||
|
$dates = [];
|
||||||
|
$firstYear = (int) $from->format('Y');
|
||||||
|
$lastYear = (int) $to->format('Y');
|
||||||
|
|
||||||
|
for ($year = $firstYear; $year <= $lastYear; ++$year) {
|
||||||
|
$candidate = $this->solidarityDayResolver->pentecostMonday($year);
|
||||||
|
if ($candidate >= $from && $candidate <= $to) {
|
||||||
|
$dates[] = $candidate->format('Y-m-d');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Déficit forfaitaire du jour de solidarité pour les contrats CUSTOM < 35h.
|
||||||
|
*
|
||||||
|
* Le jour est neutralisé puis chargé du prorata légal : on remplace la valeur réelle
|
||||||
|
* du jour ($workedMinutes : RTT posé, heures saisies, vide, ou crédit férié virtuel)
|
||||||
|
* par l'attendu contractuel du jour ($expectedMinutes = workDaysHours), puis on
|
||||||
|
* retranche le prorata = 7h/35h × heuresHebdo = 12 min par heure hebdo. Sur une
|
||||||
|
* semaine par ailleurs normale, le net vaut exactement −prorata. Renvoie le delta à
|
||||||
|
* ajouter à weeklyOvertimeTotalMinutes (0 hors périmètre : non-CUSTOM ou ≥ 35h).
|
||||||
|
*/
|
||||||
|
private function computeSolidarityDeficitAdjustment(
|
||||||
|
?Contract $contractAtSolidarity,
|
||||||
|
int $expectedMinutes,
|
||||||
|
int $workedMinutes,
|
||||||
|
): int {
|
||||||
|
$weeklyHours = $contractAtSolidarity?->getWeeklyHours();
|
||||||
|
$type = ContractType::resolve(
|
||||||
|
$contractAtSolidarity?->getName(),
|
||||||
|
$contractAtSolidarity?->getTrackingMode(),
|
||||||
|
$weeklyHours,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ContractType::CUSTOM !== $type || null === $weeklyHours || $weeklyHours >= 35) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$prorata = (int) round($weeklyHours * 12);
|
||||||
|
|
||||||
|
return ($expectedMinutes - $workedMinutes) - $prorata;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Wire it into `computeRecoveryByWeek`**
|
||||||
|
|
||||||
|
(a) Just before the weeks loop, after `$results = [];` (≈ line 165), resolve the solidarity dates once:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$results = [];
|
||||||
|
$solidarityDates = $this->resolveSolidarityDatesInRange($periodFrom, $periodTo);
|
||||||
|
```
|
||||||
|
|
||||||
|
(b) Inside the week loop, immediately after `$weeklyOvertimeTotalMinutes = ...` is computed (the `$isWeekPresenceTracking ? 0 : $weeklyTotalMinutes - $overtimeReferenceMinutes;` assignment, ≈ line 243-245) and BEFORE the `[$rawBase25, $rawBase50] = ...` line, insert:
|
||||||
|
|
||||||
|
```php
|
||||||
|
foreach ($solidarityDates as $solidarityDate) {
|
||||||
|
// isset ⇒ le jour de solidarité fait partie du sommage de CETTE semaine
|
||||||
|
// (donc ≤ limitDate et ≥ rttStartDate). Sinon : jour futur ou hors service → pas de déficit.
|
||||||
|
if (!isset($dailyWorkedMinutes[$solidarityDate])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$contractAtSolidarity = $employeeContractsByDate[$solidarityDate] ?? null;
|
||||||
|
$solidarityIsoDay = (int) new DateTimeImmutable($solidarityDate)->format('N');
|
||||||
|
// Attendu RÉEL du jour (planning workDaysHours), pas la répartition uniforme :
|
||||||
|
// c'est ce qui rend la neutralisation correcte (cf. spec).
|
||||||
|
$solidarityExpected = $this->dailyReferenceResolver->resolve(
|
||||||
|
$contractAtSolidarity?->getWeeklyHours(),
|
||||||
|
$solidarityIsoDay,
|
||||||
|
$workDaysByDate[$employeeId][$solidarityDate] ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$weeklyOvertimeTotalMinutes += $this->computeSolidarityDeficitAdjustment(
|
||||||
|
$contractAtSolidarity,
|
||||||
|
$solidarityExpected,
|
||||||
|
$dailyWorkedMinutes[$solidarityDate],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `make test`
|
||||||
|
Expected: PASS — les 7 nouveaux tests verts, toute la suite verte (le pre-commit relance aussi la suite complète).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Verify the service container still builds (autowiring)**
|
||||||
|
|
||||||
|
Run: `docker exec php-sirh-fpm php bin/console debug:container App\\Service\\Rtt\\RttRecoveryComputationService 2>&1 | tail -20`
|
||||||
|
Expected: le service est listé, sans erreur d'argument non résolu (`SolidarityDayResolver` autowiré). Si erreur d'autowiring : ajouter explicitement l'argument dans `config/services.yaml` sous `RttRecoveryComputationService` — mais normalement inutile.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Service/Rtt/RttRecoveryComputationService.php tests/Service/Rtt/RttRecoveryComputationServiceTest.php
|
||||||
|
git commit -m "feat(rtt) : solidarity-day deficit for CUSTOM <35h contracts"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Vérification sur données de production
|
||||||
|
|
||||||
|
**Files:** aucun fichier de code. Génère des snapshots « after » et compare.
|
||||||
|
|
||||||
|
Contexte : le workflow before/after existe déjà (`docs/verifications/` = avant, `docs/verifications-after/` = après). La commande `app:verification:snapshot` rend la vue onglet RTT par mois.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Generate the "after" snapshot for the witnesses + a control**
|
||||||
|
|
||||||
|
Ewa (id 31, CUSTOM 4h), Nadia (id 22, CUSTOM 4h), et un témoin 35h ou 39h (choisir un id présent — vérifier en base) pour prouver la non-régression.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
docker exec php-sirh-fpm php bin/console app:verification:snapshot 31 22 --rtt-year=2026 --output-dir=docs/verifications-after
|
||||||
|
```
|
||||||
|
Expected: génère les fichiers Markdown sans erreur.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Inspect Ewa's solidarity week (S22, semaine du 25/05/2026)**
|
||||||
|
|
||||||
|
Ouvrir le snapshot d'Ewa (`docs/verifications-after/…`) et vérifier :
|
||||||
|
- Semaine du 2026-05-25 : **Heure** et **Total** = −0h48 (−48 min), **Cumul** réduit de 48 min.
|
||||||
|
- Colonnes 25 % / 50 % = 0 sur cette semaine.
|
||||||
|
- La semaine du 2026-06-01 (lundi 1er juin) conserve son −2h existant, distinct.
|
||||||
|
|
||||||
|
Si l'écart ne vaut pas −48 min : NE PAS « ajuster jusqu'à ce que ça passe ». Relire `computeSolidarityDeficitAdjustment` et la valeur `expectedMinutes` (doit valoir `workDaysHours[lundi]`, ex. 120) — un écart signale un bug réel (utiliser systematic-debugging).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Confirm non-regression for a standard contract**
|
||||||
|
|
||||||
|
Snapshot d'un employé 35h/39h ayant un RTT posé sur le 25/05/2026 : la semaine doit être **inchangée** vs `docs/verifications/` (le déficit solidarité ne s'applique pas, le RTT posé garde son effet).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit the after-snapshots (regression baseline)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/verifications-after
|
||||||
|
git commit -m "test(rtt) : after-snapshot proving solidarity deficit on Ewa/Nadia S22"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Documentation (règle projet obligatoire)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `CLAUDE.md`
|
||||||
|
- Modify: `doc/functional-rules.md`
|
||||||
|
- Modify: `doc/rtt-tab.md`
|
||||||
|
- Modify: `frontend/data/documentation-content.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: CLAUDE.md — section Overtime Rules**
|
||||||
|
|
||||||
|
Sous la puce CUSTOM existante (« CUSTOM contracts … Le déficit … réduit le cumul RTT 1:1 »), ajouter une sous-puce :
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- **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). 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 pose un RTT qui draine ~7h). Date via `App\Service\Rtt\SolidarityDayResolver` (computus, indépendant d'`EXCLUDED_PUBLIC_HOLIDAYS`). Appliqué dans `RttRecoveryComputationService::{resolveSolidarityDatesInRange, computeSolidarityDeficitAdjustment}`.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: doc/functional-rules.md**
|
||||||
|
|
||||||
|
Dans la section RTT / heures supplémentaires (près des règles CUSTOM), ajouter un paragraphe :
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### Jour de solidarité (contrats CUSTOM < 35h)
|
||||||
|
|
||||||
|
Le Lundi de Pentecôte (jour de solidarité) impose une contribution proratisée aux temps
|
||||||
|
partiels < 35h. La RH pose un RTT sur ce jour pour tous les salariés ; pour les contrats
|
||||||
|
standard (35h/39h) cela draine ~7h du cumul RTT (comportement inchangé). Pour les CUSTOM
|
||||||
|
< 35h, poser un RTT entier n'a pas de sens : le logiciel **neutralise** le jour (quel que
|
||||||
|
soit ce qui y est saisi) et applique un déficit forfaitaire `7/35 × heuresHebdo`
|
||||||
|
(= 12 min par heure hebdo : 4h → 48 min, 28h → 5h36). Ce déficit réduit le cumul RTT
|
||||||
|
(peut le rendre négatif, reporté à l'exercice suivant) et se cumule avec les autres
|
||||||
|
déficits/surplus de la semaine. Date calculée par computus (Pâques + 50 jours),
|
||||||
|
indépendante de la liste `EXCLUDED_PUBLIC_HOLIDAYS`.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: doc/rtt-tab.md**
|
||||||
|
|
||||||
|
Dans la section « Règle de calcul — contrats CUSTOM », ajouter un sous-bloc :
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
#### Jour de solidarité (CUSTOM < 35h)
|
||||||
|
|
||||||
|
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).
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: frontend/data/documentation-content.ts**
|
||||||
|
|
||||||
|
Repérer l'article RTT pour les contrats partiels / CUSTOM (recherche `CUSTOM` ou `rtt-compteurs`). Ajouter un bloc de texte (échapper les apostrophes `\'`) décrivant la règle :
|
||||||
|
|
||||||
|
```
|
||||||
|
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 hebdo, soit 12 minutes par heure
|
||||||
|
hebdomadaire : 4h → 48 min). Ce déficit réduit le cumul RTT, peu importe ce qui est saisi
|
||||||
|
ce jour-là.
|
||||||
|
```
|
||||||
|
|
||||||
|
Respecter la structure `DocBlock` existante (même type de bloc que les paragraphes voisins).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the suite and commit**
|
||||||
|
|
||||||
|
Run: `make test`
|
||||||
|
Expected: PASS (les docs ne cassent rien ; la suite reste verte).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add CLAUDE.md doc/functional-rules.md doc/rtt-tab.md frontend/data/documentation-content.ts
|
||||||
|
git commit -m "docs(rtt) : document solidarity-day deficit for CUSTOM <35h"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review (rempli pendant la rédaction)
|
||||||
|
|
||||||
|
- **Couverture spec** : SolidarityDayResolver (T1) ✓ ; injection + neutralisation + prorata dans computeRecoveryByWeek (T2) ✓ ; périmètre CUSTOM < 35h + garde ≥ 35h (T2, `computeSolidarityDeficitAdjustment`) ✓ ; robustesse limitDate/rttStartDate via `isset($dailyWorkedMinutes)` (T2 step 5) ✓ ; contrat lu au jour de solidarité (T2 step 5) ✓ ; propagation clôture/rollover/snapshot via totalMinutes (inchangé, vérifié T3) ✓ ; cas limites (T2 tests + T3) ✓ ; docs (T4) ✓.
|
||||||
|
- **Pas de placeholder** : tout le code est fourni.
|
||||||
|
- **Cohérence des noms** : `pentecostMonday`, `resolveSolidarityDatesInRange`, `computeSolidarityDeficitAdjustment`, `$solidarityDayResolver`, `$dailyReferenceResolver`, `$workDaysByDate`, `$employeeId`, `$dailyWorkedMinutes`, `$employeeContractsByDate` — alignés avec le code existant de `computeRecoveryByWeek`.
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# Export PDF des heures — vue Jour (par sites)
|
||||||
|
|
||||||
|
**Date** : 2026-06-08
|
||||||
|
**Branche** : feature/SIRH-35-export-des-heures-employe
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Ajouter un bouton **Exporter** sur l'écran « Heures », réservé aux administrateurs,
|
||||||
|
qui produit un **PDF d'une journée** reprenant les colonnes de la vue Jour (sans la
|
||||||
|
colonne de validation), pour les employés des sites sélectionnés, **regroupés par site**.
|
||||||
|
|
||||||
|
## Décisions validées
|
||||||
|
|
||||||
|
| Sujet | Choix |
|
||||||
|
|-------|-------|
|
||||||
|
| Format | PDF (Twig → Dompdf) |
|
||||||
|
| Période | Un seul jour |
|
||||||
|
| Orientation | A4 **portrait**, mise en page compacte (objectif : tenir sur une page ; débordement multipage seulement si le nombre d'employés l'impose) |
|
||||||
|
| Regroupement | Une section par site |
|
||||||
|
| Accès | `ROLE_ADMIN` uniquement |
|
||||||
|
|
||||||
|
## Comportement frontend
|
||||||
|
|
||||||
|
### Bouton
|
||||||
|
|
||||||
|
- Dans `frontend/pages/hours.vue`, à droite du titre « Heures » (le conteneur titre est
|
||||||
|
déjà `flex flex-wrap items-center justify-between`).
|
||||||
|
- Visible uniquement si `isAdmin` (déjà exposé par `useHoursPage`).
|
||||||
|
- Style cohérent avec les autres boutons d'action de l'app ; libellé « Exporter »
|
||||||
|
(préfixe non requis ici, ce n'est pas un « + Ajouter »).
|
||||||
|
|
||||||
|
### Drawer `HoursDayExportDrawer.vue`
|
||||||
|
|
||||||
|
Nouveau composant utilisant `AppDrawer` (mode create — bouton centré).
|
||||||
|
|
||||||
|
Champs :
|
||||||
|
1. **Date** — champ date (input date), prérempli avec `selectedDate` de l'écran.
|
||||||
|
2. **Sites** — `MalioSelectCheckbox` avec `display-select-all`, mêmes options que la
|
||||||
|
toolbar (`sites` du composable), présélectionné sur `selectedSiteIds` courants.
|
||||||
|
|
||||||
|
Bouton **« Exporter »** : désactivé si aucune date ou aucun site sélectionné.
|
||||||
|
|
||||||
|
### Déclenchement
|
||||||
|
|
||||||
|
- À la validation : `usePdfPrinter().printPdf(url)` avec
|
||||||
|
`GET /work-hours/day-export?workDate=YYYY-MM-DD&siteIds=1,2,3`.
|
||||||
|
- Le téléchargement réutilise le pattern blob existant (`usePdfPrinter`).
|
||||||
|
- État `isLoading` sur le bouton pendant la génération.
|
||||||
|
|
||||||
|
### Câblage dans `hours.vue` / `useHoursPage.ts`
|
||||||
|
|
||||||
|
- `hours.vue` gère l'état d'ouverture du drawer et passe `sites`, `selectedSiteIds`,
|
||||||
|
`selectedDate`, `isAdmin`.
|
||||||
|
- L'appel d'export peut vivre dans un petit handler local (`hours.vue`) ou dans le
|
||||||
|
composable ; au choix de l'implémentation, en gardant `useHoursPage` comme source des
|
||||||
|
données affichées.
|
||||||
|
|
||||||
|
## Portée des données (identique à l'écran Jour)
|
||||||
|
|
||||||
|
- Employés **non-conducteurs** (`isDriver !== true`).
|
||||||
|
- **Sous contrat** à la date choisie.
|
||||||
|
- Appartenant aux **sites cochés**.
|
||||||
|
- **Tous les employés sous contrat sont affichés**, même sans saisie (lignes vides) —
|
||||||
|
cohérent avec la règle des exports heures annuelles.
|
||||||
|
|
||||||
|
## Colonnes du PDF
|
||||||
|
|
||||||
|
Mêmes colonnes que la vue Jour, **sans la colonne Valider** :
|
||||||
|
|
||||||
|
`Nom` · `Statut` · `Début matin` · `Fin matin` · `Début après-midi` ·
|
||||||
|
`Fin après-midi` · `Début soir` · `Fin soir` · `Jour` · `Nuit` · `Total`
|
||||||
|
|
||||||
|
- **Statut** : libellé d'absence (ou formation, ou nom du férié) si présent, sinon vide.
|
||||||
|
- **Heures** (`Début/Fin` matin/après-midi/soir) : valeurs `WorkHour` brutes (`HH:MM`),
|
||||||
|
vides si non saisies.
|
||||||
|
- **Jour / Nuit / Total** : calculés comme à l'écran — minutes jour vs nuit, total
|
||||||
|
incluant le crédit d'absence (`countAsWorkedHours`) et le **crédit virtuel férié**
|
||||||
|
(`HolidayVirtualHoursResolver`).
|
||||||
|
- Week-ends / fériés : lignes grisées/colorées comme dans les templates existants.
|
||||||
|
|
||||||
|
## Architecture backend
|
||||||
|
|
||||||
|
### ApiResource `WorkHourDayExport`
|
||||||
|
|
||||||
|
`src/ApiResource/WorkHourDayExport.php` — calqué sur `EmployeeYearlyHoursBulkPrint` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/work-hours/day-export',
|
||||||
|
provider: WorkHourDayExportProvider::class,
|
||||||
|
parameters: [
|
||||||
|
new QueryParameter(key: 'workDate', required: true),
|
||||||
|
new QueryParameter(key: 'siteIds', required: true),
|
||||||
|
],
|
||||||
|
security: "is_granted('ROLE_ADMIN')"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Provider `WorkHourDayExportProvider`
|
||||||
|
|
||||||
|
`src/State/WorkHourDayExportProvider.php` :
|
||||||
|
|
||||||
|
1. Lire/valider `workDate` (`Y-m-d`) et `siteIds` (CSV d'entiers).
|
||||||
|
2. Charger les employés (`EmployeeRepository::findAll()` — feature admin-only),
|
||||||
|
filtrer : non-drivers, site ∈ siteIds.
|
||||||
|
3. Pour chaque site (ordre `displayOrder`), trier les employés par nom.
|
||||||
|
4. Filtrer les employés sous contrat à la date (le builder ignore déjà les jours hors
|
||||||
|
contrat — un employé sans contrat ce jour produit une ligne vide à exclure).
|
||||||
|
5. Construire les lignes via `YearlyHoursExportBuilder` (méthode dédiée, voir ci-dessous).
|
||||||
|
6. Rendre le Twig → Dompdf (`A4`, `portrait`), renvoyer `Response` binaire avec
|
||||||
|
`Content-Disposition: attachment; filename="heures_jour_YYYY-MM-DD.pdf"`.
|
||||||
|
|
||||||
|
### Réutilisation `YearlyHoursExportBuilder`
|
||||||
|
|
||||||
|
Ajouter une méthode publique :
|
||||||
|
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* @param list<Employee> $employees
|
||||||
|
* @return list<array{employeeId:int, employeeName:string, statut:?string,
|
||||||
|
* morningFrom:string, morningTo:string, afternoonFrom:string, afternoonTo:string,
|
||||||
|
* eveningFrom:string, eveningTo:string, dayHours:string, nightHours:string,
|
||||||
|
* total:string, isWeekend:bool, hasContract:bool}>
|
||||||
|
*/
|
||||||
|
public function buildDayRowsForEmployees(array $employees, DateTimeImmutable $date): array
|
||||||
|
```
|
||||||
|
|
||||||
|
- Réutilise les helpers privés existants (`computeMetrics`, résolution d'absences,
|
||||||
|
`HolidayVirtualHoursResolver`, `EmployeeContractResolver`, fériés) — **source unique
|
||||||
|
de vérité** pour le calcul des cellules d'une journée.
|
||||||
|
- Émet en plus `dayHours` / `nightHours` (issus de `WorkMetrics.dayMinutes` /
|
||||||
|
`nightMinutes`) que l'export annuel n'affichait pas par ligne en mode TIME.
|
||||||
|
- Les employés sans contrat ce jour sont exclus (pas de ligne).
|
||||||
|
- Le `statut` agrège absence / formation / libellé férié (réutilise la logique de
|
||||||
|
résolution d'absence/formation déjà présente dans le contexte jour si nécessaire).
|
||||||
|
|
||||||
|
> Note : la vue Jour mélange potentiellement modes TIME et PRESENCE selon le contrat à
|
||||||
|
> la date. Pour l'export, on suit le mode résolu à la date (comme l'écran). En mode
|
||||||
|
> PRESENCE, les cellules horaires restent vides et `Total` exprime les demi-journées,
|
||||||
|
> identique à l'affichage écran.
|
||||||
|
|
||||||
|
### Template `templates/work-hour-day-export/print.html.twig`
|
||||||
|
|
||||||
|
- A4 portrait, marges fines, police ~9px (réf. `employee-yearly-hours/print.html.twig`).
|
||||||
|
- Barre de titre : « Heures — {date} » + date d'export en haut à droite.
|
||||||
|
- Une `<h2>` par site, suivie d'un tableau avec les 11 colonnes ci-dessus.
|
||||||
|
- Week-ends / fériés grisés (`#c0c0c0` / `#b3e5fc`) comme les templates existants.
|
||||||
|
- `table-layout: auto`, largeurs compactes pour viser une page.
|
||||||
|
|
||||||
|
## Limites connues
|
||||||
|
|
||||||
|
- Un grand nombre d'employés (beaucoup de sites cochés) peut déborder sur plusieurs
|
||||||
|
pages — on vise une page sans la garantir.
|
||||||
|
- Pas de risque mémoire particulier (un seul jour, volume très inférieur à l'export
|
||||||
|
annuel tous employés).
|
||||||
|
|
||||||
|
## Documentation à mettre à jour (règles CLAUDE.md)
|
||||||
|
|
||||||
|
1. `doc/` : nouvelle section (ou ajout à un doc heures existant) décrivant l'export jour.
|
||||||
|
2. `frontend/data/documentation-content.ts` : entrée niveau **admin** dans la section
|
||||||
|
Heures.
|
||||||
|
3. `CLAUDE.md` : note sous la section heures/exports (provider, builder réutilisé,
|
||||||
|
colonnes, scope identique écran, portrait).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Test unitaire `YearlyHoursExportBuilder::buildDayRowsForEmployees` : un employé TIME
|
||||||
|
avec saisie (vérifier day/night/total), un employé sans contrat (exclu), un jour férié
|
||||||
|
(crédit virtuel), une absence `countAsWorkedHours`.
|
||||||
|
- (Optionnel) test provider : validation des paramètres `workDate` / `siteIds`.
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Déficit RTT pris en compte pour les contrats CUSTOM (4h, etc.)
|
||||||
|
|
||||||
|
Date : 2026-06-09
|
||||||
|
Branche : `feature/SIRH-36-corriger-le-calcule-des-rtt-des-contrat-4h`
|
||||||
|
|
||||||
|
## Contexte & problème
|
||||||
|
|
||||||
|
Les salariées avec un contrat 4h (type CUSTOM : `weeklyHours` ≠ 35/39, non INTERIM/FORFAIT,
|
||||||
|
mode TIME) voient, dans l'onglet RTT, des semaines travaillées **en dessous** de leurs heures
|
||||||
|
contractuelles afficher un déficit dans la colonne « Heure » (ex. Ewa S23 : −2h) **sans aucun
|
||||||
|
effet** : « Total » = 0 et « Cumul » = 0.
|
||||||
|
|
||||||
|
Cause : `RttRecoveryComputationService::computeRecoveryByWeek` écrête le total hebdo des CUSTOM
|
||||||
|
avec `totalMinutes = max(0, $weeklyOvertimeTotalMinutes)`. Le déficit est donc supprimé. C'est
|
||||||
|
le comportement métier documenté jusqu'ici (« CUSTOM : le déficit n'impacte pas le solde »).
|
||||||
|
|
||||||
|
Décision métier (validée avec le client) : **le déficit doit être pris en compte et réduire le
|
||||||
|
cumul**, comme pour les 35h/39h, avec un affichage propre dans l'onglet RTT.
|
||||||
|
|
||||||
|
## Décisions validées
|
||||||
|
|
||||||
|
1. **Le cumul peut devenir négatif** (identique aux 35h/39h, comportement déjà assumé dans le
|
||||||
|
code — cf. `RttClosingBalanceService::fold` ligne 98 « leftover may push the balance
|
||||||
|
negative, as on screen »). Le négatif est reporté à l'exercice suivant.
|
||||||
|
2. **Déficit visible** en colonnes « Heure » + « Total » + « Cumul ». Les colonnes 25%/50%
|
||||||
|
restent à **0** pour un contrat CUSTOM (un 4h n'a pas de bonus, donc pas de tranches).
|
||||||
|
|
||||||
|
## Principe technique
|
||||||
|
|
||||||
|
Retirer l'écrêtage `max(0, …)` pour les semaines CUSTOM : le déficit (négatif) circule dans
|
||||||
|
`WeekRecoveryDetail::totalMinutes` et réduit le cumul. La seule spécificité CUSTOM reste :
|
||||||
|
récupération 1h = 1h, sans bonus 25/50.
|
||||||
|
|
||||||
|
### Le point délicat : ne pas drainer les tranches 25/50
|
||||||
|
|
||||||
|
Pour un 35h/39h, une semaine déficitaire **draine** les tranches 25/50 accumulées via la cascade
|
||||||
|
de `EmployeeRttSummaryProvider` (lignes 149-174) et de `RttClosingBalanceService::fold` (lignes
|
||||||
|
92-99), ce qui affiche des valeurs négatives en « Total 25% / 50% ».
|
||||||
|
|
||||||
|
Pour un CUSTOM, la récup n'est jamais bucketisée (elle vit uniquement dans `totalMinutes`). La
|
||||||
|
cascade ne doit donc **pas** s'appliquer aux semaines CUSTOM, sinon le déficit apparaîtrait en
|
||||||
|
négatif dans « Total 25% » (affichage sale, et incohérent avec les récups positives qui, elles,
|
||||||
|
n'y figurent pas).
|
||||||
|
|
||||||
|
Solution : un drapeau **`isFlatRecovery`** (récupération plate 1:1, sans tranches) porté par la
|
||||||
|
semaine, qui désactive la cascade dans le provider.
|
||||||
|
|
||||||
|
## Changements
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
1. **`src/Dto/Rtt/WeekRecoveryDetail.php`** : ajout `public bool $isFlatRecovery = false`.
|
||||||
|
2. **`src/Dto/Rtt/EmployeeRttWeekSummary.php`** : ajout `public bool $isFlatRecovery = false`.
|
||||||
|
3. **`src/Service/Rtt/RttRecoveryComputationService.php`**
|
||||||
|
(`computeRecoveryByWeek`, branche `$isCustomContract`) :
|
||||||
|
- `totalMinutes = $weeklyOvertimeTotalMinutes` (signé — plus de `max(0, …)`).
|
||||||
|
- `isFlatRecovery: true` sur le `WeekRecoveryDetail` retourné.
|
||||||
|
- Les buckets `base25/bonus25/base50/bonus50` restent à 0 (inchangé).
|
||||||
|
- Cas non-CUSTOM et PRESENCE/INTERIM inchangés (`isFlatRecovery` reste `false`).
|
||||||
|
4. **`src/State/EmployeeRttSummaryProvider.php`**
|
||||||
|
- `buildWeekSummaries` : propager `isFlatRecovery` du `WeekRecoveryDetail` vers
|
||||||
|
l'`EmployeeRttWeekSummary` — dans le cas mono-mois **et** dans le cas semaine à cheval sur
|
||||||
|
deux mois (les deux instances héritent du drapeau).
|
||||||
|
- Cascade déficit (ligne 150) : condition devient
|
||||||
|
`if ($week->totalMinutes >= 0 || $week->isFlatRecovery)`. Pour une semaine CUSTOM
|
||||||
|
déficitaire, on ne draine pas : les buckets restent 0, `cumulativeBalanceMinutes`
|
||||||
|
(déjà basé sur `totalMinutes`, ligne 197) intègre le déficit.
|
||||||
|
- Dans la reconstruction de la branche `else` (semaine déficitaire normale), conserver
|
||||||
|
explicitement `isFlatRecovery: $week->isFlatRecovery` (toujours `false` à ce point, mais
|
||||||
|
explicite pour la clarté).
|
||||||
|
5. **`src/Command/DumpVerificationSnapshotCommand.php`** : ce command duplique la cascade du
|
||||||
|
provider (lignes ~695-716). Mettre à jour la condition de cascade pour respecter
|
||||||
|
`isFlatRecovery`, et propager le drapeau dans sa reconstruction des week summaries, afin que
|
||||||
|
les snapshots before/after restent fidèles à l'app.
|
||||||
|
|
||||||
|
### Aucun changement
|
||||||
|
|
||||||
|
- **`src/Service/Rtt/RttClosingBalanceService.php`** : `fold` gère déjà `totalMinutes` négatif
|
||||||
|
(branche déficit lignes 92-99) et le remainder CUSTOM (lignes 83-87). Le report N+1 intègre
|
||||||
|
donc automatiquement le déficit. Pas de modification.
|
||||||
|
- **`frontend/components/employees/RttTab.vue`** : aucun. Les sous-colonnes Base/25%/50% sont
|
||||||
|
déjà écrêtées à 0 sur les semaines déficitaires (`totalMinutes >= 0 ? … : 0`). Avec buckets =
|
||||||
|
0 côté back, « Total 25%/50% » = 0, et « Heure »/« Total »/« Cumul » affichent le déficit.
|
||||||
|
- **Pas de migration** : aucun changement de schéma.
|
||||||
|
|
||||||
|
## Effets de bord (assumés, cohérents)
|
||||||
|
|
||||||
|
- **Récap congés** (`LeaveRecapRowBuilder::…` via `computeTotalRecoveryForExercise`) : la valeur
|
||||||
|
RTT reflètera aussi les déficits et peut devenir négative. Cohérent avec la décision.
|
||||||
|
- **Rollover / report** : la clôture d'exercice (`computeClosingBalance`) intègre désormais les
|
||||||
|
déficits. Les lignes `employee_rtt_balances` déjà stockées (calculées avec l'ancienne logique,
|
||||||
|
déficit = 0) doivent être rafraîchies après déploiement :
|
||||||
|
`php bin/console app:rtt:rollover --force --recompute` (ne touche pas les lignes
|
||||||
|
`is_locked`). Ex. Ewa : clôture 2026 passe de 0 à −2h, donc report d'ouverture 2027 = −2h.
|
||||||
|
- **Carry / Report row** : pour un CUSTOM, le report reste stocké/affiché dans la tranche
|
||||||
|
`base25` (convention pré-existante du `fold`, remainder parking) — comportement inchangé, hors
|
||||||
|
périmètre.
|
||||||
|
|
||||||
|
## Tests (TDD)
|
||||||
|
|
||||||
|
- **`tests/Service/Rtt/RttClosingBalanceServiceTest.php`** : nouveau cas — un `WeekRecoveryDetail`
|
||||||
|
CUSTOM **déficitaire** (`totalMinutes` négatif) diminue bien la clôture (somme = report +
|
||||||
|
Σ semaines − payés, négatif inclus).
|
||||||
|
- **`tests/State/EmployeeRttSummaryProviderTest.php`** : semaine CUSTOM déficitaire
|
||||||
|
(`isFlatRecovery = true`, `totalMinutes < 0`) → buckets 25/50 restent 0, `cumulativeBalance`
|
||||||
|
réduit du déficit (pas de drainage des tranches). Vérifier aussi qu'une semaine 35h/39h
|
||||||
|
déficitaire continue de drainer (non-régression).
|
||||||
|
- **`tests/Service/Rtt/RttRecoveryComputationServiceTest.php`** : si réalisable en intégration —
|
||||||
|
contrat CUSTOM, semaine travaillée sous les heures → `totalMinutes` négatif et
|
||||||
|
`isFlatRecovery = true`.
|
||||||
|
|
||||||
|
## Documentation (obligatoire, même intervention)
|
||||||
|
|
||||||
|
- `doc/rtt-rollover.md` et/ou `doc/rtt-tab.md` : mettre à jour la règle CUSTOM (déficit
|
||||||
|
désormais compté).
|
||||||
|
- `frontend/data/documentation-content.ts` : section RTT — déficit des contrats CUSTOM.
|
||||||
|
- `CLAUDE.md` : section « Overtime Rules » — corriger « deficit doesn't impact balance » pour les
|
||||||
|
CUSTOM ; documenter `isFlatRecovery`.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# Design — Déficit « jour de solidarité » pour les contrats CUSTOM < 35h
|
||||||
|
|
||||||
|
Date : 2026-06-11
|
||||||
|
Branche : `feature/SIRH-36-corriger-le-calcule-des-rtt-des-contrat-4h`
|
||||||
|
Statut : validé (brainstorming)
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
Le jour de solidarité (Lundi de Pentecôte) impose à chaque salarié une contribution
|
||||||
|
de travail non rémunérée : 7h/an pour un temps plein (35h), proratisée pour les temps
|
||||||
|
partiels. La RH matérialise cette contribution en **posant une absence de type RTT**
|
||||||
|
sur le Lundi de Pentecôte pour **tous** les salariés, y compris les contrats < 35h.
|
||||||
|
|
||||||
|
Pour les contrats standard (35h/39h), poser un RTT d'une journée draine ~7h du cumul RTT
|
||||||
|
accumulé — ce qui correspond exactement à l'obligation. Ce comportement **fonctionne
|
||||||
|
déjà et ne doit pas changer**.
|
||||||
|
|
||||||
|
Pour les contrats **CUSTOM < 35h** (ex. 4h, 25h, 28h — `weeklyHours ≠ 35 et ≠ 39`,
|
||||||
|
mode TIME), poser une absence RTT (type `R`, `countAsWorkedHours = false`) produit un
|
||||||
|
déficit égal au **créneau travaillé du jour** (ex. Ewa, 4h, travaille 2h le lundi →
|
||||||
|
−2h), et non au prorata légal attendu (`7/35 × 4h = 48 min`). Le montant naturel dépend
|
||||||
|
du planning du jour, pas de l'obligation. C'est le bug à corriger.
|
||||||
|
|
||||||
|
## Règle métier validée
|
||||||
|
|
||||||
|
- **Périmètre** : contrats **CUSTOM avec `weeklyHours < 35`** uniquement. 35h, 39h,
|
||||||
|
Forfait, Intérim, et CUSTOM ≥ 35h : aucun changement.
|
||||||
|
- **Date** : Lundi de Pentecôte (= Pâques + 50 jours), calculé par computus, indépendant
|
||||||
|
de l'env `EXCLUDED_PUBLIC_HOLIDAYS` (qui n'est plus la source de vérité).
|
||||||
|
- **Montant** : `prorata = round(weeklyHours × 12)` minutes (7h/35h × 60 = 12 min par
|
||||||
|
heure hebdo). Ex. 4h → 48 min, 25h → 5h00, 28h → 5h36.
|
||||||
|
- **Net forfaitaire et inconditionnel** : au net, le jour de solidarité vaut **exactement
|
||||||
|
`−prorata`** dans le cumul RTT, quel que soit ce qui est posé ce jour-là (absence RTT,
|
||||||
|
heures travaillées, ou rien). On **neutralise** l'effet naturel du jour puis on applique
|
||||||
|
le forfait. Garantit l'absence de double comptage avec le RTT posé par la RH, et reste
|
||||||
|
correct même si la RH oublie de poser le RTT.
|
||||||
|
- **Cumul** : le déficit se cumule avec tout autre déficit/surplus de la même semaine,
|
||||||
|
réduit le cumul RTT (peut le rendre négatif), et est reporté à l'exercice suivant.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Point d'injection unique
|
||||||
|
|
||||||
|
Tout passe par `App\Service\Rtt\RttRecoveryComputationService::computeRecoveryByWeek`,
|
||||||
|
le calcul **partagé** consommé par :
|
||||||
|
- `EmployeeRttSummaryProvider` (onglet RTT),
|
||||||
|
- `computeTotalRecoveryForExercise` → `RttClosingBalanceService` (clôture / rollover),
|
||||||
|
- `DumpVerificationSnapshotCommand` (commande de vérification).
|
||||||
|
|
||||||
|
En posant le déficit dans `totalMinutes` / `overtimeMinutes` à cet endroit, il se propage
|
||||||
|
partout sans duplication. Le drapeau `isFlatRecovery` (déjà existant pour les CUSTOM)
|
||||||
|
reste `true` → le provider ne draine pas les tranches 25/50 et le fold reporte le déficit
|
||||||
|
en N+1.
|
||||||
|
|
||||||
|
### Nouveau service pur : `SolidarityDayResolver`
|
||||||
|
|
||||||
|
```
|
||||||
|
final class SolidarityDayResolver
|
||||||
|
{
|
||||||
|
// Lundi de Pentecôte = dimanche de Pâques + 50 jours.
|
||||||
|
public function pentecostMonday(int $year): DateTimeImmutable;
|
||||||
|
|
||||||
|
// Easter via l'algorithme de Meeus/Jones/Butcher (calendrier grégorien),
|
||||||
|
// sans dépendance à l'extension calendar PHP.
|
||||||
|
private function easterSunday(int $year): DateTimeImmutable;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pur, déterministe, aucune dépendance réseau (le chemin de calcul RTT n'a aujourd'hui
|
||||||
|
aucune dépendance HTTP — on le préserve). Trivial à tester unitairement.
|
||||||
|
|
||||||
|
### Modification de `computeRecoveryByWeek`
|
||||||
|
|
||||||
|
Le service reçoit `SolidarityDayResolver` par injection. Avant la boucle des semaines, on
|
||||||
|
résout les Lundi de Pentecôte des années civiles couvertes par `[periodFrom, periodTo]`
|
||||||
|
(exercice Juin N-1 → Mai N → années N-1 et N) et on retient ceux dans la fenêtre.
|
||||||
|
|
||||||
|
Dans la boucle, après le calcul de `weeklyOvertimeTotalMinutes` et **uniquement** quand
|
||||||
|
un jour de solidarité `S` tombe dans la semaine **et** a été inclus dans le sommage
|
||||||
|
(`isset($dailyWorkedMinutes[S])`, donc `S ≤ limitDate` et `S ≥ rttStartDate`) :
|
||||||
|
|
||||||
|
```
|
||||||
|
$contractAtS = $employeeContractsByDate[$S] ?? null;
|
||||||
|
$weeklyHours = $contractAtS?->getWeeklyHours();
|
||||||
|
$typeAtS = ContractType::resolve($contractAtS?->getName(), $contractAtS?->getTrackingMode(), $weeklyHours);
|
||||||
|
|
||||||
|
if (ContractType::CUSTOM === $typeAtS && null !== $weeklyHours && $weeklyHours < 35) {
|
||||||
|
$isoDayS = (int) (new DateTimeImmutable($S))->format('N');
|
||||||
|
$workDaysForS = $workDaysByDate[$employeeId][$S] ?? null; // {iso_day: minutes}
|
||||||
|
// Heures contractuelles RÉELLES du jour (planning workDaysHours), PAS la
|
||||||
|
// répartition uniforme weeklyHours/5 — c'est ce qui rend le net = -prorata.
|
||||||
|
$expectedS = $this->dailyReferenceResolver->resolve($weeklyHours, $isoDayS, $workDaysForS);
|
||||||
|
$workedS = $dailyWorkedMinutes[$S]; // déjà calculé dans la boucle des jours
|
||||||
|
$prorata = (int) round($weeklyHours * 12);
|
||||||
|
|
||||||
|
// 1) faire compter le jour comme s'il était travaillé normalement (annule la
|
||||||
|
// valeur réelle du jour, quelle qu'elle soit : RTT posé, heures, vide, crédit
|
||||||
|
// férié virtuel) ; 2) appliquer le forfait solidarité.
|
||||||
|
$weeklyOvertimeTotalMinutes += ($expectedS - $workedS) - $prorata;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis `buildWeekRecoveryDetail(...)` est appelé tel quel : pour un CUSTOM,
|
||||||
|
`totalMinutes = overtimeMinutes = weeklyOvertimeTotalMinutes` (signé), bandes 25/50 = 0,
|
||||||
|
`isFlatRecovery = true`.
|
||||||
|
|
||||||
|
> **Pourquoi `workDaysHours` et pas la référence hebdo CUSTOM** : la référence CUSTOM
|
||||||
|
> (`computeWeeklyCustomReferenceMinutes`) répartit `weeklyHours` uniformément sur les 5
|
||||||
|
> jours ouvrés (`weeklyHours/5`), sans tenir compte du planning réel. Neutraliser le jour
|
||||||
|
> avec cette valeur uniforme (48 min pour le lundi d'Ewa) laisserait le manque des autres
|
||||||
|
> jours → −2h au lieu de −48 min. En neutralisant avec l'attendu RÉEL du jour
|
||||||
|
> (`workDaysHours[lundi] = 120 min`), le terme `(attendu − travaillé)` ramène la semaine à
|
||||||
|
> son net « normal » (0 pour une semaine pleine), puis le forfait applique exactement
|
||||||
|
> −prorata. `DailyReferenceMinutesResolver::resolve(weeklyHours, isoDay, workDaysMinutes)`
|
||||||
|
> renvoie déjà cet attendu réel quand `workDaysMinutes` est fourni (obligatoire pour tout
|
||||||
|
> CUSTOM < 35h). Fallback uniforme si absent.
|
||||||
|
>
|
||||||
|
> **Robustesse `EXCLUDED` / férié** : `(attendu − travaillé)` annule n'importe quelle
|
||||||
|
> valeur de `$workedS`, y compris un éventuel crédit férié virtuel si le Lundi de Pentecôte
|
||||||
|
> cessait d'être exclu. Le résultat ne dépend donc pas de l'état d'`EXCLUDED_PUBLIC_HOLIDAYS`.
|
||||||
|
|
||||||
|
## Cas limites
|
||||||
|
|
||||||
|
| Cas | Comportement |
|
||||||
|
|-----|--------------|
|
||||||
|
| Jour de solidarité futur (`> limitDate`) | Pas de déficit (semaine/jour non sommés). Appliqué une fois le jour passé. |
|
||||||
|
| 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. |
|
||||||
|
| 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. |
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
### `SolidarityDayResolverTest`
|
||||||
|
- Pentecôte 2024 = 20 mai 2024 ; 2025 = 9 juin 2025 ; 2026 = 25 mai 2026.
|
||||||
|
- (optionnel) Pâques pivot : 2025 = 20 avril.
|
||||||
|
|
||||||
|
### `RttRecoveryComputationServiceTest` (ajouts)
|
||||||
|
- CUSTOM 4h, RTT posé sur le jour de solidarité → semaine = −48 min, `isFlatRecovery = true`,
|
||||||
|
base/bonus 25/50 = 0.
|
||||||
|
- CUSTOM 4h, heures travaillées ce jour-là → semaine = −48 min (net forcé).
|
||||||
|
- CUSTOM 4h, rien de posé → semaine = −48 min.
|
||||||
|
- CUSTOM 4h avec un autre jour vide la même semaine → −48 min + l'autre déficit (cumul).
|
||||||
|
- CUSTOM 36h → 0 (hors périmètre).
|
||||||
|
- 35h avec RTT posé sur le jour de solidarité → inchangé (déficit plein, drainage tranches).
|
||||||
|
- Jour de solidarité au-delà de `limitDate` → 0.
|
||||||
|
- `computeTotalRecoveryForExercise` : le déficit solidarité se retrouve dans le total
|
||||||
|
d'exercice (→ clôture/report N+1).
|
||||||
|
|
||||||
|
### Vérification données prod
|
||||||
|
- Ewa (id 31, 4h, Lun+Jeu) : semaine du 25 mai 2026 (S22) = −48 min ; le −2h de la S23
|
||||||
|
(lundi 1er juin non saisi) reste distinct et inchangé.
|
||||||
|
|
||||||
|
## Hors scope / inchangé
|
||||||
|
|
||||||
|
- Front `RttTab.vue` : déjà propre (clamp des sous-colonnes 25/50 à 0 pour les semaines
|
||||||
|
déficitaires) → aucun changement.
|
||||||
|
- Migrations : aucune.
|
||||||
|
- `EXCLUDED_PUBLIC_HOLIDAYS`, `HolidayVirtualHoursResolver`, traitement des autres fériés :
|
||||||
|
inchangés.
|
||||||
|
- Comportement des contrats 35h/39h/Forfait/Intérim sur le jour de solidarité : inchangé.
|
||||||
|
|
||||||
|
## Documentation à mettre à jour (règle projet)
|
||||||
|
|
||||||
|
- `CLAUDE.md` — section Overtime Rules / contrats CUSTOM : ajouter la règle du jour de
|
||||||
|
solidarité (prorata 12 min/h, net forcé, périmètre < 35h).
|
||||||
|
- `frontend/data/documentation-content.ts` — doc in-app RTT.
|
||||||
|
- `doc/rtt-tab.md` et/ou `doc/functional-rules.md` — règle métier détaillée.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="modelValue" class="fixed inset-0 z-50">
|
<div v-if="modelValue" class="fixed inset-0 z-[60]">
|
||||||
<Transition name="drawer-backdrop">
|
<Transition name="drawer-backdrop">
|
||||||
<div class="absolute inset-0 bg-black/40" @click="close" />
|
<div class="absolute inset-0 bg-black/40" @click="close" />
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|||||||
@@ -313,8 +313,16 @@ const isLastExerciseOfPhase = computed(() => {
|
|||||||
return props.selectedYear === endYear
|
return props.selectedYear === endYear
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Retroactive payment is allowed on the immediately previous exercise (Option B):
|
||||||
|
// the backend recomputes the next exercise's report so the carry stays correct.
|
||||||
|
const isPreviousExercise = computed(() =>
|
||||||
|
props.selectedYear !== null
|
||||||
|
&& props.currentYear !== null
|
||||||
|
&& props.selectedYear === props.currentYear - 1
|
||||||
|
)
|
||||||
|
|
||||||
const isPayDisabled = computed(() =>
|
const isPayDisabled = computed(() =>
|
||||||
isHistoricalYear.value && !isLastExerciseOfPhase.value
|
isHistoricalYear.value && !isLastExerciseOfPhase.value && !isPreviousExercise.value
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleYearChange = (event: Event) => {
|
const handleYearChange = (event: Event) => {
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<template>
|
||||||
|
<AppDrawer v-model="drawerOpen" title="Export des heures">
|
||||||
|
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="hours-export-date">
|
||||||
|
Date <span class="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="hours-export-date"
|
||||||
|
v-model="selectedDate"
|
||||||
|
type="date"
|
||||||
|
class="mt-2 w-full rounded-md border border-black px-3 py-2 text-md text-neutral-900"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700">
|
||||||
|
Sites <span class="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
v-model="selectedSites"
|
||||||
|
:options="siteOptions"
|
||||||
|
groupClass="w-full mt-2"
|
||||||
|
label="Sites"
|
||||||
|
display-select-all
|
||||||
|
display-tag
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
:disabled="isLoading || !selectedDate || selectedSites.length === 0"
|
||||||
|
>
|
||||||
|
<template v-if="isLoading">Génération en cours...</template>
|
||||||
|
<template v-else>Exporter</template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppDrawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import AppDrawer from '~/components/AppDrawer.vue'
|
||||||
|
import type { Site } from '~/services/dto/site'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
sites: Site[]
|
||||||
|
initialDate: string
|
||||||
|
isLoading?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:modelValue', value: boolean): void
|
||||||
|
(event: 'submit', payload: { date: string; siteIds: number[] }): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const drawerOpen = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value: boolean) => emit('update:modelValue', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedDate = ref(props.initialDate)
|
||||||
|
const selectedSites = ref<number[]>([])
|
||||||
|
|
||||||
|
const siteOptions = computed(() =>
|
||||||
|
props.sites.map((site) => ({ label: site.name, value: site.id }))
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!selectedDate.value || selectedSites.value.length === 0) return
|
||||||
|
emit('submit', { date: selectedDate.value, siteIds: [...selectedSites.value] })
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
selectedDate.value = props.initialDate
|
||||||
|
selectedSites.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@@ -2,12 +2,14 @@ import type { Employee } from '~/services/dto/employee'
|
|||||||
import { CONTRACT_TYPES } from '~/services/dto/contract'
|
import { CONTRACT_TYPES } from '~/services/dto/contract'
|
||||||
import { getEmployee } from '~/services/employees'
|
import { getEmployee } from '~/services/employees'
|
||||||
import { useEmployeeContractPhase } from '~/composables/useEmployeeContractPhase'
|
import { useEmployeeContractPhase } from '~/composables/useEmployeeContractPhase'
|
||||||
|
import { getEmployeeOvertimeContingent, type OvertimeContingent } from '~/services/employee-overtime-contingent'
|
||||||
|
|
||||||
export const useEmployeeDetailPage = () => {
|
export const useEmployeeDetailPage = () => {
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const employee = ref<Employee | null>(null)
|
const employee = ref<Employee | null>(null)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const activeTab = ref<'contract' | 'leave' | 'rtt' | 'mileage' | 'formation' | 'bonus' | 'observation'>('contract')
|
const activeTab = ref<'contract' | 'leave' | 'rtt' | 'mileage' | 'formation' | 'bonus' | 'observation'>('contract')
|
||||||
|
const overtimeContingent = ref<OvertimeContingent | null>(null)
|
||||||
|
|
||||||
const phase = useEmployeeContractPhase(employee)
|
const phase = useEmployeeContractPhase(employee)
|
||||||
|
|
||||||
@@ -28,6 +30,18 @@ export const useEmployeeDetailPage = () => {
|
|||||||
return contract.name || '-'
|
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 loadEmployee = async () => {
|
||||||
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||||
const employeeId = Number(idParam)
|
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.
|
// qui proviennent du récap congés — nécessaire même quand on ouvre un autre onglet.
|
||||||
await leave.loadLeaveData()
|
await leave.loadLeaveData()
|
||||||
}
|
}
|
||||||
|
await loadOvertimeContingent()
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -94,6 +109,18 @@ export const useEmployeeDetailPage = () => {
|
|||||||
if (presence === undefined || presence === null) return ''
|
if (presence === undefined || presence === null) return ''
|
||||||
return ` (${formatDays(presence)} présence)`
|
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 rtt = useEmployeeRtt(employee, loadEmployee, phase.selectedPhase)
|
||||||
const mileage = useEmployeeMileage(employee, loadEmployee)
|
const mileage = useEmployeeMileage(employee, loadEmployee)
|
||||||
const formation = useEmployeeFormation(employee, loadEmployee)
|
const formation = useEmployeeFormation(employee, loadEmployee)
|
||||||
@@ -147,6 +174,8 @@ export const useEmployeeDetailPage = () => {
|
|||||||
employeeContractWorkLabel,
|
employeeContractWorkLabel,
|
||||||
forfaitRemainingDaysLabel,
|
forfaitRemainingDaysLabel,
|
||||||
nonForfaitPresenceLabel,
|
nonForfaitPresenceLabel,
|
||||||
|
overtimeContingentLabel,
|
||||||
|
overtimeContingentExceeded,
|
||||||
...phase,
|
...phase,
|
||||||
...contract,
|
...contract,
|
||||||
...leave,
|
...leave,
|
||||||
|
|||||||
@@ -81,6 +81,17 @@ export const documentationSections: DocSection[] = [
|
|||||||
{ type: 'list', content: 'Jour : total des heures dans la plage 06:00–21:00\nNuit : total des heures dans les plages 00:00–06:00 et 21:00–24:00\nTotal : somme des heures de jour et de nuit' },
|
{ type: 'list', content: 'Jour : total des heures dans la plage 06:00–21:00\nNuit : total des heures dans les plages 00:00–06:00 et 21:00–24:00\nTotal : somme des heures de jour et de nuit' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'export-heures-jour',
|
||||||
|
title: 'Exporter les heures (PDF par jour)',
|
||||||
|
requiredLevel: 'site_manager',
|
||||||
|
blocks: [
|
||||||
|
{ type: 'paragraph', content: 'Le bouton « Exporter », à droite du titre « Heures » (visible uniquement en vue Jour), ouvre un panneau permettant de générer un PDF des heures d\'une journée. Choisissez la date et les sites concernés.' },
|
||||||
|
{ type: 'paragraph', content: 'Les administrateurs peuvent exporter tous les sites. Un chef de site ne voit dans le panneau que ses propres sites et n\'exporte que ceux-ci.' },
|
||||||
|
{ type: 'paragraph', content: 'Le PDF est organisé par site et reprend les colonnes de la vue Jour (nom, statut, horaires matin/après-midi/soir, jour, nuit, total en gras), sans la colonne de validation. Les employés sous contrat ce jour-là apparaissent même sans saisie.' },
|
||||||
|
{ type: 'paragraph', content: 'La colonne Statut affiche le code du type d\'absence (ex. « AT ») sur sa couleur. Une légende sous le tableau associe chaque code présent à son libellé.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'commentaire-semaine',
|
id: 'commentaire-semaine',
|
||||||
title: 'Commentaires de semaine (admin)',
|
title: 'Commentaires de semaine (admin)',
|
||||||
@@ -364,8 +375,8 @@ export const documentationSections: DocSection[] = [
|
|||||||
requiredLevel: 'admin',
|
requiredLevel: 'admin',
|
||||||
blocks: [
|
blocks: [
|
||||||
{ type: 'paragraph', content: 'Les règles de calcul des heures supplémentaires dépendent du type de contrat.' },
|
{ type: 'paragraph', content: 'Les règles de calcul des heures supplémentaires dépendent du type de contrat.' },
|
||||||
{ type: 'list', content: 'Contrats ≤ 35h : +25% de 35h à 43h, +50% au-delà de 43h\nContrats ≥ 39h : +25% de 39h à 43h, +50% au-delà de 43h\nContrats CUSTOM (4h, 25h, etc.) : 1h supplémentaire = 1h de récupération, pas de bonus\nINTERIM : aucune récupération, aucun bonus' },
|
{ type: 'list', content: 'Contrats ≤ 35h : +25% de 35h à 43h, +50% au-delà de 43h\nContrats ≥ 39h : +25% de 39h à 43h, +50% au-delà de 43h\nContrats CUSTOM (4h, 25h, etc.) : 1h supplémentaire = 1h de récupération, pas de bonus. Une semaine sous les heures contractuelles réduit le cumul RTT (1h manquante = -1h), sans passer par les tranches 25/50\nINTERIM : aucune récupération, aucun bonus' },
|
||||||
{ type: 'note', content: 'En cas de déficit hebdomadaire (heures travaillées < heures contrat), le déficit est déduit du cumul RTT : d\'abord des heures à 50%, puis des heures à 25%.' },
|
{ type: 'note', content: 'En cas de déficit hebdomadaire (heures travaillées < heures contrat), le déficit est déduit du cumul RTT. Pour un 35h/39h, il est puisé d\'abord dans les heures à 50%, puis à 25%. Pour un contrat CUSTOM (4h, etc.), il réduit directement le cumul (pas de tranches 25/50) ; le cumul peut devenir négatif et est reporté à l\'exercice suivant.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -494,7 +505,7 @@ export const documentationSections: DocSection[] = [
|
|||||||
blocks: [
|
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: '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: '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.' },
|
{ 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.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -525,6 +536,8 @@ export const documentationSections: DocSection[] = [
|
|||||||
{ type: 'note', content: 'Les contrats INTERIM et le mode PRESENCE n\'accumulent pas de RTT (affiché à 0).' },
|
{ type: 'note', content: 'Les contrats INTERIM et le mode PRESENCE n\'accumulent pas de RTT (affiché à 0).' },
|
||||||
{ type: 'note', content: 'Au passage à l\'exercice suivant (1er juin), le « Report N-1 » du nouvel exercice reprend exactement le « Disponible » de fin d\'exercice précédent, c\'est-à-dire report précédent + acquis − RTT payés. Le report déjà présent en début d\'année n\'est donc jamais perdu.' },
|
{ type: 'note', content: 'Au passage à l\'exercice suivant (1er juin), le « Report N-1 » du nouvel exercice reprend exactement le « Disponible » de fin d\'exercice précédent, c\'est-à-dire report précédent + acquis − RTT payés. Le report déjà présent en début d\'année n\'est donc jamais perdu.' },
|
||||||
{ type: 'paragraph', content: 'La colonne "Cumul" affiche le solde RTT à la fin de chaque semaine : Report N-1 + somme des heures hebdomadaires jusqu\'à la semaine concernée − paiements RTT des mois précédents. Un paiement enregistré sur le mois M n\'est déduit qu\'à partir des semaines du mois M+1. Permet la comparaison ligne à ligne avec un suivi RH externe (Excel).' },
|
{ type: 'paragraph', content: 'La colonne "Cumul" affiche le solde RTT à la fin de chaque semaine : Report N-1 + somme des heures hebdomadaires jusqu\'à la semaine concernée − paiements RTT des mois précédents. Un paiement enregistré sur le mois M n\'est déduit qu\'à partir des semaines du mois M+1. Permet la comparaison ligne à ligne avec un suivi RH externe (Excel).' },
|
||||||
|
{ type: 'note', content: 'Contrats CUSTOM (ex. 4h) : une semaine travaillée sous les heures contractuelles génère un déficit qui réduit le cumul RTT (1h manquante = -1h), sans tranches 25/50. Le cumul peut devenir négatif et est reporté à l\'exercice suivant.' },
|
||||||
|
{ type: '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à.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -534,6 +547,8 @@ export const documentationSections: DocSection[] = [
|
|||||||
blocks: [
|
blocks: [
|
||||||
{ type: 'paragraph', content: 'L\'administrateur RH peut enregistrer un paiement RTT depuis l\'onglet RTT de la fiche employé.' },
|
{ type: 'paragraph', content: 'L\'administrateur RH peut enregistrer un paiement RTT depuis l\'onglet RTT de la fiche employé.' },
|
||||||
{ type: 'list', content: 'Saisie : mois, nombre de minutes, taux (25% ou 50%)\nPlusieurs paiements possibles par mois\nLes heures payées sont soustraites du solde disponible' },
|
{ type: 'list', content: 'Saisie : mois, nombre de minutes, taux (25% ou 50%)\nPlusieurs paiements possibles par mois\nLes heures payées sont soustraites du solde disponible' },
|
||||||
|
{ type: 'paragraph', content: 'Le paiement est possible sur l\'exercice courant et sur l\'exercice immédiatement précédent (paiement rétroactif, ex. des RTT de mai réglés après la bascule du 1er juin).' },
|
||||||
|
{ type: 'note', content: 'Un paiement saisi sur l\'exercice précédent recalcule automatiquement le « Report N-1 » de l\'exercice courant : aucun double comptage. Si ce report a déjà été verrouillé (validé), le paiement rétroactif est refusé — déverrouillez-le d\'abord.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -625,6 +640,19 @@ 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: '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: '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: '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.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,6 +28,11 @@
|
|||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<p class="font-bold text-[22px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}{{ forfaitRemainingDaysLabel }}{{ nonForfaitPresenceLabel }}</p>
|
<p class="font-bold text-[22px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}{{ forfaitRemainingDaysLabel }}{{ nonForfaitPresenceLabel }}</p>
|
||||||
<p class="text-[18px]">{{ employee.site?.name ?? '-' }}</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>
|
</div>
|
||||||
<div v-if="showPicker" class="mt-3 flex items-center gap-3">
|
<div v-if="showPicker" class="mt-3 flex items-center gap-3">
|
||||||
@@ -300,6 +305,8 @@ const {
|
|||||||
employeeContractWorkLabel,
|
employeeContractWorkLabel,
|
||||||
forfaitRemainingDaysLabel,
|
forfaitRemainingDaysLabel,
|
||||||
nonForfaitPresenceLabel,
|
nonForfaitPresenceLabel,
|
||||||
|
overtimeContingentLabel,
|
||||||
|
overtimeContingentExceeded,
|
||||||
contractForm,
|
contractForm,
|
||||||
createContractForm,
|
createContractForm,
|
||||||
isContractDrawerOpen,
|
isContractDrawerOpen,
|
||||||
|
|||||||
@@ -230,6 +230,32 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</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">
|
<div class="flex justify-center pt-2">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
label="Valider"
|
label="Valider"
|
||||||
@@ -264,15 +290,18 @@ const isDrawerOpen = ref(false)
|
|||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const isExportDrawerOpen = 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 exportYear = ref<number>(new Date().getFullYear())
|
||||||
const exportMonth = ref<number | ''>(new Date().getMonth() + 1)
|
const exportMonth = ref<number | ''>(new Date().getMonth() + 1)
|
||||||
const exportSalaryMonth = ref<string>(`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`)
|
const exportSalaryMonth = ref<string>(`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`)
|
||||||
|
const exportSiteIds = ref<number[]>([])
|
||||||
|
|
||||||
const exportTypeOptions = [
|
const exportTypeOptions = [
|
||||||
{ label: 'Récap. congés', value: 'leave-recap' },
|
{ label: 'Récap. congés', value: 'leave-recap' },
|
||||||
{ label: 'Récap. salaire', value: 'salary-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 exportYearOptions = computed(() => {
|
||||||
const current = new Date().getFullYear()
|
const current = new Date().getFullYear()
|
||||||
@@ -301,11 +330,17 @@ const isExportValid = computed(() => {
|
|||||||
if (exportChoice.value === 'yearly-hours') {
|
if (exportChoice.value === 'yearly-hours') {
|
||||||
return exportYear.value > 0 && exportMonth.value !== ''
|
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
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
const onExportChoiceChange = (value: string | number | null) => {
|
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 { printPdf } = usePdfPrinter()
|
||||||
const sitesInitialized = ref(false)
|
const sitesInitialized = ref(false)
|
||||||
@@ -605,6 +640,7 @@ const openExportDrawer = () => {
|
|||||||
exportYear.value = now.getFullYear()
|
exportYear.value = now.getFullYear()
|
||||||
exportMonth.value = now.getMonth() + 1
|
exportMonth.value = now.getMonth() + 1
|
||||||
exportSalaryMonth.value = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
exportSalaryMonth.value = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
exportSiteIds.value = []
|
||||||
isExportDrawerOpen.value = true
|
isExportDrawerOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -618,6 +654,11 @@ const handleExportValidate = async () => {
|
|||||||
await printPdf(`/salary-recap/print?month=${exportSalaryMonth.value}`)
|
await printPdf(`/salary-recap/print?month=${exportSalaryMonth.value}`)
|
||||||
} else if (choice === 'yearly-hours') {
|
} else if (choice === 'yearly-hours') {
|
||||||
await printPdf(`/yearly-hours/print-all?year=${exportYear.value}&month=${exportMonth.value}`)
|
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}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,24 @@
|
|||||||
<div class="h-full overflow-hidden flex flex-col">
|
<div class="h-full overflow-hidden flex flex-col">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||||
<h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Heures</h1>
|
<h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Heures</h1>
|
||||||
|
<MalioButton
|
||||||
|
v-if="(isAdmin || isSiteManager) && viewMode === 'day'"
|
||||||
|
label="Export"
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:download"
|
||||||
|
icon-position="left"
|
||||||
|
@click="isExportDrawerOpen = true"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<HoursDayExportDrawer
|
||||||
|
v-model="isExportDrawerOpen"
|
||||||
|
:sites="sites"
|
||||||
|
:initial-date="selectedDate"
|
||||||
|
:is-loading="isExporting"
|
||||||
|
@submit="handleExport"
|
||||||
|
/>
|
||||||
|
|
||||||
<HoursToolbar
|
<HoursToolbar
|
||||||
v-model:selected-date="selectedDate"
|
v-model:selected-date="selectedDate"
|
||||||
v-model:view-mode="viewMode"
|
v-model:view-mode="viewMode"
|
||||||
@@ -213,6 +229,21 @@ const {
|
|||||||
reloadWeeklySummary
|
reloadWeeklySummary
|
||||||
} = useHoursPage()
|
} = useHoursPage()
|
||||||
|
|
||||||
|
const { printPdf } = usePdfPrinter()
|
||||||
|
const isExportDrawerOpen = ref(false)
|
||||||
|
const isExporting = ref(false)
|
||||||
|
|
||||||
|
const handleExport = async (payload: { date: string; siteIds: number[] }) => {
|
||||||
|
isExporting.value = true
|
||||||
|
try {
|
||||||
|
const siteIdsParam = payload.siteIds.join(',')
|
||||||
|
await printPdf(`/work-hours/day-export?workDate=${payload.date}&siteIds=${siteIdsParam}`)
|
||||||
|
isExportDrawerOpen.value = false
|
||||||
|
} finally {
|
||||||
|
isExporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: 'Heures'
|
title: 'Heures'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type EmployeeRttWeekSummary = {
|
|||||||
bonus50Minutes: number
|
bonus50Minutes: number
|
||||||
totalMinutes: number
|
totalMinutes: number
|
||||||
cumulativeBalanceMinutes: number
|
cumulativeBalanceMinutes: number
|
||||||
|
isFlatRecovery: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RttMonthPayment = {
|
export type RttMonthPayment = {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export interface OvertimeContingent {
|
||||||
|
year: number
|
||||||
|
paidMinutes: number
|
||||||
|
capHours: number
|
||||||
|
isDriver: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getEmployeeOvertimeContingent = async (employeeId: number, year?: number) => {
|
||||||
|
const api = useApi()
|
||||||
|
const query: Record<string, number> = {}
|
||||||
|
if (year) query.year = year
|
||||||
|
return api.get<OvertimeContingent>(`/employees/${employeeId}/overtime-contingent`, query, { toast: false })
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ApiResource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use App\State\EmployeeOvertimeContingentProvider;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/employees/{id}/overtime-contingent',
|
||||||
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
|
provider: EmployeeOvertimeContingentProvider::class
|
||||||
|
),
|
||||||
|
],
|
||||||
|
paginationEnabled: false
|
||||||
|
)]
|
||||||
|
final class EmployeeOvertimeContingent
|
||||||
|
{
|
||||||
|
public int $year = 0;
|
||||||
|
public int $paidMinutes = 0;
|
||||||
|
public int $capHours = 0;
|
||||||
|
public bool $isDriver = false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,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 {}
|
||||||
@@ -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\WorkHourDayExportProvider;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/work-hours/day-export',
|
||||||
|
provider: WorkHourDayExportProvider::class,
|
||||||
|
parameters: [
|
||||||
|
new QueryParameter(key: 'workDate', required: true),
|
||||||
|
new QueryParameter(key: 'siteIds', required: true),
|
||||||
|
],
|
||||||
|
security: "is_granted('ROLE_USER')"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
final class WorkHourDayExport {}
|
||||||
@@ -636,6 +636,7 @@ final class DumpVerificationSnapshotCommand extends Command
|
|||||||
base50Minutes: $detail->base50Minutes,
|
base50Minutes: $detail->base50Minutes,
|
||||||
bonus50Minutes: $detail->bonus50Minutes,
|
bonus50Minutes: $detail->bonus50Minutes,
|
||||||
totalMinutes: $detail->totalMinutes,
|
totalMinutes: $detail->totalMinutes,
|
||||||
|
isFlatRecovery: $detail->isFlatRecovery,
|
||||||
);
|
);
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@@ -672,6 +673,7 @@ final class DumpVerificationSnapshotCommand extends Command
|
|||||||
base50Minutes: (int) round($detail->base50Minutes * $ratio),
|
base50Minutes: (int) round($detail->base50Minutes * $ratio),
|
||||||
bonus50Minutes: (int) round($detail->bonus50Minutes * $ratio),
|
bonus50Minutes: (int) round($detail->bonus50Minutes * $ratio),
|
||||||
totalMinutes: (int) round($detail->totalMinutes * $ratio),
|
totalMinutes: (int) round($detail->totalMinutes * $ratio),
|
||||||
|
isFlatRecovery: $detail->isFlatRecovery,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -692,7 +694,7 @@ final class DumpVerificationSnapshotCommand extends Command
|
|||||||
$cumulative25 = $carry->base25Minutes + $carry->bonus25Minutes;
|
$cumulative25 = $carry->base25Minutes + $carry->bonus25Minutes;
|
||||||
|
|
||||||
foreach ($weeks as $i => $week) {
|
foreach ($weeks as $i => $week) {
|
||||||
if ($week->totalMinutes >= 0) {
|
if ($week->totalMinutes >= 0 || $week->isFlatRecovery) {
|
||||||
$cumulative50 += $week->base50Minutes + $week->bonus50Minutes;
|
$cumulative50 += $week->base50Minutes + $week->bonus50Minutes;
|
||||||
$cumulative25 += $week->base25Minutes + $week->bonus25Minutes;
|
$cumulative25 += $week->base25Minutes + $week->bonus25Minutes;
|
||||||
|
|
||||||
@@ -714,6 +716,7 @@ final class DumpVerificationSnapshotCommand extends Command
|
|||||||
base50Minutes: $from50 > 0 ? -$from50 : 0,
|
base50Minutes: $from50 > 0 ? -$from50 : 0,
|
||||||
bonus50Minutes: 0,
|
bonus50Minutes: 0,
|
||||||
totalMinutes: $week->totalMinutes,
|
totalMinutes: $week->totalMinutes,
|
||||||
|
isFlatRecovery: $week->isFlatRecovery,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,5 +18,6 @@ final class EmployeeRttWeekSummary
|
|||||||
public int $bonus50Minutes = 0,
|
public int $bonus50Minutes = 0,
|
||||||
public int $totalMinutes = 0,
|
public int $totalMinutes = 0,
|
||||||
public int $cumulativeBalanceMinutes = 0,
|
public int $cumulativeBalanceMinutes = 0,
|
||||||
|
public bool $isFlatRecovery = false,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,5 +17,6 @@ final class WeekRecoveryDetail
|
|||||||
public int $bonus50Minutes = 0,
|
public int $bonus50Minutes = 0,
|
||||||
public int $totalMinutes = 0,
|
public int $totalMinutes = 0,
|
||||||
public array $dailyMinutes = [],
|
public array $dailyMinutes = [],
|
||||||
|
public bool $isFlatRecovery = false,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
* @extends ServiceEntityRepository<EmployeeRttPayment>
|
||||||
*/
|
*/
|
||||||
final class EmployeeRttPaymentRepository extends ServiceEntityRepository
|
class EmployeeRttPaymentRepository extends ServiceEntityRepository
|
||||||
{
|
{
|
||||||
public function __construct(ManagerRegistry $registry)
|
public function __construct(ManagerRegistry $registry)
|
||||||
{
|
{
|
||||||
@@ -60,4 +60,31 @@ final class EmployeeRttPaymentRepository extends ServiceEntityRepository
|
|||||||
->getResult()
|
->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\AbsenceSegmentsResolver;
|
||||||
use App\Service\WorkHours\DailyReferenceMinutesResolver;
|
use App\Service\WorkHours\DailyReferenceMinutesResolver;
|
||||||
use App\Service\WorkHours\HolidayVirtualHoursResolver;
|
use App\Service\WorkHours\HolidayVirtualHoursResolver;
|
||||||
|
use App\Service\WorkHours\NightHoursCalculator;
|
||||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
|
|
||||||
@@ -33,6 +34,8 @@ final readonly class RttRecoveryComputationService
|
|||||||
private EmployeeContractResolver $contractResolver,
|
private EmployeeContractResolver $contractResolver,
|
||||||
private DailyReferenceMinutesResolver $dailyReferenceResolver,
|
private DailyReferenceMinutesResolver $dailyReferenceResolver,
|
||||||
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
|
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
|
||||||
|
private SolidarityDayResolver $solidarityDayResolver,
|
||||||
|
private NightHoursCalculator $nightHoursCalculator,
|
||||||
string $rttStartDate = '',
|
string $rttStartDate = '',
|
||||||
) {
|
) {
|
||||||
$this->rttStartDate = '' !== $rttStartDate ? new DateTimeImmutable($rttStartDate) : null;
|
$this->rttStartDate = '' !== $rttStartDate ? new DateTimeImmutable($rttStartDate) : null;
|
||||||
@@ -162,7 +165,8 @@ final readonly class RttRecoveryComputationService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$results = [];
|
$results = [];
|
||||||
|
$solidarityDates = $this->resolveSolidarityDatesInRange($periodFrom, $periodTo);
|
||||||
foreach ($weeks as $week) {
|
foreach ($weeks as $week) {
|
||||||
$weekStart = $week['start'];
|
$weekStart = $week['start'];
|
||||||
$weekEnd = $week['end'];
|
$weekEnd = $week['end'];
|
||||||
@@ -235,7 +239,7 @@ final readonly class RttRecoveryComputationService
|
|||||||
$overtimeReferenceMinutes = $isCustomContract
|
$overtimeReferenceMinutes = $isCustomContract
|
||||||
? $this->computeWeeklyCustomReferenceMinutes($weekDays, $employeeContractsByDate)
|
? $this->computeWeeklyCustomReferenceMinutes($weekDays, $employeeContractsByDate)
|
||||||
: $this->computeWeeklyOvertimeReferenceMinutes($weekDays, $employeeContractsByDate);
|
: $this->computeWeeklyOvertimeReferenceMinutes($weekDays, $employeeContractsByDate);
|
||||||
$overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($weekDays, $employeeContractsByDate);
|
$overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($weekDays, $employeeContractsByDate);
|
||||||
// Plafond séparant 25 %/50 % : seuil de départ proraté + largeur de la bande +25 %
|
// Plafond séparant 25 %/50 % : seuil de départ proraté + largeur de la bande +25 %
|
||||||
// (4h pour un 39h, 8h pour un 35h). Il se décale ainsi avec une embauche en milieu
|
// (4h pour un 39h, 8h pour un 35h). Il se décale ainsi avec une embauche en milieu
|
||||||
// de semaine au lieu de rester bloqué à 43h, ce qui ouvre la tranche 50 %.
|
// de semaine au lieu de rester bloqué à 43h, ce qui ouvre la tranche 50 %.
|
||||||
@@ -244,35 +248,96 @@ final readonly class RttRecoveryComputationService
|
|||||||
? 0
|
? 0
|
||||||
: $weeklyTotalMinutes - $overtimeReferenceMinutes;
|
: $weeklyTotalMinutes - $overtimeReferenceMinutes;
|
||||||
|
|
||||||
[$rawBase25, $rawBase50] = $this->computeOvertimeBaseMinutes($weeklyTotalMinutes, $overtime25StartMinutes, $overtime50StartMinutes);
|
foreach ($solidarityDates as $solidarityDate) {
|
||||||
|
// isset ⇒ le jour de solidarité fait partie du sommage de CETTE semaine
|
||||||
|
// (donc ≤ limitDate et ≥ rttStartDate). Sinon : jour futur ou hors service → pas de déficit.
|
||||||
|
if (!isset($dailyWorkedMinutes[$solidarityDate])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$base25 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : $rawBase25;
|
$contractAtSolidarity = $employeeContractsByDate[$solidarityDate] ?? null;
|
||||||
$bonus25 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : (int) round($base25 * 0.25);
|
// Le Lundi de Pentecôte est toujours un lundi (ISO 1), mais on le dérive pour rester explicite.
|
||||||
$base50 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : $rawBase50;
|
$solidarityIsoDay = (int) new DateTimeImmutable($solidarityDate)->format('N');
|
||||||
$bonus50 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : (int) round($base50 * 0.5);
|
// Attendu RÉEL du jour (planning workDaysHours), pas la répartition uniforme :
|
||||||
|
// c'est ce qui rend la neutralisation correcte (cf. spec).
|
||||||
|
$solidarityExpected = $this->dailyReferenceResolver->resolve(
|
||||||
|
$contractAtSolidarity?->getWeeklyHours(),
|
||||||
|
$solidarityIsoDay,
|
||||||
|
$workDaysByDate[$employeeId][$solidarityDate] ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
if ($isWeekPresenceTracking || $disableOvertimeBonuses) {
|
$weeklyOvertimeTotalMinutes += $this->computeSolidarityDeficitAdjustment(
|
||||||
$totalMinutes = 0;
|
$contractAtSolidarity,
|
||||||
} elseif ($isCustomContract) {
|
$solidarityExpected,
|
||||||
$totalMinutes = max(0, $weeklyOvertimeTotalMinutes);
|
$dailyWorkedMinutes[$solidarityDate],
|
||||||
} else {
|
);
|
||||||
$totalMinutes = $weeklyOvertimeTotalMinutes + $bonus25 + $bonus50;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$results[$weekKey] = new WeekRecoveryDetail(
|
[$rawBase25, $rawBase50] = $this->computeOvertimeBaseMinutes($weeklyTotalMinutes, $overtime25StartMinutes, $overtime50StartMinutes);
|
||||||
overtimeMinutes: $weeklyOvertimeTotalMinutes,
|
|
||||||
base25Minutes: $base25,
|
$results[$weekKey] = $this->buildWeekRecoveryDetail(
|
||||||
bonus25Minutes: $bonus25,
|
$isWeekPresenceTracking,
|
||||||
base50Minutes: $base50,
|
$disableOvertimeBonuses,
|
||||||
bonus50Minutes: $bonus50,
|
$isCustomContract,
|
||||||
totalMinutes: $totalMinutes,
|
$weeklyOvertimeTotalMinutes,
|
||||||
dailyMinutes: $dailyWorkedMinutes,
|
$rawBase25,
|
||||||
|
$rawBase50,
|
||||||
|
$dailyWorkedMinutes,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $results;
|
return $results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assemble le détail de récupération d'une semaine à partir des drapeaux résolus et
|
||||||
|
* des bandes d'heures sup brutes.
|
||||||
|
*
|
||||||
|
* - PRESENCE / INTERIM (bonus désactivés) : aucune récupération.
|
||||||
|
* - CUSTOM : récupération plate 1h = 1h, sans tranches 25/50 ; l'heure sup signée EST
|
||||||
|
* le total, donc une semaine travaillée sous les heures contractuelles produit un
|
||||||
|
* total négatif (déficit qui réduit le solde). Marquée isFlatRecovery pour que le
|
||||||
|
* provider ne draine pas les tranches 25/50.
|
||||||
|
* - Standard 35h/39h : heures sup + bonus 25 %/50 %.
|
||||||
|
*
|
||||||
|
* @param array<string, int> $dailyMinutes
|
||||||
|
*/
|
||||||
|
private function buildWeekRecoveryDetail(
|
||||||
|
bool $isPresence,
|
||||||
|
bool $disableBonuses,
|
||||||
|
bool $isCustom,
|
||||||
|
int $overtimeTotalMinutes,
|
||||||
|
int $rawBase25,
|
||||||
|
int $rawBase50,
|
||||||
|
array $dailyMinutes,
|
||||||
|
): WeekRecoveryDetail {
|
||||||
|
$noBands = $isPresence || $disableBonuses || $isCustom;
|
||||||
|
|
||||||
|
$base25 = $noBands ? 0 : $rawBase25;
|
||||||
|
$bonus25 = $noBands ? 0 : (int) round($base25 * 0.25);
|
||||||
|
$base50 = $noBands ? 0 : $rawBase50;
|
||||||
|
$bonus50 = $noBands ? 0 : (int) round($base50 * 0.5);
|
||||||
|
|
||||||
|
if ($isPresence || $disableBonuses) {
|
||||||
|
$totalMinutes = 0;
|
||||||
|
} elseif ($isCustom) {
|
||||||
|
$totalMinutes = $overtimeTotalMinutes; // signé : le déficit réduit le solde
|
||||||
|
} else {
|
||||||
|
$totalMinutes = $overtimeTotalMinutes + $bonus25 + $bonus50;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WeekRecoveryDetail(
|
||||||
|
overtimeMinutes: $overtimeTotalMinutes,
|
||||||
|
base25Minutes: $base25,
|
||||||
|
bonus25Minutes: $bonus25,
|
||||||
|
base50Minutes: $base50,
|
||||||
|
bonus50Minutes: $bonus50,
|
||||||
|
totalMinutes: $totalMinutes,
|
||||||
|
dailyMinutes: $dailyMinutes,
|
||||||
|
isFlatRecovery: $isCustom,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private function computeMetrics(WorkHour $workHour): WorkMetrics
|
private function computeMetrics(WorkHour $workHour): WorkMetrics
|
||||||
{
|
{
|
||||||
$driverDay = $workHour->getDayHoursMinutes() ?? 0;
|
$driverDay = $workHour->getDayHoursMinutes() ?? 0;
|
||||||
@@ -296,13 +361,12 @@ final readonly class RttRecoveryComputationService
|
|||||||
];
|
];
|
||||||
|
|
||||||
$totalMinutes = 0;
|
$totalMinutes = 0;
|
||||||
$nightMinutes = 0;
|
|
||||||
foreach ($ranges as [$from, $to]) {
|
foreach ($ranges as [$from, $to]) {
|
||||||
$totalMinutes += $this->intervalMinutes($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(
|
return new WorkMetrics(
|
||||||
dayMinutes: $dayMinutes,
|
dayMinutes: $dayMinutes,
|
||||||
@@ -348,35 +412,6 @@ final readonly class RttRecoveryComputationService
|
|||||||
return max(0, $end - $start);
|
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 list<string> $days
|
||||||
* @param array<string, ?Contract> $contractsByDate
|
* @param array<string, ?Contract> $contractsByDate
|
||||||
@@ -415,6 +450,59 @@ final readonly class RttRecoveryComputationService
|
|||||||
return $weekDays[0];
|
return $weekDays[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lundi(s) de Pentecôte (jour de solidarité) inclus dans [from, to]. Un exercice
|
||||||
|
* Juin N-1 → Mai N couvre les années civiles N-1 et N ; on retient les dates dans la fenêtre.
|
||||||
|
*
|
||||||
|
* @return list<string> dates au format 'Y-m-d'
|
||||||
|
*/
|
||||||
|
private function resolveSolidarityDatesInRange(DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||||
|
{
|
||||||
|
$dates = [];
|
||||||
|
$firstYear = (int) $from->format('Y');
|
||||||
|
$lastYear = (int) $to->format('Y');
|
||||||
|
|
||||||
|
for ($year = $firstYear; $year <= $lastYear; ++$year) {
|
||||||
|
$candidate = $this->solidarityDayResolver->pentecostMonday($year);
|
||||||
|
if ($candidate >= $from && $candidate <= $to) {
|
||||||
|
$dates[] = $candidate->format('Y-m-d');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Déficit forfaitaire du jour de solidarité pour les contrats CUSTOM < 35h.
|
||||||
|
*
|
||||||
|
* Le jour est neutralisé puis chargé du prorata légal : on remplace la valeur réelle
|
||||||
|
* du jour ($workedMinutes : RTT posé, heures saisies, vide, ou crédit férié virtuel)
|
||||||
|
* par l'attendu contractuel du jour ($expectedMinutes = workDaysHours), puis on
|
||||||
|
* retranche le prorata = 7h/35h × heuresHebdo = 12 min par heure hebdo. Sur une
|
||||||
|
* semaine par ailleurs normale, le net vaut exactement −prorata. Renvoie le delta à
|
||||||
|
* ajouter à weeklyOvertimeTotalMinutes (0 hors périmètre : non-CUSTOM ou ≥ 35h).
|
||||||
|
*/
|
||||||
|
private function computeSolidarityDeficitAdjustment(
|
||||||
|
?Contract $contractAtSolidarity,
|
||||||
|
int $expectedMinutes,
|
||||||
|
int $workedMinutes,
|
||||||
|
): int {
|
||||||
|
$weeklyHours = $contractAtSolidarity?->getWeeklyHours();
|
||||||
|
$type = ContractType::resolve(
|
||||||
|
$contractAtSolidarity?->getName(),
|
||||||
|
$contractAtSolidarity?->getTrackingMode(),
|
||||||
|
$weeklyHours,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ContractType::CUSTOM !== $type || null === $weeklyHours || $weeklyHours >= 35) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$prorata = (int) round($weeklyHours * 12);
|
||||||
|
|
||||||
|
return ($expectedMinutes - $workedMinutes) - $prorata;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<string> $days
|
* @param list<string> $days
|
||||||
* @param array<string, ?Contract> $contractsByDate
|
* @param array<string, ?Contract> $contractsByDate
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Rtt;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résout le jour de solidarité (Lundi de Pentecôte) d'une année.
|
||||||
|
*
|
||||||
|
* Pur et déterministe : Pâques via l'algorithme de Meeus/Jones/Butcher (calendrier
|
||||||
|
* grégorien), sans dépendance à l'extension calendar ni au réseau. Lundi de Pentecôte
|
||||||
|
* = dimanche de Pâques + 50 jours.
|
||||||
|
*/
|
||||||
|
final class SolidarityDayResolver
|
||||||
|
{
|
||||||
|
public function pentecostMonday(int $year): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->easterSunday($year)->modify('+50 days');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function easterSunday(int $year): DateTimeImmutable
|
||||||
|
{
|
||||||
|
$a = $year % 19;
|
||||||
|
$b = intdiv($year, 100);
|
||||||
|
$c = $year % 100;
|
||||||
|
$d = intdiv($b, 4);
|
||||||
|
$e = $b % 4;
|
||||||
|
$f = intdiv($b + 8, 25);
|
||||||
|
$g = intdiv($b - $f + 1, 3);
|
||||||
|
$h = (19 * $a + $b - $d - $g + 15) % 30;
|
||||||
|
$i = intdiv($c, 4);
|
||||||
|
$k = $c % 4;
|
||||||
|
$l = (32 + 2 * $e + 2 * $i - $h - $k) % 7;
|
||||||
|
$m = intdiv($a + 11 * $h + 22 * $l, 451);
|
||||||
|
|
||||||
|
$month = intdiv($h + $l - 7 * $m + 114, 31);
|
||||||
|
$day = (($h + $l - 7 * $m + 114) % 31) + 1;
|
||||||
|
|
||||||
|
return new DateTimeImmutable(sprintf('%04d-%02d-%02d', $year, $month, $day));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,65 @@
|
|||||||
|
<?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,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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] ?? [];
|
||||||
|
$months = $this->calculator->monthlyBaseMinutes($employeePayments, $civilYear);
|
||||||
|
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,8 +11,8 @@ use App\Entity\WorkHour;
|
|||||||
use App\Enum\ContractNature;
|
use App\Enum\ContractNature;
|
||||||
use App\Enum\ContractType;
|
use App\Enum\ContractType;
|
||||||
use App\Enum\TrackingMode;
|
use App\Enum\TrackingMode;
|
||||||
use App\Repository\AbsenceRepository;
|
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||||
use App\Repository\WorkHourRepository;
|
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||||
use App\Service\Contracts\EmployeeContractResolver;
|
use App\Service\Contracts\EmployeeContractResolver;
|
||||||
use App\Service\PublicHolidayServiceInterface;
|
use App\Service\PublicHolidayServiceInterface;
|
||||||
use DateInterval;
|
use DateInterval;
|
||||||
@@ -22,13 +22,14 @@ use Throwable;
|
|||||||
class YearlyHoursExportBuilder
|
class YearlyHoursExportBuilder
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private WorkHourRepository $workHourRepository,
|
private WorkHourReadRepositoryInterface $workHourRepository,
|
||||||
private AbsenceRepository $absenceRepository,
|
private AbsenceReadRepositoryInterface $absenceRepository,
|
||||||
private EmployeeContractResolver $contractResolver,
|
private EmployeeContractResolver $contractResolver,
|
||||||
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
||||||
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
|
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
|
||||||
private PublicHolidayServiceInterface $publicHolidayService,
|
private PublicHolidayServiceInterface $publicHolidayService,
|
||||||
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
|
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
|
||||||
|
private NightHoursCalculator $nightHoursCalculator,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,6 +104,137 @@ class YearlyHoursExportBuilder
|
|||||||
return $this->buildForEmployees([$employee], $from, $to);
|
return $this->buildForEmployees([$employee], $from, $to);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit une ligne par employé pour une seule journée (vue Jour de l'écran Heures).
|
||||||
|
* Réutilise les helpers de calcul de cellule pour rester l'unique source de vérité.
|
||||||
|
* Les employés sans contrat ce jour sont exclus (comme l'écran).
|
||||||
|
*
|
||||||
|
* @param list<Employee> $employees
|
||||||
|
*
|
||||||
|
* @return list<array{employeeId:int, employeeName:string, statut:?string, statutLabel:?string, statutColor:?string,
|
||||||
|
* morningFrom:string, morningTo:string, afternoonFrom:string, afternoonTo:string,
|
||||||
|
* eveningFrom:string, eveningTo:string, dayHours:string, nightHours:string,
|
||||||
|
* total:string, isWeekend:bool, isHoliday:bool}>
|
||||||
|
*/
|
||||||
|
public function buildDayRowsForEmployees(array $employees, DateTimeImmutable $date): array
|
||||||
|
{
|
||||||
|
$ymd = $date->format('Y-m-d');
|
||||||
|
$days = [$ymd];
|
||||||
|
|
||||||
|
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($date, $date, $employees);
|
||||||
|
$absences = $this->absenceRepository->findForPrint($date, $date, $employees);
|
||||||
|
$contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
|
||||||
|
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
|
||||||
|
$workDaysMap = $this->contractResolver->resolveWorkDaysMinutesForEmployeesAndDays($employees, $days);
|
||||||
|
$holidayMap = $this->buildHolidayMap($date, $date);
|
||||||
|
|
||||||
|
$workHourMap = $this->buildWorkHourMap($workHours);
|
||||||
|
$absenceMap = $this->buildAbsenceMap($absences, $days);
|
||||||
|
|
||||||
|
$isoDay = (int) $date->format('N');
|
||||||
|
$isWeekend = $isoDay >= 6;
|
||||||
|
$holidayLabel = $holidayMap[$ymd] ?? null;
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($employees as $employee) {
|
||||||
|
$employeeId = $employee->getId();
|
||||||
|
$contract = $contractMap[$employeeId][$ymd] ?? null;
|
||||||
|
|
||||||
|
// Hors contrat ce jour → exclu (avant embauche / après départ / suspension).
|
||||||
|
if (null === $contract) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$wh = $workHourMap[$employeeId][$ymd] ?? null;
|
||||||
|
$absenceData = $this->resolveAbsenceDataForEmployee($absenceMap[$employeeId] ?? [], $days, $employee);
|
||||||
|
$hasAbsence = $absenceData['hasDayAbsence'][$ymd] ?? false;
|
||||||
|
|
||||||
|
$isDriver = $driverMap[$employeeId][$ymd] ?? false;
|
||||||
|
$mode = $this->resolveSegmentMode($contract->getTrackingMode(), $isDriver);
|
||||||
|
$creditedMinutes = $absenceData['credited'][$ymd] ?? 0;
|
||||||
|
$virtualMinutes = $this->holidayVirtualHoursResolver->resolveVirtualCredit(
|
||||||
|
$contract,
|
||||||
|
$date,
|
||||||
|
$hasAbsence,
|
||||||
|
$workDaysMap[$employeeId][$ymd] ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Colonne Statut = code d'absence (ex. « AT »), pas le libellé.
|
||||||
|
$statut = ($absenceData['codes'][$ymd] ?? '') ?: null;
|
||||||
|
$statutLabel = $absenceData['labels'][$ymd] ?? null;
|
||||||
|
$statutColor = ($absenceData['colors'][$ymd] ?? '') ?: null;
|
||||||
|
if (null === $statut && null !== $holidayLabel) {
|
||||||
|
// Férié sans absence : badge bleu clair, comme la vue Jour.
|
||||||
|
$statut = $holidayLabel;
|
||||||
|
$statutLabel = null;
|
||||||
|
$statutColor = '#b3e5fc';
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = [
|
||||||
|
'employeeId' => $employeeId,
|
||||||
|
'employeeName' => trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? '')),
|
||||||
|
'statut' => $statut,
|
||||||
|
'statutLabel' => $statutLabel,
|
||||||
|
'statutColor' => $statutColor,
|
||||||
|
'morningFrom' => '',
|
||||||
|
'morningTo' => '',
|
||||||
|
'afternoonFrom' => '',
|
||||||
|
'afternoonTo' => '',
|
||||||
|
'eveningFrom' => '',
|
||||||
|
'eveningTo' => '',
|
||||||
|
'dayHours' => '',
|
||||||
|
'nightHours' => '',
|
||||||
|
'total' => '',
|
||||||
|
'isWeekend' => $isWeekend,
|
||||||
|
'isHoliday' => null !== $holidayLabel,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ('presence' === $mode) {
|
||||||
|
$absentMorning = $absenceData['absentMorning'][$ymd] ?? false;
|
||||||
|
$absentAfternoon = $absenceData['absentAfternoon'][$ymd] ?? false;
|
||||||
|
$morning = (($wh?->getIsPresentMorning() ?? false) && !$absentMorning) ? 0.5 : 0.0;
|
||||||
|
$afternoon = (($wh?->getIsPresentAfternoon() ?? false) && !$absentAfternoon) ? 0.5 : 0.0;
|
||||||
|
$total = $morning + $afternoon;
|
||||||
|
$row['total'] = $total > 0 ? (string) $total : '';
|
||||||
|
} elseif ('driver' === $mode) {
|
||||||
|
$dayMin = $wh?->getDayHoursMinutes() ?? 0;
|
||||||
|
$nightMin = $wh?->getNightHoursMinutes() ?? 0;
|
||||||
|
$workshop = $wh?->getWorkshopHoursMinutes() ?? 0;
|
||||||
|
$totalMin = $dayMin + $nightMin + $workshop + $creditedMinutes;
|
||||||
|
if ($virtualMinutes > $totalMin) {
|
||||||
|
$totalMin = $virtualMinutes;
|
||||||
|
}
|
||||||
|
$row['dayHours'] = $dayMin > 0 ? $this->formatMinutes($dayMin) : '';
|
||||||
|
$row['nightHours'] = $nightMin > 0 ? $this->formatMinutes($nightMin) : '';
|
||||||
|
$row['total'] = $totalMin > 0 ? $this->formatMinutes($totalMin) : '';
|
||||||
|
} else {
|
||||||
|
$metrics = null !== $wh ? $this->computeMetrics($wh) : new WorkMetrics();
|
||||||
|
$metrics->addCreditedMinutes($creditedMinutes);
|
||||||
|
$dayMin = $metrics->dayMinutes;
|
||||||
|
$nightMin = $metrics->nightMinutes;
|
||||||
|
$totalMin = $metrics->totalMinutes;
|
||||||
|
if ($virtualMinutes > $totalMin) {
|
||||||
|
$dayMin += $virtualMinutes - $totalMin;
|
||||||
|
$totalMin = $virtualMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row['morningFrom'] = $wh?->getMorningFrom() ?? '';
|
||||||
|
$row['morningTo'] = $wh?->getMorningTo() ?? '';
|
||||||
|
$row['afternoonFrom'] = $wh?->getAfternoonFrom() ?? '';
|
||||||
|
$row['afternoonTo'] = $wh?->getAfternoonTo() ?? '';
|
||||||
|
$row['eveningFrom'] = $wh?->getEveningFrom() ?? '';
|
||||||
|
$row['eveningTo'] = $wh?->getEveningTo() ?? '';
|
||||||
|
$row['dayHours'] = $dayMin > 0 ? $this->formatMinutes($dayMin) : '';
|
||||||
|
$row['nightHours'] = $nightMin > 0 ? $this->formatMinutes($nightMin) : '';
|
||||||
|
$row['total'] = $totalMin > 0 ? $this->formatMinutes($totalMin) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
public function buildContractLabel(Employee $employee): ?string
|
public function buildContractLabel(Employee $employee): ?string
|
||||||
{
|
{
|
||||||
$contract = $employee->getContract();
|
$contract = $employee->getContract();
|
||||||
@@ -169,12 +301,14 @@ class YearlyHoursExportBuilder
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{credited: array<string, int>, labels: array<string, string>, absentMorning: array<string, bool>, absentAfternoon: array<string, bool>, hasDayAbsence: array<string, bool>}
|
* @return array{credited: array<string, int>, codes: array<string, string>, labels: array<string, string>, colors: array<string, string>, absentMorning: array<string, bool>, absentAfternoon: array<string, bool>, hasDayAbsence: array<string, bool>}
|
||||||
*/
|
*/
|
||||||
private function resolveAbsenceDataForEmployee(array $absences, array $days, Employee $employee): array
|
private function resolveAbsenceDataForEmployee(array $absences, array $days, Employee $employee): array
|
||||||
{
|
{
|
||||||
$credited = [];
|
$credited = [];
|
||||||
|
$codes = [];
|
||||||
$labels = [];
|
$labels = [];
|
||||||
|
$colors = [];
|
||||||
$absentMorning = [];
|
$absentMorning = [];
|
||||||
$absentAfternoon = [];
|
$absentAfternoon = [];
|
||||||
$hasDayAbsence = [];
|
$hasDayAbsence = [];
|
||||||
@@ -194,7 +328,9 @@ class YearlyHoursExportBuilder
|
|||||||
$absentMorning[$date] = ($absentMorning[$date] ?? false) || $isMorning;
|
$absentMorning[$date] = ($absentMorning[$date] ?? false) || $isMorning;
|
||||||
$absentAfternoon[$date] = ($absentAfternoon[$date] ?? false) || $isAfternoon;
|
$absentAfternoon[$date] = ($absentAfternoon[$date] ?? false) || $isAfternoon;
|
||||||
if (!isset($labels[$date])) {
|
if (!isset($labels[$date])) {
|
||||||
|
$codes[$date] = $absence->getType()?->getCode() ?? '';
|
||||||
$labels[$date] = $absence->getType()?->getLabel() ?? '';
|
$labels[$date] = $absence->getType()?->getLabel() ?? '';
|
||||||
|
$colors[$date] = $absence->getType()?->getColor() ?? '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +341,9 @@ class YearlyHoursExportBuilder
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'credited' => $credited,
|
'credited' => $credited,
|
||||||
|
'codes' => $codes,
|
||||||
'labels' => $labels,
|
'labels' => $labels,
|
||||||
|
'colors' => $colors,
|
||||||
'absentMorning' => $absentMorning,
|
'absentMorning' => $absentMorning,
|
||||||
'absentAfternoon' => $absentAfternoon,
|
'absentAfternoon' => $absentAfternoon,
|
||||||
'hasDayAbsence' => $hasDayAbsence,
|
'hasDayAbsence' => $hasDayAbsence,
|
||||||
@@ -404,14 +542,12 @@ class YearlyHoursExportBuilder
|
|||||||
];
|
];
|
||||||
|
|
||||||
$totalMinutes = 0;
|
$totalMinutes = 0;
|
||||||
$nightMinutes = 0;
|
|
||||||
|
|
||||||
foreach ($ranges as [$from, $to]) {
|
foreach ($ranges as [$from, $to]) {
|
||||||
$totalMinutes += $this->intervalMinutes($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(
|
return new WorkMetrics(
|
||||||
dayMinutes: $dayMinutes,
|
dayMinutes: $dayMinutes,
|
||||||
@@ -459,35 +595,6 @@ class YearlyHoursExportBuilder
|
|||||||
return max(0, $end - $start);
|
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
|
private function formatMinutes(int $minutes): string
|
||||||
{
|
{
|
||||||
if (0 === $minutes) {
|
if (0 === $minutes) {
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?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 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 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);
|
||||||
|
$output->isDriver = $employee->getIsDriver();
|
||||||
|
$output->capHours = $this->calculator->capHours($output->isDriver);
|
||||||
|
|
||||||
|
return $output;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,12 +8,15 @@ use ApiPlatform\Metadata\Operation;
|
|||||||
use ApiPlatform\State\ProcessorInterface;
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
use App\ApiResource\EmployeeRttPaymentInput;
|
use App\ApiResource\EmployeeRttPaymentInput;
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeRttBalance;
|
||||||
use App\Entity\EmployeeRttPayment;
|
use App\Entity\EmployeeRttPayment;
|
||||||
use App\Repository\EmployeeRepository;
|
use App\Repository\EmployeeRepository;
|
||||||
|
use App\Repository\EmployeeRttBalanceRepository;
|
||||||
use App\Repository\EmployeeRttPaymentRepository;
|
use App\Repository\EmployeeRttPaymentRepository;
|
||||||
use App\Service\AuditLogger;
|
use App\Service\AuditLogger;
|
||||||
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
||||||
use App\Service\Exercise\ExerciseYearResolver;
|
use App\Service\Exercise\ExerciseYearResolver;
|
||||||
|
use App\Service\Rtt\RttClosingBalanceService;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Psr\Clock\ClockInterface;
|
use Psr\Clock\ClockInterface;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
@@ -24,11 +27,13 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private EmployeeRepository $employeeRepository,
|
private EmployeeRepository $employeeRepository,
|
||||||
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
||||||
|
private EmployeeRttBalanceRepository $rttBalanceRepository,
|
||||||
private EntityManagerInterface $entityManager,
|
private EntityManagerInterface $entityManager,
|
||||||
private AuditLogger $auditLogger,
|
private AuditLogger $auditLogger,
|
||||||
private EmployeeContractPhaseResolver $phaseResolver,
|
private EmployeeContractPhaseResolver $phaseResolver,
|
||||||
private ClockInterface $clock,
|
private ClockInterface $clock,
|
||||||
private ExerciseYearResolver $exerciseYearResolver,
|
private ExerciseYearResolver $exerciseYearResolver,
|
||||||
|
private RttClosingBalanceService $rttClosingService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EmployeeRttPaymentInput
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EmployeeRttPaymentInput
|
||||||
@@ -51,10 +56,20 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
|
|||||||
throw new UnprocessableEntityHttpException('month must be between 1 and 12.');
|
throw new UnprocessableEntityHttpException('month must be between 1 and 12.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$year = $data->year ?? $this->resolveCurrentExerciseYear();
|
$year = $data->year ?? $this->resolveCurrentExerciseYear();
|
||||||
|
$currentExerciseYear = $this->resolveCurrentExerciseYear();
|
||||||
|
|
||||||
$this->assertYearAllowedForPayment($employee, $year);
|
$this->assertYearAllowedForPayment($employee, $year);
|
||||||
|
|
||||||
|
// Option B — retroactive payment on the previous exercise: the next exercise's
|
||||||
|
// opening report (a frozen snapshot) must be recomputed so the carry stays correct.
|
||||||
|
// Refuse upfront if that report has been locked (validated) by RH.
|
||||||
|
$downstreamBalance = null;
|
||||||
|
if ($year === $currentExerciseYear - 1) {
|
||||||
|
$downstreamBalance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $currentExerciseYear);
|
||||||
|
$this->assertReportNotLocked($downstreamBalance);
|
||||||
|
}
|
||||||
|
|
||||||
$payment = $this->rttPaymentRepository->findOneByEmployeeYearMonth($employee, $year, $data->month);
|
$payment = $this->rttPaymentRepository->findOneByEmployeeYearMonth($employee, $year, $data->month);
|
||||||
|
|
||||||
if (null === $payment) {
|
if (null === $payment) {
|
||||||
@@ -81,7 +96,24 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
|
|||||||
['new' => ['month' => $data->month, 'year' => $year, 'base25' => $data->base25Minutes, 'bonus25' => $data->bonus25Minutes, 'base50' => $data->base50Minutes, 'bonus50' => $data->bonus50Minutes]],
|
['new' => ['month' => $data->month, 'year' => $year, 'base25' => $data->base25Minutes, 'bonus25' => $data->bonus25Minutes, 'base50' => $data->base50Minutes, 'bonus50' => $data->bonus50Minutes]],
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->entityManager->flush();
|
// Persist the payment and, atomically, refresh the next exercise's opening report.
|
||||||
|
// The flush inside the transaction makes the new payment visible to the closing
|
||||||
|
// recomputation (same DB connection), so the carry reflects it.
|
||||||
|
$this->entityManager->wrapInTransaction(function () use ($employee, $year, $downstreamBalance): void {
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
if (null !== $downstreamBalance) {
|
||||||
|
$closing = $this->rttClosingService->computeClosingBalance($employee, $year);
|
||||||
|
$downstreamBalance
|
||||||
|
->setOpeningBase25Minutes($closing->base25Minutes)
|
||||||
|
->setOpeningBonus25Minutes($closing->bonus25Minutes)
|
||||||
|
->setOpeningBase50Minutes($closing->base50Minutes)
|
||||||
|
->setOpeningBonus50Minutes($closing->bonus50Minutes)
|
||||||
|
->touch()
|
||||||
|
;
|
||||||
|
$this->entityManager->flush();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$data->year = $year;
|
$data->year = $year;
|
||||||
|
|
||||||
@@ -94,14 +126,15 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allow payment when the requested exercise is either the current one
|
* Allow payment when the requested exercise is the current one, the
|
||||||
* or the last exercise of a closed contract phase (the one containing
|
* immediately previous one (retroactive payment — Option B), or the last
|
||||||
* the phase end date). Reject any other exercise (past or future).
|
* exercise of a closed contract phase (the one containing the phase end
|
||||||
|
* date). Reject any other exercise (older past or future).
|
||||||
*/
|
*/
|
||||||
private function assertYearAllowedForPayment(Employee $employee, int $year): void
|
private function assertYearAllowedForPayment(Employee $employee, int $year): void
|
||||||
{
|
{
|
||||||
$currentExerciseYear = $this->resolveCurrentExerciseYear();
|
$currentExerciseYear = $this->resolveCurrentExerciseYear();
|
||||||
if ($year === $currentExerciseYear) {
|
if ($year === $currentExerciseYear || $year === $currentExerciseYear - 1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +149,21 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
throw new UnprocessableEntityHttpException(
|
throw new UnprocessableEntityHttpException(
|
||||||
'RTT payment is only allowed on the current exercise or the last exercise of a closed contract phase.'
|
'RTT payment is only allowed on the current exercise, the previous one, or the last exercise of a closed contract phase.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuse a retroactive payment when the next exercise's opening report has
|
||||||
|
* been locked (validated) by RH: recomputing it would either be impossible
|
||||||
|
* or silently desync the carry. A missing report (null) never blocks.
|
||||||
|
*/
|
||||||
|
private function assertReportNotLocked(?EmployeeRttBalance $downstreamBalance): void
|
||||||
|
{
|
||||||
|
if (null !== $downstreamBalance && $downstreamBalance->isLocked()) {
|
||||||
|
throw new UnprocessableEntityHttpException(
|
||||||
|
'Impossible : le report RTT de l\'exercice suivant est verrouillé. Déverrouillez-le pour saisir un paiement rétroactif.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use App\Repository\WorkHourRepository;
|
|||||||
use App\Security\EmployeeScopeService;
|
use App\Security\EmployeeScopeService;
|
||||||
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
||||||
use App\Service\Exercise\ExerciseYearResolver;
|
use App\Service\Exercise\ExerciseYearResolver;
|
||||||
|
use App\Service\Rtt\RttClosingBalanceService;
|
||||||
use App\Service\Rtt\RttRecoveryComputationService;
|
use App\Service\Rtt\RttRecoveryComputationService;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
@@ -43,6 +44,7 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
|||||||
private WorkHourRepository $workHourRepository,
|
private WorkHourRepository $workHourRepository,
|
||||||
private EmployeeContractPhaseResolver $phaseResolver,
|
private EmployeeContractPhaseResolver $phaseResolver,
|
||||||
private ExerciseYearResolver $exerciseYearResolver,
|
private ExerciseYearResolver $exerciseYearResolver,
|
||||||
|
private RttClosingBalanceService $rttClosingService,
|
||||||
string $rttStartDate = '',
|
string $rttStartDate = '',
|
||||||
) {
|
) {
|
||||||
$this->rttStartDate = '' !== $rttStartDate ? $rttStartDate : null;
|
$this->rttStartDate = '' !== $rttStartDate ? $rttStartDate : null;
|
||||||
@@ -140,36 +142,13 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
|||||||
$summary->rttStartDate = $this->rttStartDate;
|
$summary->rttStartDate = $this->rttStartDate;
|
||||||
$summary->weeks = $this->buildWeekSummaries($weekRanges, $currentByWeekStart, $periodFrom, $periodTo);
|
$summary->weeks = $this->buildWeekSummaries($weekRanges, $currentByWeekStart, $periodFrom, $periodTo);
|
||||||
|
|
||||||
// Post-process: distribute deficit weeks across cumulative balance (50% first, then 25%)
|
// Post-process: distribute deficit weeks across cumulative balance (50% first, then 25%).
|
||||||
$cumulative50 = $carry->base50Minutes + $carry->bonus50Minutes;
|
// Flat-recovery (CUSTOM) weeks are skipped — their deficit only reduces the running cumul.
|
||||||
$cumulative25 = $carry->base25Minutes + $carry->bonus25Minutes;
|
$summary->weeks = $this->applyDeficitCascade(
|
||||||
|
$summary->weeks,
|
||||||
foreach ($summary->weeks as $i => $week) {
|
$carry->base25Minutes + $carry->bonus25Minutes,
|
||||||
if ($week->totalMinutes >= 0) {
|
$carry->base50Minutes + $carry->bonus50Minutes,
|
||||||
$cumulative50 += $week->base50Minutes + $week->bonus50Minutes;
|
);
|
||||||
$cumulative25 += $week->base25Minutes + $week->bonus25Minutes;
|
|
||||||
} else {
|
|
||||||
$deficit = -$week->totalMinutes;
|
|
||||||
$from50 = min($deficit, max(0, $cumulative50));
|
|
||||||
$from25 = $deficit - $from50;
|
|
||||||
|
|
||||||
$cumulative50 -= $from50;
|
|
||||||
$cumulative25 -= $from25;
|
|
||||||
|
|
||||||
$summary->weeks[$i] = new EmployeeRttWeekSummary(
|
|
||||||
month: $week->month,
|
|
||||||
weekNumber: $week->weekNumber,
|
|
||||||
weekStart: $week->weekStart,
|
|
||||||
weekEnd: $week->weekEnd,
|
|
||||||
overtimeMinutes: $week->overtimeMinutes,
|
|
||||||
base25Minutes: $from25 > 0 ? -$from25 : 0,
|
|
||||||
bonus25Minutes: 0,
|
|
||||||
base50Minutes: $from50 > 0 ? -$from50 : 0,
|
|
||||||
bonus50Minutes: 0,
|
|
||||||
totalMinutes: $week->totalMinutes,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$payments = $this->rttPaymentRepository->findByEmployeeAndYear($employee, $year);
|
$payments = $this->rttPaymentRepository->findByEmployeeAndYear($employee, $year);
|
||||||
$monthBuckets = [];
|
$monthBuckets = [];
|
||||||
@@ -231,8 +210,12 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No stored report row yet (before the 1st-June rollover materialises it):
|
||||||
|
// compute the previous exercise's full closing (opening + earned − paid) so the
|
||||||
|
// carry already reflects retroactive payments and the incoming report — matching
|
||||||
|
// what the rollover would persist. Falling back to earned-only would drop both.
|
||||||
return [
|
return [
|
||||||
$this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $year - 1),
|
$this->rttClosingService->computeClosingBalance($employee, $year - 1),
|
||||||
5,
|
5,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -350,6 +333,54 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
|||||||
return $weekEnd;
|
return $weekEnd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distribue les semaines déficitaires sur les tranches 25/50 accumulées (50 % d'abord,
|
||||||
|
* puis 25 %), en réécrivant les buckets affichés de chaque semaine déficitaire avec les
|
||||||
|
* montants négatifs drainés.
|
||||||
|
*
|
||||||
|
* Les semaines à récupération plate (CUSTOM 1h = 1h) sont ignorées : elles n'ont pas de
|
||||||
|
* tranches 25/50, donc leur déficit ne réduit que le cumul courant (calculé ensuite à
|
||||||
|
* partir de totalMinutes) et les colonnes 25/50 restent à 0.
|
||||||
|
*
|
||||||
|
* @param list<EmployeeRttWeekSummary> $weeks
|
||||||
|
*
|
||||||
|
* @return list<EmployeeRttWeekSummary>
|
||||||
|
*/
|
||||||
|
private function applyDeficitCascade(array $weeks, int $cumulative25, int $cumulative50): array
|
||||||
|
{
|
||||||
|
foreach ($weeks as $i => $week) {
|
||||||
|
if ($week->totalMinutes >= 0 || $week->isFlatRecovery) {
|
||||||
|
$cumulative50 += $week->base50Minutes + $week->bonus50Minutes;
|
||||||
|
$cumulative25 += $week->base25Minutes + $week->bonus25Minutes;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$deficit = -$week->totalMinutes;
|
||||||
|
$from50 = min($deficit, max(0, $cumulative50));
|
||||||
|
$from25 = $deficit - $from50;
|
||||||
|
|
||||||
|
$cumulative50 -= $from50;
|
||||||
|
$cumulative25 -= $from25;
|
||||||
|
|
||||||
|
$weeks[$i] = new EmployeeRttWeekSummary(
|
||||||
|
month: $week->month,
|
||||||
|
weekNumber: $week->weekNumber,
|
||||||
|
weekStart: $week->weekStart,
|
||||||
|
weekEnd: $week->weekEnd,
|
||||||
|
overtimeMinutes: $week->overtimeMinutes,
|
||||||
|
base25Minutes: $from25 > 0 ? -$from25 : 0,
|
||||||
|
bonus25Minutes: 0,
|
||||||
|
base50Minutes: $from50 > 0 ? -$from50 : 0,
|
||||||
|
bonus50Minutes: 0,
|
||||||
|
totalMinutes: $week->totalMinutes,
|
||||||
|
isFlatRecovery: $week->isFlatRecovery,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $weeks;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build week summaries, splitting weeks that span two months into two entries
|
* Build week summaries, splitting weeks that span two months into two entries
|
||||||
* with values distributed proportionally based on daily worked minutes.
|
* with values distributed proportionally based on daily worked minutes.
|
||||||
@@ -387,6 +418,7 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
|||||||
base50Minutes: $detail->base50Minutes,
|
base50Minutes: $detail->base50Minutes,
|
||||||
bonus50Minutes: $detail->bonus50Minutes,
|
bonus50Minutes: $detail->bonus50Minutes,
|
||||||
totalMinutes: $detail->totalMinutes,
|
totalMinutes: $detail->totalMinutes,
|
||||||
|
isFlatRecovery: $detail->isFlatRecovery,
|
||||||
);
|
);
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@@ -427,6 +459,7 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
|||||||
base50Minutes: (int) round($detail->base50Minutes * $ratio),
|
base50Minutes: (int) round($detail->base50Minutes * $ratio),
|
||||||
bonus50Minutes: (int) round($detail->bonus50Minutes * $ratio),
|
bonus50Minutes: (int) round($detail->bonus50Minutes * $ratio),
|
||||||
totalMinutes: (int) round($detail->totalMinutes * $ratio),
|
totalMinutes: (int) round($detail->totalMinutes * $ratio),
|
||||||
|
isFlatRecovery: $detail->isFlatRecovery,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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\Contracts\EmployeeContractResolver;
|
||||||
use App\Service\PublicHolidayServiceInterface;
|
use App\Service\PublicHolidayServiceInterface;
|
||||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||||
|
use App\Service\WorkHours\NightHoursCalculator;
|
||||||
use DateInterval;
|
use DateInterval;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Dompdf\Dompdf;
|
use Dompdf\Dompdf;
|
||||||
@@ -45,6 +46,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
|||||||
private PublicHolidayServiceInterface $publicHolidayService,
|
private PublicHolidayServiceInterface $publicHolidayService,
|
||||||
private EmployeeLeaveSummaryProvider $leaveSummaryProvider,
|
private EmployeeLeaveSummaryProvider $leaveSummaryProvider,
|
||||||
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
||||||
|
private NightHoursCalculator $nightHoursCalculator,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
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 :
|
// 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é
|
// 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).
|
// 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));
|
$yearStart = new DateTimeImmutable(sprintf('%d-01-01', $year));
|
||||||
$ytdAbsences = $this->absenceRepository->findForPrint($yearStart, $to, $employees);
|
$ytdAbsences = $this->absenceRepository->findForPrint($yearStart, $to, $employees);
|
||||||
$ytdAbsenceMap = $this->buildAbsenceMap($ytdAbsences);
|
$ytdAbsenceMap = $this->buildAbsenceMap($ytdAbsences);
|
||||||
$rttPayments = $this->rttPaymentRepository->findByYearAndMonth($year, $monthNumber);
|
$rttPayments = $this->rttPaymentRepository->findByYearAndMonth($year, $monthNumber);
|
||||||
|
|
||||||
$bonuses = $this->bonusRepository->findByMonth($from, $to);
|
$bonuses = $this->bonusRepository->findByMonth($from, $to);
|
||||||
$mileages = $this->mileageAllowanceRepository->findByMonth($from, $to);
|
$mileages = $this->mileageAllowanceRepository->findByMonth($from, $to);
|
||||||
@@ -472,7 +474,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
|||||||
$ytdAbsences,
|
$ytdAbsences,
|
||||||
static fn (Absence $a): bool => 'C' === $a->getType()?->getCode()
|
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']];
|
$conges = ['count' => $split['count'], 'dates' => $split['dates']];
|
||||||
$presenceDays += $split['n1PresenceDays'];
|
$presenceDays += $split['n1PresenceDays'];
|
||||||
} else {
|
} else {
|
||||||
@@ -524,14 +526,13 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
|||||||
];
|
];
|
||||||
|
|
||||||
$totalMinutes = 0;
|
$totalMinutes = 0;
|
||||||
$nightMinutes = 0;
|
|
||||||
|
|
||||||
foreach ($ranges as [$from, $to]) {
|
foreach ($ranges as [$from, $to]) {
|
||||||
$totalMinutes += $this->intervalMinutes($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 [
|
return [
|
||||||
'nightMinutes' => $nightMinutes,
|
'nightMinutes' => $nightMinutes,
|
||||||
@@ -578,27 +579,6 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
|||||||
return max(0, $end - $start);
|
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.
|
* 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).
|
* 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;
|
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,
|
* 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
|
* 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;
|
$covered = 0.0;
|
||||||
if ($remaining > 0.0) {
|
if ($remaining > 0.0) {
|
||||||
$covered = min($remaining, $amount);
|
$covered = min($remaining, $amount);
|
||||||
$remaining -= $covered;
|
$remaining -= $covered;
|
||||||
}
|
}
|
||||||
$displayed = $amount - $covered;
|
$displayed = $amount - $covered;
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\EmployeeRepository;
|
||||||
|
use App\Service\WorkHours\YearlyHoursExportBuilder;
|
||||||
|
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 WorkHourDayExportProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Environment $twig,
|
||||||
|
private readonly RequestStack $requestStack,
|
||||||
|
private EmployeeRepository $employeeRepository,
|
||||||
|
private YearlyHoursExportBuilder $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);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workDateRaw = (string) $request->query->get('workDate');
|
||||||
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $workDateRaw)) {
|
||||||
|
throw new UnprocessableEntityHttpException('workDate must use YYYY-MM-DD format.');
|
||||||
|
}
|
||||||
|
$date = new DateTimeImmutable($workDateRaw);
|
||||||
|
|
||||||
|
$siteIdsRaw = (string) $request->query->get('siteIds', '');
|
||||||
|
$siteIds = array_values(array_filter(array_map(
|
||||||
|
static fn (string $value): int => (int) trim($value),
|
||||||
|
explode(',', $siteIdsRaw),
|
||||||
|
), static fn (int $id): bool => $id > 0));
|
||||||
|
if ([] === $siteIds) {
|
||||||
|
throw new UnprocessableEntityHttpException('siteIds is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Périmètre selon le profil : admin → tous, chef de site → ses sites uniquement.
|
||||||
|
// Les siteIds demandés ne peuvent donc pas déborder du scope de l'utilisateur.
|
||||||
|
$employees = $this->employeeRepository->findScoped($user);
|
||||||
|
|
||||||
|
// Regroupement par site (ordre displayOrder), non-conducteurs uniquement.
|
||||||
|
$bySite = [];
|
||||||
|
$siteMeta = [];
|
||||||
|
foreach ($employees as $employee) {
|
||||||
|
if (true === $employee->getIsDriver()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$site = $employee->getSite();
|
||||||
|
if (null === $site || !in_array($site->getId(), $siteIds, true)) {
|
||||||
|
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 = [];
|
||||||
|
$legend = [];
|
||||||
|
foreach ($siteMeta as $siteId => $meta) {
|
||||||
|
$siteEmployees = $bySite[$siteId];
|
||||||
|
// Même tri que le calendrier : ordre manuel (displayOrder) puis nom, puis prénom.
|
||||||
|
usort($siteEmployees, static function ($a, $b): int {
|
||||||
|
return [$a->getDisplayOrder(), $a->getLastName(), $a->getFirstName()]
|
||||||
|
<=> [$b->getDisplayOrder(), $b->getLastName(), $b->getFirstName()];
|
||||||
|
});
|
||||||
|
|
||||||
|
$rows = $this->exportBuilder->buildDayRowsForEmployees($siteEmployees, $date);
|
||||||
|
if ([] === $rows) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$groups[] = ['siteName' => $meta['name'], 'siteColor' => $meta['color'], 'rows' => $rows];
|
||||||
|
|
||||||
|
// Légende : codes d'absence présents (hors férié), dédupliqués par code.
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if ($row['isHoliday'] || null === $row['statut'] || null === $row['statutLabel']) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$legend[$row['statut']] ??= [
|
||||||
|
'code' => $row['statut'],
|
||||||
|
'label' => $row['statutLabel'],
|
||||||
|
'color' => $row['statutColor'] ?? '#e8e8e8',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ksort($legend);
|
||||||
|
$legend = array_values($legend);
|
||||||
|
|
||||||
|
$options = new Options();
|
||||||
|
$options->set('isRemoteEnabled', true);
|
||||||
|
$dompdf = new Dompdf($options);
|
||||||
|
|
||||||
|
$html = $this->twig->render('work-hour-day-export/print.html.twig', [
|
||||||
|
'groups' => $groups,
|
||||||
|
'legend' => $legend,
|
||||||
|
'dateLabel' => $date->format('d/m/Y'),
|
||||||
|
'exportedAt' => new DateTimeImmutable('now')->format('d/m/Y H:i'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$dompdf->loadHtml($html);
|
||||||
|
$dompdf->setPaper('A4', 'portrait');
|
||||||
|
$dompdf->render();
|
||||||
|
|
||||||
|
$filename = sprintf('heures_jour_%s.pdf', $date->format('Y-m-d'));
|
||||||
|
|
||||||
|
return new Response($dompdf->output(), Response::HTTP_OK, [
|
||||||
|
'Content-Type' => 'application/pdf',
|
||||||
|
'Content-Disposition' => sprintf('attachment; filename="%s"', $filename),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ use App\Service\PublicHolidayServiceInterface;
|
|||||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||||
use App\Service\WorkHours\DailyReferenceMinutesResolver;
|
use App\Service\WorkHours\DailyReferenceMinutesResolver;
|
||||||
use App\Service\WorkHours\HolidayVirtualHoursResolver;
|
use App\Service\WorkHours\HolidayVirtualHoursResolver;
|
||||||
|
use App\Service\WorkHours\NightHoursCalculator;
|
||||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
@@ -51,6 +52,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
|
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
|
||||||
private PublicHolidayServiceInterface $publicHolidayService,
|
private PublicHolidayServiceInterface $publicHolidayService,
|
||||||
private EmployeeWeekCommentRepository $weekCommentRepository,
|
private EmployeeWeekCommentRepository $weekCommentRepository,
|
||||||
|
private NightHoursCalculator $nightHoursCalculator,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary
|
||||||
@@ -433,14 +435,12 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
];
|
];
|
||||||
|
|
||||||
$totalMinutes = 0;
|
$totalMinutes = 0;
|
||||||
$nightMinutes = 0;
|
|
||||||
|
|
||||||
foreach ($ranges as [$from, $to]) {
|
foreach ($ranges as [$from, $to]) {
|
||||||
$totalMinutes += $this->intervalMinutes($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(
|
return new WorkMetrics(
|
||||||
dayMinutes: $dayMinutes,
|
dayMinutes: $dayMinutes,
|
||||||
@@ -489,37 +489,6 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
return max(0, $end - $start);
|
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
|
* @param array<string, ?Contract> $contractsByDate
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,16 +7,16 @@ namespace App\Util;
|
|||||||
use DateTimeImmutable;
|
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
|
final class LeaveRecapCutoff
|
||||||
{
|
{
|
||||||
public static function resolveCutoff(DateTimeImmutable $today): DateTimeImmutable
|
public static function resolveCutoff(DateTimeImmutable $today): DateTimeImmutable
|
||||||
{
|
{
|
||||||
$currentWeekMonday = $today->modify('monday this week')->setTime(0, 0);
|
$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);
|
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>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Heures - {{ dateLabel }}</title>
|
||||||
|
<style>
|
||||||
|
@page { size: A4 portrait; margin: 4mm; }
|
||||||
|
html, body { margin: 0; padding: 2mm; font-family: Helvetica, sans-serif; font-size: 10px; }
|
||||||
|
.title-bar { position: relative; margin: 0 0 3mm 0; }
|
||||||
|
h1 { text-align: center; font-size: 15px; margin: 0; }
|
||||||
|
.export-date { position: absolute; top: 0; right: 0; font-size: 9px; color: #333; padding-top: 4px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; table-layout: fixed; border: 2px solid #0a0a0a; }
|
||||||
|
th, td { border: 1px solid #0a0a0a; padding: 4px 2px; vertical-align: middle; text-align: center; overflow: hidden; }
|
||||||
|
th { font-weight: 700; background: #f0f0f0; white-space: normal; }
|
||||||
|
td { white-space: nowrap; }
|
||||||
|
td.name { text-align: left; }
|
||||||
|
tr.site-title td { font-weight: bold; font-size: 11px; text-transform: uppercase; text-align: left; padding: 2px 6px; white-space: nowrap; }
|
||||||
|
tr.weekend td { background: #c0c0c0; }
|
||||||
|
td.total { font-weight: bold; }
|
||||||
|
table.legend { width: auto; table-layout: auto; margin-top: 4mm; font-size: 10px; border: 0; border-collapse: collapse; }
|
||||||
|
table.legend td { border: 0; padding: 2px 0; vertical-align: middle; overflow: visible; white-space: nowrap; }
|
||||||
|
table.legend .legend-title { font-weight: bold; padding-right: 8px; }
|
||||||
|
table.legend .legend-box-cell { padding-left: 12px; }
|
||||||
|
table.legend .legend-box { display: inline-block; box-sizing: content-box; width: 14px; height: 14px; padding: 3px; line-height: 14px; text-align: center; font-weight: bold; font-size: 9px; }
|
||||||
|
table.legend .legend-label { padding-left: 4px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="title-bar">
|
||||||
|
<h1>Heures du {{ dateLabel }}</h1>
|
||||||
|
<div class="export-date">Édité le {{ exportedAt }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 21%;">Nom</th>
|
||||||
|
<th style="width: 13%;">Statut</th>
|
||||||
|
<th style="width: 8%;">Début matin</th>
|
||||||
|
<th style="width: 8%;">Fin matin</th>
|
||||||
|
<th style="width: 8%;">Début après-midi</th>
|
||||||
|
<th style="width: 8%;">Fin après-midi</th>
|
||||||
|
<th style="width: 8%;">Début soir</th>
|
||||||
|
<th style="width: 8%;">Fin soir</th>
|
||||||
|
<th style="width: 6%;">Jour</th>
|
||||||
|
<th style="width: 6%;">Nuit</th>
|
||||||
|
<th style="width: 6%;">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for group in groups %}
|
||||||
|
<tr class="site-title">
|
||||||
|
<td colspan="11" style="background: {{ group.siteColor ?: '#e8e8e8' }};">{{ group.siteName }}</td>
|
||||||
|
</tr>
|
||||||
|
{% for row in group.rows %}
|
||||||
|
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
|
||||||
|
<td class="name">{{ row.employeeName }}</td>
|
||||||
|
<td{% if row.statutColor %} style="background: {{ row.statutColor }};"{% endif %}>{{ row.statut }}</td>
|
||||||
|
<td>{{ row.morningFrom }}</td>
|
||||||
|
<td>{{ row.morningTo }}</td>
|
||||||
|
<td>{{ row.afternoonFrom }}</td>
|
||||||
|
<td>{{ row.afternoonTo }}</td>
|
||||||
|
<td>{{ row.eveningFrom }}</td>
|
||||||
|
<td>{{ row.eveningTo }}</td>
|
||||||
|
<td>{{ row.dayHours }}</td>
|
||||||
|
<td>{{ row.nightHours }}</td>
|
||||||
|
<td class="total">{{ row.total }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if legend is not empty %}
|
||||||
|
<table class="legend">
|
||||||
|
{% for chunk in legend|batch(6) %}
|
||||||
|
<tr>
|
||||||
|
<td class="legend-title">{% if loop.first %}Légende :{% endif %}</td>
|
||||||
|
{% for item in chunk %}
|
||||||
|
<td class="legend-box-cell">
|
||||||
|
<span class="legend-box" style="background: {{ item.color ?: '#e8e8e8' }};">{{ item.code }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="legend-label">{{ item.label }}</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -80,6 +80,23 @@ final class RttClosingBalanceServiceTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testCustomDeficitWeekReducesClosingBalance(): void
|
||||||
|
{
|
||||||
|
// CUSTOM (4h) : une semaine de récup +3h puis une semaine déficitaire -1h
|
||||||
|
// (toutes deux sans tranches 25/50). Le déficit doit réduire la clôture.
|
||||||
|
$recovery = new WeekRecoveryDetail(totalMinutes: 180, isFlatRecovery: true); // +3h
|
||||||
|
$deficit = new WeekRecoveryDetail(totalMinutes: -60, isFlatRecovery: true); // -1h
|
||||||
|
|
||||||
|
$closing = $this->service()->fold(new WeekRecoveryDetail(), [$recovery, $deficit], $this->payments());
|
||||||
|
|
||||||
|
// 3h - 1h = 2h reportées, et la somme des buckets égale toujours le total.
|
||||||
|
self::assertSame(120, $closing->totalMinutes);
|
||||||
|
self::assertSame(
|
||||||
|
120,
|
||||||
|
$closing->base25Minutes + $closing->bonus25Minutes + $closing->base50Minutes + $closing->bonus50Minutes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function testBucketSumAlwaysEqualsTotalInvariant(): void
|
public function testBucketSumAlwaysEqualsTotalInvariant(): void
|
||||||
{
|
{
|
||||||
$opening = new WeekRecoveryDetail(base25Minutes: 200, bonus25Minutes: 50, base50Minutes: 100, bonus50Minutes: 50, totalMinutes: 400);
|
$opening = new WeekRecoveryDetail(base25Minutes: 200, bonus25Minutes: 50, base50Minutes: 100, bonus50Minutes: 50, totalMinutes: 400);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Tests\Service\Rtt;
|
namespace App\Tests\Service\Rtt;
|
||||||
|
|
||||||
use App\Entity\Contract;
|
use App\Entity\Contract;
|
||||||
|
use App\Enum\TrackingMode;
|
||||||
use App\Service\Rtt\RttRecoveryComputationService;
|
use App\Service\Rtt\RttRecoveryComputationService;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use ReflectionClass;
|
use ReflectionClass;
|
||||||
@@ -113,6 +114,159 @@ final class RttRecoveryComputationServiceTest extends TestCase
|
|||||||
self::assertSame(3 * 60, $base50);
|
self::assertSame(3 * 60, $base50);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testBuildWeekDetailCustomDeficitKeepsSignedTotalAndFlatFlag(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
// CUSTOM, semaine sous les heures : overtime -120 (worked 2h sur réf 4h).
|
||||||
|
$detail = $this->invokePrivate(
|
||||||
|
$service,
|
||||||
|
'buildWeekRecoveryDetail',
|
||||||
|
false, // isPresence
|
||||||
|
false, // disableBonuses
|
||||||
|
true, // isCustom
|
||||||
|
-120, // overtimeTotalMinutes
|
||||||
|
0, // rawBase25
|
||||||
|
0, // rawBase50
|
||||||
|
[], // dailyMinutes
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(-120, $detail->totalMinutes);
|
||||||
|
self::assertTrue($detail->isFlatRecovery);
|
||||||
|
self::assertSame(0, $detail->base25Minutes);
|
||||||
|
self::assertSame(0, $detail->base50Minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBuildWeekDetailCustomPositiveIsFlatOneToOne(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
$detail = $this->invokePrivate($service, 'buildWeekRecoveryDetail', false, false, true, 180, 0, 0, []);
|
||||||
|
|
||||||
|
self::assertSame(180, $detail->totalMinutes); // 1h = 1h
|
||||||
|
self::assertTrue($detail->isFlatRecovery);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBuildWeekDetailStandardKeepsBucketsAndBonuses(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
// 39h : overtime 300, base25 240, base50 60.
|
||||||
|
$detail = $this->invokePrivate($service, 'buildWeekRecoveryDetail', false, false, false, 300, 240, 60, []);
|
||||||
|
|
||||||
|
self::assertFalse($detail->isFlatRecovery);
|
||||||
|
self::assertSame(240, $detail->base25Minutes);
|
||||||
|
self::assertSame(60, $detail->bonus25Minutes); // round(240 * 0.25)
|
||||||
|
self::assertSame(60, $detail->base50Minutes);
|
||||||
|
self::assertSame(30, $detail->bonus50Minutes); // round(60 * 0.5)
|
||||||
|
self::assertSame(300 + 60 + 30, $detail->totalMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CUSTOM 4h, jour de solidarité non travaillé (RTT posé ou vide) : delta = (attendu − 0) − prorata.
|
||||||
|
* attendu lundi = workDaysHours = 120 ; prorata = round(4×12) = 48 ; delta = 120 − 48 = 72.
|
||||||
|
* (Combiné au naturel −120 de la semaine, donne −48 min.).
|
||||||
|
*/
|
||||||
|
public function testSolidarityAdjustmentCustomNotWorkedNeutralisesToProrata(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
$delta = $this->invokePrivate(
|
||||||
|
$service,
|
||||||
|
'computeSolidarityDeficitAdjustment',
|
||||||
|
self::customContract(4),
|
||||||
|
120, // expectedMinutes (workDaysHours du lundi)
|
||||||
|
0, // workedMinutes (RTT posé / vide)
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(72, $delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CUSTOM 4h, jour de solidarité travaillé normalement (120) : delta = (120 − 120) − 48 = −48.
|
||||||
|
*/
|
||||||
|
public function testSolidarityAdjustmentCustomWorkedNormallyChargesProrata(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', self::customContract(4), 120, 120);
|
||||||
|
|
||||||
|
self::assertSame(-48, $delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CUSTOM 4h, jour de solidarité travaillé en plus (240) : delta = (120 − 240) − 48 = −168.
|
||||||
|
* Le surplus du jour de solidarité n'est PAS crédité (jour neutralisé, net forcé à −prorata).
|
||||||
|
*/
|
||||||
|
public function testSolidarityAdjustmentCustomWorkedExtraStillNetsProrata(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', self::customContract(4), 120, 240);
|
||||||
|
|
||||||
|
self::assertSame(-168, $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
|
||||||
|
* workDaysHours où la valeur du lundi diffère, expected ≠ prorata et le delta serait non nul.
|
||||||
|
*/
|
||||||
|
public function testSolidarityAdjustmentCustom28hUsesProrata(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', self::customContract(28), 336, 0);
|
||||||
|
|
||||||
|
self::assertSame(0, $delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CUSTOM ≥ 35h (36h) : hors périmètre → delta 0.
|
||||||
|
*/
|
||||||
|
public function testSolidarityAdjustmentCustom36hOutOfScope(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', self::customContract(36), 999, 0);
|
||||||
|
|
||||||
|
self::assertSame(0, $delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 35h : type H35 (pas CUSTOM) → delta 0 (comportement inchangé, RTT posé fait foi).
|
||||||
|
*/
|
||||||
|
public function testSolidarityAdjustment35hOutOfScope(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
$contract = new Contract()->setName('35h')->setTrackingMode(TrackingMode::TIME)->setWeeklyHours(35);
|
||||||
|
|
||||||
|
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', $contract, 420, 0);
|
||||||
|
|
||||||
|
self::assertSame(0, $delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aucun contrat ce jour-là (salarié parti / pas encore embauché) → delta 0.
|
||||||
|
*/
|
||||||
|
public function testSolidarityAdjustmentNoContractIsZero(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', null, 0, 0);
|
||||||
|
|
||||||
|
self::assertSame(0, $delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function customContract(int $weeklyHours): Contract
|
||||||
|
{
|
||||||
|
return new Contract()
|
||||||
|
->setName('Temps partiel')
|
||||||
|
->setTrackingMode(TrackingMode::TIME)
|
||||||
|
->setWeeklyHours($weeklyHours)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
private function invokePrivate(object $obj, string $method, mixed ...$args): mixed
|
private function invokePrivate(object $obj, string $method, mixed ...$args): mixed
|
||||||
{
|
{
|
||||||
return new ReflectionClass($obj::class)->getMethod($method)->invoke($obj, ...$args);
|
return new ReflectionClass($obj::class)->getMethod($method)->invoke($obj, ...$args);
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service\Rtt;
|
||||||
|
|
||||||
|
use App\Service\Rtt\SolidarityDayResolver;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class SolidarityDayResolverTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Lundi de Pentecôte = dimanche de Pâques + 50 jours.
|
||||||
|
* 2024 : Pâques 31/03 → 20/05 ; 2025 : Pâques 20/04 → 09/06 ; 2026 : Pâques 05/04 → 25/05.
|
||||||
|
*/
|
||||||
|
#[DataProvider('pentecostCases')]
|
||||||
|
public function testPentecostMonday(int $year, string $expected): void
|
||||||
|
{
|
||||||
|
$resolver = new SolidarityDayResolver();
|
||||||
|
|
||||||
|
self::assertSame($expected, $resolver->pentecostMonday($year)->format('Y-m-d'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return iterable<string, array{int, string}>
|
||||||
|
*/
|
||||||
|
public static function pentecostCases(): iterable
|
||||||
|
{
|
||||||
|
yield '2024' => [2024, '2024-05-20'];
|
||||||
|
|
||||||
|
yield '2025' => [2025, '2025-06-09'];
|
||||||
|
|
||||||
|
yield '2026' => [2026, '2026-05-25'];
|
||||||
|
|
||||||
|
// Century-boundary year: Easter 2000-04-23 → Whit Monday 2000-06-12
|
||||||
|
// (verified with: easter_date(2000) → date('+50 days'))
|
||||||
|
yield '2000' => [2000, '2000-06-12'];
|
||||||
|
|
||||||
|
// Late-April Easter (2011-04-24) → Whit Monday 2011-06-13
|
||||||
|
// (verified with: easter_date(2011) → date('+50 days'))
|
||||||
|
yield '2011' => [2011, '2011-06-13'];
|
||||||
|
|
||||||
|
// Easter on April 25 — exercises the computus corrective $m branch:
|
||||||
|
// Easter 2038-04-25 → Whit Monday 2038-06-14
|
||||||
|
// (verified with: easter_date(2038) → date('+50 days'))
|
||||||
|
yield '2038' => [2038, '2038-06-14'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The returned date must always be a Monday (ISO weekday = 1).
|
||||||
|
* Verified for 2025 as a representative case.
|
||||||
|
*/
|
||||||
|
public function testPentecostMondayIsAMonday(): void
|
||||||
|
{
|
||||||
|
$resolver = new SolidarityDayResolver();
|
||||||
|
|
||||||
|
self::assertSame('1', $resolver->pentecostMonday(2025)->format('N'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service\WorkHours;
|
||||||
|
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeRttPayment;
|
||||||
|
use App\Repository\EmployeeRttPaymentRepository;
|
||||||
|
use App\Service\WorkHours\OvertimeContingentExportBuilder;
|
||||||
|
use App\Service\WorkHours\OvertimePaidContingentCalculator;
|
||||||
|
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());
|
||||||
|
|
||||||
|
$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());
|
||||||
|
$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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,116 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service\WorkHours;
|
||||||
|
|
||||||
|
use App\Entity\Contract;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\WorkHour;
|
||||||
|
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||||
|
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||||
|
use App\Service\Contracts\EmployeeContractResolver;
|
||||||
|
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;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use ReflectionProperty;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class YearlyHoursDayRowsTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testTimeContractRowComputesHoursAndExcludesNoContract(): void
|
||||||
|
{
|
||||||
|
$date = new DateTimeImmutable('2026-06-08'); // lundi
|
||||||
|
|
||||||
|
$contract = new Contract();
|
||||||
|
$contract->setName('35h');
|
||||||
|
$contract->setTrackingMode(Contract::TRACKING_TIME);
|
||||||
|
$contract->setWeeklyHours(35);
|
||||||
|
|
||||||
|
$withContract = new Employee();
|
||||||
|
$withContract->setFirstName('Jean')->setLastName('Dupont');
|
||||||
|
$this->setEmployeeId($withContract, 1);
|
||||||
|
|
||||||
|
$noContract = new Employee();
|
||||||
|
$noContract->setFirstName('Paul')->setLastName('Martin');
|
||||||
|
$this->setEmployeeId($noContract, 2);
|
||||||
|
|
||||||
|
$workHour = new WorkHour();
|
||||||
|
$workHour->setEmployee($withContract)
|
||||||
|
->setWorkDate($date)
|
||||||
|
->setMorningFrom('08:00')->setMorningTo('12:00')
|
||||||
|
->setAfternoonFrom('13:00')->setAfternoonTo('17:00')
|
||||||
|
;
|
||||||
|
|
||||||
|
$workHourRepo = $this->createStub(WorkHourReadRepositoryInterface::class);
|
||||||
|
$workHourRepo->method('findByDateRangeAndEmployees')->willReturn([$workHour]);
|
||||||
|
|
||||||
|
$absenceRepo = $this->createStub(AbsenceReadRepositoryInterface::class);
|
||||||
|
$absenceRepo->method('findForPrint')->willReturn([]);
|
||||||
|
|
||||||
|
$contractResolver = $this->createStub(EmployeeContractResolver::class);
|
||||||
|
$contractResolver->method('resolveForEmployeesAndDays')->willReturn([
|
||||||
|
1 => ['2026-06-08' => $contract],
|
||||||
|
2 => ['2026-06-08' => null],
|
||||||
|
]);
|
||||||
|
$contractResolver->method('resolveIsDriverForEmployeesAndDays')->willReturn([
|
||||||
|
1 => ['2026-06-08' => false],
|
||||||
|
2 => ['2026-06-08' => false],
|
||||||
|
]);
|
||||||
|
$contractResolver->method('resolveWorkDaysMinutesForEmployeesAndDays')->willReturn([
|
||||||
|
1 => ['2026-06-08' => null],
|
||||||
|
2 => ['2026-06-08' => null],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$holidayService = $this->createStub(PublicHolidayServiceInterface::class);
|
||||||
|
$holidayService->method('getHolidaysDayByYears')->willReturn([]);
|
||||||
|
|
||||||
|
// No holiday on this Monday → virtual credit resolves to 0 via the real resolver.
|
||||||
|
$virtualResolver = new HolidayVirtualHoursResolver(
|
||||||
|
new DailyReferenceMinutesResolver(),
|
||||||
|
$holidayService,
|
||||||
|
$contractResolver,
|
||||||
|
);
|
||||||
|
|
||||||
|
$builder = new YearlyHoursExportBuilder(
|
||||||
|
$workHourRepo,
|
||||||
|
$absenceRepo,
|
||||||
|
$contractResolver,
|
||||||
|
new AbsenceSegmentsResolver(),
|
||||||
|
new WorkedHoursCreditPolicy($contractResolver, new DailyReferenceMinutesResolver()),
|
||||||
|
$holidayService,
|
||||||
|
$virtualResolver,
|
||||||
|
new NightHoursCalculator(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$rows = $builder->buildDayRowsForEmployees([$withContract, $noContract], $date);
|
||||||
|
|
||||||
|
self::assertCount(1, $rows);
|
||||||
|
self::assertSame(1, $rows[0]['employeeId']);
|
||||||
|
self::assertSame('Dupont Jean', $rows[0]['employeeName']);
|
||||||
|
self::assertSame('08:00', $rows[0]['morningFrom']);
|
||||||
|
self::assertSame('17:00', $rows[0]['afternoonTo']);
|
||||||
|
self::assertSame('8h', $rows[0]['total']);
|
||||||
|
self::assertSame('8h', $rows[0]['dayHours']);
|
||||||
|
self::assertSame('', $rows[0]['nightHours']);
|
||||||
|
self::assertNull($rows[0]['statut']);
|
||||||
|
self::assertNull($rows[0]['statutLabel']);
|
||||||
|
self::assertNull($rows[0]['statutColor']);
|
||||||
|
self::assertFalse($rows[0]['isWeekend']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setEmployeeId(Employee $employee, int $id): void
|
||||||
|
{
|
||||||
|
$ref = new ReflectionProperty(Employee::class, 'id');
|
||||||
|
$ref->setAccessible(true);
|
||||||
|
$ref->setValue($employee, $id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ namespace App\Tests\State;
|
|||||||
use App\Entity\Contract;
|
use App\Entity\Contract;
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
use App\Entity\EmployeeContractPeriod;
|
use App\Entity\EmployeeContractPeriod;
|
||||||
|
use App\Entity\EmployeeRttBalance;
|
||||||
use App\Enum\ContractNature;
|
use App\Enum\ContractNature;
|
||||||
use App\Enum\TrackingMode;
|
use App\Enum\TrackingMode;
|
||||||
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
||||||
@@ -74,6 +75,54 @@ final class EmployeeRttPaymentProcessorTest extends TestCase
|
|||||||
$this->invokePrivate($processor, 'assertYearAllowedForPayment', $employee, 2030);
|
$this->invokePrivate($processor, 'assertYearAllowedForPayment', $employee, 2030);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testPaymentAllowedOnPreviousExercise(): void
|
||||||
|
{
|
||||||
|
// Today = 2026-05-19 → current exercise = 2026. Retroactive payment on the
|
||||||
|
// immediately previous exercise (2025) is now allowed (Option B).
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$processor = $this->buildProcessorWithClock(new DateTimeImmutable('2026-05-19'));
|
||||||
|
|
||||||
|
$this->invokePrivate($processor, 'assertYearAllowedForPayment', $employee, 2025);
|
||||||
|
|
||||||
|
// No exception → previous exercise accepted.
|
||||||
|
self::assertTrue(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPaymentStillRejectedTwoExercisesBack(): void
|
||||||
|
{
|
||||||
|
// 2024 is two exercises before current (2026) and not a closed-phase end → still rejected.
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$processor = $this->buildProcessorWithClock(new DateTimeImmutable('2026-05-19'));
|
||||||
|
|
||||||
|
$this->expectException(UnprocessableEntityHttpException::class);
|
||||||
|
$this->invokePrivate($processor, 'assertYearAllowedForPayment', $employee, 2024);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRetroactivePaymentRefusedWhenDownstreamReportLocked(): void
|
||||||
|
{
|
||||||
|
$processor = $this->buildProcessorWithClock(new DateTimeImmutable('2026-05-19'));
|
||||||
|
|
||||||
|
$locked = new EmployeeRttBalance();
|
||||||
|
$locked->setIsLocked(true);
|
||||||
|
|
||||||
|
$this->expectException(UnprocessableEntityHttpException::class);
|
||||||
|
$this->invokePrivate($processor, 'assertReportNotLocked', $locked);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRetroactivePaymentAllowedWhenDownstreamReportMissingOrUnlocked(): void
|
||||||
|
{
|
||||||
|
$processor = $this->buildProcessorWithClock(new DateTimeImmutable('2026-05-19'));
|
||||||
|
|
||||||
|
$unlocked = new EmployeeRttBalance();
|
||||||
|
$unlocked->setIsLocked(false);
|
||||||
|
|
||||||
|
// Neither a missing (null) nor an unlocked downstream report must block payment.
|
||||||
|
$this->invokePrivate($processor, 'assertReportNotLocked', null);
|
||||||
|
$this->invokePrivate($processor, 'assertReportNotLocked', $unlocked);
|
||||||
|
|
||||||
|
self::assertTrue(true);
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Test harness helpers.
|
// Test harness helpers.
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Tests\State;
|
namespace App\Tests\State;
|
||||||
|
|
||||||
use App\Dto\Contracts\ContractPhase;
|
use App\Dto\Contracts\ContractPhase;
|
||||||
|
use App\Dto\Rtt\EmployeeRttWeekSummary;
|
||||||
use App\Entity\Contract;
|
use App\Entity\Contract;
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
use App\Entity\EmployeeContractPeriod;
|
use App\Entity\EmployeeContractPeriod;
|
||||||
@@ -201,6 +202,45 @@ final class EmployeeRttSummaryProviderTest extends TestCase
|
|||||||
self::assertSame(2030, $year);
|
self::assertSame(2030, $year);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testFlatDeficitWeekIsNotDrainedFromTiers(): void
|
||||||
|
{
|
||||||
|
$provider = $this->buildProvider([]);
|
||||||
|
|
||||||
|
// Semaine CUSTOM déficitaire (-120), aucune tranche accumulée.
|
||||||
|
$weeks = [$this->weekSummary(-120, true)];
|
||||||
|
$result = $this->invokePrivate($provider, 'applyDeficitCascade', $weeks, 0, 0);
|
||||||
|
|
||||||
|
// Buckets restent à 0 ; le total négatif est conservé (le cumul est calculé ailleurs).
|
||||||
|
self::assertSame(0, $result[0]->base25Minutes);
|
||||||
|
self::assertSame(0, $result[0]->base50Minutes);
|
||||||
|
self::assertSame(-120, $result[0]->totalMinutes);
|
||||||
|
self::assertTrue($result[0]->isFlatRecovery);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStandardDeficitWeekDrainsFiftyThenTwentyFive(): void
|
||||||
|
{
|
||||||
|
$provider = $this->buildProvider([]);
|
||||||
|
|
||||||
|
// Semaine 35h/39h déficitaire (-100), avec 60 en 50% et 120 en 25% accumulés.
|
||||||
|
$weeks = [$this->weekSummary(-100, false)];
|
||||||
|
$result = $this->invokePrivate($provider, 'applyDeficitCascade', $weeks, 120, 60);
|
||||||
|
|
||||||
|
self::assertSame(-60, $result[0]->base50Minutes); // 60 drainés du 50%
|
||||||
|
self::assertSame(-40, $result[0]->base25Minutes); // 40 restants drainés du 25%
|
||||||
|
self::assertSame(-100, $result[0]->totalMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFlatPositiveWeekIsUntouched(): void
|
||||||
|
{
|
||||||
|
$provider = $this->buildProvider([]);
|
||||||
|
|
||||||
|
$weeks = [$this->weekSummary(180, true)];
|
||||||
|
$result = $this->invokePrivate($provider, 'applyDeficitCascade', $weeks, 0, 0);
|
||||||
|
|
||||||
|
self::assertSame(180, $result[0]->totalMinutes);
|
||||||
|
self::assertSame(0, $result[0]->base25Minutes);
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Test harness helpers.
|
// Test harness helpers.
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -247,6 +287,21 @@ final class EmployeeRttSummaryProviderTest extends TestCase
|
|||||||
return $employee;
|
return $employee;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function weekSummary(int $totalMinutes, bool $isFlat, int $base25 = 0, int $base50 = 0): EmployeeRttWeekSummary
|
||||||
|
{
|
||||||
|
return new EmployeeRttWeekSummary(
|
||||||
|
month: 6,
|
||||||
|
weekNumber: 1,
|
||||||
|
weekStart: '2026-06-01',
|
||||||
|
weekEnd: '2026-06-07',
|
||||||
|
overtimeMinutes: $totalMinutes,
|
||||||
|
base25Minutes: $base25,
|
||||||
|
base50Minutes: $base50,
|
||||||
|
totalMinutes: $totalMinutes,
|
||||||
|
isFlatRecovery: $isFlat,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build an uninitialized provider with a RequestStack pre-loaded with the given query.
|
* Build an uninitialized provider with a RequestStack pre-loaded with the given query.
|
||||||
*
|
*
|
||||||
@@ -256,7 +311,7 @@ final class EmployeeRttSummaryProviderTest extends TestCase
|
|||||||
* only setting the properties that the tested private methods actually read:
|
* only setting the properties that the tested private methods actually read:
|
||||||
* `requestStack` and `phaseResolver`.
|
* `requestStack` and `phaseResolver`.
|
||||||
*
|
*
|
||||||
* @param array<string, string> $request query parameters (year, phaseId, ...)
|
* @param array<string, string> $request
|
||||||
*/
|
*/
|
||||||
private function buildProvider(array $request = []): EmployeeRttSummaryProvider
|
private function buildProvider(array $request = []): EmployeeRttSummaryProvider
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ use App\Service\PublicHolidayServiceInterface;
|
|||||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||||
use App\Service\WorkHours\DailyReferenceMinutesResolver;
|
use App\Service\WorkHours\DailyReferenceMinutesResolver;
|
||||||
use App\Service\WorkHours\HolidayVirtualHoursResolver;
|
use App\Service\WorkHours\HolidayVirtualHoursResolver;
|
||||||
|
use App\Service\WorkHours\NightHoursCalculator;
|
||||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
||||||
use App\State\WorkHourWeeklySummaryProvider;
|
use App\State\WorkHourWeeklySummaryProvider;
|
||||||
use DateTime;
|
use DateTime;
|
||||||
@@ -69,6 +70,7 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
|
|||||||
$this->buildHolidayResolver(),
|
$this->buildHolidayResolver(),
|
||||||
$this->buildHolidayService(),
|
$this->buildHolidayService(),
|
||||||
$this->buildWeekCommentRepoStub(),
|
$this->buildWeekCommentRepoStub(),
|
||||||
|
new NightHoursCalculator(),
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->expectException(AccessDeniedHttpException::class);
|
$this->expectException(AccessDeniedHttpException::class);
|
||||||
@@ -133,6 +135,7 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
|
|||||||
$this->buildHolidayResolver(),
|
$this->buildHolidayResolver(),
|
||||||
$this->buildHolidayService(),
|
$this->buildHolidayService(),
|
||||||
$this->buildWeekCommentRepoStub(),
|
$this->buildWeekCommentRepoStub(),
|
||||||
|
new NightHoursCalculator(),
|
||||||
);
|
);
|
||||||
|
|
||||||
$result = $provider->provide(new Get());
|
$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