Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49ad6306ea | |||
| 9d2e70f81e | |||
| 370bbb491f | |||
| f0387233e4 | |||
| 081d92b9f4 | |||
| 143278a368 | |||
| 2802f9524c | |||
| 589018064b | |||
| 9cc5024e25 | |||
| b6c0dfb90b | |||
| 9dff25d61a | |||
| 6f9d19bda3 | |||
| 2745f4e476 | |||
| 1edb8d956f | |||
| c01e1f89a7 | |||
| ac8a36eb4f |
@@ -35,6 +35,7 @@
|
|||||||
- 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`.
|
||||||
- **É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 +66,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
|
||||||
@@ -90,6 +92,21 @@
|
|||||||
- Sur un exercice passé, le bouton **+ Payer les RTT** est désactivé (pas de paiement rétroactif).
|
- Sur un exercice passé, le bouton **+ Payer les RTT** est désactivé (pas de paiement rétroactif).
|
||||||
- Doc : `doc/rtt-tab.md`.
|
- Doc : `doc/rtt-tab.md`.
|
||||||
|
|
||||||
|
## Rollover RTT (cron `app:rtt:rollover`)
|
||||||
|
- Bascule le **1er juin** (idempotente) : crée la ligne `employee_rtt_balances` du nouvel exercice (`targetYear`) pour chaque employé éligible (ni INTERIM, ni PRESENCE).
|
||||||
|
- **Report = solde de clôture de l'exercice N-1**, pas seulement l'acquis : `report_ouverture(N-1) + acquis(N-1) − RTT payés(N-1)`. C'est exactement le **disponible** affiché par `EmployeeRttSummaryProvider` (`carry + currentYearRecovery − totalPaid`). Le report stocké pour N reprend donc le disponible de fin N-1 ; le report déjà présent en début d'année n'est jamais perdu, et les heures payées ne sont pas re-créditées.
|
||||||
|
- 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`.
|
||||||
|
- ⚠️ 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`.
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
## 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`.
|
||||||
@@ -109,7 +126,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.105'
|
app.version: '0.1.113'
|
||||||
|
|||||||
+17
-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,9 +374,9 @@ 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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+18
-3
@@ -79,15 +79,30 @@ Commande quotidienne (cron) idempotente.
|
|||||||
- le `01/06`: calcule et persiste le report pour chaque employe eligible
|
- le `01/06`: calcule et persiste le report pour chaque employe eligible
|
||||||
- les autres jours: sortie sans action
|
- les autres jours: sortie sans action
|
||||||
- option manuelle: `--force` pour executer hors date metier (reprise/correction)
|
- option manuelle: `--force` pour executer hors date metier (reprise/correction)
|
||||||
|
- option manuelle: `--recompute` pour recalculer et **ecraser** les lignes existantes au lieu de les sauter (reprise apres correction). Les lignes verrouillees (`is_locked = true`, validees RH) ne sont jamais ecrasees.
|
||||||
|
|
||||||
Date d'effet:
|
Date d'effet:
|
||||||
- au `1er juin` (meme date que le rollover conges non forfait)
|
- au `1er juin` (meme date que le rollover conges non forfait)
|
||||||
|
|
||||||
Traitement par employe:
|
Traitement par employe:
|
||||||
1. verifier l'eligibilite (ni INTERIM, ni suivi PRESENCE)
|
1. verifier l'eligibilite (ni INTERIM, ni suivi PRESENCE)
|
||||||
2. verifier qu'aucune ligne n'existe deja pour `(employee, targetYear)` (idempotence)
|
2. en mode normal: si une ligne existe deja pour `(employee, targetYear)`, la sauter (idempotence). En mode `--recompute`: la recalculer, sauf si elle est verrouillee.
|
||||||
3. calculer la somme des minutes de recuperation de l'exercice N-1
|
3. calculer le **solde de cloture** de l'exercice N-1 (= disponible affiche en fin d'exercice) :
|
||||||
4. creer la ligne du nouvel exercice avec ce total en `opening_minutes`
|
`report d'ouverture N-1 + acquis N-1 − RTT payes N-1`
|
||||||
|
- le **report d'ouverture N-1** vient de la ligne `employee_rtt_balances` de l'exercice N-1 (import go-live ou rollover precedent) ; a defaut, calcul dynamique des acquis de N-2.
|
||||||
|
- l'**acquis N-1** = somme des minutes de recuperation hebdomadaires de l'exercice N-1.
|
||||||
|
- les **RTT payes N-1** (`employee_rtt_payments`) sont deduits.
|
||||||
|
4. creer (ou mettre a jour) la ligne du nouvel exercice avec ce solde, reparti sur les 4 tranches `opening_base25/bonus25/base50/bonus50`.
|
||||||
|
|
||||||
|
> 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`.
|
||||||
|
|
||||||
## 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. ✓
|
||||||
@@ -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,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>
|
||||||
@@ -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.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -523,7 +534,10 @@ export const documentationSections: DocSection[] = [
|
|||||||
blocks: [
|
blocks: [
|
||||||
{ type: 'list', content: 'Report N-1 : solde de l\'exercice précédent\nAcquis : cumul des heures supplémentaires de l\'exercice en cours\nDisponible : report + acquis − payé\nPayé : RTT convertis en salaire (soustraits du disponible)' },
|
{ type: 'list', content: 'Report N-1 : solde de l\'exercice précédent\nAcquis : cumul des heures supplémentaires de l\'exercice en cours\nDisponible : report + acquis − payé\nPayé : RTT convertis en salaire (soustraits du disponible)' },
|
||||||
{ 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: '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à.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -533,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.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use App\Enum\ContractType;
|
|||||||
use App\Enum\TrackingMode;
|
use App\Enum\TrackingMode;
|
||||||
use App\Repository\EmployeeRepository;
|
use App\Repository\EmployeeRepository;
|
||||||
use App\Repository\EmployeeRttBalanceRepository;
|
use App\Repository\EmployeeRttBalanceRepository;
|
||||||
use App\Service\Rtt\RttRecoveryComputationService;
|
use App\Service\Rtt\RttClosingBalanceService;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
@@ -32,7 +32,7 @@ final class RttRolloverCommand extends Command
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly EmployeeRepository $employeeRepository,
|
private readonly EmployeeRepository $employeeRepository,
|
||||||
private readonly EmployeeRttBalanceRepository $rttBalanceRepository,
|
private readonly EmployeeRttBalanceRepository $rttBalanceRepository,
|
||||||
private readonly RttRecoveryComputationService $rttRecoveryService,
|
private readonly RttClosingBalanceService $rttClosingService,
|
||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
#[Autowire(service: 'monolog.logger.cron')]
|
#[Autowire(service: 'monolog.logger.cron')]
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
@@ -48,15 +48,22 @@ final class RttRolloverCommand extends Command
|
|||||||
InputOption::VALUE_NONE,
|
InputOption::VALUE_NONE,
|
||||||
'Run rollover regardless of business date (manual recovery mode).'
|
'Run rollover regardless of business date (manual recovery mode).'
|
||||||
);
|
);
|
||||||
|
$this->addOption(
|
||||||
|
'recompute',
|
||||||
|
null,
|
||||||
|
InputOption::VALUE_NONE,
|
||||||
|
'Recompute and overwrite existing (non-locked) balances instead of skipping them.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
{
|
{
|
||||||
$io = new SymfonyStyle($input, $output);
|
$io = new SymfonyStyle($input, $output);
|
||||||
$today = new DateTimeImmutable('today');
|
$today = new DateTimeImmutable('today');
|
||||||
$force = (bool) $input->getOption('force');
|
$force = (bool) $input->getOption('force');
|
||||||
|
$recompute = (bool) $input->getOption('recompute');
|
||||||
|
|
||||||
$this->logger->info('app:rtt:rollover started.', ['date' => $today->format('Y-m-d'), 'force' => $force]);
|
$this->logger->info('app:rtt:rollover started.', ['date' => $today->format('Y-m-d'), 'force' => $force, 'recompute' => $recompute]);
|
||||||
|
|
||||||
if (!$force && '06-01' !== $today->format('m-d')) {
|
if (!$force && '06-01' !== $today->format('m-d')) {
|
||||||
$message = 'No RTT rollover today: business date is not 01/06.';
|
$message = 'No RTT rollover today: business date is not 01/06.';
|
||||||
@@ -68,6 +75,7 @@ final class RttRolloverCommand extends Command
|
|||||||
|
|
||||||
$targetYear = $this->resolveTargetYear($today);
|
$targetYear = $this->resolveTargetYear($today);
|
||||||
$created = 0;
|
$created = 0;
|
||||||
|
$updated = 0;
|
||||||
$skipped = 0;
|
$skipped = 0;
|
||||||
|
|
||||||
foreach ($this->employeeRepository->findAll() as $employee) {
|
foreach ($this->employeeRepository->findAll() as $employee) {
|
||||||
@@ -83,36 +91,53 @@ final class RttRolloverCommand extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
$existing = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $targetYear);
|
$existing = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $targetYear);
|
||||||
if (null !== $existing) {
|
if (null !== $existing && !$recompute) {
|
||||||
$this->logger->info('Employee skipped: balance already exists.', ['employeeId' => $employee->getId(), 'year' => $targetYear]);
|
$this->logger->info('Employee skipped: balance already exists.', ['employeeId' => $employee->getId(), 'year' => $targetYear]);
|
||||||
++$skipped;
|
++$skipped;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (null !== $existing && $existing->isLocked()) {
|
||||||
|
// Never overwrite a balance an RH user has validated/frozen.
|
||||||
|
$this->logger->info('Employee skipped: balance is locked.', ['employeeId' => $employee->getId(), 'year' => $targetYear]);
|
||||||
|
++$skipped;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$previousYear = $targetYear - 1;
|
$previousYear = $targetYear - 1;
|
||||||
$carry = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $previousYear);
|
// Closing of the previous exercise = opening report + earned − paid.
|
||||||
|
$closing = $this->rttClosingService->computeClosingBalance($employee, $previousYear);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$this->logger->error('Error computing carry for employee.', ['employeeId' => $employee->getId(), 'error' => $e->getMessage()]);
|
$this->logger->error('Error computing closing balance for employee.', ['employeeId' => $employee->getId(), 'error' => $e->getMessage()]);
|
||||||
++$skipped;
|
++$skipped;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$balance = new EmployeeRttBalance()
|
$balance = $existing ?? new EmployeeRttBalance()
|
||||||
->setEmployee($employee)
|
->setEmployee($employee)
|
||||||
->setYear($targetYear)
|
->setYear($targetYear)
|
||||||
->setOpeningBase25Minutes($carry->base25Minutes)
|
|
||||||
->setOpeningBonus25Minutes($carry->bonus25Minutes)
|
|
||||||
->setOpeningBase50Minutes($carry->base50Minutes)
|
|
||||||
->setOpeningBonus50Minutes($carry->bonus50Minutes)
|
|
||||||
->setIsLocked(false)
|
->setIsLocked(false)
|
||||||
;
|
;
|
||||||
|
|
||||||
$this->entityManager->persist($balance);
|
$balance
|
||||||
$this->logger->info('Balance created.', ['employeeId' => $employee->getId(), 'year' => $targetYear, 'carryMinutes' => $carry->totalMinutes]);
|
->setOpeningBase25Minutes($closing->base25Minutes)
|
||||||
++$created;
|
->setOpeningBonus25Minutes($closing->bonus25Minutes)
|
||||||
|
->setOpeningBase50Minutes($closing->base50Minutes)
|
||||||
|
->setOpeningBonus50Minutes($closing->bonus50Minutes)
|
||||||
|
;
|
||||||
|
|
||||||
|
if (null === $existing) {
|
||||||
|
$this->entityManager->persist($balance);
|
||||||
|
$this->logger->info('Balance created.', ['employeeId' => $employee->getId(), 'year' => $targetYear, 'carryMinutes' => $closing->totalMinutes]);
|
||||||
|
++$created;
|
||||||
|
} else {
|
||||||
|
$balance->touch();
|
||||||
|
$this->logger->info('Balance recomputed.', ['employeeId' => $employee->getId(), 'year' => $targetYear, 'carryMinutes' => $closing->totalMinutes]);
|
||||||
|
++$updated;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -124,7 +149,7 @@ final class RttRolloverCommand extends Command
|
|||||||
return Command::FAILURE;
|
return Command::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
$message = sprintf('RTT rollover done: %d created, %d skipped.', $created, $skipped);
|
$message = sprintf('RTT rollover done: %d created, %d recomputed, %d skipped.', $created, $updated, $skipped);
|
||||||
$this->logger->info($message);
|
$this->logger->info($message);
|
||||||
$io->success($message);
|
$io->success($message);
|
||||||
|
|
||||||
|
|||||||
@@ -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,164 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Rtt;
|
||||||
|
|
||||||
|
use App\Dto\Rtt\WeekRecoveryDetail;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Repository\EmployeeRttBalanceRepository;
|
||||||
|
use App\Repository\EmployeeRttPaymentRepository;
|
||||||
|
use App\State\EmployeeRttSummaryProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the closing RTT balance of an exercise — the amount that must become the
|
||||||
|
* opening report of the next exercise.
|
||||||
|
*
|
||||||
|
* Closing = opening report (N) + net earned (N) − RTT paid (N).
|
||||||
|
*
|
||||||
|
* This mirrors the "disponible" exposed by {@see EmployeeRttSummaryProvider}
|
||||||
|
* (carry + currentYearRecovery − totalPaid), so the report carried to N+1 always equals
|
||||||
|
* the balance the RTT tab displayed for N. The previous rollover only took the earned
|
||||||
|
* minutes and dropped both the incoming report and the payments.
|
||||||
|
*/
|
||||||
|
final readonly class RttClosingBalanceService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private RttRecoveryComputationService $recoveryService,
|
||||||
|
private EmployeeRttBalanceRepository $balanceRepository,
|
||||||
|
private EmployeeRttPaymentRepository $paymentRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function computeClosingBalance(Employee $employee, int $exerciseYear): WeekRecoveryDetail
|
||||||
|
{
|
||||||
|
[$from, $to] = $this->recoveryService->resolveExerciseBounds($exerciseYear);
|
||||||
|
$weeks = $this->recoveryService->buildWeeksForExercise($from, $to);
|
||||||
|
$weekRanges = array_map(
|
||||||
|
static fn (array $week): array => [
|
||||||
|
'weekNumber' => (int) $week['weekNumber'],
|
||||||
|
'start' => $week['start'],
|
||||||
|
'end' => $week['end'],
|
||||||
|
],
|
||||||
|
$weeks
|
||||||
|
);
|
||||||
|
|
||||||
|
// The exercise is fully closed at rollover time, so count every week up to its end.
|
||||||
|
$byWeek = $this->recoveryService->computeRecoveryByWeek($employee, $weekRanges, $from, $to, $to);
|
||||||
|
|
||||||
|
$orderedDetails = [];
|
||||||
|
foreach ($weekRanges as $week) {
|
||||||
|
$key = $week['start']->format('Y-m-d');
|
||||||
|
$orderedDetails[] = $byWeek[$key] ?? new WeekRecoveryDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
$opening = $this->resolveOpeningReport($employee, $exerciseYear);
|
||||||
|
$payments = $this->sumPayments($employee, $exerciseYear);
|
||||||
|
|
||||||
|
return $this->fold($opening, $orderedDetails, $payments);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure accumulation of the closing balance per bucket.
|
||||||
|
*
|
||||||
|
* Guarantees `sum(buckets) === opening.total + Σ week.total − payments.total`,
|
||||||
|
* i.e. the carried report matches the displayed disponible regardless of how the
|
||||||
|
* deficit cascade or the custom-recovery remainder is distributed across buckets.
|
||||||
|
*
|
||||||
|
* @param list<WeekRecoveryDetail> $weeks chronological order
|
||||||
|
*/
|
||||||
|
public function fold(WeekRecoveryDetail $opening, array $weeks, WeekRecoveryDetail $payments): WeekRecoveryDetail
|
||||||
|
{
|
||||||
|
$b25 = $opening->base25Minutes;
|
||||||
|
$bo25 = $opening->bonus25Minutes;
|
||||||
|
$b50 = $opening->base50Minutes;
|
||||||
|
$bo50 = $opening->bonus50Minutes;
|
||||||
|
|
||||||
|
foreach ($weeks as $week) {
|
||||||
|
if ($week->totalMinutes >= 0) {
|
||||||
|
$b25 += $week->base25Minutes;
|
||||||
|
$bo25 += $week->bonus25Minutes;
|
||||||
|
$b50 += $week->base50Minutes;
|
||||||
|
$bo50 += $week->bonus50Minutes;
|
||||||
|
|
||||||
|
// Recovery not attributed to any 25/50 bucket (CUSTOM 1h=1h, rounding):
|
||||||
|
// park it in the plain 25%-base bucket so the bucket sum keeps the total.
|
||||||
|
$remainder = $week->totalMinutes
|
||||||
|
- ($week->base25Minutes + $week->bonus25Minutes + $week->base50Minutes + $week->bonus50Minutes);
|
||||||
|
$b25 += $remainder;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deficit week: drain the 50%-tier before the 25%-tier (mirrors
|
||||||
|
// EmployeeRttSummaryProvider's cumulative cascade).
|
||||||
|
$deficit = -$week->totalMinutes;
|
||||||
|
[$b50, $deficit] = $this->consume($b50, $deficit);
|
||||||
|
[$bo50, $deficit] = $this->consume($bo50, $deficit);
|
||||||
|
[$b25, $deficit] = $this->consume($b25, $deficit);
|
||||||
|
$bo25 -= $deficit; // leftover may push the balance negative, as on screen
|
||||||
|
}
|
||||||
|
|
||||||
|
$b25 -= $payments->base25Minutes;
|
||||||
|
$bo25 -= $payments->bonus25Minutes;
|
||||||
|
$b50 -= $payments->base50Minutes;
|
||||||
|
$bo50 -= $payments->bonus50Minutes;
|
||||||
|
|
||||||
|
return new WeekRecoveryDetail(
|
||||||
|
base25Minutes: $b25,
|
||||||
|
bonus25Minutes: $bo25,
|
||||||
|
base50Minutes: $b50,
|
||||||
|
bonus50Minutes: $bo50,
|
||||||
|
totalMinutes: $b25 + $bo25 + $b50 + $bo50,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The opening report of $year: the stored balance row when present, else the
|
||||||
|
* dynamic fallback (earned in $year-1). Same resolution as
|
||||||
|
* EmployeeRttSummaryProvider::resolveCarry.
|
||||||
|
*/
|
||||||
|
private function resolveOpeningReport(Employee $employee, int $year): WeekRecoveryDetail
|
||||||
|
{
|
||||||
|
$balance = $this->balanceRepository->findOneByEmployeeAndYear($employee, $year);
|
||||||
|
if (null !== $balance) {
|
||||||
|
return new WeekRecoveryDetail(
|
||||||
|
base25Minutes: $balance->getOpeningBase25Minutes(),
|
||||||
|
bonus25Minutes: $balance->getOpeningBonus25Minutes(),
|
||||||
|
base50Minutes: $balance->getOpeningBase50Minutes(),
|
||||||
|
bonus50Minutes: $balance->getOpeningBonus50Minutes(),
|
||||||
|
totalMinutes: $balance->getTotalOpeningMinutes(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->recoveryService->computeTotalRecoveryForExercise($employee, $year - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sumPayments(Employee $employee, int $year): WeekRecoveryDetail
|
||||||
|
{
|
||||||
|
$b25 = $bo25 = $b50 = $bo50 = 0;
|
||||||
|
foreach ($this->paymentRepository->findByEmployeeAndYear($employee, $year) as $payment) {
|
||||||
|
$b25 += $payment->getBase25Minutes();
|
||||||
|
$bo25 += $payment->getBonus25Minutes();
|
||||||
|
$b50 += $payment->getBase50Minutes();
|
||||||
|
$bo50 += $payment->getBonus50Minutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WeekRecoveryDetail(
|
||||||
|
base25Minutes: $b25,
|
||||||
|
bonus25Minutes: $bo25,
|
||||||
|
base50Minutes: $b50,
|
||||||
|
bonus50Minutes: $bo50,
|
||||||
|
totalMinutes: $b25 + $bo25 + $b50 + $bo50,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{int, int} [remaining bucket, remaining deficit]
|
||||||
|
*/
|
||||||
|
private function consume(int $bucket, int $deficit): array
|
||||||
|
{
|
||||||
|
$take = min($deficit, max(0, $bucket));
|
||||||
|
|
||||||
|
return [$bucket - $take, $deficit - $take];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ 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,
|
||||||
string $rttStartDate = '',
|
string $rttStartDate = '',
|
||||||
) {
|
) {
|
||||||
$this->rttStartDate = '' !== $rttStartDate ? new DateTimeImmutable($rttStartDate) : null;
|
$this->rttStartDate = '' !== $rttStartDate ? new DateTimeImmutable($rttStartDate) : null;
|
||||||
@@ -162,7 +163,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 +237,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 +246,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;
|
||||||
@@ -415,6 +478,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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,8 +22,8 @@ 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,
|
||||||
@@ -103,6 +103,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 +300,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 +327,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 +340,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,
|
||||||
|
|||||||
@@ -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,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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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>
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service\Rtt;
|
||||||
|
|
||||||
|
use App\Dto\Rtt\WeekRecoveryDetail;
|
||||||
|
use App\Service\Rtt\RttClosingBalanceService;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use ReflectionClass;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The service constructor takes final-class collaborators (repositories,
|
||||||
|
* RttRecoveryComputationService) that PHPUnit cannot double. The fold logic is
|
||||||
|
* pure (no $this dependency), so it is exercised via newInstanceWithoutConstructor.
|
||||||
|
*
|
||||||
|
* Invariant under test: the bucket sum of the closing balance ALWAYS equals
|
||||||
|
* opening_report + net_earned - paid
|
||||||
|
* which is exactly the "disponible" the RTT tab shows for that exercise — so the
|
||||||
|
* report carried to the next exercise matches the displayed balance.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class RttClosingBalanceServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testOpeningReportIsCarriedForwardOnTopOfEarned(): void
|
||||||
|
{
|
||||||
|
// Regression for the reported bug: the previous exercise's opening report
|
||||||
|
// (e.g. go-live import or unused carry) must be included, not dropped.
|
||||||
|
$opening = new WeekRecoveryDetail(base25Minutes: 600, totalMinutes: 600); // 10h report
|
||||||
|
$week = new WeekRecoveryDetail(base25Minutes: 240, bonus25Minutes: 60, totalMinutes: 300); // +5h earned
|
||||||
|
|
||||||
|
$closing = $this->service()->fold($opening, [$week], $this->payments());
|
||||||
|
|
||||||
|
// 10h report + 5h earned = 15h carried (NOT 5h).
|
||||||
|
self::assertSame(900, $closing->totalMinutes);
|
||||||
|
self::assertSame(600 + 240, $closing->base25Minutes);
|
||||||
|
self::assertSame(60, $closing->bonus25Minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPaymentsAreDeductedFromClosing(): void
|
||||||
|
{
|
||||||
|
$opening = new WeekRecoveryDetail(base25Minutes: 600, totalMinutes: 600);
|
||||||
|
$week = new WeekRecoveryDetail(base25Minutes: 240, bonus25Minutes: 60, totalMinutes: 300);
|
||||||
|
|
||||||
|
// 7h paid out of the 25% base bucket.
|
||||||
|
$closing = $this->service()->fold($opening, [$week], $this->payments(b25: 420));
|
||||||
|
|
||||||
|
self::assertSame(900 - 420, $closing->totalMinutes);
|
||||||
|
self::assertSame(600 + 240 - 420, $closing->base25Minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeficitWeekConsumesFiftyTierBeforeTwentyFiveTier(): void
|
||||||
|
{
|
||||||
|
// Opening: 60min in 50%-base, 120min in 25%-base.
|
||||||
|
$opening = new WeekRecoveryDetail(base25Minutes: 120, base50Minutes: 60, totalMinutes: 180);
|
||||||
|
// Deficit week of 100min (worked less than reference): buckets 0, negative total.
|
||||||
|
$deficit = new WeekRecoveryDetail(totalMinutes: -100);
|
||||||
|
|
||||||
|
$closing = $this->service()->fold($opening, [$deficit], $this->payments());
|
||||||
|
|
||||||
|
// 50%-base absorbs 60 first, the remaining 40 hits the 25%-base.
|
||||||
|
self::assertSame(0, $closing->base50Minutes);
|
||||||
|
self::assertSame(80, $closing->base25Minutes);
|
||||||
|
self::assertSame(80, $closing->totalMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCustomRecoveryWithoutBucketsStillCountsInTotal(): void
|
||||||
|
{
|
||||||
|
// CUSTOM contract: positive total recovery (1h=1h) but every 25/50 bucket is 0.
|
||||||
|
$custom = new WeekRecoveryDetail(totalMinutes: 180); // 3h plain recovery
|
||||||
|
|
||||||
|
$closing = $this->service()->fold(new WeekRecoveryDetail(), [$custom], $this->payments());
|
||||||
|
|
||||||
|
// The 3h must survive into the carried report (sum of buckets == total).
|
||||||
|
self::assertSame(180, $closing->totalMinutes);
|
||||||
|
self::assertSame(
|
||||||
|
180,
|
||||||
|
$closing->base25Minutes + $closing->bonus25Minutes + $closing->base50Minutes + $closing->bonus50Minutes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
$opening = new WeekRecoveryDetail(base25Minutes: 200, bonus25Minutes: 50, base50Minutes: 100, bonus50Minutes: 50, totalMinutes: 400);
|
||||||
|
$weeks = [
|
||||||
|
new WeekRecoveryDetail(base25Minutes: 240, bonus25Minutes: 60, totalMinutes: 300),
|
||||||
|
new WeekRecoveryDetail(totalMinutes: -500), // deeper deficit than tiers hold
|
||||||
|
new WeekRecoveryDetail(totalMinutes: 90), // custom-style recovery
|
||||||
|
];
|
||||||
|
|
||||||
|
$closing = $this->service()->fold($opening, $weeks, $this->payments(b25: 120, b50: 30));
|
||||||
|
|
||||||
|
$bucketSum = $closing->base25Minutes + $closing->bonus25Minutes + $closing->base50Minutes + $closing->bonus50Minutes;
|
||||||
|
self::assertSame($closing->totalMinutes, $bucketSum);
|
||||||
|
// opening 400 + earned (300 - 500 + 90 = -110) - paid 150 = 140
|
||||||
|
self::assertSame(140, $closing->totalMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function service(): RttClosingBalanceService
|
||||||
|
{
|
||||||
|
return new ReflectionClass(RttClosingBalanceService::class)->newInstanceWithoutConstructor();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function payments(int $b25 = 0, int $bo25 = 0, int $b50 = 0, int $bo50 = 0): WeekRecoveryDetail
|
||||||
|
{
|
||||||
|
return new WeekRecoveryDetail(
|
||||||
|
base25Minutes: $b25,
|
||||||
|
bonus25Minutes: $bo25,
|
||||||
|
base50Minutes: $b50,
|
||||||
|
bonus50Minutes: $bo50,
|
||||||
|
totalMinutes: $b25 + $bo25 + $b50 + $bo50,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,114 @@
|
|||||||
|
<?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\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,
|
||||||
|
);
|
||||||
|
|
||||||
|
$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;
|
||||||
@@ -156,8 +157,11 @@ final class EmployeeRttSummaryProviderTest extends TestCase
|
|||||||
$provider = $this->buildProvider([]);
|
$provider = $this->buildProvider([]);
|
||||||
$year = $this->invokePrivate($provider, 'resolveYear', $currentPhase);
|
$year = $this->invokePrivate($provider, 'resolveYear', $currentPhase);
|
||||||
|
|
||||||
// Today is 2026-05-19 → current RTT exercise (Juin N-1 → Mai N) = 2026.
|
// No params → current RTT exercise (Juin N-1 → Mai N). Derive the expectation
|
||||||
self::assertSame(2026, $year);
|
// from today so the test is not pinned to a single calendar date.
|
||||||
|
$today = new DateTimeImmutable('today');
|
||||||
|
$expected = (int) $today->format('n') >= 6 ? (int) $today->format('Y') + 1 : (int) $today->format('Y');
|
||||||
|
self::assertSame($expected, $year);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testInvalidYearFormatReturns422(): void
|
public function testInvalidYearFormatReturns422(): void
|
||||||
@@ -198,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.
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -244,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.
|
||||||
*
|
*
|
||||||
@@ -253,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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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