Compare commits

..

23 Commits

Author SHA1 Message Date
gitea-actions 49ad6306ea chore: bump version to v0.1.113
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 1m9s
2026-06-11 08:46:19 +00:00
tristan 9d2e70f81e feat(recap-conges) : cutoff S-1 au lieu de S-2
Auto Tag Develop / tag (push) Successful in 13s
Le récap des congés se fige désormais à la fin de la semaine précédente
(S-1) au lieu de l'avant-dernière (S-2), incluant ainsi une semaine de
plus. Demande métier.

- LeaveRecapCutoff : -14j -> -7j
- Test unitaire figeant la règle S-1
- Doc fonctionnelle, doc in-app et CLAUDE.md mis à jour

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:45:33 +02:00
gitea-actions 370bbb491f chore: bump version to v0.1.112
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Failing after 28s
2026-06-11 08:37:05 +00:00
tristan f0387233e4 [#SIRH-36] corriger calcule rtt contrat custom (#27)
Auto Tag Develop / tag (push) Successful in 7s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #27
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-11 08:36:57 +00:00
gitea-actions 081d92b9f4 chore: bump version to v0.1.111
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 3m38s
2026-06-11 06:36:10 +00:00
tristan 143278a368 feat(heures) : export PDF jour accessible aux chefs de site (périmètre par site)
Auto Tag Develop / tag (push) Successful in 10s
L'export des heures de la vue Jour était réservé aux admins. Il est désormais
ouvert aux chefs de site, restreint à leurs sites :
- sécurité endpoint ROLE_ADMIN -> ROLE_USER
- périmètre résolu côté backend via EmployeeRepository::findScoped() (un siteIds
  hors périmètre est ignoré, aucune fuite inter-sites)
- bouton Exporter visible pour admin + chef de site (masqué pour ROLE_SELF)
- doc, doc in-app et CLAUDE.md mis à jour

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:31:30 +02:00
gitea-actions 2802f9524c chore: bump version to v0.1.110
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 33s
2026-06-09 15:35:39 +00:00
tristan 589018064b feat(heures) : tri des employés par ordre manuel sur l'export PDF jour (#26)
Auto Tag Develop / tag (push) Successful in 7s
Le tri intra-site de l'export PDF des heures (vue Jour) reprend désormais celui du calendrier : **`displayOrder` (ordre manuel) → nom → prénom**, au lieu du nom seul.

`doc/` (CLAUDE.md) mis à jour. Tests backend verts (173/361).

Reviewed-on: #26
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-09 15:35:32 +00:00
gitea-actions 9cc5024e25 chore: bump version to v0.1.109
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 36s
2026-06-09 14:04:56 +00:00
tristan b6c0dfb90b feat(heures) : codes d'absence, total en gras et légende sur l'export PDF jour (#25)
Auto Tag Develop / tag (push) Successful in 7s
Affinements de l'export PDF des heures (vue Jour) :

- **Colonne Statut** : affiche le **code** du type d'absence (ex. `AT`) au lieu du libellé, sur sa couleur de fond. Férié sans absence inchangé (nom du férié sur fond bleu clair).
- **Colonne Total** en gras.
- **Légende** sous le tableau : carré coloré contenant le code + libellé à droite, 6 éléments par ligne, triée et dédupliquée (hors férié).
- **Bouton Exporter masqué en vue Semaine** (visible uniquement en vue Jour).

Docs mises à jour : `doc/hours-day-export.md`, `frontend/data/documentation-content.ts`, `CLAUDE.md`. Tests backend verts (173/361).

Reviewed-on: #25
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-09 14:04:50 +00:00
gitea-actions 9dff25d61a chore: bump version to v0.1.108
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 52s
2026-06-09 12:59:10 +00:00
tristan 6f9d19bda3 feat(heures) : export PDF des heures (vue jour) par sites (#24)
Auto Tag Develop / tag (push) Successful in 7s
## Résumé
Ajoute un bouton **Exporter** (admin uniquement) à droite du titre « Heures » qui génère un **PDF d'une journée**, regroupé par site, reprenant les colonnes de la vue Jour **sans la colonne « Valider »**.

- Drawer : champ date (préremplit la date affichée) + cases à cocher des sites (préselectionnées sur le filtre courant).
- Portée identique à l'écran : non-conducteurs, sous contrat à la date, sites cochés (lignes vides incluses).
- Jour/Nuit/Total incluent le crédit d'absence et le crédit virtuel férié.

## Implémentation
- Back : `WorkHourDayExport` (ApiResource) + `WorkHourDayExportProvider`, endpoint `GET /work-hours/day-export?workDate=&siteIds=` (ROLE_ADMIN).
- Calcul des cellules mutualisé via `YearlyHoursExportBuilder::buildDayRowsForEmployees` (source unique de vérité).
- Gabarit `templates/work-hour-day-export/print.html.twig` (A4 portrait compact).
- Front : `HoursDayExportDrawer.vue` + câblage dans `pages/hours.vue`.
- Docs : `doc/hours-day-export.md`, `documentation-content.ts`, `CLAUDE.md`.

## Tests
- Test unitaire `YearlyHoursDayRowsTest` ajouté.
- Suite complète verte : 173 tests, 359 assertions.

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

Reviewed-on: #24
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-09 12:59:04 +00:00
gitea-actions 2745f4e476 chore: bump version to v0.1.107
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 34s
2026-06-08 13:27:41 +00:00
tristan 1edb8d956f feat(rtt) : paiement RTT rétroactif sur l'exercice précédent (#23)
Auto Tag Develop / tag (push) Successful in 7s
## Besoin RH
Pouvoir saisir un paiement RTT sur l'exercice précédent (ex. RTT de mai réglés après la bascule du 1er juin).

## Implémentation (Option B)
- Paiement autorisé sur l'exercice courant + l'exercice immédiatement précédent (N-1).
- Après saisie sur N-1, le report d'ouverture de l'exercice courant est recalculé automatiquement (computeClosingBalance) dans une transaction → aucun double comptage.
- Refus si ce report est verrouillé (is_locked) : la RH le déverrouille d'abord.
- Fallback EmployeeRttSummaryProvider::resolveCarry aligné sur computeClosingBalance : disponible correct même sans ligne stockée.
- Front : bouton « + Payer les RTT » actif sur l'exercice précédent.
- Docs : CLAUDE.md, doc/rtt-tab.md, documentation-content.ts.

## Vérification
-  172 tests OK, cs-fixer OK, conteneur compile.

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

Reviewed-on: #23
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-08 13:27:34 +00:00
gitea-actions c01e1f89a7 chore: bump version to v0.1.106
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 34s
2026-06-08 08:56:27 +00:00
tristan ac8a36eb4f [#SIRH-34] fix RTT bascule ne fonctionne pas (#22)
Auto Tag Develop / tag (push) Successful in 7s
La bascule app:rtt:rollover ne reprenait que les RTT acquis de l'exercice qui
se terminait : le report d'ouverture déjà présent était perdu et les paiements
n'étaient pas déduits. Le nouveau report reprend le solde de clôture =
report d'ouverture(N-1) + acquis(N-1) − RTT payés(N-1), soit le "Disponible"
affiché par EmployeeRttSummaryProvider.

- nouveau RttClosingBalanceService (fold pur testé : invariant somme tranches =
  disponible, cascade déficit 50% avant 25%, récup CUSTOM non perdue)
- RttRolloverCommand branché dessus + option --recompute (écrase les lignes
  existantes non verrouillées, pour reprise d'une bascule erronée)
- test date-sensible EmployeeRttSummaryProviderTest rendu robuste
- docs: doc/rtt-rollover.md, CLAUDE.md, documentation-content.ts

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #22
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-08 08:56:19 +00:00
gitea-actions 3bf48164d2 chore: bump version to v0.1.105
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 53s
2026-06-08 08:18:50 +00:00
tristan 516ba45794 Merge remote-tracking branch 'origin/develop' into develop
Auto Tag Develop / tag (push) Successful in 7s
2026-06-08 10:18:41 +02:00
gitea-actions 97557ae284 chore: bump version to v0.1.104
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 1m2s
2026-06-02 06:26:50 +00:00
tristan a41bd632cf Retour RH: vue jour par date, RTT mi-semaine, récap salaire & exports, panier de nuit (#21)
Auto Tag Develop / tag (push) Successful in 11s
## Correctifs RH (branche fix/retour-rh)

### Vue Jour (Heures)
- Mode saisie/présence, libellé de contrat et sauvegarde résolus **à la date affichée** (et non au contrat courant). Corrige les salariés passés 39h/35h → Forfait.

### RTT — heures supplémentaires
- Proratisation du **plafond 25%/50%** pour les embauches en milieu de semaine (la bande +25% se décale au lieu de rester bloquée à 43h). Témoin Dylan : 4h à 25% + 3h à 50%.

### Récap salaire (PDF mensuel)
- Forfait : congés imputés **N-1** non affichés et comptés en présence.
- Colonne « Heures payés » **scindée 25% / 50%** (en-tête fusionné).
- **Exclusion des salariés sans contrat** sur le mois (ex. Marine, contrat terminé).

### Exports heures annuelles (par salarié + tous)
- **Tous les jours sous contrat** affichés, même vides/non saisis (corrige les lignes manquantes).
- Samedis/dimanches en **gris plus foncé**.

### Panier de nuit
- **Ne s'applique pas aux conducteurs** (vue semaine + récap salaire).

## Tests
- 11 tests ajoutés. Suite verte hors un test legacy pré-existant dépendant de la date (`EmployeeRttSummaryProviderTest::testNoQueryParamsKeepsLegacyYearDefaulting`, non modifié par cette branche).

## À noter (hors scope)
- L'export heures annuelles *tous salariés* peut dépasser `memory_limit=256M` (Dompdf) — limitation **pré-existante**, non corrigée ici.

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

Reviewed-on: #21
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-02 06:26:40 +00:00
tristan 387cff2293 docs: spec vue jour résolution contrat à la date affichée
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:37:26 +02:00
gitea-actions 8b34a429cc chore: bump version to v0.1.103
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 45s
2026-05-26 14:09:11 +00:00
tristan cf2e12c8ba [#SIRH-32] Ajouter l'exercice 2026/2027 dans les congés/RTT (#20)
Auto Tag Develop / tag (push) Successful in 9s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #20
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-26 14:09:02 +00:00
69 changed files with 6119 additions and 207 deletions
+3
View File
@@ -29,3 +29,6 @@ docker/.env.docker.local
###> lexik/jwt-authentication-bundle ###
/config/jwt/*.pem
###< lexik/jwt-authentication-bundle ###
# Local DB dump (not versioned)
/sirh.sql
+29 -7
View File
@@ -33,12 +33,15 @@
- Contract nature (per period): CDI, CDD, INTERIM
- **Agence d'intérim** (`InterimAgency` entity, table `interim_agencies`): optionnelle sur `EmployeeContractPeriod` quand nature = INTERIM. Pas de CRUD UI — gérée en BDD. API lecture seule `GET /interim_agencies`. Affichée "Intérim (NomAgence)" sur la liste employés et l'historique contrat.
- Employee contract history: `employee_contract_periods`, resolved by `EmployeeContractResolver`
- **Écrans Heures / Heures Conducteurs (vue jour)** : le libellé nature (CDI/CDD/Intérim) sous le nom de l'employé est résolu **à la date filtrée** via `WorkHourDayContext.contractNature` (alimenté par `EmployeeContractResolver::resolveNatureForEmployeeAndDate`), pas via `Employee.currentContractNature` (qui est résolu à aujourd'hui).
- **É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.
- **É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.
- **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.
- **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 with `countAsWorkedHours=true`: credit minutes (TIME) or nothing (PRESENCE)
- Driver periods (`isDriver=true` on `EmployeeContractPeriod`): separate screen `/driver-hours`, uses `dayHoursMinutes`/`nightHoursMinutes` + meal/overnight flags on `WorkHour`
- **Panier de nuit (PN) — conducteurs exclus** : le panier de nuit (règle nuit > jour OU nuit ≥ 4h) **ne s'applique qu'aux non-conducteurs**. Un jour conducteur ne crédite jamais de PN, ni sur la vue semaine (`WorkHourWeeklySummaryProvider`, garde `!$isDateDriver`) ni sur le récap salaire (`SalaryRecapPrintProvider`, bloc `if ($isDriver)` sans incrément). Les conducteurs ont leurs propres primes (PDJ/repas/nuitée).
## Fériés
- Source : API gouv via `PublicHolidayService` (cache 30j)
@@ -63,28 +66,47 @@
## Overtime Rules
- Contracts <= 35h: +25% from 35h to 43h, +50% beyond
- Contracts >= 39h: +25% from 39h to 43h, +50% beyond
- CUSTOM contracts (weeklyHours ≠ 35 and ≠ 39, not INTERIM/FORFAIT): reference = actual contractual hours, no 25%/50% bonuses (1h overtime = 1h recovery), 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%.
- **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
- Driver contracts: RTT uses `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` instead of morning/afternoon/evening time ranges
- FORFAIT weekend/holiday bonus: each weekend or public holiday day worked gives bonus leave (full day if morning+afternoon, 0.5 if only one). Added to acquired days, no cap. PRESENCE mode only.
- **FORFAIT — jours de présence et N-1** : les congés posés et imputés sur le stock N-1 ne décrémentent **pas** les jours de présence affichés (`presenceDaysByMonth` et `presenceDaysToToday`). Implémenté dans `EmployeeLeaveSummaryProvider::computePresenceDaysByMonth` via un budget N-1 (= `previousYearTakenDays`) consommé chronologiquement avant comptage des absences. Pour les non-forfait, ce budget vaut toujours 0 → comportement inchangé.
- **Récap salaire (export PDF mensuel)** : même règle appliquée dans `SalaryRecapPrintProvider` — un congé forfait imputé N-1 n'est ni affiché en colonne congés ni soustrait, et compte comme jour de présence. Le budget N-1 vient de `EmployeeLeaveSummaryProvider::resolvePreviousYearTakenDays()` (méthode publique mutualisée, qui reproduit `provide()` : phase courante + recalcul jours payés, donc **même budget que la fiche employé**). Comme l'export est mensuel, les congés sont chargés depuis le 1er janvier (`findForPrint(yearStart, to)`) et le budget consommé chronologiquement par `splitForfaitCongesByN1()`. Non-forfait ou budget N-1 = 0 → `countAbsencesByCode(['C'])` inchangé.
- **Colonne « Heures payés » scindée 25 %/50 %** : en-tête fusionné (`colspan=2`) + deux sous-colonnes `25%`/`50%` dans le template `salary-recap/print.html.twig`. Données : `paid25Hours` = `base25Minutes`, `paid50Hours` = `base50Minutes` (bases seules, **hors bonus** — total inchangé vs l'ancienne colonne unique). `buildRttPaymentMap` renvoie `['m25','m50']` par employé. Le tableau a désormais 20 colonnes (`colspan` des lignes site/vide ajusté).
- **Jours de présence — borne début de contrat** : `presenceDaysByMonth`/`presenceDaysToToday` sont calculés à partir de `resolveEarliestContractStartWithinRange` (début de contrat dans l'exercice), pas du début d'exercice brut. Évite de compter comme « présents » les jours ouvrés antérieurs à l'embauche pour une entrée en cours d'exercice (ex. CDD : Dylan passait de 43,5 à 246 sans la borne). Sans effet pour un employé présent depuis avant l'exercice ni pour le forfait (déjà capé au début de phase).
## Onglet Congés (fiche employé)
- Calendrier annuel des congés (`frontend/components/employees/LeaveTab.vue`) — période = Janvier→Décembre pour FORFAIT, Juin(N-1)→Mai(N) pour les autres contrats. Règle pilotée par le **contrat courant** (cf. `EmployeeLeaveSummaryProvider::resolveYear`), même quand on consulte une année passée.
- **Sélecteur d'année** en pied de calendrier (zone scrollable, à gauche). Plage : de l'exercice courant jusqu'à `max(floor_contrat, floor_data_start_date)``floor_contrat` = premier exercice avec contrat ouvert (`employee.contractHistory[].startDate`) ; `floor_data_start_date` = exercice contenant `RTT_START_DATE` (env, ex. `2026-02-23` → exercice 2026). Le double plancher empêche de remonter avant la mise en service du logiciel. Format : `2026` pour FORFAIT, `Juin 2025 → Mai 2026` sinon.
- **Sélecteur d'année** en pied de calendrier (zone scrollable, à gauche). Plage : de l'exercice **suivant** (exercice courant + 1 sur une phase ouverte ; exercice de fin de phase si clôturée) jusqu'à `max(floor_contrat, floor_data_start_date)``floor_contrat` = premier exercice avec contrat ouvert (`employee.contractHistory[].startDate`) ; `floor_data_start_date` = exercice contenant `RTT_START_DATE` (env, ex. `2026-02-23` → exercice 2026). Le double plancher empêche de remonter avant la mise en service du logiciel. Format : `2026` pour FORFAIT, `Juin 2025 → Mai 2026` sinon.
- Changement d'année → recharge complète de l'onglet via `useEmployeeLeave.setSelectedLeaveYear(year)` (reload de `getEmployeeLeaveSummary?year=YYYY` + `listAbsences` + `listPublicHolidays`). Backend : filtre `?year=YYYY` validé 2000-2100, et `EmployeeLeaveSummary` expose `dataStartDate` (env `RTT_START_DATE`, injecté via `services.yaml`).
- Sur un exercice passé (`selectedYear !== currentYear`), les boutons crayon **Jours fractionnés** et **Année N-1 payés** sont **désactivés** : pas d'édition rétroactive des stocks de report.
- Sur un exercice **autre que l'exercice courant** (`selectedYear !== currentYear`, passé ou futur), les boutons crayon **Jours fractionnés** et **Année N-1 payés** sont **désactivés** : pas d'édition rétroactive des stocks de report (ni d'édition anticipée sur un exercice futur).
- Doc : `doc/leave-tab.md`.
## Onglet RTT (fiche employé)
- Tableau hebdomadaire (`frontend/components/employees/RttTab.vue`) — exercice fixe Juin(N-1)→Mai(N). Onglet **masqué pour les FORFAIT** (`showRttTab`).
- **Sélecteur d'année** sous le tableau dans la zone scrollable. Même mécanique que l'onglet Congés (double plancher) : `max(floor_contrat, floor_rttStartDate)`. Format unique : `Juin 2025 → Mai 2026`.
- **Sélecteur d'année** sous le tableau dans la zone scrollable. Double plancher `max(floor_contrat, floor_rttStartDate)`. Borne haute = exercice courant : **contrairement à l'onglet Congés, le RTT ne propose PAS l'exercice suivant** (consulter un exercice RTT à venir — heures non saisies, rien à payer — n'a pas de sens ; cf. `availableRttYears`). Format unique : `Juin 2025 → Mai 2026`.
- Changement d'année → recharge via `useEmployeeRtt.setSelectedRttYear(year)` (`getEmployeeRttSummary?year=YYYY`). `EmployeeRttSummary.rttStartDate` est déjà exposé (champ existant) — il sert à la fois au floor du sélecteur et au masquage des lignes Report avant la mise en service.
- Sur un exercice passé, le bouton **+ Payer les RTT** est désactivé (pas de paiement rétroactif).
- 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)
- 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`.
@@ -104,7 +126,7 @@
## Récap. congés (écran)
- Accès via sidebar `Récap. congés`, conditionné au flag `User.hasLeaveRecapAccess` (défaut `false`) — activé au create/edit user. Le flag s'applique à tous les profils, y compris admin.
- Scope : `ROLE_ADMIN` → tous les employés, `ROLE_USER` (chef de site) → employés de ses sites, `ROLE_SELF` → sa ligne
- Cutoff temporel : fin de la semaine S-2 (dimanche 23:59:59). Formule : `dimanche(lundi_semaine_courante 14j)`. Pas de gate `isValid`.
- Cutoff temporel : fin de la semaine S-1 (dimanche 23:59:59). Formule : `dimanche(lundi_semaine_courante 7j)`. Pas de gate `isValid`.
- Helper : `App\Util\LeaveRecapCutoff::resolveCutoff()`
- Colonnes : Nom, Prénom, Contrat, CP N-1 restant, CP N, Samedis, RTT — identiques au PDF
- Service partagé : `LeaveRecapRowBuilder` consommé par `LeaveRecapPrintProvider` (as-of today) et `EmployeeLeaveRecapProvider` (as-of cutoff)
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.102'
app.version: '0.1.113'
+27 -5
View File
@@ -61,6 +61,9 @@ Documents complementaires:
- Libellé nature de contrat (CDI/CDD/Intérim) affiché sous le nom:
- résolu à la date filtrée (période de contrat couvrant ce jour), pas à aujourd'hui
- masqué si aucun contrat à cette date (cas rarissime en vue jour puisque l'employé est alors déjà filtré)
- **Vue Jour (Heures) — contrat à la date affichée** : le mode de suivi (saisie d'heures vs cases de présence), le libellé de contrat et la logique de sauvegarde sont résolus selon la période de contrat valable à la date filtrée (champs `trackingMode`/`weeklyHours`/`contractType`/`contractName` portés par `WorkHourDayContext`, alimentés par `EmployeeContractResolver::resolveForEmployeeAndDate`), et non selon le contrat courant de l'employé. Un salarié passé 39h/35h → Forfait conserve donc la saisie d'heures sur ses dates antérieures à la bascule, et bascule en cases de présence à partir de la date de passage en forfait. La vue Semaine était déjà résolue par date.
- **Exports heures annuelles (par salarié et tous salariés)** : affichent **tous les jours sous contrat**, même vides ou non saisis, jusqu'à la date du jour ; seuls les jours hors contrat (avant embauche, après départ, suspension) sont omis. Les samedis et dimanches sont grisés (gris foncé), les jours fériés en bleu.
- **Récap salaire (export PDF mensuel)** : seuls les salariés ayant un contrat couvrant tout ou partie du mois imprimé apparaissent (filtre `hasContractInRange`). Un salarié dont le contrat est terminé avant le mois (ex. parti en février) n'est pas listé sur le récap des mois suivants.
## 4) Absences
@@ -123,6 +126,11 @@ Documents complementaires:
- contrats >= 39h: de 39h à 43h
- Tranche 50%:
- au-delà de 43h
- Embauche/fin de contrat en milieu de semaine (calcul RTT — `RttRecoveryComputationService`):
- les seuils sont proratisés aux jours réellement contractés de la semaine (les jours hors contrat ne comptent pas)
- le seuil de départ du 25% **et** le plafond 25%/50% sont décalés ensemble ; la bande 25% garde sa largeur réglementaire (4h pour un 39h, 8h pour un 35h)
- une semaine d'embauche peut ainsi ouvrir à la fois du 25% et du 50% (ex. CDD 39h embauché le jeudi, 22h travaillées → 4h à 25% + 3h à 50%)
- note: la synthèse de l'écran Heures (vue semaine) n'applique pas cette proratisation (calcul distinct dans `WorkHourWeeklySummaryProvider`)
- Date de début RTT (`RTT_START_DATE` dans `.env`):
- les semaines dont la fin est antérieure à cette date sont ignorées dans le calcul de récupération
- permet d'éviter les déficits fictifs avant la mise en service du logiciel
@@ -132,7 +140,20 @@ Documents complementaires:
- 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)
- 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`:
- pas de bonus 25%
- pas de bonus 50%
@@ -160,7 +181,7 @@ Documents complementaires:
- Validation: même logique que les heures classiques (`isValid`, `isSiteValid`, bulk)
- Vue semaine:
- jour/nuit/atelier par jour + indicateurs repas/dîner/nuitée
- panier de nuit (PN): affiché par jour si (nightMinutes > dayMinutes) OU (nightMinutes >= 240, soit au moins 4h de travail entre 21h et 6h), et total hebdo dans la colonne Jour/Nuit sem.
- panier de nuit (PN): **ne s'applique pas aux conducteurs** (ils disposent de leurs propres primes repas/nuitée). Aucun PN n'est crédité sur un jour conducteur, ni sur la vue semaine conducteurs ni sur le récap salaire. La règle PN (nuit > jour OU nuit ≥ 4h) ne concerne que les non-conducteurs.
- totaux hebdo: jour, nuit, atelier, total, compteurs petit déj/déjeuner/dîner/nuitée
- les conducteurs utilisent `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` pour le calcul RTT (au lieu des créneaux morning/afternoon/evening)
- Le flag `isDriver` est sur `EmployeeContractPeriod` (un employé peut changer de statut chauffeur selon la période)
@@ -261,6 +282,7 @@ Seuls les employés dont au moins une période de contrat intersecte la période
- pris: basé sur toutes les absences (demi-journées incluses)
- restants = acquis - pris (borné à 0)
- paiement congés N-1: saisie RH via `PATCH /employees/{id}/paid-leave-days` (body: `paidLeaveDays`, `year`). Stocké dans `employee_leave_balances.paid_leave_days`. Les jours payés réduisent le stock N-1 **avant** l'attribution des jours pris : `disponible_N-1 = max(0, acquis_N-1 - payés)`, puis `pris_N-1 = min(disponible_N-1, total_pris)`, surplus pris basculé sur N. Reste à prendre N-1 = `max(0, disponible_N-1 - pris_N-1)`. Uniquement pour les contrats forfait.
- jours de présence et récap salaire: pour un forfait, les jours de congé imputés sur le stock N-1 (`previousYearTakenDays`) **ne réduisent pas** les jours de présence et **ne s'affichent pas** comme congés. Sur l'export Récap salaire (mensuel), le budget N-1 est consommé chronologiquement depuis le 1er janvier ; les jours couverts deviennent des jours de présence, les jours au-delà restent affichés en congés. Le budget est le même que la fiche employé (jours payés déduits du stock N-1 d'abord).
- report annuel:
- le reliquat (`restants`) de l'exercice précédent est reporté dans les acquis de l'exercice courant
- pour `CDI`/`CDD` non forfait: report séparé jours + samedis
@@ -352,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_USER` (chef de site) : employés des sites autorisés (`UserSiteRole`)
- `ROLE_SELF` : uniquement son employé lié
- **Cutoff temporel** : le récap est figé à la fin de la semaine S-2 (dimanche 23:59:59)
- Formule : `cutoffDate = dimanche(lundi_semaine_courante 14 jours)`
- Exemple : mardi 14/04/2026 (S16) → dimanche 05/04/2026 (fin S14)
- **Cutoff temporel** : le récap est figé à la fin de la semaine S-1 (dimanche 23:59:59)
- Formule : `cutoffDate = dimanche(lundi_semaine_courante 7 jours)`
- Exemple : mardi 14/04/2026 (S16) → dimanche 12/04/2026 (fin S15)
- `isValid` n'entre PAS en compte : cutoff purement temporel
- Les heures et absences postérieures au cutoff sont ignorées dans les calculs
- Colonnes identiques au PDF (voir §10)
+38
View File
@@ -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`.
+3 -3
View File
@@ -2,14 +2,14 @@
## Objet
Vue tableau des soldes de congés par employé, figée à un cutoff temporel (fin de semaine S-2).
Vue tableau des soldes de congés par employé, figée à un cutoff temporel (fin de semaine S-1).
Complémentaire à l'export PDF admin : mêmes colonnes, accès étendu aux employés et chefs de site.
## Cutoff
La formule est : `cutoffDate = dimanche de (lundi de la semaine courante 14 jours)`.
La formule est : `cutoffDate = dimanche de (lundi de la semaine courante 7 jours)`.
Exemple : mardi 14/04/2026 (S16) → **dimanche 05/04/2026 23:59:59** (fin S14).
Exemple : mardi 14/04/2026 (S16) → **dimanche 12/04/2026 23:59:59** (fin S15).
Le cutoff est purement temporel : l'état `isValid` des heures n'entre pas en compte. Les heures
et absences postérieures au cutoff sont ignorées dans le calcul des soldes.
+13 -5
View File
@@ -68,7 +68,8 @@ Etat implementation:
- la table est creee
- le calcul de synthese conges lit en priorite `opening_days/opening_saturdays` de cette table quand une ligne existe pour `(employee, rule_code, year)`
- si aucune ligne n'existe, le calcul reste base sur le report dynamique N-1
- la commande `app:leave:rollover` calcule aussi le report dynamique N-1 si la ligne N-1 n'est pas encore persistée (pas de reset a 0 par defaut)
- **le report dynamique (`LeaveBalanceComputationService::computeDynamicClosingForYear`, qui alimente le solde d'ouverture de l'exercice suivant) ancre lui aussi sur cette table** : pour chaque exercice de sa boucle, si une ligne bootstrap existe il part de `opening_days/opening_saturdays` (et ajoute l'offset `taken_days/taken_saturdays`) au lieu de recalculer depuis l'embauche. Sans cet ancrage, la clôture d'un exercice consulté en avance (ex. exercice suivant) cumulerait une année pleine d'acquisition par exercice antérieur à la mise en service — aucune absence historique n'étant saisie (cas Aurore : 88 jours au lieu de 31).
- la commande `app:leave:rollover` recalcule **toujours** le report via `computeDynamicClosingForYear(N-1)` (et ne se fie plus au `closing_days` stocké, qui n'est qu'un placeholder = `opening`), puis fige ce résultat dans le `closing_days` de l'exercice qui se termine ; voir § 6
### Definition des colonnes
@@ -120,12 +121,19 @@ Date d'effet:
- non forfait: au `1er juin`
Traitement par employe:
1. lire l'exercice precedent
2. determiner le report:
1. determiner le report de l'exercice precedent:
- si cloture `paidLeaveSettled=true` sur la periode precedente => report `0`
- sinon report = `closing` exercice precedent
- sinon report = **cloture reelle recalculee** via `computeDynamicClosingForYear(exercicePrecedent)` (acquisition + samedis + fractionnes pris, ancree sur l'`opening_days` bootstrap de chaque exercice). On **ne se fie PAS** au `closing_days` stocke : il n'est jamais recalcule apres creation (toujours egal a l'`opening`), donc s'y fier propagerait l'ouverture sans jamais crediter l'acquisition de l'annee (cas Aurore : report 0 au lieu de 31).
2. **figer** ce report dans `closing_days/closing_saturdays` de la ligne de l'exercice qui se termine (la colonne contient enfin un vrai solde de cloture, auditable).
3. creer la ligne du nouvel exercice avec ce report en `opening_*`
4. initialiser `accrued/taken/closing` pour le nouvel exercice
4. initialiser `accrued/taken/closing` pour le nouvel exercice (= `opening` a la creation)
### Correction manuelle d'un solde (RH / comptable)
Le verrouillage (`is_locked`) n'est pas utilise ; les corrections se font directement en BDD. Deux garde-fous rendent cela sur :
- **Idempotence** : le cron ne cree la ligne d'un exercice que si elle n'existe pas (les lignes existantes sont ignorees). Une ligne corrigee a la main n'est donc **jamais** ecrasee par un passage ulterieur du cron (meme avec `--force`).
- **Le bon levier est `opening_days`, pas `closing_days`** : `computeDynamicClosingForYear` part de l'`opening_days` de chaque exercice comme ancre. Corriger l'`opening_days` d'un exercice (ou la donnee de fond : absence, fractionne, paye) se propage automatiquement aux reports des exercices suivants. Editer un `closing_days` d'un exercice **pas encore bascule** est inutile (il sera recalcule a la bascule) ; une fois la ligne suivante creee, plus rien n'y touche.
## 7) Donnees a fournir au go-live
+16 -5
View File
@@ -24,12 +24,12 @@ Cette règle suit `EmployeeLeaveSummaryProvider::resolveYear()` côté backend :
Position : **en bas du calendrier**, à gauche, à l'intérieur de la zone scrollable. Il scrolle donc avec les mois et apparaît sous la grille.
Plage proposée :
- du plus récent (= année courante) au plus ancien ;
- du plus récent au plus ancien. La borne haute est l'exercice **suivant** (exercice courant + 1) lorsque la phase de contrat est ouverte, afin de consulter en avance les congés posés sur l'exercice à venir ; pour une phase clôturée, la borne haute reste l'exercice de fin de phase ;
- **double plancher** : l'année minimum est `max(floor_historique_contrat, floor_data_start_date)`
- **floor_historique_contrat** : dérivé de `employee.contractHistory[].startDate` — premier exercice où l'employé avait un contrat ouvert
- **floor_data_start_date** : dérivé de l'env `RTT_START_DATE` (date de mise en service du logiciel, ex. `2026-02-23` → exercice 2026 / année forfait 2026). Aucune donnée historique n'existe avant cette date, donc on ne propose pas d'années antérieures même si le contrat de l'employé est plus ancien.
- la valeur est exposée par l'API `GET /employees/{id}/leave-summary` via le champ `dataStartDate` (peuplé depuis l'env serveur).
- en cas d'historique manquant **et** d'env absente, la plage se réduit à l'année courante.
- en cas d'historique manquant **et** d'env absente, la plage se réduit à l'exercice courant et à l'exercice suivant.
Format des libellés :
- FORFAIT : `2026`, `2025`, `2024`
@@ -39,13 +39,24 @@ Comportement :
- changer d'année recharge l'intégralité de l'onglet (`getEmployeeLeaveSummary?year=YYYY` + `listAbsences` + `listPublicHolidays`) ;
- les compteurs du bandeau reflètent l'année sélectionnée.
## Verrouillage des éditions sur années passées
## Compteurs du bandeau
Quand `selectedYear !== currentYear` (consultation d'une année antérieure) :
- **Acquis** : jours de report N-1 + jours acquis sur l'exercice courant.
- **Pris** : jours de congés posés et validés sur l'exercice.
- **Reste** : acquis pris.
- **En cours d'acquisition** (non-forfait) : affiché au format `net / brut`.
- `net` (`accruingDays`) : généré de l'exercice restant, déduit des congés posés en anticipé (au-delà du report acquis).
- `brut` (`accruingDaysTotal` = `generatedDays + generatedSaturdays`) : total généré sur l'exercice à ce jour, avant cette déduction.
- La RH voit ainsi le total réellement acquis même si une partie a déjà été consommée en anticipé. Forfait : pas d'en-cours (affiche `0`, sans fraction).
- **N-1** (non-forfait) ou **Samedis** (FORFAIT) : solde de l'exercice précédent / jours de repos samedis.
## Verrouillage des éditions hors exercice courant
Quand `selectedYear !== currentYear` (consultation d'une année **différente de l'exercice courant**, passée ou future) :
- le bouton crayon **Jours fractionnés** (non-FORFAIT) est désactivé ;
- le bouton crayon **Année N-1 payés** (FORFAIT) est désactivé.
Justification : modifier rétroactivement les stocks de report ou les jours fractionnés d'un exercice clos décalerait silencieusement les soldes de toutes les années postérieures. La consultation reste possible, l'édition non.
Justification : modifier les stocks de report ou les jours fractionnés d'un exercice clos décalerait silencieusement les soldes des années postérieures ; les éditer sur un exercice futur (pas encore démarré) n'aurait pas de sens. La consultation reste possible, l'édition non.
## Sélecteur de phase de contrat
+18 -3
View File
@@ -79,15 +79,30 @@ Commande quotidienne (cron) idempotente.
- le `01/06`: calcule et persiste le report pour chaque employe eligible
- les autres jours: sortie sans action
- 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:
- au `1er juin` (meme date que le rollover conges non forfait)
Traitement par employe:
1. verifier l'eligibilite (ni INTERIM, ni suivi PRESENCE)
2. verifier qu'aucune ligne n'existe deja pour `(employee, targetYear)` (idempotence)
3. calculer la somme des minutes de recuperation de l'exercice N-1
4. creer la ligne du nouvel exercice avec ce total en `opening_minutes`
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 le **solde de cloture** de l'exercice N-1 (= disponible affiche en fin d'exercice) :
`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
+32 -4
View File
@@ -16,12 +16,31 @@ 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`.
## 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
Position : sous la table, à l'intérieur de la zone scrollable, à gauche.
Plage proposée :
- du plus récent (= exercice courant) au plus ancien ;
- du plus récent (= exercice courant) au plus ancien. Contrairement à l'onglet Congés, le RTT **ne propose pas** l'exercice suivant (consulter un exercice RTT à venir n'a pas de sens) ; pour une phase clôturée, la borne haute reste l'exercice de fin de phase ;
- **double plancher** : `max(floor_historique_contrat, floor_data_start_date)`
- **floor_historique_contrat** : dérivé de `employee.contractHistory[].startDate` — premier exercice où l'employé avait un contrat ouvert
- **floor_data_start_date** : exercice contenant `RTT_START_DATE` (env, ex. `2026-02-23` → exercice 2026)
@@ -32,11 +51,20 @@ Comportement :
- changer d'exercice recharge `getEmployeeRttSummary?year=YYYY` (le backend valide 20002100) ;
- la table redéploie les semaines de l'exercice sélectionné, navigation par mois conservée.
## Verrouillage des édition 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
@@ -0,0 +1,321 @@
# En-cours d'acquisition « net / brut » — 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 l'onglet Congés de la fiche employé, afficher l'en-cours d'acquisition au format `{net} / {brut généré à ce jour}` pour les non-forfait, afin que la RH voie le total acquis même quand des congés ont été pris en anticipé.
**Architecture :** Exposition d'une valeur **déjà calculée** côté backend (`generatedDays + generatedSaturdays`) via un nouveau champ `accruingDaysTotal` sur `EmployeeLeaveSummary`, puis affichage en fraction côté Nuxt. Aucune nouvelle règle métier ; `accruingDays` (net) reste le numérateur inchangé.
**Tech Stack :** Backend Symfony / API Platform (State Provider + ApiResource DTO). Frontend Nuxt 4 / Vue 3 / TypeScript. Tests : PHPUnit (backend) ; pas de harnais frontend → vérification manuelle.
**Référence spec :** `docs/superpowers/specs/2026-05-26-en-cours-acquisition-net-brut-design.md`
---
## File Structure
- `src/State/EmployeeLeaveSummaryProvider.php` — calcule `accruingDaysTotal` dans `computeYearSummary` et le recopie sur le DTO.
- `src/ApiResource/EmployeeLeaveSummary.php` — nouvelle propriété exposée `accruingDaysTotal`.
- `frontend/services/dto/employee-leave-summary.ts` — champ TS `accruingDaysTotal`.
- `frontend/components/employees/LeaveTab.vue` — affichage `net / brut` (non-forfait).
- `doc/leave-tab.md` + `frontend/data/documentation-content.ts` — documentation.
Aucun fichier créé ; 6 fichiers modifiés.
---
### Task 1 : Backend — exposer `accruingDaysTotal`
**Files:**
- Modify: `src/State/EmployeeLeaveSummaryProvider.php`
- Modify: `src/ApiResource/EmployeeLeaveSummary.php`
- [ ] **Step 1 : Calculer `accruingDaysTotal` dans les deux branches de `computeYearSummary`**
Dans `src/State/EmployeeLeaveSummaryProvider.php`, branche non-forfait, remplacer :
```php
$acquiredDays = $carryDays;
$accruingDays = $remainingGenerated + $remainingGeneratedSaturdays;
```
par :
```php
$acquiredDays = $carryDays;
$accruingDays = $remainingGenerated + $remainingGeneratedSaturdays;
// Brut généré à ce jour, AVANT imputation des congés pris en anticipé
// (dénominateur de l'affichage « net / brut » sur l'onglet Congés).
$accruingDaysTotal = $generatedDays + $generatedSaturdays;
```
Puis, branche forfait, remplacer :
```php
$acquiredDays = $leavePolicy['acquiredDays'];
$accruingDays = 0.0;
```
par :
```php
$acquiredDays = $leavePolicy['acquiredDays'];
$accruingDays = 0.0;
$accruingDaysTotal = 0.0;
```
- [ ] **Step 2 : Ajouter la clé au tableau `targetSummary`**
Toujours dans `computeYearSummary`, remplacer :
```php
'accruingDays' => $accruingDays,
```
par :
```php
'accruingDays' => $accruingDays,
'accruingDaysTotal' => $accruingDaysTotal,
```
- [ ] **Step 3 : Déclarer la clé dans le PHPDoc de retour**
Dans le bloc `@return null|array{ ... }` de `computeYearSummary`, remplacer :
```php
* accruingDays: float,
```
par :
```php
* accruingDays: float,
* accruingDaysTotal: float,
```
- [ ] **Step 4 : Recopier la valeur sur le DTO dans `provide()`**
Remplacer :
```php
$summary->accruingDays = $yearSummary['accruingDays'];
```
par :
```php
$summary->accruingDays = $yearSummary['accruingDays'];
$summary->accruingDaysTotal = $yearSummary['accruingDaysTotal'];
```
- [ ] **Step 5 : Ajouter la propriété sur l'ApiResource**
Dans `src/ApiResource/EmployeeLeaveSummary.php`, remplacer :
```php
public float $accruingDays = 0.0;
```
par :
```php
public float $accruingDays = 0.0;
/** Brut généré sur l'exercice à ce jour (= accruingDays + congés pris en anticipé). Dénominateur de l'affichage « net / brut ». */
public float $accruingDaysTotal = 0.0;
```
- [ ] **Step 6 : Lancer la suite PHPUnit (non-régression)**
Run: `docker exec -t -u www-data php-sirh-fpm php vendor/bin/phpunit`
Expected: `OK (151 tests, ...)` — vert. (Le champ est une exposition pure ; aucun test existant ne doit casser. Le service n'est pas unit-testable en isolation à cause des dépôts `final`, cf. note spec.)
- [ ] **Step 7 : Vérification sur données réelles (jetable, non commitée)**
Créer `src/Command/TmpVerifyAccruingCommand.php` :
```php
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Employee;
use App\State\EmployeeLeaveSummaryProvider;
use Doctrine\ORM\EntityManagerInterface;
use ReflectionMethod;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'app:tmp-verify-accruing')]
final class TmpVerifyAccruingCommand extends Command
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly EmployeeLeaveSummaryProvider $provider,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$m = new ReflectionMethod(EmployeeLeaveSummaryProvider::class, 'computeYearSummary');
foreach ($this->em->getRepository(Employee::class)->findAll() as $e) {
$s = $m->invoke($this->provider, $e, 2026, 0.0, null, null);
if (null === $s || 'CDI_CDD_NON_FORFAIT' !== $s['ruleCode']) {
continue;
}
$output->writeln(sprintf(
'#%d %s : en-cours net=%.2f / brut=%.2f',
$e->getId(),
$e->getLastName(),
$s['accruingDays'],
$s['accruingDaysTotal'],
));
}
return Command::SUCCESS;
}
}
```
Run: `docker exec -t php-sirh-fpm php /var/www/html/bin/console app:tmp-verify-accruing --env=dev`
Expected: chaque ligne affiche `net=… / brut=…` avec `net ≤ brut`. Pour un salarié sans congé anticipé, `net == brut` ; pour un salarié ayant débordé, `net < brut`.
Puis supprimer le fichier :
```bash
rm src/Command/TmpVerifyAccruingCommand.php
```
- [ ] **Step 8 : Commit**
```bash
git add src/State/EmployeeLeaveSummaryProvider.php src/ApiResource/EmployeeLeaveSummary.php
git commit -m "feat : exposer accruingDaysTotal (brut généré) sur le récap congés
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 2 : Frontend — afficher « net / brut »
**Files:**
- Modify: `frontend/services/dto/employee-leave-summary.ts`
- Modify: `frontend/components/employees/LeaveTab.vue`
- [ ] **Step 1 : Ajouter le champ au DTO TypeScript**
Dans `frontend/services/dto/employee-leave-summary.ts`, remplacer :
```ts
accruingDays: number
```
par :
```ts
accruingDays: number
accruingDaysTotal: number
```
- [ ] **Step 2 : Afficher la fraction dans la case « En cours d'acquisition »**
Dans `frontend/components/employees/LeaveTab.vue`, remplacer :
```vue
<p class="col-start-4 p-[10px] border-b border-primary-500"><strong class="uppercase font-semibold">En cours d'acquisition :</strong>
{{ formatCount(summary?.accruingDays) }} Jours
</p>
```
par :
```vue
<p class="col-start-4 p-[10px] border-b border-primary-500"><strong class="uppercase font-semibold">En cours d'acquisition :</strong>
<template v-if="!isForfaitRule">{{ formatCount(summary?.accruingDays) }} / {{ formatCount(summary?.accruingDaysTotal) }} Jours</template>
<template v-else>{{ formatCount(summary?.accruingDays) }} Jours</template>
</p>
```
- [ ] **Step 3 : Vérification manuelle (dev server)**
Run: `make dev-nuxt` puis ouvrir la fiche d'un employé **non-forfait**.
Attendu :
- La case « En cours d'acquisition » affiche deux nombres séparés par `/` (ex. `14,50 / 17,50` ou `17,50 / 17,50` si aucun congé anticipé).
- Sur un employé **forfait**, la case affiche un seul nombre (`0`), inchangé.
(Ne pas lancer `npm run build`.)
- [ ] **Step 4 : Commit**
```bash
git add frontend/services/dto/employee-leave-summary.ts frontend/components/employees/LeaveTab.vue
git commit -m "feat : afficher l'en-cours d'acquisition au format net / brut (onglet Congés)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 3 : Documentation
**Files:**
- Modify: `doc/leave-tab.md`
- Modify: `frontend/data/documentation-content.ts`
- [ ] **Step 1 : `doc/leave-tab.md`**
Repérer la section décrivant les compteurs du header (recherche : `grep -n "acquisition\|En cours\|compteur" doc/leave-tab.md`). Ajouter (ou compléter la puce correspondante) avec ce texte :
```markdown
- **En cours d'acquisition** (non-forfait) : affiché au format `net / brut`.
- `net` (`accruingDays`) : généré de l'exercice restant, déduit des congés posés en anticipé (au-delà du report acquis).
- `brut` (`accruingDaysTotal` = `generatedDays + generatedSaturdays`) : total généré sur l'exercice à ce jour, avant cette déduction.
- La RH voit ainsi le total réellement acquis même si une partie a déjà été consommée en anticipé. Forfait : pas d'en-cours (affiche `0`, sans fraction).
```
- [ ] **Step 2 : `frontend/data/documentation-content.ts`**
Repérer le paragraphe de l'article « Onglet Congés » décrivant les compteurs (recherche : `grep -n "en cours d.acquisition\|En cours\|acquis" frontend/data/documentation-content.ts`). Ajouter un bloc `note` dans le tableau `blocks` de cet article :
```ts
{ type: 'note', content: 'La case « En cours d\'acquisition » affiche deux valeurs : à gauche les jours encore à acquérir (déduction faite des congés déjà posés en anticipé), à droite le total brut acquis sur l\'exercice à ce jour. Exemple : « 14,50 / 17,50 » signifie 17,50 jours acquis dont 3 déjà pris en anticipé.' },
```
> Insérer ce bloc juste après le paragraphe qui présente les compteurs de l'exercice de congés (celui mentionnant l'exercice Juin→Mai / les jours acquis). Respecter l'indentation existante (10 espaces) et l'échappement des apostrophes (`\'`).
- [ ] **Step 3 : Vérifier la cohérence**
Run: `grep -rn "accruingDaysTotal\|net / brut\|14,50 / 17,50" doc/leave-tab.md frontend/data/documentation-content.ts`
Expected : la doc fonctionnelle mentionne le format `net / brut` et la doc in-app contient la note d'exemple.
- [ ] **Step 4 : Commit**
```bash
git add doc/leave-tab.md frontend/data/documentation-content.ts
git commit -m "docs : en-cours d'acquisition affiché net / brut sur l'onglet Congés
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
## Self-Review
**1. Couverture de la spec :**
- Nouveau champ `accruingDaysTotal` = `generatedDays + generatedSaturdays` (non-forfait), `0` (forfait) → Task 1 Steps 1-2. ✓
- Exposition DTO PHP + recopie provider → Task 1 Steps 3-5. ✓
- DTO TS → Task 2 Step 1. ✓
- Affichage `net / brut` non-forfait, inchangé forfait → Task 2 Step 2. ✓
- Docs `doc/leave-tab.md` + in-app → Task 3. ✓
- Invariant `accruingDays ≤ accruingDaysTotal` → vérifié en Task 1 Step 7. ✓
- Hors périmètre (RTT, récap, header) → aucun fichier de ces zones touché. ✓
**2. Placeholders :** aucun « TBD/TODO » ; tout le code est fourni. Les Steps 1-2 de Task 3 demandent un `grep` pour localiser l'ancre exacte (le texte à insérer est fourni intégralement) car la position dans `documentation-content.ts` dépend de l'article ; c'est une instruction d'insertion, pas un placeholder de contenu.
**3. Cohérence des types/noms :** `accruingDaysTotal` (float PHP / number TS) utilisé identiquement dans le provider, le tableau `targetSummary`, le PHPDoc, l'ApiResource, le DTO TS et le template. `accruingDays` (numérateur) reste inchangé. La variable `$accruingDaysTotal` est définie dans les deux branches avant la construction de `targetSummary` (comme `$accruingDays`).
@@ -0,0 +1,267 @@
# Exercice suivant dans les sélecteurs Congés et RTT — 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:** Faire apparaître toujours l'exercice **suivant** (exercice courant + 1) dans les sélecteurs d'exercice des onglets Congés et RTT de la fiche employé, pour une phase de contrat ouverte.
**Architecture :** Changement **frontend uniquement**. Le backend calcule déjà l'exercice suivant pour une phase ouverte (`clampYearToPhase` ne plafonne pas vers le haut quand `phase.endDate` est nul). On déplace la borne haute (`maxYear`) des deux `computed` `availableLeaveYears` / `availableRttYears` de « exercice courant » à « exercice courant + 1 » lorsque la phase est ouverte ; la borne reste l'exercice de fin de phase pour une phase clôturée. La borne basse, la sélection par défaut (exercice courant) et le verrouillage des éditions (`isHistoricalYear`) sont inchangés.
**Tech Stack :** Nuxt 4 / Vue 3 / TypeScript (composables). Backend Symfony inchangé (les tests PHPUnit doivent rester verts). Pas de harnais de test frontend dans le projet → vérification manuelle via `make dev-nuxt`.
**Référence spec :** `docs/superpowers/specs/2026-05-26-exercice-suivant-conges-rtt-design.md`
---
## File Structure
- `frontend/composables/useEmployeeLeave.ts` — borne haute du sélecteur Congés (`availableLeaveYears`).
- `frontend/composables/useEmployeeRtt.ts` — borne haute du sélecteur RTT (`availableRttYears`).
- `doc/leave-tab.md` — doc fonctionnelle, section « Sélecteur d'année ».
- `doc/rtt-tab.md` — doc fonctionnelle, section « Sélecteur d'année ».
- `CLAUDE.md` — bullets « Sélecteur d'année » des sections Congés et RTT.
- `frontend/data/documentation-content.ts` — documentation in-app (2 paragraphes).
Aucun fichier créé ; 6 fichiers modifiés.
---
### Task 1 : Borne haute = exercice suivant sur les deux composables
**Files:**
- Modify: `frontend/composables/useEmployeeLeave.ts` (computed `availableLeaveYears`)
- Modify: `frontend/composables/useEmployeeRtt.ts` (computed `availableRttYears`)
- [ ] **Step 1 : Modifier `availableLeaveYears` dans `useEmployeeLeave.ts`**
Remplacer ce bloc (déclaration de `phaseEndYear`) :
```ts
// Plage = exercices intersectant la phase.
const phaseStartYear = computeLeaveYearForDate(new Date(`${phase.startDate}T00:00:00`))
const phaseEndYear = phase.endDate
? computeLeaveYearForDate(new Date(`${phase.endDate}T00:00:00`))
: currentLeaveYear.value
```
par :
```ts
// Plage = exercices intersectant la phase.
const phaseStartYear = computeLeaveYearForDate(new Date(`${phase.startDate}T00:00:00`))
// Borne haute : fin de phase si clôturée ; sinon l'exercice SUIVANT (courant + 1),
// pour pouvoir consulter en avance les congés posés sur l'exercice à venir.
const maxYear = phase.endDate
? computeLeaveYearForDate(new Date(`${phase.endDate}T00:00:00`))
: currentLeaveYear.value + 1
```
Puis, plus bas, supprimer la ligne devenue redondante en remplaçant :
```ts
const minYear = dataFloor !== null ? Math.max(phaseStartYear, dataFloor) : phaseStartYear
const maxYear = phaseEndYear
```
par :
```ts
const minYear = dataFloor !== null ? Math.max(phaseStartYear, dataFloor) : phaseStartYear
```
(La variable `maxYear` est désormais déclarée plus haut ; la boucle `for (let y = maxYear; ...)` qui suit est inchangée.)
- [ ] **Step 2 : Modifier `availableRttYears` dans `useEmployeeRtt.ts`**
Remplacer ce bloc :
```ts
// Plage = exercices intersectant la phase.
const phaseStartYear = computeRttYearForDate(new Date(`${phase.startDate}T00:00:00`))
const phaseEndYear = phase.endDate
? computeRttYearForDate(new Date(`${phase.endDate}T00:00:00`))
: currentRttYear.value
```
par :
```ts
// Plage = exercices intersectant la phase.
const phaseStartYear = computeRttYearForDate(new Date(`${phase.startDate}T00:00:00`))
// Borne haute : fin de phase si clôturée ; sinon l'exercice SUIVANT (courant + 1),
// pour rester cohérent avec le sélecteur de l'onglet Congés.
const maxYear = phase.endDate
? computeRttYearForDate(new Date(`${phase.endDate}T00:00:00`))
: currentRttYear.value + 1
```
Puis remplacer :
```ts
const minYear = dataFloor !== null ? Math.max(phaseStartYear, dataFloor) : phaseStartYear
const maxYear = phaseEndYear
```
par :
```ts
const minYear = dataFloor !== null ? Math.max(phaseStartYear, dataFloor) : phaseStartYear
```
- [ ] **Step 3 : Vérifier qu'il ne reste aucune référence à `phaseEndYear`**
Run: `grep -rn "phaseEndYear" frontend/composables/`
Expected: aucune sortie (la variable a été supprimée des deux fichiers).
- [ ] **Step 4 : Vérification manuelle dans le dev server**
Run: `make dev-nuxt` puis, dans le navigateur, ouvrir la fiche d'un employé **non-forfait avec phase courante ouverte**.
Attendu :
- Onglet **Congés** → le menu déroulant en pied de calendrier propose l'exercice **suivant** en première position (ex. aujourd'hui exercice `Juin 2025 → Mai 2026` → l'option `Juin 2026 → Mai 2027` est présente), et l'onglet s'ouvre **par défaut** sur l'exercice courant.
- Onglet **RTT** → idem, l'exercice suivant est proposé.
- Sélectionner l'exercice suivant : les boutons **Jours fractionnés** / **Année N-1 payés** (Congés) et **+ Payer les RTT** (RTT) sont **désactivés** (car `isHistoricalYear` = vrai), et aucun bandeau « Vous consultez l'historique » n'apparaît (ce bandeau dépend de la phase, pas de l'année).
- Ouvrir la fiche d'un employé ayant une **phase clôturée** (sélecteur « Vue contrat ») : le sélecteur d'exercice de cette phase **ne propose pas** d'exercice au-delà de la fin de phase (comportement inchangé).
- [ ] **Step 5 : Commit**
```bash
git add frontend/composables/useEmployeeLeave.ts frontend/composables/useEmployeeRtt.ts
git commit -m "feat : proposer l'exercice suivant dans les sélecteurs Congés et RTT
Sur une phase de contrat ouverte, la borne haute des sélecteurs d'exercice
(availableLeaveYears / availableRttYears) passe de l'exercice courant à
l'exercice suivant (courant + 1), pour consulter en avance les congés/RTT
posés sur l'exercice à venir. Phase clôturée : borne inchangée (fin de phase).
Sélection par défaut et verrouillage des éditions inchangés.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
(Le hook pre-commit lance PHPUnit ; les 151 tests doivent rester verts — aucun changement backend.)
---
### Task 2 : Mettre à jour la documentation
**Files:**
- Modify: `doc/leave-tab.md` (section « Sélecteur d'année »)
- Modify: `doc/rtt-tab.md` (section « Sélecteur d'année »)
- Modify: `CLAUDE.md` (bullets sélecteur Congés et RTT)
- Modify: `frontend/data/documentation-content.ts` (2 paragraphes)
- [ ] **Step 1 : `doc/leave-tab.md`**
Dans la section « ## Sélecteur d'année » (vers la ligne 26 « Plage proposée : »), remplacer la première puce :
```markdown
- du plus récent (= exercice courant) au plus ancien ;
```
> Remarque : si cette puce n'existe pas telle quelle dans ce fichier, ajouter à la place, juste après la ligne `Plage proposée :`, la puce ci-dessous.
par :
```markdown
- du plus récent au plus ancien. La borne haute est l'exercice **suivant** (exercice courant + 1) lorsque la phase de contrat est ouverte, afin de consulter en avance les congés posés sur l'exercice à venir ; pour une phase clôturée, la borne haute reste l'exercice de fin de phase ;
```
- [ ] **Step 2 : `doc/rtt-tab.md`**
Dans la section « ## Sélecteur d'année », remplacer la puce (ligne 24) :
```markdown
- du plus récent (= exercice courant) au plus ancien ;
```
par :
```markdown
- du plus récent au plus ancien. La borne haute est l'exercice **suivant** (exercice courant + 1) sur une phase ouverte (cohérent avec l'onglet Congés) ; pour une phase clôturée, elle reste l'exercice de fin de phase ;
```
- [ ] **Step 3 : `CLAUDE.md` — section Onglet Congés (ligne 76)**
Remplacer le segment :
```
Plage : de l'exercice courant jusqu'à `max(floor_contrat, floor_data_start_date)`
```
par :
```
Plage : de l'exercice **suivant** (exercice courant + 1 sur une phase ouverte ; exercice de fin de phase si clôturée) jusqu'à `max(floor_contrat, floor_data_start_date)`
```
- [ ] **Step 4 : `CLAUDE.md` — section Onglet RTT (ligne 83)**
Remplacer la phrase :
```
Même mécanique que l'onglet Congés (double plancher) : `max(floor_contrat, floor_rttStartDate)`.
```
par :
```
Même mécanique que l'onglet Congés : borne haute = exercice suivant (courant + 1) sur phase ouverte, double plancher `max(floor_contrat, floor_rttStartDate)`.
```
- [ ] **Step 5 : `frontend/data/documentation-content.ts` — paragraphe Congés (ligne 483)**
Remplacer le contenu du paragraphe :
```ts
{ type: 'paragraph', content: 'Un sélecteur d\'année est disponible en bas du calendrier (zone scrollable, à gauche). Il permet de consulter les exercices passés. La plage proposée part de l\'exercice courant et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel — il est inutile de remonter plus loin, aucune donnée n\'a été saisie avant.' },
```
par :
```ts
{ type: 'paragraph', content: 'Un sélecteur d\'année est disponible en bas du calendrier (zone scrollable, à gauche). Il permet de consulter l\'exercice suivant ainsi que les exercices passés. La plage proposée part de l\'exercice suivant (l\'exercice à venir, pour consulter en avance les congés déjà posés) et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel — il est inutile de remonter plus loin, aucune donnée n\'a été saisie avant.' },
```
- [ ] **Step 6 : `frontend/data/documentation-content.ts` — paragraphe RTT (ligne 539)**
Remplacer le contenu du paragraphe :
```ts
{ type: 'paragraph', content: 'Un sélecteur d\'exercice est disponible en bas du tableau RTT (zone scrollable, à gauche). Il permet de consulter les exercices passés (Juin → Mai). La plage proposée part de l\'exercice courant et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel.' },
```
par :
```ts
{ type: 'paragraph', content: 'Un sélecteur d\'exercice est disponible en bas du tableau RTT (zone scrollable, à gauche). Il permet de consulter l\'exercice suivant ainsi que les exercices passés (Juin → Mai). La plage proposée part de l\'exercice suivant et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel.' },
```
- [ ] **Step 7 : Vérifier la cohérence des chaînes éditées**
Run: `grep -n "exercice suivant" frontend/data/documentation-content.ts doc/leave-tab.md doc/rtt-tab.md CLAUDE.md`
Expected: les 6 emplacements modifiés ci-dessus apparaissent (2 dans documentation-content.ts, 1 dans chaque doc, 2 dans CLAUDE.md).
- [ ] **Step 8 : Commit**
```bash
git add doc/leave-tab.md doc/rtt-tab.md CLAUDE.md frontend/data/documentation-content.ts
git commit -m "docs : sélecteurs Congés/RTT proposent l'exercice suivant
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
## Self-Review
**1. Couverture de la spec :**
- « Borne haute = exercice courant + 1 sur phase ouverte, fin de phase si clôturée » → Task 1, Steps 1-2. ✓
- « Forfait (année civile) et non-forfait » → le `+1` porte sur le numéro d'exercice produit par `computeLeaveYearForDate`/`computeRttYearForDate`, donc valable pour les deux règles. ✓
- « Sélection par défaut inchangée » → aucun changement à `initSelected*Year` ; vérifié en Step 4. ✓
- « Verrouillage des éditions / pas de bandeau passé » → aucun changement à `isHistoricalYear` ; vérifié en Step 4. ✓
- « Périmètre Congés + RTT » → Task 1 touche les deux composables. ✓
- « Docs : leave-tab.md, rtt-tab.md, CLAUDE.md, documentation-content.ts » → Task 2. ✓
- « Tests backend restent verts » → hook pre-commit, Task 1 Step 5. ✓
**2. Placeholders :** aucun « TBD/TODO » ; tout le code et toutes les chaînes sont fournis. La Step 1 de Task 2 prévoit le cas où l'ancre exacte diffère (instruction de repli explicite).
**3. Cohérence des types/noms :** la variable `maxYear` est désormais déclarée en amont dans les deux composables ; la ligne `const maxYear = phaseEndYear` est supprimée ; `phaseEndYear` n'existe plus (vérifié Step 3). La boucle `for (let y = maxYear; y >= minYear; ...)` reste valide.
@@ -0,0 +1,307 @@
# Vue Jour — contrat résolu à la date affichée — 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:** Faire que l'écran Heures — vue Jour affiche/sauvegarde la saisie d'heures (TIME) vs cases de présence (PRESENCE) et le libellé de contrat selon le contrat valable **à la date affichée**, et non le contrat courant de l'employé.
**Architecture:** Le provider backend `WorkHourDayContextProvider` résout déjà le contrat à la date demandée. On expose 4 champs de contrat supplémentaires sur la ligne du jour (`DayContextRow`), on les reflète dans le DTO TS, puis le composable `useHoursPage` lit ces champs par date (fallback sur `employee.contract` si pas de ligne du jour). `handleSave` en hérite automatiquement.
**Tech Stack:** Symfony / API Platform (PHP 8.4, PHPUnit) côté backend ; Nuxt 4 / Vue 3 / TypeScript côté frontend.
Spec : `docs/superpowers/specs/2026-06-01-day-view-per-date-tracking-mode-design.md`
---
## File Structure
- `src/Dto/WorkHours/DayContextRow.php` — DTO ligne du jour : +4 champs.
- `src/State/WorkHourDayContextProvider.php` — peuple les 4 champs depuis le contrat du jour.
- `tests/State/WorkHourDayContextProviderTest.php` — test de résolution par date.
- `frontend/services/dto/work-hour.ts` — type `WorkHourDayContextRow` : +4 champs.
- `frontend/composables/useHoursPage.ts` — helpers résolus par date.
- `doc/` + `frontend/data/documentation-content.ts` + `CLAUDE.md` — documentation.
---
### Task 1: Backend — exposer le contrat du jour sur `DayContextRow`
**Files:**
- Modify: `src/Dto/WorkHours/DayContextRow.php`
- Modify: `src/State/WorkHourDayContextProvider.php:60-71`
- Test: `tests/State/WorkHourDayContextProviderTest.php`
- [ ] **Step 1: Écrire le test en échec**
Ajouter cette méthode dans `tests/State/WorkHourDayContextProviderTest.php` (après `testBuildsRowsWithAbsenceCredits`). Elle vérifie que la ligne porte le contrat **à la date demandée** pour un employé 39h→Forfait. On remplace le resolver stub par un callback dépendant de la date.
```php
public function testRowCarriesContractAtRequestedDate(): void
{
$user = new User();
$timeContract = new Contract()
->setName('Contrat')
->setTrackingMode(Contract::TRACKING_TIME)
->setWeeklyHours(39)
;
$forfaitContract = new Contract()
->setName('Forfait')
->setTrackingMode(Contract::TRACKING_PRESENCE)
->setWeeklyHours(null)
;
$employee = new Employee()
->setFirstName('Jean')
->setLastName('Test')
->setContract($forfaitContract)
;
$this->setEntityId($employee, 1);
// Resolver renvoie le contrat 39h avant 2026-03-01, le forfait à partir de cette date.
$resolver = $this->createStub(EmployeeContractResolver::class);
$resolver->method('resolveForEmployeeAndDate')->willReturnCallback(
static fn (Employee $e, \DateTimeImmutable $d): ?Contract =>
$d < new \DateTimeImmutable('2026-03-01') ? $timeContract : $forfaitContract
);
$resolver->method('resolveNatureForEmployeeAndDate')->willReturn(ContractNature::CDI);
$this->requestStack->push(new Request(query: ['workDate' => '2026-02-16']));
$this->security->method('getUser')->willReturn($user);
$this->employeeRepository->method('findScoped')->with($user)->willReturn([$employee]);
$this->absenceRepository->method('findByDateAndEmployees')->willReturn([]);
$provider = new WorkHourDayContextProvider(
$this->security,
$this->requestStack,
$this->employeeRepository,
$this->absenceRepository,
$this->formationRepository,
$resolver,
new AbsenceSegmentsResolver(),
new WorkedHoursCreditPolicy($this->buildResolverStub(), new DailyReferenceMinutesResolver()),
$this->buildHolidayResolver(),
);
$row = $provider->provide(new Get())->rows[0];
self::assertSame('TIME', $row['trackingMode']);
self::assertSame(39, $row['weeklyHours']);
self::assertSame('39H', $row['contractType']);
self::assertSame('Contrat', $row['contractName']);
}
```
- [ ] **Step 2: Lancer le test, vérifier l'échec**
Run: `make test`
Expected: FAIL — `Undefined array key "trackingMode"` (le DTO ne porte pas encore le champ).
- [ ] **Step 3: Ajouter les 4 champs au DTO**
Dans `src/Dto/WorkHours/DayContextRow.php`, ajouter au constructeur après `public ?string $contractNature = null,` :
```php
public ?string $trackingMode = null,
public ?int $weeklyHours = null,
public ?string $contractType = null,
public ?string $contractName = null,
```
Mettre à jour le PHPDoc de `toArray()` (ajouter les 4 clés après `contractNature:?string`) :
```php
* contractNature:?string,
* trackingMode:?string,
* weeklyHours:?int,
* contractType:?string,
* contractName:?string
```
Et le corps de `toArray()`, après `'contractNature' => $this->contractNature,` :
```php
'trackingMode' => $this->trackingMode,
'weeklyHours' => $this->weeklyHours,
'contractType' => $this->contractType,
'contractName' => $this->contractName,
```
- [ ] **Step 4: Peupler les champs dans le provider**
Dans `src/State/WorkHourDayContextProvider.php`, dans l'appel `new DayContextRow(...)` (lignes 65-71), ajouter après `contractNature: $contractNature,` :
```php
trackingMode: $contract?->getTrackingMode(),
weeklyHours: $contract?->getWeeklyHours(),
contractType: $contract?->getType()->value,
contractName: $contract?->getName(),
```
- [ ] **Step 5: Lancer le test, vérifier le succès**
Run: `make test`
Expected: PASS (le nouveau test et les 150 autres ; le test legacy `EmployeeRttSummaryProviderTest::testNoQueryParamsKeepsLegacyYearDefaulting` peut rester rouge — pré-existant, dépendant de la date, hors périmètre).
- [ ] **Step 6: Commit**
```bash
git add src/Dto/WorkHours/DayContextRow.php src/State/WorkHourDayContextProvider.php tests/State/WorkHourDayContextProviderTest.php
git commit -m "[#SIRH] Vue jour: exposer le contrat du jour sur DayContextRow"
```
---
### Task 2: Frontend — lire le contrat par date dans la vue Jour
**Files:**
- Modify: `frontend/services/dto/work-hour.ts:103-118`
- Modify: `frontend/composables/useHoursPage.ts`
- [ ] **Step 1: Refléter les 4 champs dans le DTO TS**
Dans `frontend/services/dto/work-hour.ts`, type `WorkHourDayContextRow`, ajouter après `contractNature?: ...` (ligne 117) :
```typescript
trackingMode?: TrackingMode | null
weeklyHours?: number | null
contractType?: string | null
contractName?: string | null
```
(`TrackingMode` est déjà importé dans ce fichier — utilisé ligne 73.)
- [ ] **Step 2: Ajouter un résolveur de contrat par date dans le composable**
Dans `frontend/composables/useHoursPage.ts`, juste avant `const isPresenceTracking` (ligne 353), insérer :
```typescript
// Résout le contrat à la date affichée (ligne du jour), avec repli sur le contrat courant.
const resolveDayContract = (employee: Employee) => {
const dayRow = dayContextByEmployeeId.value.get(employee.id)
if (dayRow?.hasContractAtDate) {
return {
trackingMode: dayRow.trackingMode ?? null,
weeklyHours: dayRow.weeklyHours ?? null,
type: dayRow.contractType ?? null,
name: dayRow.contractName ?? ''
}
}
return {
trackingMode: employee.contract?.trackingMode ?? null,
weeklyHours: employee.contract?.weeklyHours ?? null,
type: employee.contract?.type ?? null,
name: employee.contract?.name ?? ''
}
}
```
- [ ] **Step 3: Brancher les helpers sur le contrat du jour**
Toujours dans `frontend/composables/useHoursPage.ts`, remplacer les définitions actuelles (lignes 353-358 et 367-377).
Remplacer :
```typescript
const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === TRACKING_MODES.PRESENCE
const isTimeTracking = (employee: Employee) => !isPresenceTracking(employee)
const is4hContract = (employeeId: number) => {
const employee = employees.value.find((e) => e.id === employeeId)
return employee?.contract?.weeklyHours === 4
}
```
par :
```typescript
const isPresenceTracking = (employee: Employee) => resolveDayContract(employee).trackingMode === TRACKING_MODES.PRESENCE
const isTimeTracking = (employee: Employee) => !isPresenceTracking(employee)
const is4hContract = (employeeId: number) => {
const employee = employees.value.find((e) => e.id === employeeId)
return employee ? resolveDayContract(employee).weeklyHours === 4 : false
}
```
Remplacer :
```typescript
const contractLabel = (employee: Employee) => {
const contract = employee.contract
if (!contract) return '-'
if (contract.type === CONTRACT_TYPES.INTERIM) {
return contract.name
}
if (contract.weeklyHours !== null && contract.weeklyHours !== undefined && contract.trackingMode === TRACKING_MODES.TIME) {
return `${contract.weeklyHours}h`
}
return contract.name
}
```
par :
```typescript
const contractLabel = (employee: Employee) => {
const contract = resolveDayContract(employee)
if (!contract.type && !contract.name) return '-'
if (contract.type === CONTRACT_TYPES.INTERIM) {
return contract.name
}
if (contract.weeklyHours !== null && contract.weeklyHours !== undefined && contract.trackingMode === TRACKING_MODES.TIME) {
return `${contract.weeklyHours}h`
}
return contract.name
}
```
- [ ] **Step 4: Vérification statique de lecture**
Relire le diff : `resolveDayContract` est défini après `dayContextByEmployeeId` (ligne 201) et `employees` — donc disponible. `isPresenceTracking` reste de signature `(employee: Employee)` ⇒ aucun appelant à modifier (`HoursDayView.vue`, `handleSave` ligne 1073 inchangés). `CONTRACT_TYPES`/`TRACKING_MODES` déjà importés (ligne 8).
- [ ] **Step 5: Commit**
```bash
git add frontend/services/dto/work-hour.ts frontend/composables/useHoursPage.ts
git commit -m "[#SIRH] Vue jour: saisie/présence et libellé résolus à la date affichée"
```
---
### Task 3: Documentation
**Files:**
- Modify: `doc/functional-rules.md`
- Modify: `frontend/data/documentation-content.ts`
- Modify: `CLAUDE.md`
- [ ] **Step 1: Doc fonctionnelle**
Il n'existe pas de doc dédiée « Heures » dans `doc/` ; ajouter le paragraphe suivant dans `doc/functional-rules.md` (section traitant des écrans Heures / du contrat, ou en fin de fichier sous un titre `## Vue Jour — contrat à la date affichée`) :
> **Vue Jour (Heures) — contrat à la date affichée** : le mode de suivi (saisie d'heures vs cases de présence), le libellé de contrat et la logique de sauvegarde sont résolus selon la période de contrat valable à la date filtrée (champs `trackingMode`/`weeklyHours`/`contractType`/`contractName` portés par `WorkHourDayContext`), et non selon le contrat courant de l'employé. Un salarié passé 39h/35h → Forfait conserve donc la saisie d'heures sur ses dates antérieures à la bascule, et bascule en cases de présence à partir de la date de passage en forfait.
- [ ] **Step 2: Doc in-app**
Dans `frontend/data/documentation-content.ts`, repérer la section/article de l'écran « Heures » (`grep -n "Heures" frontend/data/documentation-content.ts`) et ajouter un bloc texte :
> Sur la vue Jour, l'affichage (saisie d'heures ou présence) et le libellé de contrat correspondent au contrat de l'employé à la date consultée. Si un salarié a changé de type de contrat (ex. passage en forfait), les jours antérieurs restent affichés selon l'ancien contrat.
- [ ] **Step 3: CLAUDE.md**
Dans `CLAUDE.md`, section « Écrans Heures / Heures Conducteurs (vue jour) », compléter la puce existante sur `contractNature` par une phrase :
> Idem pour le **mode de suivi 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). Le composable lit `resolveDayContract()` (`useHoursPage.ts`), ce qui pilote aussi `handleSave` (heures vs présence par date).
- [ ] **Step 4: Commit**
```bash
git add doc/ frontend/data/documentation-content.ts CLAUDE.md
git commit -m "docs: vue jour contrat à la date affichée (doc + in-app + CLAUDE.md)"
```
---
## Vérification finale (manuelle, par l'utilisateur)
- Sur la fiche d'un salarié passé 39h/35h → Forfait, écran Heures vue Jour :
- naviguer une date **avant** la bascule → champs de saisie d'heures, libellé `39h`/`35h` ;
- naviguer une date **après** la bascule → cases de présence, libellé `Forfait` ;
- éditer puis enregistrer une date avant la bascule → les heures sont conservées (pas de flags présence).
@@ -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 `(1200)48 = +72` ; appliqué au naturel `120` ⇒ semaine **48 min**.
- travaillé normalement (`worked = 120`) → delta `(120120)48 = 48` ; naturel `0`**48 min**.
- travaillé en plus (`worked = 240`) → delta `(120240)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,94 @@
# Afficher l'en-cours d'acquisition « net / brut » sur l'onglet Congés
**Date** : 2026-05-26
**Ticket** : SIRH-32 (retour RH)
**Statut** : design validé
## Contexte
Sur l'onglet **Congés** de la fiche employé, la case **« En cours d'acquisition »**
affiche un seul nombre. Ce nombre est l'en-cours **net** : quand un salarié pose
des congés **en anticipé** (au-delà de son report acquis), ces jours sont imputés
sur la génération de l'exercice et **réduisent** l'en-cours affiché.
La RH a besoin de voir aussi le **total brut généré** sur l'exercice à ce jour,
afin de connaître ce qui a réellement été acquis indépendamment de ce qui a déjà
été consommé en anticipé.
Nouveau format demandé : `{net} / {brut}` — ex. `14,50 / 17,50`.
## Définitions (non-forfait, `CDI_CDD_NON_FORFAIT`)
Dans `EmployeeLeaveSummaryProvider::computeYearSummary` :
- `generatedDays` / `generatedSaturdays` = acquisition **brute** de l'exercice à
ce jour (prorata mensuel, capée à aujourd'hui pour l'exercice courant).
- `remainingToImpute` (+ samedis) = congés pris au-delà du report acquis, imputés
sur la génération.
- `accruingDays` (champ existant, **numérateur**) =
`(generatedDays remainingToImpute) + (generatedSaturdays remainingSaturdaysToImpute)`
→ en-cours **net**.
- **Nouveau** : `accruingDaysTotal` (**dénominateur**) = `generatedDays + generatedSaturdays`
→ brut généré à ce jour, avant imputation des congés anticipés.
Invariant : `accruingDays ≤ accruingDaysTotal`. La différence = jours pris en
anticipé imputés sur la génération.
Côté **forfait** (`FORFAIT_218`) : pas d'acquisition « en cours »
(`accruingDays = 0`) → `accruingDaysTotal = 0`.
## Changements
### 1. Backend
- `EmployeeLeaveSummaryProvider::computeYearSummary` : ajouter
`accruingDaysTotal` au tableau retourné.
- Branche non-forfait : `generatedDays + generatedSaturdays`.
- Branche forfait : `0.0`.
- `App\ApiResource\EmployeeLeaveSummary` : nouvelle propriété publique
`float $accruingDaysTotal = 0.0`.
- Le provider recopie `yearSummary['accruingDaysTotal']` dans
`$summary->accruingDaysTotal` (à côté de `accruingDays`). Sur retour anticipé
(`yearSummary === null`), la valeur reste `0.0` (comme `accruingDays`).
### 2. Frontend
- `frontend/services/dto/employee-leave-summary.ts` : ajouter
`accruingDaysTotal: number`.
- `frontend/components/employees/LeaveTab.vue`, case « En cours d'acquisition » :
- **Non-forfait** : afficher `{{ formatCount(accruingDays) }} / {{ formatCount(accruingDaysTotal) }}`.
- **Forfait** : inchangé (afficher `accruingDays` seul, soit `0`).
- Réutiliser `isForfaitRule` (déjà présent) pour la condition.
### 3. Documentation
- `doc/leave-tab.md` : décrire le format `net / brut` de l'en-cours d'acquisition.
- `frontend/data/documentation-content.ts` : note expliquant que l'en-cours
affiche `net / total brut acquis` et que les congés anticipés réduisent le net.
## Comportements conservés
- Quand aucun congé anticipé n'est pris : numérateur = dénominateur
(ex. `17,50 / 17,50`). C'est voulu — la RH veut le total visible en permanence.
- Les autres compteurs du header (Année acquis, Pris, Reste à prendre, Samedis,
N-1…) sont inchangés.
- Aucun changement de calcul : `accruingDaysTotal` est une **exposition** d'une
valeur déjà calculée (`generatedDays + generatedSaturdays`), pas une nouvelle
règle métier.
## Hors périmètre
- Onglet RTT (pas d'en-cours d'acquisition).
- Écran Récap congés (pas de colonne en-cours d'acquisition).
- Header de la fiche employé (présence / jours à travailler) — inchangé.
- Affichage de la fraction pour le forfait (pas d'en-cours → non pertinent).
## Tests
- Backend : ajouter un test sur `computeYearSummary` (via le harnais de tests
existant du provider, par réflexion) vérifiant que `accruingDaysTotal` =
`generatedDays + generatedSaturdays` et `≥ accruingDays` dans un cas avec congés
anticipés. À défaut de chemin testable simple (collaborateurs `final`), couvrir
l'arithmétique exposée et vérifier manuellement l'affichage.
- Pas de harnais de test frontend ; vérification manuelle de l'affichage
`net / brut` (non-forfait) et `0` (forfait).
@@ -0,0 +1,118 @@
# Proposer toujours l'exercice suivant dans les sélecteurs Congés et RTT
**Date** : 2026-05-26
**Ticket** : SIRH-32
**Statut** : design validé
## Contexte
Les onglets **Congés** et **RTT** de la fiche employé proposent un sélecteur
d'exercice (`availableLeaveYears` / `availableRttYears`) dont la borne haute est
plafonnée à l'**exercice courant**. La RH a commencé à poser des congés sur
l'**exercice suivant**, mais ne peut pas le consulter dans la fiche employé :
l'exercice suivant n'apparaît pas dans le menu déroulant.
On veut que le sélecteur propose **toujours** l'exercice suivant pour une phase
de contrat ouverte, afin que ce besoin ne ressurgisse jamais.
## Faisabilité — déjà supportée côté backend
Aucun changement backend n'est nécessaire :
- `EmployeeLeaveSummaryProvider::clampYearToPhase` et son équivalent RTT ne
plafonnent **pas** vers le haut quand la phase est ouverte (`phase.endDate`
nul → `lastYear = null`). Une requête `?year=<exercice+1>&phaseId=<phase ouverte>`
est donc déjà calculée correctement.
- La validation `year` (20002100) couvre largement l'exercice suivant.
Le seul blocage est le **frontend**, qui calcule `maxYear = exercice courant`.
## Le changement
Frontend uniquement. Dans `frontend/composables/useEmployeeLeave.ts`
(`availableLeaveYears`) et `frontend/composables/useEmployeeRtt.ts`
(`availableRttYears`), la borne haute devient :
- **Phase ouverte** (pas de `phase.endDate`) :
`maxYear = exercice courant + 1`.
- **Phase clôturée** (`phase.endDate` présent) : **inchangé**
`maxYear = exercice de fin de phase` (on ne propose pas au-delà d'une phase
terminée).
Le `+1` porte sur le **numéro d'exercice**, donc il est correct pour le forfait
(année civile) comme pour le non-forfait (Juin N-1 → Mai N), via
`computeLeaveYearForDate` / `computeRttYearForDate`.
### Pseudo-code de la borne
```
maxYear = phase.endDate
? computeYearForDate(phase.endDate) // phase clôturée : cap à la fin de phase
: currentYear + 1 // phase ouverte : on propose l'exercice suivant
```
La borne basse (`minYear = max(phaseStartYear, dataFloor)`) est **inchangée**.
## Comportements conservés
- **Sélection par défaut** : inchangée. L'onglet s'ouvre toujours sur l'exercice
**courant** ; l'exercice suivant est seulement disponible dans le menu (pas de
saut automatique sur le futur). `initSelected*Year` continue d'initialiser sur
`current*Year`, qui reste dans la plage `[minYear ; maxYear]`.
- **Verrouillage des éditions** : `isHistoricalYear` (`selectedYear !== currentYear`)
reste tel quel. Sur l'exercice suivant, les boutons **Jours fractionnés**,
**Année N-1 payés** (onglet Congés) et **+ Payer les RTT** (onglet RTT) sont
**désactivés** — souhaitable : pas d'édition de stocks ni de paiement sur un
exercice pas encore démarré.
- **Aucune mention « passé » trompeuse** : le bandeau « Vous consultez
l'historique » est piloté par la phase (`isViewingPastPhase`), pas par l'année
sélectionnée ; sélectionner un exercice futur ne l'affiche pas.
## Affichage des congés posés sur l'exercice suivant (réponse Q1)
Le header congés (grille de compteurs de l'onglet + libellé présence du header
de fiche) reflète **le récap de l'exercice sélectionné**. Chaque récap est
calculé sur sa fenêtre `[from, to]` ; les absences/jours pris ne sont comptés que
dans cette fenêtre.
- **Sur l'exercice courant** (vue par défaut) : les congés posés sur l'exercice
suivant **n'apparaissent pas** dans les compteurs — comportement correct.
- **Sur l'exercice suivant** (sélectionné) : ils s'affichent (calendrier +
compteur « Pris »).
### Caveat fonctionnel
Sur l'exercice suivant, les compteurs **report / Année N-1 / reste** sont
**provisoires** jusqu'à la clôture de l'exercice courant (ils en dépendent). En
revanche, le **« Pris » et le calendrier** des congés posés sont exacts. À
communiquer à la RH.
## Périmètre
- Onglet **Congés** et onglet **RTT** (les deux sélecteurs partagent la même
mécanique).
- Forfait (année civile) et non-forfait (Juin→Mai).
## Hors périmètre
- Aucune modification de la mécanique de saisie d'absences (la RH pose déjà des
congés sur l'exercice suivant via les écrans Calendrier / Heures, indépendamment
de ce sélecteur).
- Pas de proposition de plusieurs exercices futurs (un seul : N+1).
- Pas d'activation des éditions de stocks/paiement sur l'exercice futur.
## Documentation à mettre à jour (règle obligatoire CLAUDE.md)
- `doc/leave-tab.md` — plage du sélecteur.
- `doc/rtt-tab.md` — plage du sélecteur.
- `CLAUDE.md` — sections « Onglet Congés » et « Onglet RTT » (description de la
plage `max(...)` → borne haute `exercice courant + 1` sur phase ouverte).
- `frontend/data/documentation-content.ts` — documentation in-app.
## Tests
Pas de harnais de test frontend dans le projet (backend PHPUnit uniquement). La
modification est de la logique de calcul de plage dans deux `computed` :
vérification manuelle (dev Nuxt) que l'exercice suivant apparaît dans les deux
sélecteurs pour une phase ouverte, et n'apparaît pas pour une phase clôturée. Les
tests backend existants doivent rester verts (aucun changement backend).
@@ -0,0 +1,88 @@
# Vue Jour (Heures) — résolution du contrat à la date affichée
Date : 2026-06-01
## Problème
Sur l'écran **Heures — vue Jour** (`HoursDayView`), l'affichage saisie d'heures (TIME)
vs cases de présence (PRESENCE), ainsi que le libellé de contrat entre parenthèses,
sont résolus à partir de `employee.contract` — c'est-à-dire le **contrat courant**
de l'employé (résolu à aujourd'hui), pas le contrat valable à la date affichée.
Conséquence pour un salarié passé d'un contrat 39h/35h (TIME) à un Forfait (PRESENCE) :
- toutes les dates **passées** s'affichent en cases de présence alors qu'elles
relevaient d'un contrat en heures ;
- pire, `handleSave` (`useHoursPage.ts:1073`) se base sur le même test : éditer
une date passée écrit des **flags de présence** au lieu des heures et écrase la saisie.
La **vue Semaine** est déjà correcte : elle résout le `trackingMode` par date côté
backend via `WeeklySummaryRow.trackingMode`. Le périmètre de ce correctif est donc
la **vue Jour uniquement**.
## Principe
Le provider backend `WorkHourDayContextProvider::provide()` résout **déjà** le contrat
à la date affichée (`EmployeeContractResolver::resolveForEmployeeAndDate`) et expose
déjà `contractNature` par date sur chaque ligne. Il suffit :
1. d'exposer sur la ligne du jour les champs de contrat manquants ;
2. de faire lire ces champs au frontend (au lieu de `employee.contract`).
L'ensemble de la ligne (toggle saisie/présence + libellé 39h/Forfait + logique 4h)
devient ainsi cohérent avec le contrat valable à la date affichée.
## Changements
### Backend
1. **`src/Dto/WorkHours/DayContextRow.php`** — ajouter 4 champs au constructeur et à
`toArray()` (+ mettre à jour le PHPDoc du retour de `toArray()`) :
- `trackingMode: ?string`
- `weeklyHours: ?int`
- `contractType: ?string`
- `contractName: ?string`
2. **`src/State/WorkHourDayContextProvider.php`** — peupler ces champs depuis le
`$contract` déjà résolu (lignes 60-71) :
- `trackingMode` = `$contract?->getTrackingMode()`
- `weeklyHours` = `$contract?->getWeeklyHours()`
- `contractType` = `$contract?->getType()->value`
- `contractName` = `$contract?->getName()`
- tous `null` si pas de contrat à la date (cohérent avec `hasContractAtDate`).
### Frontend
3. **`frontend/services/dto/work-hour.ts`** — refléter les 4 champs sur
`WorkHourDayContextRow`.
4. **`frontend/composables/useHoursPage.ts`** — `isPresenceTracking`, `isTimeTracking`,
`contractLabel`, `is4hContract` lisent le `dayContextByEmployeeId.get(employeeId)`
(résolu par date), avec **fallback** sur `employee.contract` si aucune ligne du jour
n'existe. Cela corrige automatiquement `handleSave` (ligne 1073), qui s'appuie sur
`isPresenceTracking`.
Les signatures actuelles prennent un `Employee` ; on conserve la signature et on
utilise `employee.id` en interne pour récupérer la ligne du jour.
## Hors périmètre
- Vue Semaine (déjà par date).
- Heures Conducteurs (toujours en mode TIME, pas de toggle).
- Processor de sauvegarde backend : inchangé — le frontend enverra déjà la bonne
forme (heures vs présence) par date.
## Tests / vérification
- **Test backend** (`WorkHourDayContextProvider`) : pour un employé avec historique
39h → Forfait, la ligne renvoyée porte `trackingMode=TIME`/`weeklyHours=39`/
`contractType` non-forfait sur une date **avant** la bascule, et
`trackingMode=PRESENCE`/`contractType=FORFAIT` sur une date **après**.
- **Vérification manuelle** : naviguer une date avant et après la bascule sur la fiche
du salarié → champs d'heures puis cases de présence, libellé cohérent.
## Documentation (règle obligatoire)
- `doc/` : section vue Jour — résolution du contrat (mode + libellé) à la date affichée.
- `frontend/data/documentation-content.ts` : note utilisateur correspondante.
- `CLAUDE.md` : préciser que la vue Jour résout `trackingMode`/libellé **à la date
filtrée** (au même titre que `contractNature` déjà documenté).
@@ -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 (3638h) | Hors périmètre → pas de déficit. |
| 35h/39h avec RTT posé | Inchangé (drainage ~7h via la cascade existante). |
| Autre déficit/surplus la même semaine | Le forfait s'y cumule. |
## 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 -1
View File
@@ -1,5 +1,5 @@
<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">
<div class="absolute inset-0 bg-black/40" @click="close" />
</Transition>
+1 -1
View File
@@ -12,7 +12,7 @@
{{ formatCount(summary?.remainingDays) }} Jours
</p>
<p class="col-start-4 p-[10px] border-b border-primary-500"><strong class="uppercase font-semibold">En cours d'acquisition :</strong>
{{ formatCount(summary?.accruingDays) }} Jours
{{ isForfaitRule ? formatCount(summary?.accruingDays) : `${formatCount(summary?.accruingDays)} / ${formatCount(summary?.accruingDaysTotal)}` }}
</p>
<p v-if="!isForfaitRule" class="col-start-1 p-[10px] border-r border-primary-500"><span class="uppercase font-semibold">Samedi acquis :</span>
{{ formatCount(summary?.acquiredSaturdays) }} Jours
+9 -1
View File
@@ -313,8 +313,16 @@ const isLastExerciseOfPhase = computed(() => {
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(() =>
isHistoricalYear.value && !isLastExerciseOfPhase.value
isHistoricalYear.value && !isLastExerciseOfPhase.value && !isPreviousExercise.value
)
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>
+12 -5
View File
@@ -51,9 +51,11 @@ export const useEmployeeLeave = (
// Plage = exercices intersectant la phase.
const phaseStartYear = computeLeaveYearForDate(new Date(`${phase.startDate}T00:00:00`))
const phaseEndYear = phase.endDate
// Borne haute : fin de phase si clôturée ; sinon l'exercice SUIVANT (courant + 1),
// pour pouvoir consulter en avance les congés posés sur l'exercice à venir.
const maxYear = phase.endDate
? computeLeaveYearForDate(new Date(`${phase.endDate}T00:00:00`))
: currentLeaveYear.value
: currentLeaveYear.value + 1
// Hard floor : data-start-date (env RTT_START_DATE) — le logiciel n'a pas
// d'historique avant cette date, inutile de proposer des années antérieures.
@@ -67,7 +69,6 @@ export const useEmployeeLeave = (
}
const minYear = dataFloor !== null ? Math.max(phaseStartYear, dataFloor) : phaseStartYear
const maxYear = phaseEndYear
const years: LeaveYearOption[] = []
for (let y = maxYear; y >= minYear; y -= 1) {
@@ -124,8 +125,14 @@ export const useEmployeeLeave = (
selectedLeaveYear.value = null
}
watch(() => selectedPhase.value?.id, () => {
// Reset l'année car la plage a peut-être changé.
watch(() => selectedPhase.value?.id, (newId, oldId) => {
// Ignore la résolution initiale (undefined → phase courante au montage) :
// le chargement eager du récap initialise déjà l'année sélectionnée. Sans ce
// garde, ce watch (asynchrone) s'exécute PENDANT l'await du chargement eager et
// remet selectedLeaveYear à null, ce qui laisse le sélecteur d'exercice vide.
// Même convention que le watch de useEmployeeDetailPage.
if (oldId === undefined || newId === oldId) return
// Changement de phase réel : reset l'année car la plage a peut-être changé.
selectedLeaveYear.value = null
leaveDataLoaded.value = false
// Le rechargement effectif est piloté par useEmployeeDetailPage.
+10 -4
View File
@@ -35,7 +35,10 @@ export const useEmployeeRtt = (
// Plage = exercices intersectant la phase.
const phaseStartYear = computeRttYearForDate(new Date(`${phase.startDate}T00:00:00`))
const phaseEndYear = phase.endDate
// Borne haute : fin de phase si clôturée ; sinon l'exercice courant.
// Contrairement à l'onglet Congés, on NE propose PAS l'exercice suivant en RTT :
// consulter un exercice RTT à venir (heures non saisies, rien à payer) n'a pas de sens.
const maxYear = phase.endDate
? computeRttYearForDate(new Date(`${phase.endDate}T00:00:00`))
: currentRttYear.value
@@ -50,7 +53,6 @@ export const useEmployeeRtt = (
}
const minYear = dataFloor !== null ? Math.max(phaseStartYear, dataFloor) : phaseStartYear
const maxYear = phaseEndYear
const years: RttYearOption[] = []
for (let y = maxYear; y >= minYear; y -= 1) {
@@ -95,8 +97,12 @@ export const useEmployeeRtt = (
selectedRttYear.value = null
}
watch(() => selectedPhase.value?.id, () => {
// Reset l'année car la plage a peut-être changé.
watch(() => selectedPhase.value?.id, (newId, oldId) => {
// Ignore la résolution initiale (undefined → phase courante au montage) :
// l'initialisation de l'année est pilotée par loadRttData. Même convention que
// le watch de useEmployeeDetailPage (évite un reset concurrent du sélecteur).
if (oldId === undefined || newId === oldId) return
// Changement de phase réel : reset l'année car la plage a peut-être changé.
selectedRttYear.value = null
rttDataLoaded.value = false
// Le rechargement effectif est piloté par useEmployeeDetailPage.
+23 -4
View File
@@ -350,11 +350,30 @@ export const useHoursPage = () => {
updatedAt: null
})
const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === TRACKING_MODES.PRESENCE
// Résout le contrat à la date affichée (ligne du jour), avec repli sur le contrat courant.
const resolveDayContract = (employee: Employee) => {
const dayRow = dayContextByEmployeeId.value.get(employee.id)
if (dayRow?.hasContractAtDate) {
return {
trackingMode: dayRow.trackingMode ?? null,
weeklyHours: dayRow.weeklyHours ?? null,
type: dayRow.contractType ?? null,
name: dayRow.contractName ?? ''
}
}
return {
trackingMode: employee.contract?.trackingMode ?? null,
weeklyHours: employee.contract?.weeklyHours ?? null,
type: employee.contract?.type ?? null,
name: employee.contract?.name ?? ''
}
}
const isPresenceTracking = (employee: Employee) => resolveDayContract(employee).trackingMode === TRACKING_MODES.PRESENCE
const isTimeTracking = (employee: Employee) => !isPresenceTracking(employee)
const is4hContract = (employeeId: number) => {
const employee = employees.value.find((e) => e.id === employeeId)
return employee?.contract?.weeklyHours === 4
return employee ? resolveDayContract(employee).weeklyHours === 4 : false
}
const isRowLocked = (employeeId: number) => {
const row = rows.value[employeeId]
@@ -365,8 +384,8 @@ export const useHoursPage = () => {
}
const contractLabel = (employee: Employee) => {
const contract = employee.contract
if (!contract) return '-'
const contract = resolveDayContract(employee)
if (!contract.type && !contract.name) return '-'
if (contract.type === CONTRACT_TYPES.INTERIM) {
return contract.name
}
+30 -8
View File
@@ -29,6 +29,7 @@ export const documentationSections: DocSection[] = [
{ type: 'list', content: 'Boutons "Hier" / "Aujourd\'hui" / "Demain" pour naviguer rapidement\nSélecteur de date pour choisir une date spécifique\nFiltrage par site si vous avez accès à plusieurs sites' },
{ type: 'paragraph', content: 'Seuls les employés ayant un contrat actif à la date sélectionnée sont affichés.' },
{ type: 'note', content: 'Sous le nom de l\'employé, la nature du contrat (CDI / CDD / Intérim) affichée correspond à la période couvrant la date filtrée, et non à aujourd\'hui.' },
{ type: 'paragraph', content: 'Sur la vue Jour, l\'affichage (saisie d\'heures ou cases de présence) et le libellé de contrat correspondent au contrat de l\'employé à la date consultée. Si un salarié a changé de type de contrat (par exemple un passage en forfait), les jours antérieurs à ce changement restent affichés selon l\'ancien contrat.' },
],
},
{
@@ -80,6 +81,17 @@ export const documentationSections: DocSection[] = [
{ type: 'list', content: 'Jour : total des heures dans la plage 06:0021:00\nNuit : total des heures dans les plages 00:0006:00 et 21:0024: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',
title: 'Commentaires de semaine (admin)',
@@ -363,8 +375,8 @@ export const documentationSections: DocSection[] = [
requiredLevel: 'admin',
blocks: [
{ 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: '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: '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. 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.' },
],
},
{
@@ -373,7 +385,7 @@ export const documentationSections: DocSection[] = [
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'La vue semaine conducteurs affiche des colonnes spécifiques.' },
{ type: 'list', content: 'Totaux jour / nuit / atelier par jour et par semaine\nPanier de nuit (PN) : affiché quand heures nuit > heures jour OU nuit ≥ 4h\nCompteurs hebdomadaires : petit déjeuner, déjeuner, dîner, nuitée\nRTT calculé sur jour + nuit + atelier (au lieu des créneaux classiques)' },
{ type: 'list', content: 'Totaux jour / nuit / atelier par jour et par semaine\nPanier de nuit (PN) : ne s\'applique pas aux conducteurs (ils ont leurs propres primes repas/nuitée)\nCompteurs hebdomadaires : petit déjeuner, déjeuner, dîner, nuitée\nRTT calculé sur jour + nuit + atelier (au lieu des créneaux classiques)' },
],
},
],
@@ -480,7 +492,9 @@ export const documentationSections: DocSection[] = [
blocks: [
{ type: 'paragraph', content: 'L\'onglet "Congés" sur la fiche employé affiche un calendrier annuel des congés posés (12 mois en grille 4×3) ainsi que les compteurs (acquis, pris, reste, en cours d\'acquisition, N-1 ou samedis selon le contrat).' },
{ type: 'paragraph', content: 'La période affichée dépend du type de contrat actuel : Janvier → Décembre pour FORFAIT, Juin (N-1) → Mai (N) pour les autres contrats.' },
{ type: 'paragraph', content: 'Un sélecteur d\'année est disponible en bas du calendrier (zone scrollable, à gauche). Il permet de consulter les exercices passés. La plage proposée part de l\'exercice courant et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel — il est inutile de remonter plus loin, aucune donnée n\'a été saisie avant.' },
{ type: 'note', content: 'La case « En cours d\'acquisition » affiche deux valeurs : à gauche les jours encore à acquérir (déduction faite des congés déjà posés en anticipé), à droite le total brut acquis sur l\'exercice à ce jour. Exemple : « 14,50 / 17,50 » signifie 17,50 jours acquis dont 3 déjà pris en anticipé.' },
{ type: 'paragraph', content: 'Un sélecteur d\'année est disponible en bas du calendrier (zone scrollable, à gauche). Il permet de consulter l\'exercice suivant ainsi que les exercices passés. La plage proposée part de l\'exercice suivant (l\'exercice à venir, pour consulter en avance les congés déjà posés) et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel — il est inutile de remonter plus loin, aucune donnée n\'a été saisie avant.' },
{ type: 'note', content: 'Sur l\'exercice suivant, le calendrier et les congés déjà posés sont exacts, mais les compteurs « Année acquis » et report N-1 sont provisoires : ils dépendent de la clôture de l\'exercice courant et ne se figeront qu\'à cette clôture.' },
{ type: 'note', content: 'Sur un exercice passé, les boutons d\'édition "Jours fractionnés" et "Année N-1 payés" sont désactivés. La consultation reste possible, mais on n\'autorise pas la modification rétroactive d\'un exercice clos.' },
],
},
@@ -491,7 +505,7 @@ export const documentationSections: DocSection[] = [
blocks: [
{ type: 'paragraph', content: 'L\'écran "Récap. congés" affiche un tableau figé des soldes de congés et RTT par employé. Il est accessible via la sidebar lorsque l\'accès a été activé sur le compte utilisateur.' },
{ type: 'list', content: 'Employé : voit uniquement sa propre ligne\nChef de site : voit les employés des sites qui lui sont rattachés\nAdmin : voit tous les employés, groupés par site' },
{ type: 'note', content: 'Le récap est arrêté à la fin de la semaine S-2 (dimanche). Exemple : un mardi en S16, les soldes sont calculés jusqu\'au dimanche de la S14 inclus. Les heures et absences postérieures ne sont pas comptées.' },
{ type: 'note', content: 'Le récap est arrêté à la fin de la semaine S-1 (dimanche). Exemple : un mardi en S16, les soldes sont calculés jusqu\'au dimanche de la S15 inclus. Les heures et absences postérieures ne sont pas comptées.' },
{ type: 'paragraph', content: 'Les colonnes affichées sont identiques à l\'export PDF admin (Nom, Prénom, Contrat, CP N-1 restant, CP N, Samedis, RTT). L\'accès à cet écran est géré par un flag sur l\'utilisateur, activé depuis le drawer de création/édition d\'un utilisateur par un admin.' },
],
},
@@ -510,6 +524,7 @@ export const documentationSections: DocSection[] = [
blocks: [
{ type: 'paragraph', content: 'Le RTT correspond aux heures supplémentaires accumulées, converties en temps de récupération. L\'exercice RTT va du 1er juin (N-1) au 31 mai (N).' },
{ type: 'paragraph', content: 'L\'onglet RTT sur la fiche employé affiche le détail hebdomadaire regroupé par mois, avec un compteur global en heures (1 jour = 7h = 420 minutes).' },
{ type: 'note', content: 'Pour un contrat débutant en milieu de semaine, le calcul RTT proratise les seuils d\'heures supplémentaires aux jours réellement contractés : le seuil de départ du +25 % et le plafond séparant le +25 % du +50 % sont décalés ensemble (la bande +25 % garde sa largeur : 4h pour un 39h, 8h pour un 35h). Une semaine d\'embauche peut donc générer à la fois des heures à 25 % et à 50 %.' },
],
},
{
@@ -519,7 +534,10 @@ export const documentationSections: DocSection[] = [
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: '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: '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à.' },
],
},
{
@@ -529,6 +547,8 @@ export const documentationSections: DocSection[] = [
blocks: [
{ 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: '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.' },
],
},
{
@@ -536,7 +556,7 @@ export const documentationSections: DocSection[] = [
title: 'Consulter un exercice passé',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Un sélecteur d\'exercice est disponible en bas du tableau RTT (zone scrollable, à gauche). Il permet de consulter les exercices passés (Juin → Mai). La plage proposée part de l\'exercice courant et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel.' },
{ type: 'paragraph', content: 'Un sélecteur d\'exercice est disponible en bas du tableau RTT (zone scrollable, à gauche). Il permet de consulter les exercices passés (Juin → Mai). La plage proposée part de l\'exercice courant et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel. Contrairement à l\'onglet Congés, l\'onglet RTT ne propose pas l\'exercice suivant.' },
{ type: 'note', content: 'Sur un exercice passé, le bouton « + Payer les RTT » est désactivé. Aucun paiement rétroactif n\'est autorisé pour préserver la cohérence du report N-1.' },
],
},
@@ -617,7 +637,9 @@ export const documentationSections: DocSection[] = [
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Génère un PDF A4 paysage avec le détail mensuel pour la paie.' },
{ type: 'list', content: 'Sélecteur de mois (défaut = mois courant)\nDonnées groupées par site\nColonnes : nom, base contrat, jours de présence cadre, heures de nuit, panier de nuit, heures RTT payées, congés (nombre + dates), maladie/AT (nombre + dates), primes conducteur (PDJ, repas, nuitée, samedi), observations\nColonne « Repas » chauffeur : somme déjeuner + dîner sur le mois (un jour avec les deux compte 2 repas)' },
{ type: 'list', content: 'Sélecteur de mois (défaut = mois courant)\nDonnées groupées par site\nColonnes : nom, base contrat, jours de présence cadre, heures de nuit, panier de nuit, heures RTT payées (en-tête fusionné scindé en deux sous-colonnes 25 % et 50 %), congés (nombre + dates), maladie/AT (nombre + dates), primes conducteur (PDJ, repas, nuitée, samedi), observations\nColonne « Repas » chauffeur : somme déjeuner + dîner sur le mois (un jour avec les deux compte 2 repas)' },
{ type: 'note', content: 'Seuls les salariés ayant un contrat couvrant tout ou partie du mois apparaissent : un salarié dont le contrat est terminé (ex. parti en février) n\'est pas listé sur le récap des mois suivants.' },
{ type: 'note', content: 'Forfait : un congé imputé sur le stock de l\'année précédente (N-1) n\'apparaît pas dans la colonne congés et compte comme un jour de présence. Le budget N-1 est consommé dans l\'ordre chronologique depuis janvier, de façon cohérente avec la fiche employé (les jours payés réduisent le stock N-1 d\'abord). Au-delà du budget N-1, les congés s\'affichent normalement.' },
],
},
{
@@ -635,7 +657,7 @@ export const documentationSections: DocSection[] = [
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Génère un PDF par employé avec le détail jour par jour de ses heures sur une année.' },
{ type: 'list', content: 'Accessible depuis la fiche employé (bouton imprimante)\nChoix de l\'année civile (janvier à décembre)\nColonnes adaptées au mode de suivi (TIME, PRESENCE, conducteur)\nSections séparées en cas de changement de contrat en cours d\'année\nLes jours fériés apparaissent toujours (ligne bleue) avec la mention « Férié : {nom} » dans la colonne Absence ; le total reprend les heures contractuelles créditées (hors Forfait)' },
{ type: 'list', content: 'Accessible depuis la fiche employé (bouton imprimante)\nChoix de l\'année civile (janvier à décembre)\nColonnes adaptées au mode de suivi (TIME, PRESENCE, conducteur)\nSections séparées en cas de changement de contrat en cours d\'année\nTous les jours sous contrat sont affichés, même vides ou non saisis (jusqu\'à la date du jour) ; seuls les jours hors contrat (avant embauche, après départ) sont omis\nLes samedis et dimanches sont grisés (gris foncé), les jours fériés en bleu avec « Férié : {nom} »' },
],
},
],
+31
View File
@@ -2,8 +2,24 @@
<div class="h-full overflow-hidden flex flex-col">
<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>
<MalioButton
v-if="(isAdmin || isSiteManager) && viewMode === 'day'"
label="Export"
variant="secondary"
icon-name="mdi:download"
icon-position="left"
@click="isExportDrawerOpen = true"
/>
</div>
<HoursDayExportDrawer
v-model="isExportDrawerOpen"
:sites="sites"
:initial-date="selectedDate"
:is-loading="isExporting"
@submit="handleExport"
/>
<HoursToolbar
v-model:selected-date="selectedDate"
v-model:view-mode="viewMode"
@@ -213,6 +229,21 @@ const {
reloadWeeklySummary
} = 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({
title: 'Heures'
})
@@ -10,6 +10,7 @@ export type EmployeeLeaveSummary = {
takenSaturdays: number
fractionedDays: number
accruingDays: number
accruingDaysTotal: number
previousYearAcquiredDays: number
previousYearTakenDays: number
previousYearRemainingDays: number
@@ -10,6 +10,7 @@ export type EmployeeRttWeekSummary = {
bonus50Minutes: number
totalMinutes: number
cumulativeBalanceMinutes: number
isFlatRecovery: boolean
}
export type RttMonthPayment = {
+4
View File
@@ -115,6 +115,10 @@ export type WorkHourDayContextRow = {
formationLabel?: string | null
virtualHolidayMinutes?: number
contractNature?: 'CDI' | 'CDD' | 'INTERIM' | null
trackingMode?: TrackingMode | null
weeklyHours?: number | null
contractType?: ContractType | null
contractName?: string | null
}
export type WorkHourDayContext = {
+14 -11
View File
@@ -20,17 +20,20 @@ use App\State\EmployeeLeaveSummaryProvider;
)]
final class EmployeeLeaveSummary
{
public int $year = 0;
public bool $isSupported = false;
public string $ruleCode = '';
public float $acquiredDays = 0.0;
public float $remainingDays = 0.0;
public float $takenDays = 0.0;
public float $acquiredSaturdays = 0.0;
public float $remainingSaturdays = 0.0;
public float $takenSaturdays = 0.0;
public float $fractionedDays = 0.0;
public float $accruingDays = 0.0;
public int $year = 0;
public bool $isSupported = false;
public string $ruleCode = '';
public float $acquiredDays = 0.0;
public float $remainingDays = 0.0;
public float $takenDays = 0.0;
public float $acquiredSaturdays = 0.0;
public float $remainingSaturdays = 0.0;
public float $takenSaturdays = 0.0;
public float $fractionedDays = 0.0;
public float $accruingDays = 0.0;
/** Brut généré sur l'exercice à ce jour (= accruingDays + congés pris en anticipé). Dénominateur de l'affichage « net / brut ». */
public float $accruingDaysTotal = 0.0;
public float $previousYearAcquiredDays = 0.0;
public float $previousYearTakenDays = 0.0;
public float $previousYearRemainingDays = 0.0;
+11 -1
View File
@@ -25,13 +25,23 @@ final class WorkHourDayContext
/**
* @var list<array{
* employeeId:int,
* hasContractAtDate:bool,
* absenceLabel:?string,
* absenceColor:?string,
* absenceHalf:?string,
* absentMorning:bool,
* absentAfternoon:bool,
* creditedMinutes:int,
* creditedPresenceUnits:float
* creditedPresenceUnits:float,
* isDriverContract:bool,
* hasFormation:bool,
* formationLabel:?string,
* virtualHolidayMinutes:int,
* contractNature:?string,
* trackingMode:?string,
* weeklyHours:?int,
* contractType:?string,
* contractName:?string
* }>
*/
public array $rows = [];
+25
View File
@@ -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,
bonus50Minutes: $detail->bonus50Minutes,
totalMinutes: $detail->totalMinutes,
isFlatRecovery: $detail->isFlatRecovery,
);
continue;
@@ -672,6 +673,7 @@ final class DumpVerificationSnapshotCommand extends Command
base50Minutes: (int) round($detail->base50Minutes * $ratio),
bonus50Minutes: (int) round($detail->bonus50Minutes * $ratio),
totalMinutes: (int) round($detail->totalMinutes * $ratio),
isFlatRecovery: $detail->isFlatRecovery,
);
}
}
@@ -692,7 +694,7 @@ final class DumpVerificationSnapshotCommand extends Command
$cumulative25 = $carry->base25Minutes + $carry->bonus25Minutes;
foreach ($weeks as $i => $week) {
if ($week->totalMinutes >= 0) {
if ($week->totalMinutes >= 0 || $week->isFlatRecovery) {
$cumulative50 += $week->base50Minutes + $week->bonus50Minutes;
$cumulative25 += $week->base25Minutes + $week->bonus25Minutes;
@@ -714,6 +716,7 @@ final class DumpVerificationSnapshotCommand extends Command
base50Minutes: $from50 > 0 ? -$from50 : 0,
bonus50Minutes: 0,
totalMinutes: $week->totalMinutes,
isFlatRecovery: $week->isFlatRecovery,
);
}
+23 -12
View File
@@ -188,24 +188,35 @@ final class LeaveRolloverCommand extends Command
private function resolveCarry(Employee $employee, LeaveRuleCode $ruleCode, int $targetYear): array
{
$previousYear = $targetYear - 1;
$previous = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $previousYear);
if (null !== $previous) {
$carryDays = $previous->getClosingDays() + $previous->getFractionedDays();
$carrySaturdays = LeaveRuleCode::CDI_CDD_NON_FORFAIT === $ruleCode
? $previous->getClosingSaturdays()
: 0.0;
} else {
[$carryDays, $carrySaturdays] = $this->leaveBalanceComputationService
->computeDynamicClosingForYear($employee, $ruleCode, $previousYear)
;
}
[$from, $to] = $this->leaveBalanceComputationService->resolvePeriodBounds($ruleCode, $previousYear);
$hasSettlement = $this->leaveBalanceComputationService
->hasPaidLeaveSettledClosureBetween($employee, $from, $to)
;
if ($hasSettlement) {
return [0.0, 0.0];
$carryDays = 0.0;
$carrySaturdays = 0.0;
} else {
// Compute the REAL closing of the ending exercise. computeDynamicClosingForYear
// is bootstrap-aware (it anchors on the persisted opening balance of each year)
// and already folds in accrual, taken absences and fractioned days. We must NOT
// trust the stored closing_days: it is only ever written equal to the opening at
// row creation (placeholder), so trusting it would propagate the opening and
// ignore the year's accrual (cas Aurore : report 0 au lieu de 31).
[$carryDays, $carrySaturdays] = $this->leaveBalanceComputationService
->computeDynamicClosingForYear($employee, $ruleCode, $previousYear)
;
}
// Freeze the computed closing on the ending exercise's row so the column finally
// holds a real, auditable value. The cron is idempotent — it never reaches here for
// an already-rolled target year (existing rows are skipped upstream) — so a row that
// was corrected manually in the DB afterwards is never overwritten.
$previous = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $previousYear);
if (null !== $previous) {
$previous->setClosingDays($carryDays);
$previous->setClosingSaturdays($carrySaturdays);
}
return [$carryDays, $carrySaturdays];
+43 -18
View File
@@ -10,7 +10,7 @@ use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Repository\EmployeeRepository;
use App\Repository\EmployeeRttBalanceRepository;
use App\Service\Rtt\RttRecoveryComputationService;
use App\Service\Rtt\RttClosingBalanceService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
@@ -32,7 +32,7 @@ final class RttRolloverCommand extends Command
public function __construct(
private readonly EmployeeRepository $employeeRepository,
private readonly EmployeeRttBalanceRepository $rttBalanceRepository,
private readonly RttRecoveryComputationService $rttRecoveryService,
private readonly RttClosingBalanceService $rttClosingService,
private readonly EntityManagerInterface $entityManager,
#[Autowire(service: 'monolog.logger.cron')]
private readonly LoggerInterface $logger,
@@ -48,15 +48,22 @@ final class RttRolloverCommand extends Command
InputOption::VALUE_NONE,
'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
{
$io = new SymfonyStyle($input, $output);
$today = new DateTimeImmutable('today');
$force = (bool) $input->getOption('force');
$io = new SymfonyStyle($input, $output);
$today = new DateTimeImmutable('today');
$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')) {
$message = 'No RTT rollover today: business date is not 01/06.';
@@ -68,6 +75,7 @@ final class RttRolloverCommand extends Command
$targetYear = $this->resolveTargetYear($today);
$created = 0;
$updated = 0;
$skipped = 0;
foreach ($this->employeeRepository->findAll() as $employee) {
@@ -83,36 +91,53 @@ final class RttRolloverCommand extends Command
}
$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]);
++$skipped;
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 {
$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) {
$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;
continue;
}
$balance = new EmployeeRttBalance()
$balance = $existing ?? new EmployeeRttBalance()
->setEmployee($employee)
->setYear($targetYear)
->setOpeningBase25Minutes($carry->base25Minutes)
->setOpeningBonus25Minutes($carry->bonus25Minutes)
->setOpeningBase50Minutes($carry->base50Minutes)
->setOpeningBonus50Minutes($carry->bonus50Minutes)
->setIsLocked(false)
;
$this->entityManager->persist($balance);
$this->logger->info('Balance created.', ['employeeId' => $employee->getId(), 'year' => $targetYear, 'carryMinutes' => $carry->totalMinutes]);
++$created;
$balance
->setOpeningBase25Minutes($closing->base25Minutes)
->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 {
@@ -124,7 +149,7 @@ final class RttRolloverCommand extends Command
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);
$io->success($message);
+1
View File
@@ -18,5 +18,6 @@ final class EmployeeRttWeekSummary
public int $bonus50Minutes = 0,
public int $totalMinutes = 0,
public int $cumulativeBalanceMinutes = 0,
public bool $isFlatRecovery = false,
) {}
}
+1
View File
@@ -17,5 +17,6 @@ final class WeekRecoveryDetail
public int $bonus50Minutes = 0,
public int $totalMinutes = 0,
public array $dailyMinutes = [],
public bool $isFlatRecovery = false,
) {}
}
+13 -1
View File
@@ -21,6 +21,10 @@ final class DayContextRow
public ?string $formationLabel = null,
public int $virtualHolidayMinutes = 0,
public ?string $contractNature = null,
public ?string $trackingMode = null,
public ?int $weeklyHours = null,
public ?string $contractType = null,
public ?string $contractName = null,
) {}
public function setFormation(string $label): void
@@ -79,7 +83,11 @@ final class DayContextRow
* hasFormation:bool,
* formationLabel:?string,
* virtualHolidayMinutes:int,
* contractNature:?string
* contractNature:?string,
* trackingMode:?string,
* weeklyHours:?int,
* contractType:?string,
* contractName:?string
* }
*/
public function toArray(): array
@@ -99,6 +107,10 @@ final class DayContextRow
'formationLabel' => $this->formationLabel,
'virtualHolidayMinutes' => $this->virtualHolidayMinutes,
'contractNature' => $this->contractNature,
'trackingMode' => $this->trackingMode,
'weeklyHours' => $this->weeklyHours,
'contractType' => $this->contractType,
'contractName' => $this->contractName,
];
}
@@ -51,9 +51,20 @@ final readonly class LeaveBalanceComputationService
for ($year = $firstYear; $year <= $targetYear; ++$year) {
[$from, $to] = $this->resolvePeriodBounds($ruleCode, $year);
// Bootstrap anchor: a manually-entered opening balance (production data
// bootstrap) is the source of truth for the carry of that year — exactly
// like EmployeeLeaveSummaryProvider::computeYearSummary for the live view.
// Without it, the closing would be recomputed from the contract start with no
// historical absences, inflating the carry by one full year of accrual for
// every exercise predating the software (cas Aurore : 88 au lieu de 31).
$openingBalance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $year);
$carryDays = 0.0;
$carrySaturdays = 0.0;
if ($year > $firstYear) {
if (null !== $openingBalance) {
$carryDays = $openingBalance->getOpeningDays();
$carrySaturdays = LeaveRuleCode::CDI_CDD_NON_FORFAIT === $ruleCode ? $openingBalance->getOpeningSaturdays() : 0.0;
} elseif ($year > $firstYear) {
[$previousFrom, $previousTo] = $this->resolvePeriodBounds($ruleCode, $year - 1);
$hasSettlementOnPreviousYear = $this->periodRepository->hasPaidLeaveSettledClosureBetween($employee, $previousFrom, $previousTo);
if (!$hasSettlementOnPreviousYear) {
@@ -63,7 +74,10 @@ final readonly class LeaveBalanceComputationService
}
$effectiveFrom = $this->resolveEffectivePeriodStart($employee, $from, $to);
if ($effectiveFrom > $from) {
// A shifted start (new hire / settled closure) zeroes the dynamic carry, but
// an explicit bootstrap opening balance must be preserved (it already reflects
// the real situation at the bootstrap date).
if ($effectiveFrom > $from && null === $openingBalance) {
$carryDays = 0.0;
$carrySaturdays = 0.0;
}
@@ -74,11 +88,14 @@ final readonly class LeaveBalanceComputationService
// Business days for forfait must use the RAW holiday list (excluded holidays
// like "Lundi de Pentecôte" / journée de solidarité still count as non-working
// days for the 218-day legal target).
$totalBusinessDays = $this->countBusinessDaysInRange($from, $to, $this->buildRawPublicHolidayMap($from, $to));
$baseAcquiredDays = (float) max(0, $totalBusinessDays - self::FORFAIT_TARGET_WORKED_DAYS);
$acquiredDays = $carryDays + $baseAcquiredDays + $fractionedDays;
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
[$takenDays] = $this->computeTakenAbsences($absences, $effectiveFrom, $to, false, false);
$totalBusinessDays = $this->countBusinessDaysInRange($from, $to, $this->buildRawPublicHolidayMap($from, $to));
$baseAcquiredDays = (float) max(0, $totalBusinessDays - self::FORFAIT_TARGET_WORKED_DAYS);
$acquiredDays = $carryDays + $baseAcquiredDays + $fractionedDays;
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
[$takenDays] = $this->computeTakenAbsences($absences, $effectiveFrom, $to, false, false);
if (null !== $openingBalance) {
$takenDays += $openingBalance->getTakenDays();
}
$previousRemainingDays = max(0.0, $acquiredDays - $takenDays);
$previousRemainingSaturdays = 0.0;
@@ -120,6 +137,10 @@ final readonly class LeaveBalanceComputationService
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
[$takenDays, $takenSaturdays] = $this->computeTakenAbsences($absences, $effectiveFrom, $to, true, true);
if (null !== $openingBalance) {
$takenDays += $openingBalance->getTakenDays();
$takenSaturdays += $openingBalance->getTakenSaturdays();
}
$acquiredWithFractioned = $carryDays + $fractionedDays;
$takenFromAcquired = min(max(0.0, $acquiredWithFractioned), $takenDays);
@@ -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];
}
}
+161 -26
View File
@@ -33,6 +33,7 @@ final readonly class RttRecoveryComputationService
private EmployeeContractResolver $contractResolver,
private DailyReferenceMinutesResolver $dailyReferenceResolver,
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
private SolidarityDayResolver $solidarityDayResolver,
string $rttStartDate = '',
) {
$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) {
$weekStart = $week['start'];
$weekEnd = $week['end'];
@@ -235,38 +237,105 @@ final readonly class RttRecoveryComputationService
$overtimeReferenceMinutes = $isCustomContract
? $this->computeWeeklyCustomReferenceMinutes($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 %
// (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 %.
$overtime50StartMinutes = $overtime25StartMinutes + $this->resolveOvertime25BandWidthMinutes($weekAnchorContract);
$weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
? 0
: $weeklyTotalMinutes - $overtimeReferenceMinutes;
$base25 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : max(0, min($weeklyTotalMinutes, 43 * 60) - $overtime25StartMinutes);
$bonus25 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : (int) round($base25 * 0.25);
$base50 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : max(0, $weeklyTotalMinutes - 43 * 60);
$bonus50 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : (int) round($base50 * 0.5);
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;
}
if ($isWeekPresenceTracking || $disableOvertimeBonuses) {
$totalMinutes = 0;
} elseif ($isCustomContract) {
$totalMinutes = max(0, $weeklyOvertimeTotalMinutes);
} else {
$totalMinutes = $weeklyOvertimeTotalMinutes + $bonus25 + $bonus50;
$contractAtSolidarity = $employeeContractsByDate[$solidarityDate] ?? null;
// Le Lundi de Pentecôte est toujours un lundi (ISO 1), mais on le dérive pour rester explicite.
$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],
);
}
$results[$weekKey] = new WeekRecoveryDetail(
overtimeMinutes: $weeklyOvertimeTotalMinutes,
base25Minutes: $base25,
bonus25Minutes: $bonus25,
base50Minutes: $base50,
bonus50Minutes: $bonus50,
totalMinutes: $totalMinutes,
dailyMinutes: $dailyWorkedMinutes,
[$rawBase25, $rawBase50] = $this->computeOvertimeBaseMinutes($weeklyTotalMinutes, $overtime25StartMinutes, $overtime50StartMinutes);
$results[$weekKey] = $this->buildWeekRecoveryDetail(
$isWeekPresenceTracking,
$disableOvertimeBonuses,
$isCustomContract,
$weeklyOvertimeTotalMinutes,
$rawBase25,
$rawBase50,
$dailyWorkedMinutes,
);
}
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
{
$driverDay = $workHour->getDayHoursMinutes() ?? 0;
@@ -409,6 +478,59 @@ final readonly class RttRecoveryComputationService
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 array<string, ?Contract> $contractsByDate
@@ -452,18 +574,31 @@ final readonly class RttRecoveryComputationService
return $total;
}
private function computeOvertime25BonusMinutes(int $weeklyTotalMinutes, int $startMinutes): int
/**
* Largeur (en minutes) de la tranche +25 % pour le contrat d'ancrage de la semaine :
* 4h pour un 39h (39→43), 8h pour un 35h (35→43). Ajoutée au seuil de départ proraté
* pour obtenir le plafond 25 %/50 %.
*/
private function resolveOvertime25BandWidthMinutes(?Contract $contract): int
{
$trancheMinutes = max(0, min($weeklyTotalMinutes, 43 * 60) - $startMinutes);
$hours = $contract?->getWeeklyHours();
$startHours = (null !== $hours && $hours >= 39) ? 39 : 35;
return (int) round($trancheMinutes * 0.25);
return (43 - $startHours) * 60;
}
private function computeOvertime50BonusMinutes(int $weeklyTotalMinutes): int
/**
* Répartit les heures supplémentaires hebdomadaires entre les bases 25 % et 50 %.
* La tranche 25 % court du seuil de départ au plafond ; au-delà du plafond, c'est du 50 %.
*
* @return array{int, int} [base25Minutes, base50Minutes]
*/
private function computeOvertimeBaseMinutes(int $weeklyTotalMinutes, int $overtime25StartMinutes, int $overtime50StartMinutes): array
{
$trancheMinutes = max(0, $weeklyTotalMinutes - (43 * 60));
$base25 = max(0, min($weeklyTotalMinutes, $overtime50StartMinutes) - $overtime25StartMinutes);
$base50 = max(0, $weeklyTotalMinutes - $overtime50StartMinutes);
return (int) round($trancheMinutes * 0.5);
return [$base25, $base50];
}
private function hasDisabledOvertimeBonuses(?Contract $contract, ContractNature $contractNature): bool
+43
View File
@@ -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\ContractType;
use App\Enum\TrackingMode;
use App\Repository\AbsenceRepository;
use App\Repository\WorkHourRepository;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\PublicHolidayServiceInterface;
use DateInterval;
@@ -22,8 +22,8 @@ use Throwable;
class YearlyHoursExportBuilder
{
public function __construct(
private WorkHourRepository $workHourRepository,
private AbsenceRepository $absenceRepository,
private WorkHourReadRepositoryInterface $workHourRepository,
private AbsenceReadRepositoryInterface $absenceRepository,
private EmployeeContractResolver $contractResolver,
private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
@@ -103,6 +103,137 @@ class YearlyHoursExportBuilder
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
{
$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
{
$credited = [];
$codes = [];
$labels = [];
$colors = [];
$absentMorning = [];
$absentAfternoon = [];
$hasDayAbsence = [];
@@ -194,7 +327,9 @@ class YearlyHoursExportBuilder
$absentMorning[$date] = ($absentMorning[$date] ?? false) || $isMorning;
$absentAfternoon[$date] = ($absentAfternoon[$date] ?? false) || $isAfternoon;
if (!isset($labels[$date])) {
$codes[$date] = $absence->getType()?->getCode() ?? '';
$labels[$date] = $absence->getType()?->getLabel() ?? '';
$colors[$date] = $absence->getType()?->getColor() ?? '';
}
}
@@ -205,7 +340,9 @@ class YearlyHoursExportBuilder
return [
'credited' => $credited,
'codes' => $codes,
'labels' => $labels,
'colors' => $colors,
'absentMorning' => $absentMorning,
'absentAfternoon' => $absentAfternoon,
'hasDayAbsence' => $hasDayAbsence,
@@ -264,10 +401,9 @@ class YearlyHoursExportBuilder
$isoDay = (int) new DateTimeImmutable($date)->format('N');
$isWeekend = $isoDay >= 6;
if (!$hasData && !$isWeekend && !$isHoliday) {
continue;
}
// Tous les jours contractés sont affichés, même vides ou non saisis (lignes
// « manquantes » signalées par la RH). Seuls les jours hors contrat (avant
// embauche, après départ, suspension) sont omis.
if (!$hasData && null === $contract) {
continue;
}
+38 -2
View File
@@ -123,6 +123,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$summary->acquiredSaturdays = $yearSummary['acquiredSaturdays'];
$summary->fractionedDays = $fractionedDays;
$summary->accruingDays = $yearSummary['accruingDays'];
$summary->accruingDaysTotal = $yearSummary['accruingDaysTotal'];
$summary->takenDays = $yearSummary['takenDays'];
$summary->takenSaturdays = $yearSummary['takenSaturdays'];
$summary->remainingDays = $yearSummary['remainingDays'] + $fractionedDays;
@@ -186,6 +187,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
* acquiredDays: float,
* acquiredSaturdays: float,
* accruingDays: float,
* accruingDaysTotal: float,
* takenDays: float,
* takenSaturdays: float,
* remainingDays: float,
@@ -336,8 +338,11 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$remainingSaturdaysToImpute = max(0.0, $takenSaturdays - $takenFromAcquiredSaturdays);
$remainingGeneratedSaturdays = $generatedSaturdays - $remainingSaturdaysToImpute;
$acquiredDays = $carryDays;
$accruingDays = $remainingGenerated + $remainingGeneratedSaturdays;
$acquiredDays = $carryDays;
$accruingDays = $remainingGenerated + $remainingGeneratedSaturdays;
// Brut généré à ce jour, AVANT imputation des congés pris en anticipé
// (dénominateur de l'affichage « net / brut » sur l'onglet Congés).
$accruingDaysTotal = $generatedDays + $generatedSaturdays;
$remainingDays = $remainingAcquired;
$acquiredSaturdays = $carrySaturdays;
$remainingSaturdays = max(0.0, $remainingAcquiredSaturdays);
@@ -359,6 +364,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$acquiredDays = $leavePolicy['acquiredDays'];
$accruingDays = 0.0;
$accruingDaysTotal = 0.0;
$remainingDays = max(0.0, $acquiredDays - $takenFromCurrent);
$acquiredSaturdays = 0.0;
$remainingSaturdays = 0.0;
@@ -373,6 +379,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
'acquiredDays' => $acquiredDays,
'acquiredSaturdays' => $acquiredSaturdays,
'accruingDays' => $accruingDays,
'accruingDaysTotal' => $accruingDaysTotal,
'takenDays' => $takenDays,
'takenSaturdays' => $takenSaturdays,
'remainingDays' => $remainingDays,
@@ -404,6 +411,35 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
return null !== $balance ? $balance->getPaidLeaveDays() : 0.0;
}
/**
* Budget N-1 = nombre de jours de congé pris imputés sur le stock de l'année précédente,
* pour l'exercice de l'année donnée. Reproduit exactement la dérivation de provide()
* (phase courante + recalcul avec les jours payés) afin que les consommateurs externes
* (ex. récap salaire) voient le même budget que la fiche employé. 0 si non supporté.
*/
public function resolvePreviousYearTakenDays(Employee $employee, int $year): float
{
$phase = $this->resolveCurrentPhase($employee);
if (null === $phase) {
return 0.0;
}
$summary = $this->computeYearSummary($employee, $year, 0.0, null, $phase);
if (null === $summary) {
return 0.0;
}
$paidLeaveDays = $this->resolvePaidLeaveDays($employee, $summary['ruleCode'], $year);
if ($paidLeaveDays > 0.0) {
$summary = $this->computeYearSummary($employee, $year, $paidLeaveDays, null, $phase);
if (null === $summary) {
return 0.0;
}
}
return (float) $summary['previousYearTakenDays'];
}
private function resolveEffectivePeriodStart(
Employee $employee,
DateTimeImmutable $from,
+54 -7
View File
@@ -8,12 +8,15 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\EmployeeRttPaymentInput;
use App\Entity\Employee;
use App\Entity\EmployeeRttBalance;
use App\Entity\EmployeeRttPayment;
use App\Repository\EmployeeRepository;
use App\Repository\EmployeeRttBalanceRepository;
use App\Repository\EmployeeRttPaymentRepository;
use App\Service\AuditLogger;
use App\Service\Contracts\EmployeeContractPhaseResolver;
use App\Service\Exercise\ExerciseYearResolver;
use App\Service\Rtt\RttClosingBalanceService;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Clock\ClockInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -24,11 +27,13 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
public function __construct(
private EmployeeRepository $employeeRepository,
private EmployeeRttPaymentRepository $rttPaymentRepository,
private EmployeeRttBalanceRepository $rttBalanceRepository,
private EntityManagerInterface $entityManager,
private AuditLogger $auditLogger,
private EmployeeContractPhaseResolver $phaseResolver,
private ClockInterface $clock,
private ExerciseYearResolver $exerciseYearResolver,
private RttClosingBalanceService $rttClosingService,
) {}
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.');
}
$year = $data->year ?? $this->resolveCurrentExerciseYear();
$year = $data->year ?? $this->resolveCurrentExerciseYear();
$currentExerciseYear = $this->resolveCurrentExerciseYear();
$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);
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]],
);
$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;
@@ -94,14 +126,15 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
}
/**
* Allow payment when the requested exercise is either the current one
* or the last exercise of a closed contract phase (the one containing
* the phase end date). Reject any other exercise (past or future).
* Allow payment when the requested exercise is the current one, the
* immediately previous one (retroactive payment Option B), or the last
* 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
{
$currentExerciseYear = $this->resolveCurrentExerciseYear();
if ($year === $currentExerciseYear) {
if ($year === $currentExerciseYear || $year === $currentExerciseYear - 1) {
return;
}
@@ -116,7 +149,21 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
}
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.'
);
}
}
}
+64 -31
View File
@@ -20,6 +20,7 @@ use App\Repository\WorkHourRepository;
use App\Security\EmployeeScopeService;
use App\Service\Contracts\EmployeeContractPhaseResolver;
use App\Service\Exercise\ExerciseYearResolver;
use App\Service\Rtt\RttClosingBalanceService;
use App\Service\Rtt\RttRecoveryComputationService;
use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
@@ -43,6 +44,7 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
private WorkHourRepository $workHourRepository,
private EmployeeContractPhaseResolver $phaseResolver,
private ExerciseYearResolver $exerciseYearResolver,
private RttClosingBalanceService $rttClosingService,
string $rttStartDate = '',
) {
$this->rttStartDate = '' !== $rttStartDate ? $rttStartDate : null;
@@ -140,36 +142,13 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
$summary->rttStartDate = $this->rttStartDate;
$summary->weeks = $this->buildWeekSummaries($weekRanges, $currentByWeekStart, $periodFrom, $periodTo);
// Post-process: distribute deficit weeks across cumulative balance (50% first, then 25%)
$cumulative50 = $carry->base50Minutes + $carry->bonus50Minutes;
$cumulative25 = $carry->base25Minutes + $carry->bonus25Minutes;
foreach ($summary->weeks as $i => $week) {
if ($week->totalMinutes >= 0) {
$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,
);
}
}
// 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,
);
$payments = $this->rttPaymentRepository->findByEmployeeAndYear($employee, $year);
$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 [
$this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $year - 1),
$this->rttClosingService->computeClosingBalance($employee, $year - 1),
5,
];
}
@@ -350,6 +333,54 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
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
* with values distributed proportionally based on daily worked minutes.
@@ -387,6 +418,7 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
base50Minutes: $detail->base50Minutes,
bonus50Minutes: $detail->bonus50Minutes,
totalMinutes: $detail->totalMinutes,
isFlatRecovery: $detail->isFlatRecovery,
);
continue;
@@ -427,6 +459,7 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
base50Minutes: (int) round($detail->base50Minutes * $ratio),
bonus50Minutes: (int) round($detail->bonus50Minutes * $ratio),
totalMinutes: (int) round($detail->totalMinutes * $ratio),
isFlatRecovery: $detail->isFlatRecovery,
);
}
}
+142 -11
View File
@@ -19,6 +19,7 @@ use App\Repository\ObservationRepository;
use App\Repository\WorkHourRepository;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\PublicHolidayServiceInterface;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use DateInterval;
use DateTimeImmutable;
use Dompdf\Dompdf;
@@ -42,6 +43,8 @@ class SalaryRecapPrintProvider implements ProviderInterface
private ObservationRepository $observationRepository,
private EmployeeContractResolver $contractResolver,
private PublicHolidayServiceInterface $publicHolidayService,
private EmployeeLeaveSummaryProvider $leaveSummaryProvider,
private AbsenceSegmentsResolver $absenceSegmentsResolver,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
@@ -59,12 +62,25 @@ class SalaryRecapPrintProvider implements ProviderInterface
$from = DateTimeImmutable::createFromFormat('Y-m-d', $month.'-01');
$to = $from->modify('last day of this month');
$employees = $this->employeeRepository->findForPrintBySiteIds([]);
// N'inclure que les employés ayant un contrat couvrant tout ou partie du mois.
// Sans ce filtre, un salarié dont le contrat est terminé (ex. parti en février)
// apparaît à tort sur le récap des mois suivants.
$employees = array_values(array_filter(
$this->employeeRepository->findForPrintBySiteIds([]),
fn (Employee $employee): bool => $this->hasContractInRange($employee, $from, $to)
));
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, $employees);
$absences = $this->absenceRepository->findForPrint($from, $to, $employees);
$year = (int) $from->format('Y');
$monthNumber = (int) $from->format('n');
// Congés depuis le début de l'exercice forfait (année civile) jusqu'à la fin du mois :
// nécessaires pour consommer chronologiquement le budget N-1 d'un forfait (un congé
// imputé N-1 ne doit ni s'afficher ni manquer en présence sur le récap).
$yearStart = new DateTimeImmutable(sprintf('%d-01-01', $year));
$ytdAbsences = $this->absenceRepository->findForPrint($yearStart, $to, $employees);
$ytdAbsenceMap = $this->buildAbsenceMap($ytdAbsences);
$rttPayments = $this->rttPaymentRepository->findByYearAndMonth($year, $monthNumber);
$bonuses = $this->bonusRepository->findByMonth($from, $to);
@@ -83,7 +99,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
$mileageMap = $this->buildMileageMap($mileages);
$observationMap = $this->buildObservationMap($observations);
$siteGroups = $this->aggregateBySite($employees, $days, $contractMap, $driverMap, $workHourMap, $absenceMap, $rttPaymentMap, $bonusMap, $mileageMap, $observationMap, $holidayMap);
$siteGroups = $this->aggregateBySite($employees, $days, $contractMap, $driverMap, $workHourMap, $absenceMap, $rttPaymentMap, $bonusMap, $mileageMap, $observationMap, $holidayMap, $ytdAbsenceMap, $year, $from, $to);
$options = new Options();
$options->set('isRemoteEnabled', true);
@@ -110,6 +126,22 @@ class SalaryRecapPrintProvider implements ProviderInterface
]);
}
private function hasContractInRange(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): bool
{
$fromDay = $from->format('Y-m-d');
$toDay = $to->format('Y-m-d');
foreach ($employee->getContractPeriods() as $period) {
$start = $period->getStartDate()->format('Y-m-d');
$end = $period->getEndDate()?->format('Y-m-d');
if ($start <= $toDay && (null === $end || $end >= $fromDay)) {
return true;
}
}
return false;
}
/**
* @return list<string>
*/
@@ -164,6 +196,9 @@ class SalaryRecapPrintProvider implements ProviderInterface
/**
* @return array<int, int>
*/
/**
* @return array<int, array{m25: int, m50: int}>
*/
private function buildRttPaymentMap(array $rttPayments): array
{
$map = [];
@@ -172,7 +207,9 @@ class SalaryRecapPrintProvider implements ProviderInterface
if (!$employeeId) {
continue;
}
$map[$employeeId] = ($map[$employeeId] ?? 0) + $payment->getBase25Minutes() + $payment->getBase50Minutes();
$map[$employeeId] ??= ['m25' => 0, 'm50' => 0];
$map[$employeeId]['m25'] += $payment->getBase25Minutes();
$map[$employeeId]['m50'] += $payment->getBase50Minutes();
}
return $map;
@@ -264,6 +301,10 @@ class SalaryRecapPrintProvider implements ProviderInterface
array $mileageMap,
array $observationMap,
array $holidayMap,
array $ytdAbsenceMap,
int $year,
DateTimeImmutable $monthFrom,
DateTimeImmutable $monthTo,
): array {
$siteGroups = [];
@@ -281,11 +322,15 @@ class SalaryRecapPrintProvider implements ProviderInterface
$driverMap[$employeeId] ?? [],
$workHourMap[$employeeId] ?? [],
$absenceMap[$employeeId] ?? [],
$rttPaymentMap[$employeeId] ?? 0,
$rttPaymentMap[$employeeId] ?? ['m25' => 0, 'm50' => 0],
$bonusMap[$employeeId] ?? 0.0,
$mileageMap[$employeeId] ?? 0.0,
$observationMap[$employeeId] ?? '',
$holidayMap,
$ytdAbsenceMap[$employeeId] ?? [],
$year,
$monthFrom,
$monthTo,
);
if (!isset($siteGroups[$siteId])) {
@@ -310,11 +355,15 @@ class SalaryRecapPrintProvider implements ProviderInterface
array $driverByDate,
array $workHoursByDate,
array $absences,
int $rttPaidMinutes,
array $rttPaid,
float $bonusAmount,
float $mileageKm,
string $observation,
array $holidayMap,
array $ytdAbsences,
int $year,
DateTimeImmutable $monthFrom,
DateTimeImmutable $monthTo,
): array {
$contractName = null;
$presenceDays = 0.0;
@@ -356,9 +405,8 @@ class SalaryRecapPrintProvider implements ProviderInterface
$dayMin = $wh->getDayHoursMinutes() ?? 0;
$nightMin = $wh->getNightHoursMinutes() ?? 0;
$workshopMin = $wh->getWorkshopHoursMinutes() ?? 0;
if (($nightMin > $dayMin && $nightMin > 0) || $nightMin >= 240) {
++$nightBasketCount;
}
// Le panier de nuit ne s'applique pas aux conducteurs (primes repas/nuitée
// dédiées). Aucun panier de nuit crédité ici.
if ($wh->getHasBreakfast()) {
++$driverBreakfast;
@@ -415,11 +463,26 @@ class SalaryRecapPrintProvider implements ProviderInterface
}
}
$conges = $this->countAbsencesByCode($absences, ['C']);
// Forfait : un congé imputé sur le stock N-1 ne doit pas s'afficher dans le récap
// et doit compter comme jour de présence. On consomme le budget N-1 chronologiquement
// sur tous les congés de l'exercice (année civile) jusqu'à la fin du mois imprimé.
$n1Budget = $isForfait ? $this->leaveSummaryProvider->resolvePreviousYearTakenDays($employee, $year) : 0.0;
if ($isForfait && $n1Budget > 0.0) {
$ytdConges = array_values(array_filter(
$ytdAbsences,
static fn (Absence $a): bool => 'C' === $a->getType()?->getCode()
));
$split = $this->splitForfaitCongesByN1($ytdConges, $n1Budget, $monthFrom, $monthTo);
$conges = ['count' => $split['count'], 'dates' => $split['dates']];
$presenceDays += $split['n1PresenceDays'];
} else {
$conges = $this->countAbsencesByCode($absences, ['C']);
}
$maladie = $this->countAbsencesByCode($absences, ['M', 'AT']);
$nightHours = round($nightMinutesTotal / 60, 2);
$paidHours = round($rttPaidMinutes / 60, 2);
$paid25Hours = round(($rttPaid['m25'] ?? 0) / 60, 2);
$paid50Hours = round(($rttPaid['m50'] ?? 0) / 60, 2);
$sundayHours = round($sundayMinutesTotal / 60, 2);
$holidayHours = round($holidayMinutesTotal / 60, 2);
@@ -431,7 +494,8 @@ class SalaryRecapPrintProvider implements ProviderInterface
'mileageKm' => $mileageKm,
'nightHours' => $nightHours,
'nightBasketCount' => $nightBasketCount,
'paidHours' => $paidHours,
'paid25Hours' => $paid25Hours,
'paid50Hours' => $paid50Hours,
'sundayHours' => $sundayHours,
'holidayHours' => $holidayHours,
'bonusAmount' => $bonusAmount,
@@ -574,6 +638,73 @@ class SalaryRecapPrintProvider implements ProviderInterface
return max(0, $end - $start);
}
/**
* Répartit les congés ('C') d'un forfait entre N-1 (budget consommé chronologiquement,
* non affiché et compté en présence) et N (affiché en congé). Seuls les jours tombant
* dans le mois imprimé alimentent le retour ; les congés des mois antérieurs ne servent
* qu'à consommer le budget N-1.
*
* @param list<Absence> $ytdConges congés depuis le début d'exercice jusqu'à la fin du mois
*
* @return array{count: float, dates: string, n1PresenceDays: float}
*/
private function splitForfaitCongesByN1(
array $ytdConges,
float $n1Budget,
DateTimeImmutable $monthFrom,
DateTimeImmutable $monthTo
): array {
usort($ytdConges, static fn (Absence $a, Absence $b): int => $a->getStartDate() <=> $b->getStartDate());
$remaining = $n1Budget;
$count = 0.0;
$n1PresenceDays = 0.0;
$dayKeys = [];
foreach ($ytdConges as $absence) {
$start = DateTimeImmutable::createFromInterface($absence->getStartDate())->setTime(0, 0);
$end = DateTimeImmutable::createFromInterface($absence->getEndDate())->setTime(0, 0);
for ($day = $start; $day <= $end; $day = $day->modify('+1 day')) {
if ((int) $day->format('N') >= 6) {
continue; // week-ends ignorés
}
[$am, $pm] = $this->absenceSegmentsResolver->resolveForDate($absence, $day->format('Y-m-d'));
$amount = ($am ? 0.5 : 0.0) + ($pm ? 0.5 : 0.0);
if ($amount <= 0.0) {
continue;
}
$covered = 0.0;
if ($remaining > 0.0) {
$covered = min($remaining, $amount);
$remaining -= $covered;
}
$displayed = $amount - $covered;
// Seul le mois imprimé alimente le récap ; les mois antérieurs ne font que consommer.
if ($day < $monthFrom || $day > $monthTo) {
continue;
}
$n1PresenceDays += $covered;
if ($displayed > 0.0) {
$count += $displayed;
$dayKeys[] = $day->format('Y-m-d');
}
}
}
sort($dayKeys);
$dayKeys = array_unique($dayKeys);
return [
'count' => $count,
'dates' => implode(', ', $this->mergeDaysIntoPeriods($dayKeys)),
'n1PresenceDays' => $n1PresenceDays,
];
}
/**
* @param list<Absence> $absences
* @param list<string> $codes
+4
View File
@@ -68,6 +68,10 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
isDriverContract: $this->contractResolver->resolveIsDriverForEmployeeAndDate($employee, $workDate),
virtualHolidayMinutes: $this->holidayVirtualHoursResolver->resolveVirtualCredit($contract, $workDate, false, $workDaysMinutes),
contractNature: $contractNature,
trackingMode: $contract?->getTrackingMode(),
weeklyHours: $contract?->getWeeklyHours(),
contractType: $contract?->getType()->value,
contractName: $contract?->getName(),
);
}
+140
View File
@@ -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),
]);
}
}
+4 -1
View File
@@ -286,7 +286,10 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
$present = min(1.0, $morning + $afternoon + $creditedPresence);
}
$hasNightBasket = ($nightMinutes > $dayMinutes && $nightMinutes > 0) || $nightMinutes >= 240;
// Le panier de nuit ne s'applique pas aux conducteurs (ils ont leurs propres
// primes repas/nuitée). Réservé aux non-conducteurs.
$hasNightBasket = !$isDateDriver
&& (($nightMinutes > $dayMinutes && $nightMinutes > 0) || $nightMinutes >= 240);
if ($hasNightBasket) {
++$weeklyNightBasketCount;
}
+3 -3
View File
@@ -7,16 +7,16 @@ namespace App\Util;
use DateTimeImmutable;
/**
* Leave recap cutoff rule: as-of end of ISO week S-2 (Sunday 23:59:59).
* Leave recap cutoff rule: as-of end of ISO week S-1 (Sunday 23:59:59).
*
* Example: Tuesday 2026-04-14 (S16) Sunday 2026-04-05 23:59:59 (end of S14).
* Example: Tuesday 2026-04-14 (S16) Sunday 2026-04-12 23:59:59 (end of S15).
*/
final class LeaveRecapCutoff
{
public static function resolveCutoff(DateTimeImmutable $today): DateTimeImmutable
{
$currentWeekMonday = $today->modify('monday this week')->setTime(0, 0);
$cutoffWeekMonday = $currentWeekMonday->modify('-14 days');
$cutoffWeekMonday = $currentWeekMonday->modify('-7 days');
return $cutoffWeekMonday->modify('+6 days')->setTime(23, 59, 59);
}
@@ -81,7 +81,7 @@
td.time { text-align: center; }
td.presence { text-align: center; }
td.total { text-align: center; font-weight: bold; }
tr.weekend td { background: #f3f3f3; color: #555; }
tr.weekend td { background: #c0c0c0; color: #333; }
tr.weekend td.date { color: #333; }
tr.holiday td { background: #e1f5fe; }
@@ -70,7 +70,7 @@
td.time { text-align: center; }
td.presence { text-align: center; }
td.total { text-align: center; font-weight: bold; }
tr.weekend td { background: #f3f3f3; color: #555; }
tr.weekend td { background: #c0c0c0; color: #333; }
tr.weekend td.date { color: #333; }
tr.holiday td { background: #e1f5fe; }
+7 -4
View File
@@ -117,7 +117,7 @@
<th rowspan="2" style="width: 8mm;">Frais<br>Kms</th>
<th rowspan="2" style="width: 8mm;">Heures<br>de<br>nuit</th>
<th rowspan="2" style="width: 8mm;">Panier<br>de<br>nuit</th>
<th rowspan="2" style="width: 10mm;">Heures<br>payés</th>
<th colspan="2" style="width: 14mm;">Heures<br>payés</th>
<th rowspan="2" style="width: 8mm;">Heures<br>férié</th>
<th rowspan="2" style="width: 8mm;">Heures<br>dim.</th>
<th rowspan="2" style="width: 8mm;">Prime</th>
@@ -127,6 +127,8 @@
<th rowspan="2" style="width: 20mm;">Observations</th>
</tr>
<tr>
<th style="width: 7mm;">25%</th>
<th style="width: 7mm;">50%</th>
<th style="width: 8mm;">Nbre</th>
<th style="width: 22mm;">Date</th>
<th style="width: 8mm;">Nbre</th>
@@ -141,7 +143,7 @@
{% for siteId, group in siteGroups %}
{% set siteColor = group.color ?? '#B3E5FC' %}
<tr class="site-header">
<td style="background: {{ siteColor }}; text-align: left;" colspan="19">
<td style="background: {{ siteColor }}; text-align: left;" colspan="20">
{{ group.name }}
</td>
</tr>
@@ -153,7 +155,8 @@
<td class="num">{{ row.mileageKm > 0 ? row.mileageKm : '' }}</td>
<td class="num">{{ row.nightHours > 0 ? row.nightHours : '' }}</td>
<td class="num">{{ row.nightBasketCount > 0 ? row.nightBasketCount : '' }}</td>
<td class="num">{{ row.paidHours > 0 ? row.paidHours : '' }}</td>
<td class="num">{{ row.paid25Hours > 0 ? row.paid25Hours : '' }}</td>
<td class="num">{{ row.paid50Hours > 0 ? row.paid50Hours : '' }}</td>
<td class="num">{{ row.holidayHours > 0 ? row.holidayHours : '' }}</td>
<td class="num">{{ row.sundayHours > 0 ? row.sundayHours : '' }}</td>
<td class="num">{{ row.bonusAmount > 0 ? row.bonusAmount ~ ' €' : '' }}</td>
@@ -169,7 +172,7 @@
</tr>
{% else %}
<tr>
<td colspan="19">Aucun employé.</td>
<td colspan="20">Aucun employé.</td>
</tr>
{% endfor %}
{% endfor %}
@@ -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;
use App\Entity\Contract;
use App\Enum\TrackingMode;
use App\Service\Rtt\RttRecoveryComputationService;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
@@ -67,6 +68,205 @@ final class RttRecoveryComputationServiceTest extends TestCase
self::assertSame('2026-03-16', $anchor);
}
public function testResolveOvertime25BandWidthIs4hForH39(): void
{
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
$contract = new Contract()->setWeeklyHours(39);
self::assertSame(4 * 60, $this->invokePrivate($service, 'resolveOvertime25BandWidthMinutes', $contract));
}
public function testResolveOvertime25BandWidthIs8hForH35(): void
{
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
$contract = new Contract()->setWeeklyHours(35);
self::assertSame(8 * 60, $this->invokePrivate($service, 'resolveOvertime25BandWidthMinutes', $contract));
}
/**
* Dylan Chaboisson, semaine 12 : embauché le jeudi sur un contrat 39h.
* Total travaillé 22h (1320 min), départ 25 % proraté aux jours contractés = 15h (900 min),
* plafond 25 %/50 % = 15h + bande 4h = 19h (1140 min). Le plafond se décale avec
* l'embauche au lieu de rester bloqué à 43h, ouvrant la tranche 50 %.
*/
public function testMidWeekHireSplitsOvertimeAcross25And50(): void
{
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
[$base25, $base50] = $this->invokePrivate($service, 'computeOvertimeBaseMinutes', 1320, 900, 1140);
self::assertSame(4 * 60, $base25);
self::assertSame(3 * 60, $base50);
}
/**
* Régression : semaine pleine 39h (départ 39h, plafond 43h), 46h travaillées
* 4h à 25 % (39→43) et 3h à 50 % (43→46), comportement inchangé.
*/
public function testFullWeekOvertimeSplitUnchanged(): void
{
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
[$base25, $base50] = $this->invokePrivate($service, 'computeOvertimeBaseMinutes', 2760, 2340, 2580);
self::assertSame(4 * 60, $base25);
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 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- (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
{
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\Employee;
use App\Entity\EmployeeContractPeriod;
use App\Entity\EmployeeRttBalance;
use App\Enum\ContractNature;
use App\Enum\TrackingMode;
use App\Service\Contracts\EmployeeContractPhaseResolver;
@@ -74,6 +75,54 @@ final class EmployeeRttPaymentProcessorTest extends TestCase
$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.
// -----------------------------------------------------------------------
+61 -3
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Tests\State;
use App\Dto\Contracts\ContractPhase;
use App\Dto\Rtt\EmployeeRttWeekSummary;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
@@ -156,8 +157,11 @@ final class EmployeeRttSummaryProviderTest extends TestCase
$provider = $this->buildProvider([]);
$year = $this->invokePrivate($provider, 'resolveYear', $currentPhase);
// Today is 2026-05-19 → current RTT exercise (Juin N-1 → Mai N) = 2026.
self::assertSame(2026, $year);
// No params → current RTT exercise (Juin N-1 → Mai N). Derive the expectation
// 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
@@ -198,6 +202,45 @@ final class EmployeeRttSummaryProviderTest extends TestCase
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.
// -----------------------------------------------------------------------
@@ -244,6 +287,21 @@ final class EmployeeRttSummaryProviderTest extends TestCase
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.
*
@@ -253,7 +311,7 @@ final class EmployeeRttSummaryProviderTest extends TestCase
* only setting the properties that the tested private methods actually read:
* `requestStack` and `phaseResolver`.
*
* @param array<string, string> $request query parameters (year, phaseId, ...)
* @param array<string, string> $request
*/
private function buildProvider(array $request = []): EmployeeRttSummaryProvider
{
@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Tests\State;
use App\Entity\Absence;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use App\Enum\HalfDay;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\State\SalaryRecapPrintProvider;
use DateTime;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use ReflectionProperty;
/**
* Forfait N-1 split for the salary recap. The provider's collaborators are final classes
* PHPUnit cannot double, so the pure split helper is exercised via reflection, with a real
* AbsenceSegmentsResolver (no deps) injected into the uninitialized property.
*
* @internal
*/
final class SalaryRecapPrintProviderTest extends TestCase
{
public function testN1BudgetPartiallyCoversADayAndOverflowsToN(): void
{
// Budget N-1 = 2.5 j ; 3 congés pleins (1 j) lun/mar/mer de janvier.
// 1.0 + 1.0 + 0.5 consommés en N-1 → reste 0.5 j affiché en congé (le mercredi).
$conges = [
$this->buildConge('2026-01-05'),
$this->buildConge('2026-01-06'),
$this->buildConge('2026-01-07'),
];
$result = $this->split($conges, 2.5, '2026-01-01', '2026-01-31');
self::assertSame(2.5, $result['n1PresenceDays']);
self::assertSame(0.5, $result['count']);
self::assertSame('07/01', $result['dates']);
}
public function testN1BudgetConsumedInPriorMonthLeavesCurrentMonthFullyDisplayed(): void
{
// Budget 1 j, consommé par le congé de janvier. Récap de février → le congé de février
// est entièrement imputé N (affiché, 0 présence N-1 dans le mois).
$conges = [
$this->buildConge('2026-01-12'),
$this->buildConge('2026-02-09'),
];
$result = $this->split($conges, 1.0, '2026-02-01', '2026-02-28');
self::assertSame(0.0, $result['n1PresenceDays']);
self::assertSame(1.0, $result['count']);
self::assertSame('09/02', $result['dates']);
}
public function testZeroBudgetDisplaysAllCongesInMonth(): void
{
$conges = [$this->buildConge('2026-03-03')];
$result = $this->split($conges, 0.0, '2026-03-01', '2026-03-31');
self::assertSame(0.0, $result['n1PresenceDays']);
self::assertSame(1.0, $result['count']);
self::assertSame('03/03', $result['dates']);
}
public function testTerminatedContractExcludedFromMonth(): void
{
// Marine : contrat terminé le 26/02 → absente du récap de juin.
$employee = $this->buildEmployeeWithPeriod('2025-02-10', '2026-02-26');
self::assertFalse($this->hasInRange($employee, '2026-06-01', '2026-06-30'));
}
public function testOngoingContractIncluded(): void
{
$employee = $this->buildEmployeeWithPeriod('2025-01-01', null);
self::assertTrue($this->hasInRange($employee, '2026-06-01', '2026-06-30'));
}
public function testContractEndingOnFromDayIncluded(): void
{
$employee = $this->buildEmployeeWithPeriod('2025-01-01', '2026-06-01');
self::assertTrue($this->hasInRange($employee, '2026-06-01', '2026-06-30'));
}
public function testNoPeriodsExcluded(): void
{
self::assertFalse($this->hasInRange(new Employee(), '2026-06-01', '2026-06-30'));
}
private function hasInRange(Employee $employee, string $from, string $to): bool
{
$provider = new ReflectionClass(SalaryRecapPrintProvider::class)->newInstanceWithoutConstructor();
return new ReflectionClass($provider::class)
->getMethod('hasContractInRange')
->invoke($provider, $employee, new DateTimeImmutable($from), new DateTimeImmutable($to));
}
private function buildEmployeeWithPeriod(string $start, ?string $end): Employee
{
$employee = new Employee();
$period = new EmployeeContractPeriod();
$period->setEmployee($employee);
$period->setStartDate(new DateTimeImmutable($start));
$period->setEndDate(null !== $end ? new DateTimeImmutable($end) : null);
$employee->getContractPeriods()->add($period);
return $employee;
}
/**
* @param list<Absence> $conges
*
* @return array{count: float, dates: string, n1PresenceDays: float}
*/
private function split(array $conges, float $budget, string $from, string $to): array
{
$provider = new ReflectionClass(SalaryRecapPrintProvider::class)->newInstanceWithoutConstructor();
new ReflectionProperty(SalaryRecapPrintProvider::class, 'absenceSegmentsResolver')
->setValue($provider, new AbsenceSegmentsResolver());
return new ReflectionClass($provider::class)
->getMethod('splitForfaitCongesByN1')
->invoke($provider, $conges, $budget, new DateTimeImmutable($from), new DateTimeImmutable($to));
}
private function buildConge(string $date): Absence
{
return new Absence()
->setStartDate(new DateTime($date))
->setEndDate(new DateTime($date))
->setStartHalf(HalfDay::AM)
->setEndHalf(HalfDay::PM)
;
}
}
@@ -126,6 +126,60 @@ final class WorkHourDayContextProviderTest extends TestCase
self::assertSame(210, $result->rows[0]['creditedMinutes']);
}
public function testRowCarriesContractAtRequestedDate(): void
{
$user = new User();
$timeContract = new Contract()
->setName('Contrat')
->setTrackingMode(Contract::TRACKING_TIME)
->setWeeklyHours(39)
;
$forfaitContract = new Contract()
->setName('Forfait')
->setTrackingMode(Contract::TRACKING_PRESENCE)
->setWeeklyHours(null)
;
$employee = new Employee()
->setFirstName('Jean')
->setLastName('Test')
->setContract($forfaitContract)
;
$this->setEntityId($employee, 1);
// Resolver renvoie le contrat 39h avant 2026-03-01, le forfait à partir de cette date.
$resolver = $this->createStub(EmployeeContractResolver::class);
$resolver->method('resolveForEmployeeAndDate')->willReturnCallback(
static fn (Employee $e, \DateTimeImmutable $d): ?Contract =>
$d < new \DateTimeImmutable('2026-03-01') ? $timeContract : $forfaitContract
);
$resolver->method('resolveNatureForEmployeeAndDate')->willReturn(ContractNature::CDI);
$this->requestStack->push(new Request(query: ['workDate' => '2026-02-16']));
$this->security->method('getUser')->willReturn($user);
$this->employeeRepository->method('findScoped')->with($user)->willReturn([$employee]);
$this->absenceRepository->method('findByDateAndEmployees')->willReturn([]);
$provider = new WorkHourDayContextProvider(
$this->security,
$this->requestStack,
$this->employeeRepository,
$this->absenceRepository,
$this->formationRepository,
$resolver,
new AbsenceSegmentsResolver(),
new WorkedHoursCreditPolicy($this->buildResolverStub(), new DailyReferenceMinutesResolver()),
$this->buildHolidayResolver(),
);
$row = $provider->provide(new Get())->rows[0];
self::assertSame('TIME', $row['trackingMode']);
self::assertSame(39, $row['weeklyHours']);
self::assertSame('39H', $row['contractType']);
self::assertSame('Contrat', $row['contractName']);
}
private function buildEmployee(int $id, string $trackingMode, ?int $weeklyHours): Employee
{
$contract = new Contract()
+50
View File
@@ -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'));
}
}