Compare commits

..

55 Commits

Author SHA1 Message Date
5f2ab39cd7 style(rtt) : fix displaced docblock after week-anchor helper
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 17:13:46 +02:00
1e01962d7c docs(rtt) : document week contract-type anchor on first contracted day
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 17:10:46 +02:00
066ed7746f fix(rtt) : anchor week contract type on first contracted day
Mid-week hire weeks (e.g. CDD starting Thursday) had their first day with no
contract, so the week was classified CUSTOM and the 25%/50% overtime bonuses
were disabled. Anchor the week's contract type/nature on the first contracted
day instead, so a 35h/39h hire week keeps its overtime tiers.
Dylan CHABOISSON week 12: 7h → 8h45 (7h × 1.25).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 17:10:10 +02:00
f3d94a8590 docs(leave) : sharpen carried CP docblock (net accruing, prior-forfait case)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:36:53 +02:00
0e5661bb7f docs(leave) : document forfait mid-year entry leave calculation
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:29:38 +02:00
d2a9bd5191 docs(leave) : clarify carried CP docblock (net weekdays + gross saturdays)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:27:44 +02:00
ac40cb9813 docs(leave) : record forfait entry validation results and saturday rule
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:25:18 +02:00
52d11119a1 feat(leave) : keep posted saturdays credited in forfait carried CP
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:24:57 +02:00
7bc3cd6d25 docs(leave) : note bonusDays deliberately omitted on forfait entry year
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:19:38 +02:00
37c718c839 feat(leave) : forfait mid-year entry credits prorated repos + carried CP
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:17:07 +02:00
b43f854528 docs(leave) : clarify fractioned days excluded from carried CP
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:15:31 +02:00
dc9b49e254 feat(leave) : source carried CP from prior non-forfait phase
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:12:34 +02:00
ffa185dc5d feat(leave) : resolve phase immediately preceding a given phase
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:09:54 +02:00
7570b3b19f feat(leave) : detect forfait mid-year entry exercise
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:06:49 +02:00
2718d05cfe feat(leave) : add prorated forfait repo days helper
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:04:00 +02:00
907eb1a277 docs(leave) : plan for forfait mid-year entry leave calculation
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:02:46 +02:00
750f2bffa8 fix(rtt) : skip non-contracted days in overtime 25% threshold
computeWeeklyOvertime25StartMinutes used a ternary that fell back to 35h
when a day had no active contract. Once periodFrom was uncapped (so the
RTT week iterates the full exercise), pre-hire days were silently
contributing 7h each to the weekly threshold, erasing the 25% overtime
for the employee's first partial week.

Dylan CHABOISSON week 12 (Mar 19 hire, worked Thu 9h + Fri 9h30 +
Sat 3h30 = 22h): threshold was 36h (incorrect), now back to 15h (Thu 8h
+ Fri 7h) so the 7h above threshold + 1h45 bonus are correctly credited.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:10:51 +02:00
653845655f fix(rtt) : do not cap periodFrom at phase.startDate
The RTT week table was hiding all weeks before the phase's startDate,
so for an employee hired mid-exercise (e.g. Dylan CHABOISSON, CDD
starting 2026-03-19) March displayed only from week 12 instead of
week 9, with no visible padding for the pre-hire weeks of the exercise.

Mirrors the same fix applied to the leave provider: the exercise unit
for RTT is annual (Juin→Mai). Capping periodFrom artificially clipped
the displayed weeks. Days without a contract naturally contribute 0
minutes (no reference, no worked hours), so the cumul is correct
without the cap.

periodTo and limitDate caps at phase.endDate are preserved for closed
phases so the table doesn't extend past the phase end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:08:29 +02:00
54d9a30f71 fix : README.md 2026-05-19 14:50:15 +02:00
2acee03e00 style(employee) : use MalioSelect built-in label for the phase picker
Replaces the inline <label> with the MalioSelect's native `label` prop
to match the visual style of other selects on the employee detail page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:47:13 +02:00
bbde6ddcf3 fix(leave) : do not cap from at phase.startDate for non-forfait phases
The CP exercise (Juin N-1 → Mai N) is annual and continuous across
contract-signature changes within the same leave rule (e.g. 35h → 39h,
isDriver flip, weeklyHours bump). Capping `from` at the phase start
truncated the accrual to just the months under the latest phase,
producing wrong "en cours d'acquisition" values and dropping presence
days from earlier months on the leave-tab calendar.

For Damien GUILLOT (35h until 2025-10-31, then 39h), this gave 15 days
acquired (6 months Nov→Apr) instead of the expected 27.5 days
(11 months Jun→Apr at 2.5/month). After this fix, the H39 view shows
the full annual accrual as expected.

FORFAIT phases keep the from cap: the 218-day target is calendar-year
scoped and only counts the FORFAIT portion of the year.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:31:56 +02:00
f48f1d2f3a fix(contracts) : hide contract phases entirely before RTT_START_DATE
EmployeeContractPhaseResolver now accepts the data-start date and filters
out phases whose endDate is strictly before it. No work-hour or absence
data exists before the application launch date, so legacy contract
periods that ended before that date would surface meaningless RTT/CP
figures in the phase picker.

For employees who joined long before the software (typical legacy 35h
contracts, in production since 2014), only the current phase remains
visible — which also collapses the picker (threshold ≥ 2 phases).

The Employee entity reads RTT_START_DATE from $_SERVER/$_ENV directly
since it has no DI. The resolver service is wired via services.yaml.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:17:54 +02:00
3da1cab2c8 fix(leave) : clip leave-tab absence fetch to selected phase bounds
The annual calendar in LeaveTab.vue was showing absences from outside
the selected phase's lifespan. For an employee who switched contract
type mid-year, this leaked the old phase's absences into the new
phase's calendar view (and vice versa via the phase picker).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:04:45 +02:00
fd71e2ab65 docs(claude-md) : document contract phase view selector 2026-05-19 12:25:04 +02:00
f0dec14b29 docs(in-app) : add contract phase view help article 2026-05-19 12:24:04 +02:00
9c82e4a0f2 docs : leave/rtt tabs reference phase selector 2026-05-19 12:21:53 +02:00
835e758ed1 docs : add contract-phase-view documentation 2026-05-19 12:21:06 +02:00
a5c75a9129 feat(rtt) : enable pay button on closed phase last exercise 2026-05-19 12:17:44 +02:00
9d4a12f6cb feat(employee) : contract phase picker + past-mode banner 2026-05-19 12:05:03 +02:00
fd9e551542 feat(employee) : wire contract phase into detail page composable 2026-05-19 11:59:19 +02:00
3209be8c33 feat(rtt) : phase-aware RTT tab loading 2026-05-19 11:52:17 +02:00
2730e34f31 feat(leave) : phase-aware leave tab loading 2026-05-19 11:46:24 +02:00
ac53bbd5a1 feat(api) : phaseId query parameter on leave/rtt endpoints 2026-05-19 11:39:31 +02:00
03acaae135 feat(employee) : add useEmployeeContractPhase composable 2026-05-19 11:36:56 +02:00
5fc9dd1ec9 feat(employee) : add contractPhases TS DTO 2026-05-19 11:34:51 +02:00
8f355e05ad refactor(exercise) : extract ExerciseYearResolver to dedup year formula
Pull the "date -> leave/RTT exercise year" formula out of
EmployeeRttPaymentProcessor, EmployeeRttSummaryProvider and
EmployeeLeaveSummaryProvider into a single
App\Service\Exercise\ExerciseYearResolver. Forfait flag is parameterised
so the leave (calendar year) and RTT (Juin N-1 -> Mai N) variants share
the same implementation. Pure refactor, no behavioural change.
2026-05-19 11:33:06 +02:00
613ac02e1d feat(rtt) : allow payment on closed phase last exercise 2026-05-19 11:23:20 +02:00
8684d240bc feat(rtt) : phaseId support in EmployeeRttSummaryProvider
Mirror Task 3 (leave provider) on the RTT side: accept an optional `?phaseId`
query parameter and cap the exercise window to the phase boundaries when set.

- Inject EmployeeContractPhaseResolver.
- New helpers: resolveTargetPhase, clampYearToPhase, exerciseYearForDate.
- resolveYear now takes the phase: default year falls back to the phase end
  date when phaseId is provided; explicit year is silently clamped to the
  phase range.
- provide() narrows periodFrom / periodTo / limitDate to the phase end date
  for past phases.
- Default behavior (no phaseId) unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:15:22 +02:00
5a2a43bf51 refactor(leave) : address Task 3 review (helper, dead param, phase nature, regression test)
- Extract private helper `exerciseYearForDate(date, isForfait)` to dedupe
  the date->leave-exercise-year expression duplicated across `clampYearToPhase`
  and `resolveFirstComputationYear` (4 copies collapsed into 1 helper + 4
  call sites).

- Remove the unused `ContractPhase $phase` parameter from
  `resolveLeavePeriodBounds`: the body never reads it (the phase cap is
  applied later by `resolvePeriodBounds`).

- Add `ContractNature $contractNature` to `ContractPhase` DTO, populated
  from the first period of the group by `EmployeeContractPhaseResolver`.
  Drop the `resolveNatureForPhase` lookup in `EmployeeLeaveSummaryProvider`
  in favor of `$phase->contractNature`. Expose `contractNature` in
  `Employee::getContractPhases()` array shape for frontend use.

- Fix regression for terminated employees calling `computeYearSummary`
  without an explicit phase (LeaveRecapRowBuilder,
  DumpVerificationSnapshotCommand). Before the refactor the period bounds,
  accrual end and taken end were NOT capped at the contract end for
  terminated employees, because `Employee::getCurrentContractEndDate()`
  returns null when no period covers "today". The new fallback phase
  (`isCurrent=false`, real `endDate`) was silently capping `to`. Add an
  internal `applyPhaseEndCap` flag, true when phase is explicit, false
  for legacy callers, threaded through `resolvePeriodBounds`,
  `resolveAccrualCalculationEndDate` and `resolveTakenCalculationEndDate`.

- Add regression test
  `testTerminatedEmployeeWithoutExplicitPhaseSkipsPhaseEndCap` proving
  that legacy callers keep the natural exercise upper bound while explicit
  phase callers get the cap.

- Add `contractNature` assertion in `EmployeeContractPhaseResolverTest`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:07:38 +02:00
9efe0e81a0 feat(leave) : phaseId support in EmployeeLeaveSummaryProvider
The provider now resolves a target ContractPhase from a ?phaseId query
parameter and propagates it through resolveYear, resolvePeriodBounds,
resolveLeavePolicy, resolveAccrualCalculationEndDate,
resolveTakenCalculationEndDate, and resolveFirstComputationYear.

- phaseId missing → current phase (legacy behavior preserved)
- phaseId valid → past/current phase used for rule code, weekly hours
  and period bounds (capped at phase end)
- phaseId invalid (non-numeric or unknown) → 422
- year missing + phaseId → year derived from phase end date
- year out of phase range + phaseId → silent clamp to phase boundaries

Public methods computeYearSummary/resolvePaidLeaveDays/resolveLeaveYearForToday
remain backward-compatible for external callers (LeaveRecapRowBuilder,
DumpVerificationSnapshotCommand).
2026-05-19 10:52:56 +02:00
e7035a7c30 feat(employee) : expose contractPhases on read API 2026-05-19 10:35:07 +02:00
a56f797ed7 feat(contracts) : add EmployeeContractPhaseResolver service 2026-05-19 10:30:41 +02:00
7ee2e91e71 docs(plan) : contract phase view implementation plan
Step-by-step task plan derived from the design spec, covering backend
service + providers, frontend composables + picker UI, and documentation
updates required by CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:24:38 +02:00
a2874b545a docs(spec) : contract phase view selector design
Add design spec for the contract-phase picker on the employee detail page.
Lets HR navigate past contract phases (e.g. 39h before a switch to FORFAIT,
or a closed CDD) so they can view and settle leftover CP/RTT balances
without changing the default behavior for the current contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:16:47 +02:00
gitea-actions
b541f9ded8 chore: bump version to v0.1.101
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 38s
2026-05-04 07:59:16 +00:00
47f9bea57d feat : sélecteur d'exercice sur l'onglet RTT de la fiche employé
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Permet de consulter les exercices passés (table hebdomadaire RTT) en
réutilisant le pattern de l'onglet Congés. Plage bornée par
max(début historique contrat, RTT_START_DATE). Bouton + Payer les RTT
verrouillé sur exercices clos. Onglet masqué pour FORFAIT (inchangé).

Backend : rttStartDate désormais toujours exposé sur EmployeeRttSummary
pour que le sélecteur conserve sa borne lors de la navigation vers un
exercice passé. Le masquage existant des lignes Report continue de
fonctionner (comparaison mois-à-mois).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 09:58:50 +02:00
7cadcfa362 feat : sélecteur d'année sur l'onglet Congés de la fiche employé
Permet de consulter les exercices passés (calendrier + compteurs) sur
l'onglet Congés. La plage proposée est bornée par max(début historique
contrat, RTT_START_DATE) pour ne pas remonter avant la mise en service
du logiciel. Édition des stocks N-1 et fractionnés verrouillée sur
exercices clos.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 09:51:19 +02:00
gitea-actions
3ec0d4b074 chore: bump version to v0.1.100
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 40s
2026-04-29 15:45:14 +00:00
eaf8a11e2b feat: ajout des commentaires à la semaine (#15)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

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

Reviewed-on: #15
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-29 15:45:02 +00:00
gitea-actions
02fc94fbed chore: bump version to v0.1.99
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 39s
2026-04-29 15:28:10 +00:00
eb5910dffe feat : surlignage des jours fériés sur la vue semaine des heures
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Quand un employé n'a pas d'absence sur un jour férié, la cellule prend le fond bleu clair (#b3e5fc) et affiche le nom du férié au survol — cohérent avec la vue jour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:27:46 +02:00
78f73ed2e9 feat : ajout des jours fériés sur l'export PDF des heures
Affiche désormais une ligne dédiée pour chaque jour férié (Lun-Ven) avec la mention "Férié : {nom}" et le total créditant les heures contractuelles, comme sur l'écran Heures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:21:59 +02:00
eacf52425a fix : récap salaire chauffeur, comptage des repas (déjeuner + dîner)
Un jour avec déjeuner ET dîner cochés ne comptait qu'1 repas (||) au lieu de 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:27:00 +02:00
gitea-actions
6f43c3356f chore: bump version to v0.1.98
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 44s
2026-04-29 09:43:56 +00:00
13eeeb9c86 feat : ajout colonne Cumul sur l'écran RTT (#18)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Affiche le solde RTT à la fin de chaque semaine (report N-1 + somme
totalMinutes des semaines − paiements des mois antérieurs). Permet la
comparaison ligne à ligne avec un suivi RH externe.

Co-Authored-By: Claude Opus 4.7 (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: #18
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-29 09:43:46 +00:00
64 changed files with 6004 additions and 185 deletions

View File

@@ -48,6 +48,12 @@
- Saisie d'heures (ou de jours de présence) autorisée sur un férié
- **Crédit automatique des heures contractuelles** sur un férié Lun-Ven pour tout contrat hors Forfait, **uniquement en l'absence d'absence déclarée** : le total journalier = `max(saisie + credited_absence, référence_contractuelle)`. Référence : 35h→7h, 39h→8h Lun-Jeu/7h Ven, CUSTOM→weeklyHours/5, INTERIM→idem 35h/39h/custom selon weeklyHours. Aucune ligne BDD créée (crédit virtuel). Drivers : crédité en `dayHoursMinutes`. Impacte directement le total hebdo RTT (tranches 25%/50%). Dès qu'une absence est posée sur le férié, le crédit virtuel saute — c'est le `countAsWorkedHours` du type d'absence qui pilote. Services : `App\Service\WorkHours\HolidayVirtualHoursResolver` + `DailyReferenceMinutesResolver`. Doc complète : `doc/holiday-virtual-hours.md`.
## Commentaires de semaine
- Entité `EmployeeWeekComment` : commentaire libre par employé et semaine ISO (unique `(employee_id, week_start_date)`). `week_start_date` = lundi.
- CRUD `/employee_week_comments` `ROLE_ADMIN`. Write processor audite via `AuditLogger`.
- Picto bulle vue semaine (HoursWeekView + DriverHoursWeekView) : fond bleu/rouge. Intégré dans `WeeklySummaryRow.comment/commentId`.
- Doc : `doc/week-comments.md`.
## Validation Rules
- `isValid` (RH): locks line for everyone (admin can only untoggle validation)
- `isSiteValid` (site manager): locks for non-admin, admin can still edit
@@ -58,11 +64,41 @@
- 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
- **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%.
- 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é.
## 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.
- 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.
- 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`.
- 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`.
## 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`.
- **Filtre `RTT_START_DATE`** : les phases dont `endDate < RTT_START_DATE` sont masquées (aucune donnée logiciel avant la mise en service). Le resolver reçoit la date via DI (`services.yaml`) ; `Employee::getContractPhases()` lit `$_SERVER['RTT_START_DATE']` pour instancier le resolver côté entité.
- Exposé via `Employee.contractPhases` (`employee:read`). Endpoints `GET /employees/{id}/leave-summary` et `GET /employees/{id}/rtt-summary` acceptent `?phaseId=N` ; défaut = phase courante.
- Sélectionner une phase passée :
- Onglet **Congés** : période et règles de la phase (Juin→Mai non-forfait, Jan→Déc FORFAIT). Exercice de transition capé sur `phase.endDate`. **Cap `from` au `phase.startDate` uniquement pour FORFAIT** (sémantique année civile). Pour le non-forfait, l'exercice CP reste annuel et continu à travers les changements d'heures (35h→39h, etc.) — seul `resolveEffectivePeriodStart` clampe sur la date d'entrée en contrat des nouveaux embauchés.
- **Entrée FORFAIT en cours d'année** (année d'entrée only) : l'exercice d'entrée crédite `repos_proratisés + CP_reportés` au lieu de `max(0, businessDays218)`=0. Repos année = `jours_ouvrés_année 218 25`, proratisés par jours ouvrés. CP reportés = solde de la phase non-forfait précédente (jours ouvrés nets + samedis bruts ; un samedi posé ne réduit PAS le report ; fractionnés exclus). Nouvel embauché = repos seuls. Années pleines suivantes + forfait démarrant le 01/01 = calcul 218 inchangé (→34). Services : `EmployeeLeaveSummaryProvider::{resolveLeavePolicy (branche FORFAIT), isForfaitEntryYear, computeProratedForfaitRepoDays, resolveCarriedCpFromPriorPhase}`. Témoin Grégory : ≈13.
- Onglet **RTT** : visible ssi `phase.contractType !== FORFAIT`. Tableau hebdo affiché sur l'exercice complet (Juin→Mai) ; `periodFrom` non capé sur `phase.startDate` (les semaines avant embauche ou hors phase apparaissent à 0). `periodTo`/`limitDate` capés sur `phase.endDate` pour les phases clôturées. `+ Payer les RTT` actif uniquement sur l'exercice contenant `phase.endDate`.
- Bandeau jaune affiché en mode phase passée. Édition d'absences et des stocks de report (jours fractionnés, Année N-1 payés) désactivée.
- Sélection non persistée — chaque ouverture de fiche démarre sur la phase courante.
- CP : solder via `EmployeeContractPeriod.paidLeaveSettledClosureDate` (mécanisme existant). RTT : créer un `EmployeeRttPayment` sur le dernier exercice de la phase.
- "Exercise year for date" mutualisé dans `App\Service\Exercise\ExerciseYearResolver` (forfait = année civile, non-forfait = Juin N-1 → Mai N).
- Doc complète : `doc/contract-phase-view.md`.
## 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

View File

@@ -23,5 +23,17 @@ docker compose exec -T db psql -U root -d sirh < sirh.sql
```sql
UPDATE users SET roles = '["ROLE_ADMIN","ROLE_SUPER_ADMIN"]' WHERE username = 'emilie';
```
## Récupérer la bdd de prod en local
Sur le serveur de prod, créer le dump :
```shell
sudo -u postgres pg_dump --no-owner --no-privileges --clean --if-exists sirh_prod > /tmp/sirh_prod_$(date +%F).sql
scp user@<serveur>:/tmp/sirh_prod_2026-04-14.dump ~/workspace/
```
En local, récupérer le fichier et l'importer (remplace `YYYY-MM-DD` par la date du dump) :
```shell
scp user@<serveur>:/tmp/sirh_prod_YYYY-MM-DD.sql ~/workspace/SIRH/sirh.sql
docker compose exec -T db psql -U root -d sirh -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
docker compose exec -T db psql -U root -d sirh < ~/workspace/SIRH/sirh.sql
```

View File

@@ -35,6 +35,14 @@ services:
arguments:
$rttStartDate: '%env(RTT_START_DATE)%'
App\State\EmployeeLeaveSummaryProvider:
arguments:
$dataStartDate: '%env(RTT_START_DATE)%'
App\Service\Contracts\EmployeeContractPhaseResolver:
arguments:
$dataStartDate: '%env(RTT_START_DATE)%'
App\Repository\Contract\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository'
App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface: '@App\Repository\EmployeeContractPeriodRepository'
App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository'

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.97'
app.version: '0.1.101'

105
doc/contract-phase-view.md Normal file
View File

@@ -0,0 +1,105 @@
# Vue contrat — sélecteur de phase
## Objectif
Permettre à la RH de consulter les onglets Congés et RTT d'un employé selon une phase de contrat passée (ex. un 39h CDI avant un switch FORFAIT, ou un CDD clôturé avec solde de tout compte) sans changer le comportement par défaut sur la phase courante.
## Concept de "phase"
Une **phase** = groupe d'`EmployeeContractPeriod` consécutifs (triés par `startDate`) partageant la signature `(contract.type, weeklyHours, isDriver)`. Le service `EmployeeContractPhaseResolver` (`src/Service/Contracts/EmployeeContractPhaseResolver.php`) calcule ces phases à la volée depuis `Employee::getContractPeriods()`.
Une transition de signature (35h → 39h, 39h → FORFAIT, driver false→true, weeklyHours 28→30, etc.) ouvre une nouvelle phase. Un type différent entre deux périodes de même signature empêche leur fusion (39h → INTERIM → 39h = 3 phases).
## Filtrage par `RTT_START_DATE`
Les phases dont la date de fin est strictement antérieure à `RTT_START_DATE` (env, date de mise en service du logiciel) sont **masquées** du picker. Aucune donnée logiciel (heures, absences) n'existe avant cette date, donc consulter une phase entièrement antérieure n'a pas de sens fonctionnel.
Exemple : un employé sous 35h CDI de 2014 à 2025-10-31 puis 39h CDI depuis 2025-11-01, avec `RTT_START_DATE=2026-02-23` :
- Phase 35h (2014 → 2025-10-31) : entièrement avant → masquée
- Phase 39h (depuis 2025-11-01) : chevauche → visible
- Résultat : 1 seule phase visible → picker caché (seuil ≥ 2 phases)
Une phase dont la date de fin est égale à `RTT_START_DATE` est conservée (inégalité `>=`, non stricte).
`EmployeeContractPhaseResolver` reçoit `RTT_START_DATE` via DI (`services.yaml`). L'entité `Employee::getContractPhases()` lit la valeur depuis `$_SERVER`/`$_ENV` directement (l'entité n'a pas d'injection) et passe la chaîne au constructeur du resolver.
## Picker UI
- Position : en haut de la fiche employé, sous le nom et au-dessus des onglets.
- Libellé : `Vue contrat`.
- Caché si l'employé n'a qu'une seule phase.
- Sélection non persistée — chaque ouverture de fiche démarre sur la phase courante.
## Bandeau d'information
Affiché quand le picker est sur une phase passée. Indique que le mode lecture est partiel — les paiements de solde restent possibles, l'édition d'absences et des stocks de report est désactivée.
## Effet sur les onglets
| Onglet | Effet |
|---|---|
| Congés | Recharge avec les règles de la phase. Période Juin→Mai pour non-forfait, Jan→Déc pour FORFAIT. Exercices bornés à la phase. |
| RTT | Visible ssi `phase.contractType !== FORFAIT`. Le tableau affiche **toujours toutes les semaines de l'exercice** (Juin→Mai). Pour une phase clôturée, `periodTo` et `limitDate` sont capés à `phase.endDate` (les semaines après la fin de phase contribuent 0 au cumul). `periodFrom` n'est pas capé : les semaines avant l'embauche ou avant le début d'une phase passée apparaissent à 0 (pas de contrat → pas de référence → 0 minute). |
| Heures, Frais, Formation, Contrat, Calendrier | Non impactés. |
## Paiements de solde sur phase passée
- **RTT** : `+ Payer les RTT` activé sur le **dernier exercice de la phase** uniquement (l'exercice contenant `phase.endDate`). Les exercices antérieurs restent en lecture seule.
- **CP** : utiliser le mécanisme existant `EmployeeContractPeriod.paidLeaveSettledClosureDate` pour solder. Pas de nouveau channel.
## Transition d'exercice
Quand un exercice chevauche deux phases, les bornes sont capées différemment selon le type de phase consultée :
### Phase FORFAIT (passée ou courante)
Le cumul 218 jours est **par année civile**. Toute consultation FORFAIT cape :
- `from` à `max(phase.startDate, 1er janvier de l'année)`
- `to` à `min(phase.endDate, 31 décembre de l'année)`
Ex. switch 39h → FORFAIT au 01/05/2026, vue FORFAIT année 2026 → période = [01/05/2026, 31/12/2026].
#### Entrée en FORFAIT en cours d'année civile
L'**année d'entrée** (période partielle, ex. 01/05 → 31/12) ne calcule pas `max(0, businessDays 218)` (qui donnerait 0) mais :
jours_repos_année = jours_ouvrés_année 218 25
jours_repos_proratisés = jours_repos_année × (jours_ouvrés_période / jours_ouvrés_année)
congés_à_poser = jours_repos_proratisés + CP_reportés_phase_précédente
- jours ouvrés = Lun-Ven fériés en semaine (liste brute, incl. Pentecôte) ; prorata par jours ouvrés.
- `CP_reportés` = solde de la phase non-forfait précédente : jours ouvrés **nets** (acquis + en cours jours ouvrés posés) + samedis **bruts**. Un **samedi déjà posé ne réduit pas** le report (seuls les jours ouvrés posés le réduisent — règle comptable). Jours fractionnés exclus. Nouvel embauché forfait (pas de phase précédente) → 0, donc repos proratisés seuls.
- Périmètre : **uniquement l'année d'entrée**. Les années pleines suivantes et les forfaits démarrant un 1er janvier gardent le calcul 218 (→ 34).
- Détection : `EmployeeLeaveSummaryProvider::isForfaitEntryYear` ; calcul : `computeProratedForfaitRepoDays` + `resolveCarriedCpFromPriorPhase`.
Exemple Grégory BARRIBAULT (forfait 01/05/2026 après 39h, exercice 2026) : `6 repos + ~7 CP = ≈ 13 jours`.
### Phase non-forfait (35h / 39h / CUSTOM / INTERIM)
L'exercice CP est **annuel** (Juin N-1 → Mai N) et continu à travers les changements d'heures contractuelles dans le même régime non-forfait. La cap **n'applique pas** sur `from` :
- `from` reste à 1er juin de l'année (le contrat-entry-date est géré par `resolveEffectivePeriodStart` pour les nouveaux embauchés)
- `to` est borné à `phase.endDate` uniquement quand on consulte une **phase passée**
Ex. employé 35h jusqu'au 31/10/2025 puis 39h depuis le 01/11/2025 :
- Vue 39h (courante) sur exercice 2026 → période = [01/06/2025, 31/05/2026]. Acquis CP = exercice complet (~27.5 jours à fin avril).
- Vue 35h (passée) sur exercice 2026 → période = [01/06/2025, 31/10/2025]. Acquis CP = 5 mois de l'exercice.
**Important** : c'est intentionnel que la vue courante 39h inclue les mois Juin-Octobre travaillés en 35h dans son cumul. Le stock CP est annuel, pas par phase ; un changement d'heures ne reset pas le compteur.
## API
Les endpoints suivants acceptent `?phaseId=N` :
- `GET /employees/{id}/leave-summary`
- `GET /employees/{id}/rtt-summary`
Quand absent, ils utilisent la phase courante (comportement inchangé).
`Employee.contractPhases` (lecture, groupe `employee:read`) liste les phases au format `{id, contractType, weeklyHours, isDriver, contractNature, startDate, endDate, periodIds, isCurrent}`.
## Tests
- `tests/Service/Contracts/EmployeeContractPhaseResolverTest.php` (unit)
- `tests/State/EmployeeLeaveSummaryProviderTest.php` (functional, phaseId)
- `tests/State/EmployeeRttSummaryProviderTest.php` (functional, phaseId)
- `tests/State/EmployeeRttPaymentProcessorTest.php` (functional, dernier exo phase clôturée)
- `tests/Service/Exercise/ExerciseYearResolverTest.php` (helper extrait pendant Task 5)

View File

@@ -173,6 +173,7 @@ Documents complementaires:
- Exclusions configurables: variable d'env `EXCLUDED_PUBLIC_HOLIDAYS` (liste de libellés séparés par virgules). Par défaut `"Lundi de Pentecôte"` — journée de solidarité généralement travaillée. Le filtre s'applique à tous les consommateurs (frontend + calculs backend) en amont du retour du service.
- Onglet congés: jours fériés affichés sur le calendrier avec fond `rgb(179, 229, 252)` et nom au survol
- Écran Heures et Heures Conducteurs (vue jour): le nom du férié est affiché dans la colonne Absence sous forme de pill (fond `#b3e5fc`, icône `mdi:calendar-star`), distinct du pill absence
- Écran Heures et Heures Conducteurs (vue semaine): la cellule du jour férié prend le fond `#b3e5fc` quand l'employé n'a pas d'absence ce jour-là, avec le nom du férié au survol (`title`). Si une absence est posée, la couleur de l'absence prime ; le `title` cumule les deux libellés (`Absence — Férié : Nom`).
- Règle courante:
- absences autorisées sur jour férié (création/édition depuis l'écran Heures et le Calendrier). Quand une absence est posée, le crédit virtuel férié est désactivé — c'est le `countAsWorkedHours` du type d'absence qui pilote
- saisie d'heures ou de jours de présence autorisée — les heures saisies comptent normalement dans le total hebdo et le calcul RTT
@@ -313,6 +314,7 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- les heures payées sont soustraites du disponible RTT (`availableMinutes -= totalPaidMinutes`)
- affichage: 2 lignes par mois dans le tableau (25% et 50%)
- colonnes Total 25% et Total 50%: somme base + bonus de chaque tranche
- colonne Cumul (dernière colonne): solde RTT à la fin de chaque semaine = `report N-1 + somme totalMinutes des semaines jusqu'à celle-ci paiements RTT des mois antérieurs au mois de la semaine`. Le paiement d'un mois M n'est déduit qu'à partir des semaines du mois M+1 (cohérent avec la logique de la ligne "Report mois précédent"). Permet la comparaison ligne à ligne avec un suivi RH externe (Excel)
- ligne Report N-1 (carry rollover): affichée en juin uniquement si carry > 0
- ligne Report mois précédent: solde cumulé (carry N-1 + semaines antérieures paiements antérieurs), affichée à partir de juillet (masquée si nul)
- Reste = Report cumulé + Total du mois Payé du mois (balance courante en fin de mois)
@@ -378,7 +380,7 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
| Maladie - Nombre | Absence code 'M' ou 'AT' | Jours (demi-journées = 0.5) |
| Maladie - Date | Absence code 'M' ou 'AT' | Dates formatées dd/mm |
| CHAUFFEUR - PDJ | WorkHour.hasBreakfast | Comptage mois (chauffeurs uniquement) |
| CHAUFFEUR - REPAS | WorkHour.hasLunch + hasDinner | Comptage mois (chauffeurs uniquement) |
| CHAUFFEUR - REPAS | WorkHour.hasLunch + hasDinner | Somme sur le mois : +1 par déjeuner coché et +1 par dîner coché (un jour avec les deux compte 2 repas, chauffeurs uniquement) |
| CHAUFFEUR - NUITEE | WorkHour.hasOvernight | Comptage mois (chauffeurs uniquement) |
| CHAUFFEUR - samedi | WorkHour (samedi) | Samedis travaillés (chauffeurs uniquement) |
| Observations | — | Colonne vide pour saisie manuelle |
@@ -442,7 +444,8 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- Accessible depuis la fiche employé (bouton imprimante à droite du nom)
- Ouvre un drawer pour choisir l'année (civile, Jan-Déc)
- Génère un PDF avec le détail jour par jour des heures de l'employé
- Seuls les jours avec heures saisies ou absence sont affichés
- Seuls les jours avec heures saisies, absence, week-end ou jour férié sont affichés
- Les jours fériés apparaissent toujours sur une ligne dédiée (fond bleu clair) avec la mention "Férié : {nom}" dans la colonne Absence (même si aucune saisie)
### Colonnes selon le mode de suivi
@@ -460,6 +463,7 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- TIME non-chauffeur: somme des créneaux matin + après-midi + soir, plus minutes créditées des absences `countAsWorkedHours`
- Chauffeur: `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` + minutes créditées
- PRESENCE: 0.5 par demi-journée présente (matin/après-midi), max 1.0
- Jour férié Lun-Ven (hors Forfait, sans absence) : `total = max(saisie + crédit absence, référence contractuelle)` — même règle que l'écran Heures (cf. `HolidayVirtualHoursResolver`). Pour Forfait : pas de crédit virtuel, la ligne férié affiche juste l'éventuelle présence saisie.
### Nom du fichier

70
doc/leave-tab.md Normal file
View File

@@ -0,0 +1,70 @@
# Onglet "Congés" — fiche employé
## Vue d'ensemble
L'onglet **Congés** de la fiche employé (`frontend/components/employees/LeaveTab.vue`) affiche :
- un bandeau de compteurs (acquis, pris, reste, en cours d'acquisition, N-1 ou samedis selon le contrat) ;
- un calendrier annuel coloré des congés posés (12 mois en grille 4×3) ;
- pour chaque mois, le nombre de jours de présence (`presenceDaysByMonth`) ;
- un sélecteur d'année en pied de calendrier.
## Période affichée
La période dépend du **type de contrat actuel** de l'employé :
| Type de contrat | Période affichée |
|-------------------|--------------------------------|
| FORFAIT | Janvier → Décembre (année civile) |
| Autres | Juin (Y-1) → Mai (Y) (exercice CP) |
Cette règle suit `EmployeeLeaveSummaryProvider::resolveYear()` côté backend : la sélection FORFAIT vs non-FORFAIT se fait toujours sur le contrat **courant**, pas sur celui qui était en vigueur à l'année consultée.
## Sélecteur d'année
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 ;
- **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.
Format des libellés :
- FORFAIT : `2026`, `2025`, `2024`
- Autres : `Juin 2025 → Mai 2026`, `Juin 2024 → Mai 2025`
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
Quand `selectedYear !== currentYear` (consultation d'une année antérieure) :
- 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.
## Sélecteur de phase de contrat
Quand l'employé a plusieurs phases de contrat (`Employee.contractPhases.length > 1`), le picker `Vue contrat` en haut de la fiche permet de consulter une phase passée. L'onglet Congés bascule alors sur les règles de la phase choisie :
- Période Juin→Mai pour les phases non-forfait, Jan→Déc pour FORFAIT.
- Sélecteur d'année interne borné aux exercices intersectant la phase.
- Bornes d'exercice cappées sur `phase.endDate` côté backend (l'exercice de transition affiche les soldes acquis jusqu'à la date de fin de phase, pas au-delà).
- Boutons crayon `Jours fractionnés` / `Année N-1 payés` désactivés (lecture seule sur phase passée).
Cf. `doc/contract-phase-view.md` pour les détails complets.
## Implémentation
- Composable : `frontend/composables/useEmployeeLeave.ts`
- État : `selectedLeaveYear`, computed `currentLeaveYear`, `availableLeaveYears`
- API : `setSelectedLeaveYear(year)`, `loadLeaveData()`, `resetLoaded()`
- `resetLoaded()` (appelé au changement d'employé) remet `selectedLeaveYear = null` pour que la valeur par défaut soit recalculée à partir du nouveau contrat.
- Composant : `frontend/components/employees/LeaveTab.vue`
- Props : `selectedYear`, `availableYears`, `currentYear`
- Event : `update-selected-year`
- Page : `frontend/pages/employees/[id].vue` (câble le composable au composant)
- Backend : `EmployeeLeaveSummaryProvider` reçoit `RTT_START_DATE` via `services.yaml` (argument `$dataStartDate`) et l'expose dans la réponse `EmployeeLeaveSummary.dataStartDate`. Le filtrage `?year=YYYY` était déjà accepté (validation 20002100).

62
doc/rtt-tab.md Normal file
View File

@@ -0,0 +1,62 @@
# Onglet "RTT" — fiche employé
## Vue d'ensemble
L'onglet **RTT** de la fiche employé (`frontend/components/employees/RttTab.vue`) affiche un tableau hebdomadaire détaillé des heures supplémentaires accumulées et payées sur un exercice :
- bandeau de navigation par mois (chevrons gauche/droite) ;
- table semaine par semaine : Heure / Base 25% / 25% / Total 25% / Base 50% / 50% / Total 50% / Total / Cumul ;
- ligne Report (carry N-1 ou cumul mois précédents) ;
- ligne Total mois, ligne Payé, ligne Reste ;
- bouton « + Payer les RTT » dans le bandeau ;
- sélecteur d'exercice en pied de tableau.
L'onglet est **masqué pour les contrats FORFAIT** (filtre `showRttTab` dans `useEmployeeDetailPage`). Les FORFAIT n'accumulent pas de RTT.
## Période affichée
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`.
## 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 ;
- **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)
- la valeur est exposée par l'API `GET /employees/{id}/rtt-summary` via le champ `rttStartDate` (déjà existant — mais peuplé uniquement quand la date tombe dans l'exercice retourné, donc le composable utilise la première réponse pour borner la plage).
- format unique : `Juin 2025 → Mai 2026`, `Juin 2024 → Mai 2025`
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
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é.
La consultation reste possible, l'édition non.
## Sélecteur de phase de contrat
L'onglet RTT est visible quand la **phase de contrat sélectionnée** n'est pas FORFAIT (et non pas le contrat courant). Concrètement, sur un employé passé en FORFAIT après une période 39h :
- En vue `FORFAIT` (défaut), l'onglet RTT est masqué.
- En vue `39h` (phase passée sélectionnée via le picker `Vue contrat`), l'onglet RTT redevient visible avec les exercices Juin→Mai bornés à la phase.
Le bouton `+ Payer les RTT` est activé uniquement sur le **dernier exercice de la phase passée** (l'exercice contenant `phase.endDate`). Les exercices antérieurs sont en lecture seule.
Cf. `doc/contract-phase-view.md` pour les détails complets.
## Implémentation
- Composable : `frontend/composables/useEmployeeRtt.ts`
- État : `selectedRttYear`, computed `currentRttYear`, `availableRttYears`
- API : `setSelectedRttYear(year)`, `loadRttData()`, `resetLoaded()`
- `resetLoaded()` (appelé au changement d'employé) remet `selectedRttYear = null`.
- Composant : `frontend/components/employees/RttTab.vue`
- Props : `selectedYear`, `availableYears`, `currentYear`
- Event : `update-selected-year`
- Renommage `currentYear` (computed local de l'année du mois affiché) → `displayedMonthYear` pour éviter la collision avec la nouvelle prop.
- Page : `frontend/pages/employees/[id].vue`
- Backend : aucun changement — `EmployeeRttSummaryProvider` accepte déjà `?year=YYYY` (validation 20002100) et expose `rttStartDate`.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,503 @@
# FORFAIT mid-year entry — congés à poser 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:** Quand un employé entre en FORFAIT en cours d'année civile, calculer les congés à poser comme `jours_de_repos_proratisés + CP_acquis_reportés_de_la_phase_précédente`, au lieu du `max(0, businessDays 218)` actuel qui retourne 0.
**Architecture:** Modification ciblée de `EmployeeLeaveSummaryProvider`. Uniquement la **branche FORFAIT de `resolveLeavePolicy`**, et **uniquement l'année civile d'entrée** (période partielle). Les forfaits sur année pleine gardent le calcul 218 existant (régression nulle). Les jours de repos proratisés sont une arithmétique pure (testable isolément). Les CP reportés sont sourcés en ré-exécutant `computeYearSummary` sur la phase non-forfait précédente (récursion à un seul niveau, terminée car la phase précédente n'est pas FORFAIT).
**Tech Stack:** Symfony, API Platform State Provider, PHPUnit 12 (tests par réflexion sur `newInstanceWithoutConstructor`, cf. classes finales non-mockables).
**Règle métier de référence :** mémoire `forfait-218-prorata-pending`, validée par la comptable 2026-05-20. Cas témoin : Grégory BARRIBAULT (id 41), FORFAIT depuis 2026-05-01, précédé d'une phase 39h → **13 jours à poser** (`6 repos + ~7 CP nets`).
---
## File Structure
- **Modify** `src/State/EmployeeLeaveSummaryProvider.php`
- Nouvelle constante `FORFAIT_STANDARD_CP_DAYS = 25`
- Nouvelle méthode pure `computeProratedForfaitRepoDays(int $businessDaysYear, int $businessDaysPeriod): float`
- Nouvelle méthode `isForfaitEntryYear(ContractPhase $phase, int $year): bool`
- Nouvelle méthode `resolvePhaseImmediatelyBefore(Employee $employee, ContractPhase $phase): ?ContractPhase`
- Nouvelle méthode `resolveCarriedCpFromPriorPhase(Employee $employee, ContractPhase $forfaitPhase): float`
- Modification de la branche FORFAIT de `resolveLeavePolicy` (ligne ~713)
- **Modify** `tests/State/EmployeeLeaveSummaryProviderTest.php` (nouveaux tests, helpers existants réutilisés)
- **Modify** `CLAUDE.md` (section « Vue contrat » + « Onglet Congés »)
- **Modify** `doc/contract-phase-view.md` (section transition d'exercice FORFAIT)
- **Modify** `frontend/data/documentation-content.ts` (doc in-app onglet Congés FORFAIT)
---
## Task 1: Repos proratisés — helper arithmétique pur
**Files:**
- Modify: `src/State/EmployeeLeaveSummaryProvider.php`
- Test: `tests/State/EmployeeLeaveSummaryProviderTest.php`
- [ ] **Step 1: Write the failing test**
Ajouter dans `tests/State/EmployeeLeaveSummaryProviderTest.php` :
```php
public function testComputeProratedForfaitRepoDaysGregoryCase(): void
{
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
// 2026 : 252 jours ouvrés/an, 168 sur la période 01/05→31/12.
// repos année = 252 - 218 - 25 = 9 ; proratisé = 9 × 168/252 = 6.0
$result = $this->invokePrivate($provider, 'computeProratedForfaitRepoDays', 252, 168);
self::assertEqualsWithDelta(6.0, $result, 0.001);
}
public function testComputeProratedForfaitRepoDaysFullYearEquals9(): void
{
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
// Année pleine : 9 × 252/252 = 9.0
$result = $this->invokePrivate($provider, 'computeProratedForfaitRepoDays', 252, 252);
self::assertEqualsWithDelta(9.0, $result, 0.001);
}
public function testComputeProratedForfaitRepoDaysClampsNegativeToZero(): void
{
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
// Année avec trop peu de jours ouvrés (240 - 218 - 25 < 0) → 0
$result = $this->invokePrivate($provider, 'computeProratedForfaitRepoDays', 240, 160);
self::assertSame(0.0, $result);
}
public function testComputeProratedForfaitRepoDaysZeroYearGuard(): void
{
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
self::assertSame(0.0, $this->invokePrivate($provider, 'computeProratedForfaitRepoDays', 0, 0));
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `make test` (ou `docker compose exec -T php vendor/bin/phpunit tests/State/EmployeeLeaveSummaryProviderTest.php --filter computeProratedForfaitRepoDays`)
Expected: FAIL — `Method computeProratedForfaitRepoDays does not exist`.
- [ ] **Step 3: Write minimal implementation**
Dans `src/State/EmployeeLeaveSummaryProvider.php`, ajouter la constante près de `FORFAIT_TARGET_WORKED_DAYS` (ligne ~40) :
```php
private const int FORFAIT_TARGET_WORKED_DAYS = 218;
private const int FORFAIT_STANDARD_CP_DAYS = 25;
```
Et la méthode (à placer près de `resolveLeavePolicy`) :
```php
/**
* Jours de repos forfait proratisés sur la fraction de jours ouvrés couverte.
*
* Repos année pleine = jours_ouvrés_année 218 (cible travaillée) 25 (CP standard).
* Pour 2026 : 252 218 25 = 9, proratisés au ratio jours_ouvrés_période / jours_ouvrés_année.
*/
private function computeProratedForfaitRepoDays(int $businessDaysYear, int $businessDaysPeriod): float
{
if ($businessDaysYear <= 0) {
return 0.0;
}
$repoDaysYear = max(0, $businessDaysYear - self::FORFAIT_TARGET_WORKED_DAYS - self::FORFAIT_STANDARD_CP_DAYS);
return $repoDaysYear * $businessDaysPeriod / $businessDaysYear;
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `docker compose exec -T php vendor/bin/phpunit tests/State/EmployeeLeaveSummaryProviderTest.php --filter computeProratedForfaitRepoDays`
Expected: PASS (4 tests).
- [ ] **Step 5: Commit**
```bash
git add src/State/EmployeeLeaveSummaryProvider.php tests/State/EmployeeLeaveSummaryProviderTest.php
git commit -m "feat(leave) : add prorated forfait repo days helper"
```
---
## Task 2: Détection de l'année d'entrée en forfait
**Files:**
- Modify: `src/State/EmployeeLeaveSummaryProvider.php`
- Test: `tests/State/EmployeeLeaveSummaryProviderTest.php`
- [ ] **Step 1: Write the failing test**
```php
public function testIsForfaitEntryYearTrueOnStartYear(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$forfaitPhase = new EmployeeContractPhaseResolver()->resolvePhases($employee)[0];
$provider = $this->buildProvider();
self::assertTrue($this->invokePrivate($provider, 'isForfaitEntryYear', $forfaitPhase, 2026));
}
public function testIsForfaitEntryYearFalseOnSubsequentFullYear(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$forfaitPhase = new EmployeeContractPhaseResolver()->resolvePhases($employee)[0];
$provider = $this->buildProvider();
self::assertFalse($this->invokePrivate($provider, 'isForfaitEntryYear', $forfaitPhase, 2027));
}
public function testIsForfaitEntryYearFalseWhenForfaitStartsJan1(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2025-12-31', '2026-01-01');
$forfaitPhase = new EmployeeContractPhaseResolver()->resolvePhases($employee)[0];
$provider = $this->buildProvider();
// Forfait démarrant un 1er janvier = année pleine, pas une entrée en cours d'année.
self::assertFalse($this->invokePrivate($provider, 'isForfaitEntryYear', $forfaitPhase, 2026));
}
public function testIsForfaitEntryYearFalseForNonForfaitPhase(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$h39Phase = new EmployeeContractPhaseResolver()->resolvePhases($employee)[1];
$provider = $this->buildProvider();
self::assertFalse($this->invokePrivate($provider, 'isForfaitEntryYear', $h39Phase, 2026));
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `docker compose exec -T php vendor/bin/phpunit tests/State/EmployeeLeaveSummaryProviderTest.php --filter isForfaitEntryYear`
Expected: FAIL — `Method isForfaitEntryYear does not exist`.
- [ ] **Step 3: Write minimal implementation**
```php
/**
* Vrai si la phase FORFAIT démarre en cours de l'année civile consultée
* (donc avec une période partielle), faux pour une année pleine ou un démarrage le 1er janvier.
*/
private function isForfaitEntryYear(ContractPhase $phase, int $year): bool
{
if (ContractType::FORFAIT !== $phase->contractType) {
return false;
}
return (int) $phase->startDate->format('Y') === $year
&& '01-01' !== $phase->startDate->format('m-d');
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `docker compose exec -T php vendor/bin/phpunit tests/State/EmployeeLeaveSummaryProviderTest.php --filter isForfaitEntryYear`
Expected: PASS (4 tests).
- [ ] **Step 5: Commit**
```bash
git add src/State/EmployeeLeaveSummaryProvider.php tests/State/EmployeeLeaveSummaryProviderTest.php
git commit -m "feat(leave) : detect forfait mid-year entry exercise"
```
---
## Task 3: Résolution de la phase immédiatement précédente
**Files:**
- Modify: `src/State/EmployeeLeaveSummaryProvider.php`
- Test: `tests/State/EmployeeLeaveSummaryProviderTest.php`
- [ ] **Step 1: Write the failing test**
```php
public function testResolvePhaseImmediatelyBeforeReturnsPriorH39Phase(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
$forfaitPhase = $phases[0]; // current FORFAIT
$h39Phase = $phases[1];
$provider = $this->buildProvider();
$prior = $this->invokePrivate($provider, 'resolvePhaseImmediatelyBefore', $employee, $forfaitPhase);
self::assertNotNull($prior);
self::assertSame($h39Phase->id, $prior->id);
self::assertSame(ContractType::THIRTY_NINE_HOURS, $prior->contractType);
}
public function testResolvePhaseImmediatelyBeforeReturnsNullForFirstPhase(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
$firstPhase = $phases[1]; // the H39 (earliest)
$provider = $this->buildProvider();
self::assertNull($this->invokePrivate($provider, 'resolvePhaseImmediatelyBefore', $employee, $firstPhase));
}
```
> Note : si la signature exacte de `ContractType` du 39h n'est pas `THIRTY_NINE_HOURS`, ajuster l'assertion en lisant `src/Enum/ContractType.php`. Le test reste valide via `$prior->id`.
- [ ] **Step 2: Run test to verify it fails**
Run: `docker compose exec -T php vendor/bin/phpunit tests/State/EmployeeLeaveSummaryProviderTest.php --filter resolvePhaseImmediatelyBefore`
Expected: FAIL — `Method resolvePhaseImmediatelyBefore does not exist`.
- [ ] **Step 3: Write minimal implementation**
```php
/**
* Phase dont la date de début est la plus proche en deçà de celle de $phase
* (la phase qui précède immédiatement). Null si $phase est la première.
*/
private function resolvePhaseImmediatelyBefore(Employee $employee, ContractPhase $phase): ?ContractPhase
{
$prior = null;
foreach ($this->phaseResolver->resolvePhases($employee) as $candidate) {
if ($candidate->startDate >= $phase->startDate) {
continue;
}
if (null === $prior || $candidate->startDate > $prior->startDate) {
$prior = $candidate;
}
}
return $prior;
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `docker compose exec -T php vendor/bin/phpunit tests/State/EmployeeLeaveSummaryProviderTest.php --filter resolvePhaseImmediatelyBefore`
Expected: PASS (2 tests).
- [ ] **Step 5: Commit**
```bash
git add src/State/EmployeeLeaveSummaryProvider.php tests/State/EmployeeLeaveSummaryProviderTest.php
git commit -m "feat(leave) : resolve phase immediately preceding a given phase"
```
---
## Task 4: Source des CP reportés depuis la phase précédente
**Files:**
- Modify: `src/State/EmployeeLeaveSummaryProvider.php`
**Pas de test unitaire isolé** : la méthode ré-exécute `computeYearSummary` (dépendances repository non-mockables, cf. note `buildProvider`). La validation se fait en intégration (Task 6) contre le cas Grégory.
- [ ] **Step 1: Write implementation**
```php
/**
* CP nets encore disponibles (jours + samedis) hérités de la phase non-forfait
* précédant immédiatement une entrée en FORFAIT. 0 si aucune phase précédente
* ou si la précédente est elle-même un FORFAIT (nouvel embauché → cas 2).
*
* Le total disponible = remainingDays (acquis restant) + accruingDays (généré
* restant, samedis générés inclus) + remainingSaturdays (samedis acquis restant).
* Les congés déjà posés sous la phase précédente sont déjà déduits par
* computeYearSummary, donc on récupère bien le NET (ex. Grégory : 12 acquis 5 pris ≈ 7).
*/
private function resolveCarriedCpFromPriorPhase(Employee $employee, ContractPhase $forfaitPhase): float
{
$prior = $this->resolvePhaseImmediatelyBefore($employee, $forfaitPhase);
if (null === $prior || ContractType::FORFAIT === $prior->contractType) {
return 0.0;
}
$reference = $prior->endDate ?? new DateTimeImmutable('today');
$priorYear = $this->exerciseYearResolver->forDate($reference, false);
$summary = $this->computeYearSummary($employee, $priorYear, 0.0, null, $prior);
if (null === $summary) {
return 0.0;
}
return $summary['remainingDays'] + $summary['accruingDays'] + $summary['remainingSaturdays'];
}
```
- [ ] **Step 2: Verify it compiles / no regression**
Run: `docker compose exec -T php vendor/bin/phpunit tests/State/EmployeeLeaveSummaryProviderTest.php`
Expected: PASS (suite inchangée — la méthode n'est pas encore appelée).
- [ ] **Step 3: Commit**
```bash
git add src/State/EmployeeLeaveSummaryProvider.php
git commit -m "feat(leave) : source carried CP from prior non-forfait phase"
```
---
## Task 5: Brancher dans la branche FORFAIT de `resolveLeavePolicy`
**Files:**
- Modify: `src/State/EmployeeLeaveSummaryProvider.php:713` (branche `if (ContractType::FORFAIT === $type)`)
- [ ] **Step 1: Write implementation**
Au tout début de la branche FORFAIT de `resolveLeavePolicy`, avant le calcul `$businessDaysInPeriod` existant, insérer la dérivation « année d'entrée » :
```php
$type = $phase->contractType;
if (ContractType::FORFAIT === $type) {
$year = (int) $from->format('Y'); // période forfait = année civile
// Entrée en FORFAIT en cours d'année : repos proratisés + CP reportés de
// la phase précédente (au lieu de max(0, businessDays 218) qui donne 0).
if ($this->isForfaitEntryYear($phase, $year)) {
$yearStart = new DateTimeImmutable(sprintf('%d-01-01 00:00:00', $year));
$yearEnd = new DateTimeImmutable(sprintf('%d-12-31 00:00:00', $year));
$rawYearHolidays = $this->buildRawPublicHolidayMap($yearStart, $yearEnd);
$businessDaysYear = $this->countBusinessDays($yearStart, $yearEnd, $rawYearHolidays);
$businessDaysPeriod = $this->countBusinessDays($from, $to, $rawYearHolidays);
$repoDays = $this->computeProratedForfaitRepoDays($businessDaysYear, $businessDaysPeriod);
$carriedCp = $this->resolveCarriedCpFromPriorPhase($employee, $phase);
return [
'ruleCode' => LeaveRuleCode::FORFAIT_218->value,
'acquiredDays' => $repoDays + $carriedCp,
'acquiredSaturdays' => 0.0,
'accrualPerMonth' => 0.0,
'saturdayAccrualPerMonth' => 0.0,
'countOnlyCp' => false,
'splitSaturdays' => false,
];
}
// Année pleine : calcul 218 existant (INCHANGÉ).
$businessDaysInPeriod = $this->countBusinessDays($from, $to, $this->buildRawPublicHolidayMap($from, $to));
// ... reste de la branche FORFAIT existante (bonusDays + return) ...
```
> Conserver tel quel tout le bloc existant (`$publicHolidays`, `$weekdayHolidays`, `$bonusDays`, `return [...]`) pour le chemin année pleine. N'ajouter QUE le bloc `if ($this->isForfaitEntryYear(...))` au-dessus.
- [ ] **Step 2: Run the full provider test suite**
Run: `docker compose exec -T php vendor/bin/phpunit tests/State/EmployeeLeaveSummaryProviderTest.php`
Expected: PASS — les tests existants forfait année pleine (ex. `testForfaitPhaseStartingMidYearCapsFromAtPhaseStart`) restent verts (ils testent les bornes, pas l'acquired ; vérifier qu'aucun n'assert l'ancien acquired=0 sur l'entrée — si oui, mettre à jour cette assertion vers la nouvelle valeur).
- [ ] **Step 3: Run the whole backend suite**
Run: `make test`
Expected: PASS (suite complète).
- [ ] **Step 4: Commit**
```bash
git add src/State/EmployeeLeaveSummaryProvider.php
git commit -m "feat(leave) : forfait mid-year entry credits prorated repos + carried CP"
```
---
## Task 6: Validation d'intégration contre Grégory (id 41) — ✅ FAIT
**Résultat (probe sur BDD prod locale, supprimée après usage) :**
| Cas | Employé | Obtenu | Attendu |
|---|---|---|---|
| 1 — entrée après non-forfait | Grégory 2026 | 12.94 ≈ 13 | 13 ✓ |
| Non-régression année pleine | Geoffrey 2026 | 34.0 | 34 ✓ |
| 2 — nouvel embauché | Olivier 2025 | 0.54 (repos seuls) | repos seuls ✓ |
**Décision métier (Tristan) :** un **samedi de congé déjà posé** sous la phase précédente ne réduit PAS le report (seuls les jours ouvrés posés le réduisent). Implémenté en ré-ajoutant `takenSaturdays` dans `resolveCarriedCpFromPriorPhase` (commit `52d1111`). C'est ce qui fait passer Grégory de 11.94 à 12.94 ≈ 13.
**Files:** aucun (validation sur la BDD prod locale).
`resolveCarriedCpFromPriorPhase` réutilise **le calcul que l'UI affiche déjà** pour la phase 39h (1 samedi + 5 pris + 6 restant = 12 acquis, soit **7 nets** non pris). Aucune calibration de date n'est nécessaire : on consomme le même `computeYearSummary` que l'écran 39h, donc le 13 tombe mécaniquement (6 repos + 7 nets).
- [ ] **Step 1: Confirmer Grégory = 13**
Onglet Congés, phase FORFAIT courante, année 2026. Relever `acquiredDays`, `takenDays`, `remainingDays`.
Attendu : `remainingDays ≈ 13` (acquis ≈ 13 = 6 repos + 7 CP nets ; pris = 0 dans la fenêtre mai→déc, les 5 d'avril restent rattachés à la phase 39h).
Croiser avec l'écran de la phase 39h : il doit toujours afficher 1 samedi + 5 pris + 6 restant (inchangé).
⚠️ Vérifier que Grégory n'a **pas de jours fractionnés** sur sa phase 39h (`fractionedDays` exclus volontairement de `resolveCarriedCpFromPriorPhase`). Si la cible 13 n'est pas atteinte ET qu'il a des fractionnés, c'est l'explication — décider alors avec Tristan s'il faut les inclure.
- [ ] **Step 2: Vérifier le cas 2 (nouvel embauché forfait)**
Pour un employé FORFAIT sans phase précédente (ou dont la précédente est FORFAIT), `remainingDays` doit valoir uniquement les repos proratisés (ex. 6 si entrée 01/05/2026). Vérifier sur un employé réel ou un cas synthétique.
- [ ] **Step 3: Vérifier la non-régression année pleine**
Pour un FORFAIT présent toute l'année 2026 : `acquiredDays` doit rester `34` (= 252 218). Vérifier sur un employé forfait existant.
---
## Task 7: Documentation (OBLIGATOIRE — règle CLAUDE.md)
**Files:**
- Modify: `doc/contract-phase-view.md`
- Modify: `CLAUDE.md`
- Modify: `frontend/data/documentation-content.ts`
- [ ] **Step 1: `doc/contract-phase-view.md`**
Dans la section « ### Phase FORFAIT (passée ou courante) », ajouter un paragraphe :
```markdown
**Entrée en FORFAIT en cours d'année civile** : l'année d'entrée (période partielle,
ex. 01/05 → 31/12) ne calcule pas `max(0, businessDays 218)` (qui donnerait 0) mais :
jours_repos_année = jours_ouvrés_année 218 25
jours_repos_proratisés = jours_repos_année × (jours_ouvrés_période / jours_ouvrés_année)
congés_à_poser = jours_repos_proratisés + CP_nets_reportés_phase_précédente
Les CP reportés proviennent de la phase non-forfait immédiatement précédente (net des
congés déjà pris). Un nouvel embauché forfait (pas de phase précédente) n'a que les
repos proratisés. Les années pleines suivantes du forfait gardent le calcul 218.
Exemple Grégory BARRIBAULT (forfait 01/05/2026 après 39h) : 6 repos + ~7 CP = 13 jours.
```
- [ ] **Step 2: `CLAUDE.md`**
Dans la section « ## Vue contrat (sélecteur de phase) », sous-puce « Onglet Congés », ajouter :
```markdown
- **Entrée FORFAIT en cours d'année** : l'exercice d'entrée crédite `repos_proratisés + CP_nets_reportés` (et non `max(0, businessDays218)`=0). Repos année = `jours_ouvrés_année 218 25`, proratisés par jours ouvrés. CP reportés = CP nets de la phase non-forfait précédente. Nouvel embauché = repos seuls. Années pleines suivantes = calcul 218 inchangé. Service : `EmployeeLeaveSummaryProvider::resolveLeavePolicy` (branche FORFAIT) + `computeProratedForfaitRepoDays`/`resolveCarriedCpFromPriorPhase`.
```
- [ ] **Step 3: `frontend/data/documentation-content.ts`**
Localiser la section congés FORFAIT (rechercher « forfait » / « 218 ») et ajouter une explication utilisateur : un forfait qui démarre en cours d'année voit ses congés à poser = jours de repos proratisés + reliquat de CP de son contrat précédent.
- [ ] **Step 4: Commit**
```bash
git add doc/contract-phase-view.md CLAUDE.md frontend/data/documentation-content.ts
git commit -m "docs(leave) : document forfait mid-year entry leave calculation"
```
---
## Self-Review
**Spec coverage :**
- Repos proratisés (jours ouvrés) → Task 1 ✓
- Périmètre « année d'entrée seulement » → Task 2 + garde Task 5 ✓
- CP reportés depuis phase précédente (cas 1) → Task 3 + 4 ✓
- Cas 2 (nouvel embauché, pas de report) → garde `null/FORFAIT` dans Task 4, validé Task 6 Step 3 ✓
- Congés anticipés déduits → via le NET de `computeYearSummary` de la phase précédente (Task 4 doc) ✓
- Non-régression année pleine (= 34) → garde Task 5 + validation Task 6 Step 4 ✓
- Docs obligatoires → Task 7 ✓
**Pas de risque de calibration :** la source CP réutilise le calcul déjà affiché par l'écran de la phase 39h (12 acquis / 7 nets), donc le 13 est mécanique.
**Type consistency :** `computeProratedForfaitRepoDays(int,int): float`, `isForfaitEntryYear(ContractPhase,int): bool`, `resolvePhaseImmediatelyBefore(Employee,ContractPhase): ?ContractPhase`, `resolveCarriedCpFromPriorPhase(Employee,ContractPhase): float` — signatures cohérentes entre tâches et appels.

View File

@@ -0,0 +1,230 @@
# Vue contrat (sélecteur de phase) — Design Spec
## Objectif
Permettre à la RH de consulter les onglets Congés et RTT d'un employé **selon le contrat actuel ET selon ses contrats passés**, sans changement de comportement par défaut.
Cas qui motive : un employé passe de 39h à FORFAIT. Tant que le contrat courant est FORFAIT, les soldes CP/RTT accumulés sous l'ancien contrat 39h sont invisibles ou faussés (l'onglet RTT est masqué, la période Congés passe de Juin→Mai à Jan→Déc, les règles d'acquisition appliquent du FORFAIT_218 à toute année consultée). Conséquence : la RH ne peut plus payer les soldes restants.
Le même verrou existe pour toute fin de contrat avec **solde de tout compte** (notamment fin de CDD) suivie d'un nouveau contrat de type différent.
## Principe directeur
> Une fois passé en contrat X, on utilise toutes les règles X par défaut. Le sélecteur permet de revenir sur les phases passées pour les consulter et solder leurs reliquats.
## Concept de "phase de contrat"
Une **phase** est un groupe d'`EmployeeContractPeriod` consécutifs (triés par `startDate`) partageant la même **signature contractuelle** = `(contract.type, weeklyHours, isDriver)`. La fusion s'arrête dès qu'une période diffère sur l'un de ces trois axes, même si une période identique apparaît plus tard.
La signature inclut `weeklyHours` et `isDriver` parce que :
- `weeklyHours` détermine les tranches d'heures supp (25%/50%), le rythme d'acquisition CP (cas 4h), la base contractuelle quotidienne.
- `isDriver` change l'écran (`/driver-hours` vs `/hours`) et les colonnes de WorkHour utilisées pour le calcul RTT.
Exemples :
- CDD 39h → CDI 39h → FORFAIT : 2 phases (`39h`, `FORFAIT`).
- CDD 35h → CDI 39h : 2 phases (`35h`, `39h`).
- 3 CDD 39h consécutifs sans interruption : 1 phase (`39h`).
- 39h → INTERIM 4 mois → 39h : 3 phases (les `39h` ne fusionnent pas à travers l'`INTERIM`).
- CUSTOM 28h → CUSTOM 30h : 2 phases (`weeklyHours` diffère).
- 35h non-driver → 35h driver : 2 phases (`isDriver` diffère).
La règle de groupement vit dans un service backend, pas dans le frontend.
## Backend
### Nouveau service `EmployeeContractPhaseResolver`
Localisation : `src/Service/Contracts/EmployeeContractPhaseResolver.php`.
```php
public function resolvePhases(Employee $employee): array;
```
Retour : liste ordonnée (plus récente d'abord) de `ContractPhase` :
| Champ | Type | Description |
|---|---|---|
| `id` | `int` | `EmployeeContractPeriod.id` de la première période (par date) du groupe — sert d'identifiant stable. |
| `contractType` | `ContractType` | Type partagé par les périodes du groupe. |
| `weeklyHours` | `int` | Heures hebdomadaires (partagées par construction). |
| `isDriver` | `bool` | Driver flag (partagé par construction). |
| `startDate` | `DateTimeImmutable` | `startDate` de la première période. |
| `endDate` | `?DateTimeImmutable` | `endDate` de la dernière période, ou `null` si en cours. |
| `periodIds` | `list<int>` | IDs des périodes composant la phase, par ordre chronologique. |
| `isCurrent` | `bool` | `true` si la phase couvre la date du jour (= `endDate === null` ou `endDate >= today`). |
### Exposition API
Nouveau computed field sur `Employee` (lecture seule, groupe `employee:read`) :
```json
"contractPhases": [
{ "id": 42, "contractType": "FORFAIT", "weeklyHours": 39, "isDriver": false, "startDate": "2026-05-01", "endDate": null, "isCurrent": true },
{ "id": 17, "contractType": "THIRTY_NINE_HOURS", "weeklyHours": 39, "isDriver": false, "startDate": "2020-06-01", "endDate": "2026-04-30", "isCurrent": false }
]
```
Le calcul se fait à la sérialisation via un getter virtuel `Employee::getContractPhases(): array` qui délègue au resolver.
### Endpoints impactés
Les endpoints `GET /employees/{id}/leave-summary` et `GET /employees/{id}/rtt-summary` acceptent un nouveau paramètre optionnel :
- `?phaseId=N` : id de la phase à consulter.
- Si absent → phase courante (= comportement actuel inchangé).
- Si invalide (phase n'appartient pas à l'employé) → 422.
- `?year=YYYY` reste accepté en parallèle et continue de cibler un exercice précis.
### Modifications des providers
**`EmployeeLeaveSummaryProvider`** :
- Nouvelle méthode `resolveTargetPhase(Employee $e, ?int $phaseId): ContractPhase` qui retourne la phase demandée ou la phase courante.
- `resolveLeavePolicy(...)` reçoit la phase au lieu de lire `$employee->getContract()`. Le `contract.type` et le `weeklyHours` viennent de la signature de la phase (homogène par construction).
- `resolvePeriodBounds(...)` : les bornes de l'exercice sont en plus contraintes à `[max(periodStart, phase.startDate), min(periodEnd, phase.endDate ?? periodEnd)]`.
- `resolveYear(...)` : si `phaseId` fourni et pas de `year` explicite, default = dernier exercice intersectant la phase (= année de `phase.endDate` ou année courante si phase en cours).
- Si `?year` est fourni hors de la plage des exercices intersectant la phase → **clamp silencieux** à l'exercice valide le plus proche, pas d'erreur 422 (cohérence avec l'expérience picker frontend).
- `resolveAccrualCalculationEndDate(...)` et `resolveTakenCalculationEndDate(...)` : caps additionnels sur `phase.endDate` quand la phase n'est pas la phase courante.
- `resolveFirstComputationYear(...)` : restreint aux exercices intersectant la phase.
**`EmployeeRttSummaryProvider`** :
- Mêmes principes : `?phaseId` côté API, `resolveTargetPhase`, bornes d'exercice cappées à la phase, `rttStartDate` exposé pour borner le sélecteur d'année frontend.
- Pour une phase FORFAIT, le tab RTT est masqué frontend, donc l'endpoint n'est pas appelé en pratique. Pas de garde spécifique côté backend, le comportement existant (retour d'un summary potentiellement vide/inutile) suffit.
### Paiements de solde
**RTT — `EmployeeRttPaymentProcessor`** :
- Garde actuelle "exercice courant uniquement" devient : "exercice courant OU dernier exercice d'une phase clôturée".
- Concrètement, autoriser la création d'un `EmployeeRttPayment` sur l'exercice contenant `phase.endDate` d'une phase non courante.
- Les exercices antérieurs au dernier de la phase restent verrouillés (lecture seule).
**CP — settlement period-level** :
- Le mécanisme existant `EmployeeContractPeriod.paidLeaveSettledClosureDate` reste le canal pour solder. Aucun changement de modèle.
- Le bouton "Année N-1 payés" (FORFAIT) et "Jours fractionnés" (non-FORFAIT) restent désactivés sur une phase passée — ce ne sont pas des paiements de solde mais des éditions de stock.
### Audit
- La création d'`EmployeeRttPayment` est déjà auditée (existant).
- La modification de `paidLeaveSettledClosureDate` est déjà auditée via `EmployeeContractPeriodManager` (existant).
- Aucun audit nouveau requis.
## Frontend
### Picker
- Composant `MalioSelect` placé dans `pages/employees/[id].vue`, dans le header de la fiche, sous le nom de l'employé et au-dessus de la barre d'onglets.
- Libellé : `Vue contrat`.
- Options formatées :
- `FORFAIT — depuis 01/05/2026 (actuel)`
- `39h CDI — 01/06/2020 → 30/04/2026`
- **Caché** si `contractPhases.length <= 1` (employé mono-phase, ~majorité des cas).
- Sélection en mémoire (état du composable), **non persistée** entre navigations ou rechargements. Chaque ouverture de fiche démarre sur la phase courante.
### Bandeau d'information
Affiché quand `selectedPhase.id !== currentPhase.id` :
> Vous consultez l'historique **{contractType} — jusqu'au {endDate}**.
> Les paiements de solde sont possibles ; l'édition d'absences et des stocks de report est désactivée.
Style : bandeau jaune doux (`bg-warning-100 border-warning-300`), sous le picker, au-dessus des onglets.
### Composables
**Nouveau `useEmployeeContractPhase()`** :
- État : `selectedPhase`, `currentPhase` (computed depuis `employee.contractPhases`).
- Computed : `availablePhases`, `isViewingPastPhase`.
- API : `setSelectedPhase(phaseId)`, `resetToCurrent()`.
- `resetToCurrent()` appelé au changement d'employé.
**`useEmployeeLeave`** :
- Reçoit `phaseId` en paramètre lors des appels à `getEmployeeLeaveSummary` / `listAbsences`.
- `availableLeaveYears` borné aux exercices intersectant la phase sélectionnée.
- `setSelectedPhase` côté parent → reset de `selectedLeaveYear` et reload.
**`useEmployeeRtt`** :
- Idem pour `getEmployeeRttSummary`, `availableRttYears`.
**`useEmployeeDetailPage`** :
- `showRttTab` devient : `selectedPhase.contractType !== FORFAIT`.
- La logique de fallback ("si sur l'onglet RTT et FORFAIT, basculer ailleurs") s'applique aussi quand on bascule de phase 39h vers phase FORFAIT.
### Onglets
- **Congés** : reçoit la phase via le composable. Bouton "Année N-1 payés" / "Jours fractionnés" reste désactivé sur phase passée (idem que sur exercice passé).
- **RTT** : visibilité driver par la phase. Bouton "+ Payer les RTT" activé **uniquement sur le dernier exercice de la phase passée**, désactivé sur les exercices antérieurs de la phase.
- **Heures, Frais, Formation, Contrat, Calendrier** : non impactés.
### Format des libellés du picker
Format de la phase : `{labelContractType} — {startDateFR} → {endDateFR}`, suffixé `(actuel)` si phase courante.
`labelContractType` mapping :
- `FORFAIT``FORFAIT`
- `THIRTY_FIVE_HOURS``35h`
- `THIRTY_NINE_HOURS``39h`
- `INTERIM``Intérim`
- `CUSTOM``CUSTOM ({weeklyHours}h)` (les heures hebdo sont homogènes par construction dans une phase)
Suffixe `(driver)` ajouté quand `isDriver=true`, ex. `35h CDI (driver) — ...`.
## Migration et impact sur l'existant
- Aucune migration de données. Le concept de phase est calculé à la volée depuis l'historique existant.
- Comportement inchangé pour tout employé avec une seule phase (cas standard).
- Comportement inchangé quand `phaseId` n'est pas fourni → phase courante.
- Pas de breaking change API : `contractPhases` est un champ additionnel ; `?phaseId` est un paramètre optionnel.
## Tests
### Unit
- `EmployeeContractPhaseResolverTest` :
- Employé mono-période → 1 phase, `isCurrent=true`.
- Trois périodes même signature consécutives → 1 phase.
- Switch 39h → FORFAIT → 2 phases avec `startDate`/`endDate` correctes.
- 39h → INTERIM 4 mois → 39h → 3 phases (pas de fusion).
- 35h → 39h → 2 phases (type différent).
- CUSTOM 28h → CUSTOM 30h → 2 phases (`weeklyHours` diffère).
- 35h non-driver → 35h driver → 2 phases (`isDriver` diffère).
### Functional
- `EmployeeLeaveSummaryProvider` avec `phaseId` :
- Phase 39h passée → `ruleCode = CDI_CDD_NON_FORFAIT`, période Juin→Mai, exercice de transition capé à `phase.endDate`.
- Phase FORFAIT passée → `ruleCode = FORFAIT_218`, période Jan→Déc.
- `phaseId` invalide pour l'employé → 422.
- `?year` hors de la plage de la phase → clamp silencieux à l'exercice intersectant le plus proche.
- `EmployeeRttSummaryProvider` avec `phaseId` :
- Phase 39h passée → données RTT renvoyées, bornes cappées sur `phase.endDate`.
- `?year` hors de la plage de la phase → clamp silencieux.
- `EmployeeRttPaymentProcessor` :
- Création autorisée sur exercice de fin d'une phase passée.
- Création refusée sur un exercice antérieur d'une phase passée.
### Documentation à mettre à jour
Obligatoire par CLAUDE.md :
- `doc/contract-phase-view.md` — nouveau fichier détaillant la fonctionnalité.
- `doc/leave-tab.md` — section "Sélecteur de phase" + interaction avec le sélecteur d'année.
- `doc/rtt-tab.md` — section "Sélecteur de phase" + règle de visibilité.
- `frontend/data/documentation-content.ts` — article niveau `admin`.
- `CLAUDE.md` — bloc "Vue contrat (sélecteur de phase)" sous Onglet Congés / Onglet RTT.
## Hors scope
- Surface d'alerte automatique sur les fiches employés ayant des soldes non payés sur des phases passées (potentiel follow-up).
- Persistance de la sélection du picker entre navigations.
- Picker exposé sur le calendrier global ou tout autre écran que la fiche employé.
- Modification de `WorkHourDayContext` (déjà date-driven, pas concerné).
- Évolution du mécanisme `paidLeaveSettledClosureDate` (canal existant suffisant).
- Cas exotiques : phases overlap (interdit par la modélisation actuelle), périodes avec dates incohérentes.
## Décisions confirmées avec l'utilisateur
- Picker global en haut de la fiche, **pas** par onglet.
- Phases groupées par `contract.type` consécutif.
- Sur une phase passée : exercices antérieurs visibles **en lecture seule**, seul le dernier exercice de la phase ouvre les actions de solde (RTT pay, CP settlement period-level).
- Comportement par défaut (phase courante) strictement inchangé.

View File

@@ -33,8 +33,11 @@
{{ row.firstName }} {{ row.lastName }}
<span class="font-normal text-neutral-600">({{ row.contractName ?? '-' }})</span>
</p>
<p class="text-[11px] text-neutral-500 truncate">
{{ row.siteName ?? 'Sans site' }}<span v-if="row.contractNature"> {{ contractNatureLabel(row.contractNature) }}</span>
<p class="text-[11px] text-neutral-500 truncate inline-flex items-center gap-2">
<span>{{ row.siteName ?? 'Sans site' }}<span v-if="row.contractNature"> {{ contractNatureLabel(row.contractNature) }}</span></span>
<button v-if="isAdmin" type="button" class="inline-flex items-center justify-center rounded-md p-1 text-white transition-colors" :class="row.comment ? 'bg-red-500 hover:bg-red-600' : 'bg-primary-500 hover:bg-secondary-500'" :title="row.comment ?? 'Ajouter un commentaire'" @click="$emit('open-comment', row)">
<Icon name="mdi:comment-text-outline" size="12"/>
</button>
</p>
</div>
@@ -44,7 +47,7 @@
class="text-left leading-4 rounded-md px-2 py-1"
:class="daily.hasAbsence ? 'text-white' : ''"
:style="getDailyCellStyle(daily)"
:title="daily.absenceLabel ?? ''"
:title="cellTitle(daily)"
>
<div>J {{ formatMinutes(daily.dayMinutes) }}</div>
<div>N {{ formatMinutes(daily.nightMinutes) }}</div>
@@ -93,19 +96,37 @@
import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour'
import { contractNatureLabel } from '~/utils/contract'
const HOLIDAY_BG_COLOR = '#b3e5fc'
const getDailyCellStyle = (daily: {
hasAbsence?: boolean
absenceColor?: string | null
holidayLabel?: string | null
}) => {
if (!daily.hasAbsence) return undefined
return { backgroundColor: daily.absenceColor || '#dc2626' }
if (daily.hasAbsence) return { backgroundColor: daily.absenceColor || '#dc2626' }
if (daily.holidayLabel) return { backgroundColor: HOLIDAY_BG_COLOR }
return undefined
}
const cellTitle = (daily: {
hasAbsence?: boolean
absenceLabel?: string | null
holidayLabel?: string | null
}) => {
const parts: string[] = []
if (daily.absenceLabel) parts.push(daily.absenceLabel)
if (daily.holidayLabel) parts.push(`Férié : ${daily.holidayLabel}`)
return parts.join(' — ')
}
defineProps<{
isWeekLoading: boolean
isAdmin: boolean
weekGridCols: string
weeklySummary: WeeklyWorkHourSummary | null
weekDayHeaders: Array<{ date: string; weekday: string; dayDate: string }>
formatMinutes: (minutes: number) => string
}>()
defineEmits<{ (e: 'open-comment', row: WeeklyWorkHourSummary['rows'][number]): void }>()
</script>

View File

@@ -39,6 +39,8 @@
</div>
<button
class="flex items-center"
:class="isHistoricalYear ? 'opacity-40 cursor-not-allowed' : ''"
:disabled="isHistoricalYear"
@click="openPaidLeaveDrawer"
>
<Icon name="mdi:edit-box" size="24"/>
@@ -51,6 +53,8 @@
</div>
<button
class="flex items-center"
:class="isHistoricalYear ? 'opacity-40 cursor-not-allowed' : ''"
:disabled="isHistoricalYear"
@click="openFractionedDrawer"
>
<Icon name="mdi:edit-box" size="24"/>
@@ -90,6 +94,22 @@
</div>
</div>
</div>
<div class="mt-6 flex items-center gap-3">
<label for="leave-year-select" class="text-md font-semibold text-primary-500 uppercase">
{{ isForfaitRule ? 'Année :' : 'Exercice :' }}
</label>
<select
id="leave-year-select"
:value="selectedYear ?? ''"
:disabled="!availableYears.length"
class="border border-primary-500 rounded-md px-3 py-1 text-md font-semibold text-primary-500 bg-white focus:outline-none focus:ring-2 focus:ring-secondary-500/20 disabled:opacity-50"
@change="handleYearChange"
>
<option v-for="option in availableYears" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
</div>
<AppDrawer v-model="isFractionedDrawerOpen" title="Jours fractionnés">
<form class="space-y-4" @submit.prevent="handleSubmitFractioned">
@@ -173,17 +193,39 @@ type DayLeaveState = {
colors: string[]
}
type LeaveYearOption = {
value: number
label: string
}
const props = defineProps<{
absences: Absence[]
summary: EmployeeLeaveSummary | null
publicHolidays: Record<string, string>
selectedYear: number | null
availableYears: LeaveYearOption[]
currentYear: number | null
}>()
const emit = defineEmits<{
(event: 'update-fractioned-days', days: number): void
(event: 'update-paid-leave-days', days: number): void
(event: 'update-selected-year', year: number): void
}>()
const isHistoricalYear = computed(() =>
props.selectedYear !== null
&& props.currentYear !== null
&& props.selectedYear !== props.currentYear
)
const handleYearChange = (event: Event) => {
const target = event.target as HTMLSelectElement
const value = Number(target.value)
if (Number.isNaN(value)) return
emit('update-selected-year', value)
}
const isFractionedDrawerOpen = ref(false)
const fractionedForm = reactive({days: 0})
@@ -239,6 +281,7 @@ const currentYearTakenDays = computed(() => {
})
const displayedYear = computed(() => {
if (props.selectedYear) return props.selectedYear
if (props.summary?.year) return props.summary.year
const today = new Date()
const year = today.getFullYear()

View File

@@ -11,7 +11,7 @@
<Icon name="mdi:chevron-left" size="24"/>
</button>
<span class="text-lg font-bold tracking-wide min-w-[170px] text-center">
{{ currentMonthLabel }} {{ currentYear }}
{{ currentMonthLabel }} {{ displayedMonthYear }}
</span>
<button
class="rounded px-2 py-1 font-bold hover:bg-primary-600 disabled:opacity-40 disabled:cursor-not-allowed flex items-center"
@@ -27,7 +27,8 @@
</p>
<div class="flex justify-center">
<button
class="rounded-md bg-primary-500 px-8 py-2 font-bold text-white hover:bg-primary-600"
class="rounded-md bg-primary-500 px-8 py-2 font-bold text-white hover:bg-primary-600 disabled:opacity-40 disabled:cursor-not-allowed"
:disabled="isPayDisabled"
@click="openPaymentDrawer"
>
+ Payer les RTT
@@ -40,14 +41,15 @@
<table class="w-full table-fixed border-collapse text-[18px]">
<colgroup>
<col />
<col class="w-[11%]" />
<col class="w-[11%]" />
<col class="w-[11%]" />
<col class="w-[11%]" />
<col class="w-[11%]" />
<col class="w-[11%]" />
<col class="w-[11%]" />
<col class="w-[11%]" />
<col class="w-[10%]" />
<col class="w-[10%]" />
<col class="w-[10%]" />
<col class="w-[10%]" />
<col class="w-[10%]" />
<col class="w-[10%]" />
<col class="w-[10%]" />
<col class="w-[10%]" />
<col class="w-[10%]" />
</colgroup>
<thead>
<tr>
@@ -59,7 +61,8 @@
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">Base</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">50%</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">Total 50%</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">Total</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">Total</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">Cumul</th>
</tr>
</thead>
<tbody>
@@ -73,6 +76,7 @@
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBase50Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBase50Minutes) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBonus50Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBonus50Minutes) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(summary!.carryBase50Minutes + summary!.carryBonus50Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBase50Minutes + summary!.carryBonus50Minutes) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(summary!.carryFromPreviousYearMinutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryFromPreviousYearMinutes) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryFromPreviousYearMinutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryFromPreviousYearMinutes) }}</span></td>
</tr>
@@ -86,6 +90,7 @@
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.base50) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.base50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.bonus50) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.bonus50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(monthReport.total50) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.total50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(monthReport.total) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.total) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.total) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.total) }}</span></td>
</tr>
@@ -126,10 +131,14 @@
<span v-if="week">{{ formatMinutes(week.base50Minutes + week.bonus50Minutes) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">
<span v-if="week">{{ formatMinutes(week.totalMinutes) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
<span v-if="week">{{ formatMinutes(week.cumulativeBalanceMinutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(week.cumulativeBalanceMinutes) }}</span></span>
<span v-else>&nbsp;</span>
</td>
</tr>
<!-- Total row -->
@@ -142,7 +151,8 @@
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.base50) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.bonus50) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.total50) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.total) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.total) }}</td>
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-t-2">-</td>
</tr>
<!-- Payé row -->
@@ -155,7 +165,8 @@
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBase50Minutes) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -currentPayment.paidBase50Minutes : 0) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus50Minutes) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -currentPayment.paidBonus50Minutes : 0) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-(currentPayment.paidBase50Minutes + currentPayment.paidBonus50Minutes)) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -(currentPayment.paidBase50Minutes + currentPayment.paidBonus50Minutes) : 0) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(paidTotal) }} <span class="text-neutral-400">/ {{ formatCentiemes(paidTotal) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(paidTotal) }} <span class="text-neutral-400">/ {{ formatCentiemes(paidTotal) }}</span></td>
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500">-</td>
</tr>
<!-- Reste row -->
@@ -168,10 +179,27 @@
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.base50) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.base50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.bonus50) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.bonus50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(reste.total50) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.total50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.total) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.total) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(reste.total) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.total) }}</span></td>
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500">-</td>
</tr>
</tbody>
</table>
<div class="mt-6 flex items-center gap-3">
<label for="rtt-year-select" class="text-md font-semibold text-primary-500 uppercase">
Exercice :
</label>
<select
id="rtt-year-select"
:value="selectedYear ?? ''"
:disabled="!availableYears.length"
class="border border-primary-500 rounded-md px-3 py-1 text-md font-semibold text-primary-500 bg-white focus:outline-none focus:ring-2 focus:ring-secondary-500/20 disabled:opacity-50"
@change="handleYearChange"
>
<option v-for="option in availableYears" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
</div>
<!-- Payment Drawer -->
@@ -248,16 +276,54 @@
<script setup lang="ts">
import type { EmployeeRttSummary, EmployeeRttWeekSummary } from '~/services/dto/employee-rtt-summary'
import type { ContractPhase } from '~/services/dto/contract-phase'
import AppDrawer from '~/components/AppDrawer.vue'
type RttYearOption = {
value: number
label: string
}
const props = defineProps<{
summary: EmployeeRttSummary | null
selectedYear: number | null
availableYears: RttYearOption[]
currentYear: number | null
selectedPhase: ContractPhase | null
}>()
const emit = defineEmits<{
(event: 'submit-rtt-payment', month: number, base25Minutes: number, bonus25Minutes: number, base50Minutes: number, bonus50Minutes: number): void
(event: 'update-selected-year', year: number): void
}>()
const isHistoricalYear = computed(() =>
props.selectedYear !== null
&& props.currentYear !== null
&& props.selectedYear !== props.currentYear
)
const isLastExerciseOfPhase = computed(() => {
if (!props.selectedPhase || props.selectedPhase.isCurrent) return false
if (!props.selectedPhase.endDate) return false
const endDate = new Date(`${props.selectedPhase.endDate}T00:00:00`)
const endYear = endDate.getMonth() >= 5
? endDate.getFullYear() + 1
: endDate.getFullYear()
return props.selectedYear === endYear
})
const isPayDisabled = computed(() =>
isHistoricalYear.value && !isLastExerciseOfPhase.value
)
const handleYearChange = (event: Event) => {
const target = event.target as HTMLSelectElement
const value = Number(target.value)
if (Number.isNaN(value)) return
emit('update-selected-year', value)
}
// --- Last complete week number ---
const lastCompleteWeek = computed(() => {
@@ -313,7 +379,7 @@ const currentMonth = computed(() => orderedMonths[currentMonthIndex.value])
const currentMonthLabel = computed(() => monthLabels[currentMonth.value])
const currentYear = computed(() => {
const displayedMonthYear = computed(() => {
if (!props.summary) return ''
return currentMonth.value >= 6 ? props.summary.year - 1 : props.summary.year
})

View File

@@ -27,6 +27,7 @@
class="flex items-center justify-between rounded-md px-2 py-1 text-xs"
:class="daily.hasAbsence ? 'text-white' : 'text-primary-500'"
:style="getDailyCellStyle(daily)"
:title="cellTitle(daily)"
>
<span class="font-semibold">{{ weekDayHeaders[i]?.label ?? '' }}</span>
<span v-if="row.trackingMode === 'PRESENCE'">{{ daily.present ?? 0 }}</span>
@@ -93,8 +94,11 @@
{{ row.firstName }} {{ row.lastName }}
<span class="font-normal text-neutral-600">({{ row.contractName ?? '-' }})</span>
</p>
<p class="text-[11px] text-neutral-500 truncate">
{{ row.siteName ?? 'Sans site' }}<span v-if="row.contractNature"> {{ contractNatureLabel(row.contractNature) }}</span>
<p class="text-[11px] text-neutral-500 truncate inline-flex items-center gap-2">
<span>{{ row.siteName ?? 'Sans site' }}<span v-if="row.contractNature"> {{ contractNatureLabel(row.contractNature) }}</span></span>
<button v-if="isAdmin" type="button" class="flex items-center text-white p-1" :class="row.comment ? 'bg-red-500 hover:bg-red-600' : 'bg-primary-500 hover:bg-secondary-500'" :title="row.comment ?? 'Ajouter un commentaire'" @click="$emit('open-comment', row)">
<Icon name="mdi:comment-text-outline" size="12"/>
</button>
</p>
</div>
@@ -104,7 +108,7 @@
class="text-left leading-4 rounded-md px-2 py-1"
:class="daily.hasAbsence ? 'text-white' : ''"
:style="getDailyCellStyle(daily)"
:title="daily.absenceLabel ?? ''"
:title="cellTitle(daily)"
>
<template v-if="row.trackingMode === 'PRESENCE'">{{ daily.present ?? 0 }}</template>
<template v-else>
@@ -153,19 +157,37 @@ const isInterimContract = (contractType?: ContractType | null) => {
return contractType === CONTRACT_TYPES.INTERIM
}
const HOLIDAY_BG_COLOR = '#b3e5fc'
const getDailyCellStyle = (daily: {
hasAbsence?: boolean
absenceColor?: string | null
holidayLabel?: string | null
}) => {
if (!daily.hasAbsence) return undefined
return { backgroundColor: daily.absenceColor || '#dc2626' }
if (daily.hasAbsence) return { backgroundColor: daily.absenceColor || '#dc2626' }
if (daily.holidayLabel) return { backgroundColor: HOLIDAY_BG_COLOR }
return undefined
}
const cellTitle = (daily: {
hasAbsence?: boolean
absenceLabel?: string | null
holidayLabel?: string | null
}) => {
const parts: string[] = []
if (daily.absenceLabel) parts.push(daily.absenceLabel)
if (daily.holidayLabel) parts.push(`Férié : ${daily.holidayLabel}`)
return parts.join(' — ')
}
defineProps<{
isWeekLoading: boolean
isAdmin: boolean
weekGridCols: string
weeklySummary: WeeklyWorkHourSummary | null
weekDayHeaders: Array<{ date: string; label: string }>
formatMinutes: (minutes: number) => string
}>()
defineEmits<{ (e: 'open-comment', row: WeeklyWorkHourSummary['rows'][number]): void }>()
</script>

View File

@@ -0,0 +1,81 @@
<template>
<MalioDrawer v-model="drawerOpen" title="Commentaire">
<form class="space-y-4" @submit.prevent="onSave">
<div v-if="employeeLabel" class="text-md font-semibold text-neutral-700">{{ employeeLabel }}</div>
<div class="text-md font-semibold text-neutral-700">{{ formatWeekRange }}</div>
<MalioInputTextArea
v-model="content"
label="Commentaire"
:size="8"
:max-length="5000"
:show-counter="true"
resize="vertical"
/>
<div class="sticky bottom-0 -mx-[20px] bg-white px-[20px] py-3 flex justify-between gap-3">
<MalioButton
v-if="commentId"
label="Supprimer"
variant="danger"
:disabled="isSubmitting"
@click="onDelete"
/>
<MalioButton
label="Enregistrer"
button-class="ml-auto"
:disabled="isSubmitting || !canSubmit"
@click="onSave"
/>
</div>
</form>
</MalioDrawer>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { createWeekComment, deleteWeekComment, updateWeekComment } from '~/services/employee-week-comments'
import { getIsoWeekNumber, parseYmd } from '~/utils/date'
const props = defineProps<{
modelValue: boolean
employeeId: number | null
weekStart: string
weekEnd: string
initialContent: string
commentId: number | null
employeeLabel?: string
}>()
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void; (e: 'saved'): void }>()
const drawerOpen = computed({ get: () => props.modelValue, set: (v) => emit('update:modelValue', v) })
const content = ref('')
const isSubmitting = ref(false)
watch(() => [props.modelValue, props.initialContent] as const, ([open, init]) => { if (open) content.value = init ?? '' }, { immediate: true })
const formatWeekRange = computed(() => {
const fmt = (ymd: string) => { const p = ymd.split('-'); return p.length === 3 ? `${p[2]}/${p[1]}/${p[0]}` : ymd }
const start = parseYmd(props.weekStart)
const weekLabel = start ? `S${getIsoWeekNumber(start)}` : ''
return weekLabel ? `${weekLabel} du ${fmt(props.weekStart)} au ${fmt(props.weekEnd)}` : `${fmt(props.weekStart)}${fmt(props.weekEnd)}`
})
const canSubmit = computed(() => content.value.trim().length > 0 || props.commentId !== null)
const onSave = async () => {
if (!props.employeeId || isSubmitting.value) return
const trimmed = content.value.trim()
isSubmitting.value = true
try {
if (trimmed === '' && props.commentId) await deleteWeekComment(props.commentId)
else if (trimmed !== '' && props.commentId) await updateWeekComment(props.commentId, trimmed)
else if (trimmed !== '') await createWeekComment({ employeeId: props.employeeId, weekStartDate: props.weekStart, content: trimmed })
emit('saved'); drawerOpen.value = false
} finally { isSubmitting.value = false }
}
const onDelete = async () => {
if (!props.commentId || isSubmitting.value) return
isSubmitting.value = true
try { await deleteWeekComment(props.commentId); emit('saved'); drawerOpen.value = false } finally { isSubmitting.value = false }
}
</script>

View File

@@ -926,6 +926,15 @@ export const useDriverHoursPage = () => {
}
}
const isWeekCommentDrawerOpen = ref(false)
const weekCommentContext = ref<{ employeeId: number; employeeLabel: string; weekStart: string; weekEnd: string; content: string; commentId: number | null } | null>(null)
const openWeekCommentDrawer = (row: { employeeId: number; firstName: string; lastName: string; comment?: string | null; commentId?: number | null }) => {
if (!weeklySummary.value) return
weekCommentContext.value = { employeeId: row.employeeId, employeeLabel: `${row.firstName} ${row.lastName}`.trim(), weekStart: weeklySummary.value.weekStart, weekEnd: weeklySummary.value.weekEnd, content: row.comment ?? '', commentId: row.commentId ?? null }
isWeekCommentDrawerOpen.value = true
}
const reloadWeeklySummary = async () => { await loadWeeklySummary() }
return {
isAdmin,
isSelfUser,
@@ -993,6 +1002,10 @@ export const useDriverHoursPage = () => {
deleteAbsenceFromDrawer,
closeAbsenceDrawer,
formatMinutes,
handleSave
handleSave,
isWeekCommentDrawerOpen,
weekCommentContext,
openWeekCommentDrawer,
reloadWeeklySummary
}
}

View File

@@ -0,0 +1,83 @@
import type { Ref } from 'vue'
import type { Employee } from '~/services/dto/employee'
import type { ContractPhase } from '~/services/dto/contract-phase'
import { CONTRACT_TYPES } from '~/services/dto/contract'
const formatDateFr = (iso: string | null): string => {
if (!iso) return ''
const [y, m, d] = iso.split('-')
return `${d}/${m}/${y}`
}
const formatContractTypeLabel = (phase: ContractPhase): string => {
switch (phase.contractType) {
case CONTRACT_TYPES.FORFAIT:
return 'FORFAIT'
case CONTRACT_TYPES.H35:
return '35h'
case CONTRACT_TYPES.H39:
return '39h'
case CONTRACT_TYPES.INTERIM:
return 'Intérim'
case CONTRACT_TYPES.CUSTOM:
return `CUSTOM (${phase.weeklyHours ?? '?'}h)`
default:
return String(phase.contractType)
}
}
export const formatPhaseLabel = (phase: ContractPhase): string => {
const base = formatContractTypeLabel(phase)
const driver = phase.isDriver ? ' (driver)' : ''
const dates = phase.endDate
? `${formatDateFr(phase.startDate)}${formatDateFr(phase.endDate)}`
: `depuis ${formatDateFr(phase.startDate)}`
const suffix = phase.isCurrent ? ' (actuel)' : ''
return `${base}${driver}${dates}${suffix}`
}
export const useEmployeeContractPhase = (employee: Ref<Employee | null>) => {
const selectedPhaseId = ref<number | null>(null)
const availablePhases = computed<ContractPhase[]>(() => employee.value?.contractPhases ?? [])
const currentPhase = computed<ContractPhase | null>(() => {
return availablePhases.value.find((p) => p.isCurrent) ?? availablePhases.value[0] ?? null
})
const selectedPhase = computed<ContractPhase | null>(() => {
if (selectedPhaseId.value === null) return currentPhase.value
return availablePhases.value.find((p) => p.id === selectedPhaseId.value) ?? currentPhase.value
})
const isViewingPastPhase = computed<boolean>(() => {
if (!selectedPhase.value || !currentPhase.value) return false
return selectedPhase.value.id !== currentPhase.value.id
})
const phaseOptions = computed(() =>
availablePhases.value.map((p) => ({ value: p.id, label: formatPhaseLabel(p) }))
)
const showPicker = computed(() => availablePhases.value.length > 1)
const setSelectedPhase = (phaseId: number) => {
selectedPhaseId.value = phaseId
}
const resetToCurrent = () => {
selectedPhaseId.value = null
}
return {
selectedPhaseId,
selectedPhase,
currentPhase,
availablePhases,
phaseOptions,
showPicker,
isViewingPastPhase,
setSelectedPhase,
resetToCurrent,
}
}

View File

@@ -1,6 +1,7 @@
import type { Employee } from '~/services/dto/employee'
import { CONTRACT_TYPES } from '~/services/dto/contract'
import { getEmployee } from '~/services/employees'
import { useEmployeeContractPhase } from '~/composables/useEmployeeContractPhase'
export const useEmployeeDetailPage = () => {
const route = useRoute()
@@ -8,9 +9,11 @@ export const useEmployeeDetailPage = () => {
const isLoading = ref(false)
const activeTab = ref<'contract' | 'leave' | 'rtt' | 'mileage' | 'formation' | 'bonus' | 'observation'>('contract')
const phase = useEmployeeContractPhase(employee)
const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM')
const showRttTab = computed(() => employee.value?.contract?.type !== CONTRACT_TYPES.FORFAIT)
const isForfait = computed(() => employee.value?.contract?.type === CONTRACT_TYPES.FORFAIT)
const showRttTab = computed(() => phase.selectedPhase.value?.contractType !== CONTRACT_TYPES.FORFAIT)
const isForfait = computed(() => phase.selectedPhase.value?.contractType === CONTRACT_TYPES.FORFAIT)
const employeeContractWorkLabel = computed(() => {
const contract = employee.value?.contract
if (!contract) return '-'
@@ -29,6 +32,7 @@ export const useEmployeeDetailPage = () => {
isLoading.value = true
try {
employee.value = await getEmployee(employeeId)
phase.resetToCurrent()
if (!showLeaveTab.value && activeTab.value === 'leave') {
activeTab.value = 'contract'
@@ -66,7 +70,7 @@ export const useEmployeeDetailPage = () => {
}
const contract = useEmployeeContract(employee, loadEmployee)
const leave = useEmployeeLeave(employee, loadEmployee)
const leave = useEmployeeLeave(employee, loadEmployee, phase.selectedPhase)
const forfaitRemainingDaysLabel = computed(() => {
if (!isForfait.value) return ''
const presence = leave.leaveSummary.value?.presenceDaysToToday
@@ -74,12 +78,26 @@ export const useEmployeeDetailPage = () => {
const remaining = 218 - presence
return ` (${remaining} restants)`
})
const rtt = useEmployeeRtt(employee, loadEmployee)
const rtt = useEmployeeRtt(employee, loadEmployee, phase.selectedPhase)
const mileage = useEmployeeMileage(employee, loadEmployee)
const formation = useEmployeeFormation(employee, loadEmployee)
const bonus = useEmployeeBonus(employee, loadEmployee)
const observation = useEmployeeObservation(employee, loadEmployee)
watch(() => phase.selectedPhase.value?.id, (newId, oldId) => {
if (newId === oldId || oldId === undefined) return
// Bascule onglet si on entre dans une phase qui ne supporte plus le tab actuel
if (!showRttTab.value && activeTab.value === 'rtt') {
activeTab.value = 'leave'
}
// Recharger l'onglet courant
if (activeTab.value === 'leave' && showLeaveTab.value) {
leave.loadLeaveData()
} else if (activeTab.value === 'rtt' && showRttTab.value) {
rtt.loadRttData()
}
})
watch(activeTab, (tab) => {
if (tab === 'leave' && !leave.leaveDataLoaded.value && showLeaveTab.value) {
leave.loadLeaveData()
@@ -109,6 +127,7 @@ export const useEmployeeDetailPage = () => {
showRttTab,
employeeContractWorkLabel,
forfaitRemainingDaysLabel,
...phase,
...contract,
...leave,
...rtt,

View File

@@ -1,5 +1,6 @@
import type { Ref } from 'vue'
import type { Absence } from '~/services/dto/absence'
import type { ContractPhase } from '~/services/dto/contract-phase'
import type { EmployeeLeaveSummary } from '~/services/dto/employee-leave-summary'
import type { Employee } from '~/services/dto/employee'
import { CONTRACT_TYPES } from '~/services/dto/contract'
@@ -7,34 +8,99 @@ import { listAbsences } from '~/services/absences'
import { getEmployeeLeaveSummary, updateFractionedDays, updatePaidLeaveDays } from '~/services/employee-leave-summary'
import { listPublicHolidays } from '~/services/public-holidays'
export const useEmployeeLeave = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
export type LeaveYearOption = {
value: number
label: string
}
export const useEmployeeLeave = (
employee: Ref<Employee | null>,
reloadEmployee: () => Promise<void>,
selectedPhase: Ref<ContractPhase | null>,
) => {
const employeeAbsences = ref<Absence[]>([])
const leaveSummary = ref<EmployeeLeaveSummary | null>(null)
const publicHolidays = ref<Record<string, string>>({})
const isLeaveLoading = ref(false)
const leaveDataLoaded = ref(false)
const selectedLeaveYear = ref<number | null>(null)
const getLeaveYear = () => {
const now = new Date()
const isForfait = employee.value?.contract?.type === CONTRACT_TYPES.FORFAIT
return isForfait
? now.getFullYear()
: (now.getMonth() >= 5 ? now.getFullYear() + 1 : now.getFullYear())
const isForfaitOnPhase = computed(() =>
selectedPhase.value?.contractType === CONTRACT_TYPES.FORFAIT
)
const computeLeaveYearForDate = (date: Date): number => {
if (isForfaitOnPhase.value) return date.getFullYear()
return date.getMonth() >= 5 ? date.getFullYear() + 1 : date.getFullYear()
}
const currentLeaveYear = computed<number | null>(() => {
if (!employee.value) return null
return computeLeaveYearForDate(new Date())
})
const formatLeaveYearLabel = (year: number, isForfait: boolean): string => {
if (isForfait) return String(year)
return `Juin ${year - 1} → Mai ${year}`
}
const availableLeaveYears = computed<LeaveYearOption[]>(() => {
if (!employee.value || !selectedPhase.value || currentLeaveYear.value === null) return []
const isForfait = isForfaitOnPhase.value
const phase = selectedPhase.value
// 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
// 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.
let dataFloor: number | null = null
const dataStart = leaveSummary.value?.dataStartDate
if (dataStart) {
const dataStartDate = new Date(`${dataStart.substring(0, 10)}T00:00:00`)
if (!Number.isNaN(dataStartDate.getTime())) {
dataFloor = computeLeaveYearForDate(dataStartDate)
}
}
const minYear = dataFloor !== null ? Math.max(phaseStartYear, dataFloor) : phaseStartYear
const maxYear = phaseEndYear
const years: LeaveYearOption[] = []
for (let y = maxYear; y >= minYear; y -= 1) {
years.push({ value: y, label: formatLeaveYearLabel(y, isForfait) })
}
return years
})
const initSelectedLeaveYear = () => {
if (selectedLeaveYear.value !== null) return
if (currentLeaveYear.value !== null) {
selectedLeaveYear.value = currentLeaveYear.value
}
}
const loadLeaveData = async () => {
if (!employee.value || isLeaveLoading.value) return
initSelectedLeaveYear()
if (selectedLeaveYear.value === null) return
isLeaveLoading.value = true
try {
const isForfait = employee.value.contract?.type === CONTRACT_TYPES.FORFAIT
const leaveYear = getLeaveYear()
const from = isForfait ? `${leaveYear}-01-01` : `${leaveYear - 1}-06-01`
const to = isForfait ? `${leaveYear}-12-31` : `${leaveYear}-05-31`
const isForfait = isForfaitOnPhase.value
const leaveYear = selectedLeaveYear.value
let from = isForfait ? `${leaveYear}-01-01` : `${leaveYear - 1}-06-01`
let to = isForfait ? `${leaveYear}-12-31` : `${leaveYear}-05-31`
const phase = selectedPhase.value
if (phase?.startDate && phase.startDate > from) from = phase.startDate
if (phase?.endDate && phase.endDate < to) to = phase.endDate
const holidayYears = isForfait ? [leaveYear] : [leaveYear - 1, leaveYear]
const [absences, summary, ...holidayResults] = await Promise.all([
listAbsences({ from, to, employeeId: employee.value.id }),
getEmployeeLeaveSummary(employee.value.id, leaveYear),
getEmployeeLeaveSummary(employee.value.id, leaveYear, selectedPhase.value?.id),
...holidayYears.map((y) => listPublicHolidays('metropole', y))
])
employeeAbsences.value = absences
@@ -46,10 +112,25 @@ export const useEmployeeLeave = (employee: Ref<Employee | null>, reloadEmployee:
}
}
const setSelectedLeaveYear = async (year: number) => {
if (selectedLeaveYear.value === year) return
selectedLeaveYear.value = year
leaveDataLoaded.value = false
await loadLeaveData()
}
const resetLoaded = () => {
leaveDataLoaded.value = false
selectedLeaveYear.value = null
}
watch(() => selectedPhase.value?.id, () => {
// Reset l'année car la plage a peut-être changé.
selectedLeaveYear.value = null
leaveDataLoaded.value = false
// Le rechargement effectif est piloté par useEmployeeDetailPage.
})
const submitFractionedDays = async (days: number) => {
if (!employee.value) return
const year = leaveSummary.value?.year ?? undefined
@@ -70,6 +151,10 @@ export const useEmployeeLeave = (employee: Ref<Employee | null>, reloadEmployee:
publicHolidays,
isLeaveLoading,
leaveDataLoaded,
selectedLeaveYear,
currentLeaveYear,
availableLeaveYears,
setSelectedLeaveYear,
loadLeaveData,
resetLoaded,
submitFractionedDays,

View File

@@ -1,29 +1,107 @@
import type { Ref } from 'vue'
import type { ContractPhase } from '~/services/dto/contract-phase'
import type { EmployeeRttSummary } from '~/services/dto/employee-rtt-summary'
import type { Employee } from '~/services/dto/employee'
import { getEmployeeRttSummary, createRttPayment } from '~/services/employee-rtt-summary'
export const useEmployeeRtt = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
export type RttYearOption = {
value: number
label: string
}
export const useEmployeeRtt = (
employee: Ref<Employee | null>,
reloadEmployee: () => Promise<void>,
selectedPhase: Ref<ContractPhase | null>,
) => {
const rttSummary = ref<EmployeeRttSummary | null>(null)
const isRttLoading = ref(false)
const rttDataLoaded = ref(false)
const selectedRttYear = ref<number | null>(null)
// Exercice RTT : Juin (Y-1) → Mai (Y). Toujours, peu importe le type de contrat
// (l'onglet RTT est masqué pour les FORFAIT côté page).
const computeRttYearForDate = (date: Date): number =>
date.getMonth() >= 5 ? date.getFullYear() + 1 : date.getFullYear()
const currentRttYear = computed<number | null>(() => {
if (!employee.value) return null
return computeRttYearForDate(new Date())
})
const availableRttYears = computed<RttYearOption[]>(() => {
if (!employee.value || !selectedPhase.value || currentRttYear.value === null) return []
const phase = selectedPhase.value
// 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
// Hard floor : rttStartDate (env RTT_START_DATE) — pas d'historique avant.
let dataFloor: number | null = null
const dataStart = rttSummary.value?.rttStartDate
if (dataStart) {
const dataStartDate = new Date(`${dataStart.substring(0, 10)}T00:00:00`)
if (!Number.isNaN(dataStartDate.getTime())) {
dataFloor = computeRttYearForDate(dataStartDate)
}
}
const minYear = dataFloor !== null ? Math.max(phaseStartYear, dataFloor) : phaseStartYear
const maxYear = phaseEndYear
const years: RttYearOption[] = []
for (let y = maxYear; y >= minYear; y -= 1) {
years.push({ value: y, label: `Juin ${y - 1} → Mai ${y}` })
}
return years
})
const initSelectedRttYear = () => {
if (selectedRttYear.value !== null) return
if (currentRttYear.value !== null) {
selectedRttYear.value = currentRttYear.value
}
}
const loadRttData = async () => {
if (!employee.value || isRttLoading.value) return
initSelectedRttYear()
if (selectedRttYear.value === null) return
isRttLoading.value = true
try {
const rttYear = new Date().getMonth() >= 5 ? new Date().getFullYear() + 1 : new Date().getFullYear()
rttSummary.value = await getEmployeeRttSummary(employee.value.id, rttYear)
rttSummary.value = await getEmployeeRttSummary(
employee.value.id,
selectedRttYear.value,
selectedPhase.value?.id,
)
rttDataLoaded.value = true
} finally {
isRttLoading.value = false
}
}
const setSelectedRttYear = async (year: number) => {
if (selectedRttYear.value === year) return
selectedRttYear.value = year
rttDataLoaded.value = false
await loadRttData()
}
const resetLoaded = () => {
rttDataLoaded.value = false
selectedRttYear.value = null
}
watch(() => selectedPhase.value?.id, () => {
// Reset l'année car la plage a peut-être changé.
selectedRttYear.value = null
rttDataLoaded.value = false
// Le rechargement effectif est piloté par useEmployeeDetailPage.
})
const submitRttPayment = async (month: number, base25Minutes: number, bonus25Minutes: number, base50Minutes: number, bonus50Minutes: number) => {
if (!employee.value) return
const year = rttSummary.value?.year ?? undefined
@@ -35,6 +113,10 @@ export const useEmployeeRtt = (employee: Ref<Employee | null>, reloadEmployee: (
rttSummary,
isRttLoading,
rttDataLoaded,
selectedRttYear,
currentRttYear,
availableRttYears,
setSelectedRttYear,
loadRttData,
resetLoaded,
submitRttPayment

View File

@@ -1112,6 +1112,15 @@ export const useHoursPage = () => {
}
}
const isWeekCommentDrawerOpen = ref(false)
const weekCommentContext = ref<{ employeeId: number; employeeLabel: string; weekStart: string; weekEnd: string; content: string; commentId: number | null } | null>(null)
const openWeekCommentDrawer = (row: { employeeId: number; firstName: string; lastName: string; comment?: string | null; commentId?: number | null }) => {
if (!weeklySummary.value) return
weekCommentContext.value = { employeeId: row.employeeId, employeeLabel: `${row.firstName} ${row.lastName}`.trim(), weekStart: weeklySummary.value.weekStart, weekEnd: weeklySummary.value.weekEnd, content: row.comment ?? '', commentId: row.commentId ?? null }
isWeekCommentDrawerOpen.value = true
}
const reloadWeeklySummary = async () => { await loadWeeklySummary() }
return {
isAdmin,
isSelfUser,
@@ -1186,6 +1195,10 @@ export const useHoursPage = () => {
deleteAbsenceFromDrawer,
closeAbsenceDrawer,
formatMinutes,
handleSave
handleSave,
isWeekCommentDrawerOpen,
weekCommentContext,
openWeekCommentDrawer,
reloadWeeklySummary
}
}

View File

@@ -80,6 +80,16 @@ 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: 'commentaire-semaine',
title: 'Commentaires de semaine (admin)',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Sur la vue semaine, un picto bulle permet d\'attacher un commentaire libre sur la semaine d\'un employé.' },
{ type: 'list', content: 'Bulle bleue : pas de commentaire\nBulle rouge : un commentaire existe\nClic : ouvre le drawer avec textarea' },
{ type: 'note', content: 'Les commentaires n\'affectent aucun calcul. Pour supprimer, videz la textarea puis Enregistrer, ou bouton Supprimer.' },
],
},
],
},
{
@@ -291,6 +301,18 @@ export const documentationSections: DocSection[] = [
{ type: 'paragraph', content: 'Un employé conducteur apparaît uniquement sur l\'écran "Heures Conducteurs" et non sur l\'écran "Heures" classique.' },
],
},
{
id: 'contract-phase-view',
title: 'Vue contrat — sélecteur de phase',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Quand un employé change de type de contrat (ex. 39h → FORFAIT) ou enchaîne plusieurs CDD avec solde de tout compte, ses anciennes phases de contrat restent consultables via le sélecteur "Vue contrat" en haut de la fiche.' },
{ type: 'paragraph', content: 'Choisir une phase passée fait basculer les onglets Congés et RTT sur les règles de cette phase. L\'onglet RTT réapparaît si la phase n\'est pas un FORFAIT. Un bandeau jaune indique que vous êtes en mode historique.' },
{ type: 'paragraph', content: 'Sur une phase passée, vous pouvez :' },
{ type: 'list', content: 'Solder les RTT restants — bouton "+ Payer les RTT" actif uniquement sur le dernier exercice de la phase (celui contenant la date de fin)\nSolder les CP restants via le champ "Solde de tout compte" sur la période de contrat correspondante (onglet Contrat)' },
{ type: 'note', content: 'L\'édition d\'absences et des stocks de report (jours fractionnés, Année N-1) est désactivée en mode phase passée.' },
],
},
],
},
{
@@ -332,7 +354,7 @@ export const documentationSections: DocSection[] = [
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'La vue semaine est réservée aux administrateurs. Elle affiche une synthèse hebdomadaire par employé avec les heures supplémentaires calculées.' },
{ type: 'list', content: 'Filtrage par site et par employé\nDétail par jour avec totaux hebdomadaires\nColonnes de calcul : base, heures sup 25%, heures sup 50%, total récupération' },
{ type: 'list', content: 'Filtrage par site et par employé\nDétail par jour avec totaux hebdomadaires\nColonnes de calcul : base, heures sup 25%, heures sup 50%, total récupération\nLes jours fériés sont signalés sur la cellule du jour : fond bleu clair quand pas d\'absence, nom du férié au survol' },
],
},
{
@@ -417,6 +439,8 @@ export const documentationSections: DocSection[] = [
blocks: [
{ type: 'paragraph', content: 'Pour les contrats forfait, l\'exercice suit l\'année civile (1er janvier au 31 décembre).' },
{ type: 'list', content: 'Calcul : jours ouvrés de l\'année 218 + bonus weekend/férié\nBonus : 1 jour par jour travaillé un weekend ou jour férié (0.5 si demi-journée)\nPas de samedis\nPas de jours en cours d\'acquisition' },
{ type: 'paragraph', content: 'Lorsqu\'un salarié passe en forfait en cours d\'année (ex. après une phase 39h), ses congés à poser pour cette année-là correspondent à ses jours de repos forfait calculés au prorata de la période, augmentés du reliquat de congés payés acquis sous son contrat précédent. Un nouveau salarié embauché directement en forfait en cours d\'année n\'a que les jours de repos proratisés. Les années complètes suivantes suivent le calcul forfait habituel.' },
{ type: 'note', content: 'Le reliquat CP de la phase précédente inclut les jours ouvrés nets (acquis + en cours jours ouvrés posés) et les samedis bruts (les samedis déjà posés ne réduisent pas le report). Les jours fractionnés sont exclus.' },
],
},
{
@@ -447,6 +471,17 @@ export const documentationSections: DocSection[] = [
{ type: 'paragraph', content: 'Compteurs visibles sur l\'onglet Congé de la fiche employé : acquis, en cours d\'acquisition, pris, restant.' },
],
},
{
id: 'onglet-conges-fiche-employe',
title: 'Onglet Congés (fiche employé)',
requiredLevel: 'admin',
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: '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.' },
],
},
{
id: 'ecran-recap-conges',
title: 'Écran Récap. congés',
@@ -482,6 +517,7 @@ 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: '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).' },
],
},
{
@@ -493,6 +529,15 @@ export const documentationSections: DocSection[] = [
{ 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' },
],
},
{
id: 'rtt-selecteur-exercice',
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: '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.' },
],
},
{
id: 'rtt-semaines-mois',
title: 'Attribution des semaines aux mois',
@@ -570,7 +615,7 @@ 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' },
{ 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)' },
],
},
{
@@ -588,7 +633,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' },
{ 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)' },
],
},
],

View File

@@ -59,6 +59,10 @@
},
"leaveRecap": {
"load": "Impossible de charger le récap des congés."
},
"weekComment": {
"save": "Impossible d'enregistrer le commentaire de semaine.",
"delete": "Impossible de supprimer le commentaire de semaine."
}
},
"success": {
@@ -110,6 +114,10 @@
"create": "Observation créée.",
"update": "Observation mise à jour.",
"delete": "Observation supprimée."
},
"weekComment": {
"save": "Commentaire enregistré.",
"delete": "Commentaire supprimé."
}
}
}

View File

@@ -74,11 +74,13 @@
<DriverHoursWeekView
v-else-if="isAdmin && viewMode === 'week'"
:is-week-loading="isWeekLoading"
:is-admin="isAdmin"
:week-grid-cols="weekGridCols"
:weekly-summary="filteredWeeklySummary"
:week-day-headers="weekDayHeaders"
:format-minutes="formatMinutes"
class="max-h-[calc(100vh-300px)]"
@open-comment="openWeekCommentDrawer"
/>
</div>
@@ -110,6 +112,17 @@
@cancel="closeAbsenceDrawer"
/>
<HoursWeekCommentDrawer
v-if="weekCommentContext"
v-model="isWeekCommentDrawerOpen"
:employee-id="weekCommentContext.employeeId"
:employee-label="weekCommentContext.employeeLabel"
:week-start="weekCommentContext.weekStart"
:week-end="weekCommentContext.weekEnd"
:initial-content="weekCommentContext.content"
:comment-id="weekCommentContext.commentId"
@saved="reloadWeeklySummary"
/>
</div>
</template>
@@ -179,7 +192,11 @@ const {
formatMinutes,
isSelectedDateHoliday,
selectedHolidayLabel,
handleSave
handleSave,
isWeekCommentDrawerOpen,
weekCommentContext,
openWeekCommentDrawer,
reloadWeeklySummary
} = useDriverHoursPage()
useHead({

View File

@@ -30,6 +30,24 @@
<p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p>
</div>
</div>
<div v-if="showPicker" class="mt-3 flex items-center gap-3">
<MalioSelect
label="Contrat"
:model-value="selectedPhase?.id ?? null"
:options="phaseOptions"
group-class="w-[420px]"
min-width=""
@update:model-value="(v) => { if (v !== null) setSelectedPhase(Number(v)) }"
/>
</div>
<div
v-if="isViewingPastPhase && selectedPhase"
class="mt-3 rounded-md border border-amber-300 bg-amber-100 px-4 py-2 text-sm text-amber-900"
>
Vous consultez l'historique
<strong>{{ formatPhaseLabel(selectedPhase) }}</strong>.
Les paiements de solde sont possibles ; l'édition d'absences et des stocks de report est désactivée.
</div>
<div class="mt-[44px] border-b border-primary-500">
<div class="flex justify-center gap-16 text-2xl font-bold">
<button
@@ -160,15 +178,29 @@
:absences="employeeAbsences"
:summary="leaveSummary"
:public-holidays="publicHolidays"
:selected-year="selectedLeaveYear"
:available-years="availableLeaveYears"
:current-year="currentLeaveYear"
@update-fractioned-days="submitFractionedDays"
@update-paid-leave-days="submitPaidLeaveDays"
@update-selected-year="setSelectedLeaveYear"
/>
</div>
<div v-else-if="showRttTab && activeTab === 'rtt'" class="h-full">
<div v-if="isRttLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
</div>
<EmployeesRttTab v-else class="h-full" :summary="rttSummary" @submit-rtt-payment="submitRttPayment" />
<EmployeesRttTab
v-else
class="h-full"
:summary="rttSummary"
:selected-year="selectedRttYear"
:available-years="availableRttYears"
:current-year="currentRttYear"
:selected-phase="selectedPhase"
@submit-rtt-payment="submitRttPayment"
@update-selected-year="setSelectedRttYear"
/>
</div>
<div v-else-if="activeTab === 'mileage'" class="h-full">
<div v-if="isMileageLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
@@ -240,6 +272,7 @@
import { ref } from 'vue'
import EmployeeYearlyHoursDrawer from '~/components/EmployeeYearlyHoursDrawer.vue'
import { usePdfPrinter } from '~/composables/usePdfPrinter'
import { formatPhaseLabel } from '~/composables/useEmployeeContractPhase'
const { printPdf } = usePdfPrinter()
const isYearlyHoursDrawerOpen = ref(false)
@@ -253,6 +286,14 @@ const {
leaveSummary,
rttSummary,
publicHolidays,
selectedLeaveYear,
currentLeaveYear,
availableLeaveYears,
setSelectedLeaveYear,
selectedRttYear,
currentRttYear,
availableRttYears,
setSelectedRttYear,
showLeaveTab,
showRttTab,
contractHistory,
@@ -321,7 +362,12 @@ const {
isObservationLoading,
submitCreateObservation,
submitUpdateObservation,
submitDeleteObservation
submitDeleteObservation,
selectedPhase,
showPicker,
phaseOptions,
setSelectedPhase,
isViewingPastPhase,
} = useEmployeeDetailPage()
const handleYearlyHoursPrint = async (payload: { year: number; month: number | null }) => {

View File

@@ -81,11 +81,13 @@
<HoursWeekView
v-else-if="isAdmin && viewMode === 'week'"
:is-week-loading="isWeekLoading"
:is-admin="isAdmin"
:week-grid-cols="weekGridCols"
:weekly-summary="filteredWeeklySummary"
:week-day-headers="weekDayHeaders"
:format-minutes="formatMinutes"
class="max-h-[calc(100vh-300px)]"
@open-comment="openWeekCommentDrawer"
/>
</div>
@@ -117,6 +119,17 @@
@cancel="closeAbsenceDrawer"
/>
<HoursWeekCommentDrawer
v-if="weekCommentContext"
v-model="isWeekCommentDrawerOpen"
:employee-id="weekCommentContext.employeeId"
:employee-label="weekCommentContext.employeeLabel"
:week-start="weekCommentContext.weekStart"
:week-end="weekCommentContext.weekEnd"
:initial-content="weekCommentContext.content"
:comment-id="weekCommentContext.commentId"
@saved="reloadWeeklySummary"
/>
</div>
</template>
@@ -193,7 +206,11 @@ const {
deleteAbsenceFromDrawer,
closeAbsenceDrawer,
formatMinutes,
handleSave
handleSave,
isWeekCommentDrawerOpen,
weekCommentContext,
openWeekCommentDrawer,
reloadWeeklySummary
} = useHoursPage()
useHead({

View File

@@ -0,0 +1,13 @@
import type { ContractType } from './contract'
export type ContractPhase = {
id: number
contractType: ContractType
weeklyHours: number | null
isDriver: boolean
startDate: string
endDate: string | null
periodIds: number[]
isCurrent: boolean
contractNature: 'CDI' | 'CDD' | 'INTERIM'
}

View File

@@ -16,5 +16,6 @@ export type EmployeeLeaveSummary = {
previousYearPaidDays: number
presenceDaysByMonth: Record<string, number>
presenceDaysToToday: number
dataStartDate: string | null
}

View File

@@ -9,6 +9,7 @@ export type EmployeeRttWeekSummary = {
base50Minutes: number
bonus50Minutes: number
totalMinutes: number
cumulativeBalanceMinutes: number
}
export type RttMonthPayment = {

View File

@@ -1,5 +1,6 @@
import type { Site } from './site'
import type { Contract } from './contract'
import type { ContractPhase } from './contract-phase'
export type ContractSuspension = {
id: number
@@ -41,4 +42,5 @@ export type Employee = {
currentSuspensions?: ContractSuspension[]
currentInterimAgencyId?: number | null
currentInterimAgencyName?: string | null
contractPhases?: ContractPhase[]
}

View File

@@ -60,6 +60,7 @@ export type WeeklyWorkHourDailySummary = {
hasDinner?: boolean
hasOvernight?: boolean
virtualHolidayMinutes?: number
holidayLabel?: string | null
}
export type WeeklyWorkHourRowSummary = {
@@ -88,6 +89,8 @@ export type WeeklyWorkHourRowSummary = {
weeklyOvernightCount?: number
hasContractForWeek?: boolean
contractNature?: 'CDI' | 'CDD' | 'INTERIM' | null
comment?: string | null
commentId?: number | null
}
export type WeeklyWorkHourSummary = {

View File

@@ -1,9 +1,10 @@
import type { EmployeeLeaveSummary } from './dto/employee-leave-summary'
export const getEmployeeLeaveSummary = async (employeeId: number, year?: number) => {
export const getEmployeeLeaveSummary = async (employeeId: number, year?: number, phaseId?: number) => {
const api = useApi()
const query: Record<string, string> = {}
if (year) query.year = String(year)
if (phaseId !== undefined) query.phaseId = String(phaseId)
return api.get<EmployeeLeaveSummary>(`/employees/${employeeId}/leave-summary`, query, { toast: false })
}

View File

@@ -1,8 +1,10 @@
import type { EmployeeRttSummary } from './dto/employee-rtt-summary'
export const getEmployeeRttSummary = async (employeeId: number, year?: number) => {
export const getEmployeeRttSummary = async (employeeId: number, year?: number, phaseId?: number) => {
const api = useApi()
const query = year ? { year } : {}
const query: Record<string, number> = {}
if (year) query.year = year
if (phaseId !== undefined) query.phaseId = phaseId
return api.get<EmployeeRttSummary>(`/employees/${employeeId}/rtt-summary`, query, { toast: false })
}

View File

@@ -0,0 +1,24 @@
export type EmployeeWeekComment = {
id: number
weekStartDate: string
content: string
}
export const createWeekComment = async (payload: { employeeId: number; weekStartDate: string; content: string }) => {
const api = useApi()
return api.post<EmployeeWeekComment>('/employee_week_comments', {
employee: `/api/employees/${payload.employeeId}`,
weekStartDate: payload.weekStartDate,
content: payload.content
}, { toastSuccessKey: 'success.weekComment.save', toastErrorKey: 'errors.weekComment.save' })
}
export const updateWeekComment = async (id: number, content: string) => {
const api = useApi()
return api.patch<EmployeeWeekComment>(`/employee_week_comments/${id}`, { content }, { toastSuccessKey: 'success.weekComment.save', toastErrorKey: 'errors.weekComment.save' })
}
export const deleteWeekComment = async (id: number) => {
const api = useApi()
await api.delete(`/employee_week_comments/${id}`, {}, { toastSuccessKey: 'success.weekComment.delete', toastErrorKey: 'errors.weekComment.delete' })
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260417100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create employee_week_comments table for per-week admin annotations on the hours weekly view';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE employee_week_comments (id SERIAL NOT NULL, employee_id INT NOT NULL, week_start_date DATE NOT NULL, content TEXT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE UNIQUE INDEX uniq_employee_week_comment ON employee_week_comments (employee_id, week_start_date)');
$this->addSql('CREATE INDEX idx_ewc_week_start ON employee_week_comments (week_start_date)');
$this->addSql('ALTER TABLE employee_week_comments ADD CONSTRAINT fk_ewc_employee FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE employee_week_comments');
}
}

View File

@@ -41,4 +41,7 @@ final class EmployeeLeaveSummary
/** Cumul des jours de présence depuis le début de l'année de congé jusqu'à aujourd'hui (forfait). */
public float $presenceDaysToToday = 0.0;
/** Date de mise en service du logiciel (env RTT_START_DATE) — borne minimale pour les sélecteurs d'historique. */
public ?string $dataStartDate = null;
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Dto\Contracts;
use App\Enum\ContractNature;
use App\Enum\ContractType;
use DateTimeImmutable;
final readonly class ContractPhase
{
/**
* @param list<int> $periodIds
*/
public function __construct(
public int $id,
public ContractType $contractType,
public ?int $weeklyHours,
public bool $isDriver,
public DateTimeImmutable $startDate,
public ?DateTimeImmutable $endDate,
public array $periodIds,
public bool $isCurrent,
public ContractNature $contractNature,
) {}
}

View File

@@ -17,5 +17,6 @@ final class EmployeeRttWeekSummary
public int $base50Minutes = 0,
public int $bonus50Minutes = 0,
public int $totalMinutes = 0,
public int $cumulativeBalanceMinutes = 0,
) {}
}

View File

@@ -22,5 +22,6 @@ final class WeeklyDaySummary
public bool $hasDinner = false,
public bool $hasOvernight = false,
public int $virtualHolidayMinutes = 0,
public ?string $holidayLabel = null,
) {}
}

View File

@@ -35,5 +35,7 @@ final class WeeklySummaryRow
public int $weeklyOvernightCount = 0,
public bool $hasContractForWeek = true,
public ?string $contractNature = null,
public ?string $comment = null,
public ?int $commentId = null,
) {}
}

View File

@@ -6,9 +6,11 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use App\Dto\Contracts\ContractPhase;
use App\Dto\Employees\ContractHistoryItem;
use App\Enum\ContractNature;
use App\Repository\EmployeeRepository;
use App\Service\Contracts\EmployeeContractPhaseResolver;
use App\State\EmployeeWriteProcessor;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
@@ -428,6 +430,43 @@ class Employee
);
}
/**
* @return list<array{
* id: int,
* contractType: string,
* weeklyHours: ?int,
* isDriver: bool,
* startDate: string,
* endDate: ?string,
* periodIds: list<int>,
* isCurrent: bool,
* contractNature: string
* }>
*/
#[Groups(['employee:read'])]
public function getContractPhases(): array
{
// Read RTT_START_DATE directly here: the entity has no DI but must filter
// out contract phases that ended before the application's data start.
$rawDate = $_SERVER['RTT_START_DATE'] ?? $_ENV['RTT_START_DATE'] ?? '';
$resolver = new EmployeeContractPhaseResolver(is_string($rawDate) ? $rawDate : '');
return array_map(
static fn (ContractPhase $phase): array => [
'id' => $phase->id,
'contractType' => $phase->contractType->value,
'weeklyHours' => $phase->weeklyHours,
'isDriver' => $phase->isDriver,
'startDate' => $phase->startDate->format('Y-m-d'),
'endDate' => $phase->endDate?->format('Y-m-d'),
'periodIds' => $phase->periodIds,
'isCurrent' => $phase->isCurrent,
'contractNature' => $phase->contractNature->value,
],
$resolver->resolvePhases($this),
);
}
private function resolveCurrentContractPeriod(): ?EmployeeContractPeriod
{
$today = new DateTimeImmutable('today');

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\EmployeeWeekCommentRepository;
use App\State\EmployeeWeekCommentWriteProcessor;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
new Get(security: "is_granted('ROLE_ADMIN')"),
new GetCollection(security: "is_granted('ROLE_ADMIN')"),
new Post(security: "is_granted('ROLE_ADMIN')", processor: EmployeeWeekCommentWriteProcessor::class),
new Patch(security: "is_granted('ROLE_ADMIN')", processor: EmployeeWeekCommentWriteProcessor::class),
new Delete(security: "is_granted('ROLE_ADMIN')", processor: EmployeeWeekCommentWriteProcessor::class),
],
normalizationContext: ['groups' => ['week_comment:read'], 'datetime_format' => 'Y-m-d'],
denormalizationContext: ['groups' => ['week_comment:write'], 'datetime_format' => 'Y-m-d'],
order: ['weekStartDate' => 'DESC'],
paginationEnabled: false,
)]
#[ApiFilter(DateFilter::class, properties: ['weekStartDate'])]
#[ApiFilter(SearchFilter::class, properties: ['employee' => 'exact'])]
#[ORM\Entity(repositoryClass: EmployeeWeekCommentRepository::class)]
#[ORM\Table(name: 'employee_week_comments')]
#[ORM\UniqueConstraint(name: 'uniq_employee_week_comment', columns: ['employee_id', 'week_start_date'])]
class EmployeeWeekComment
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
#[Groups(['week_comment:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Employee::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['week_comment:read', 'week_comment:write'])]
#[Assert\NotNull]
private ?Employee $employee = null;
#[ORM\Column(type: 'date_immutable')]
#[Groups(['week_comment:read', 'week_comment:write'])]
#[Assert\NotNull]
private ?DateTimeImmutable $weekStartDate = null;
#[ORM\Column(type: 'text')]
#[Groups(['week_comment:read', 'week_comment:write'])]
#[Assert\NotBlank]
#[Assert\Length(max: 5000)]
private string $content = '';
#[ORM\Column(type: 'datetime_immutable')]
#[Groups(['week_comment:read'])]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: 'datetime_immutable')]
#[Groups(['week_comment:read'])]
private DateTimeImmutable $updatedAt;
public function __construct()
{
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
}
public function getId(): ?int
{
return $this->id;
}
public function getEmployee(): ?Employee
{
return $this->employee;
}
public function setEmployee(?Employee $employee): self
{
$this->employee = $employee;
return $this;
}
public function getWeekStartDate(): ?DateTimeImmutable
{
return $this->weekStartDate;
}
public function setWeekStartDate(?DateTimeImmutable $weekStartDate): self
{
$this->weekStartDate = $weekStartDate;
return $this;
}
public function getContent(): string
{
return $this->content;
}
public function setContent(string $content): self
{
$this->content = $content;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): DateTimeImmutable
{
return $this->updatedAt;
}
public function touchUpdatedAt(): void
{
$this->updatedAt = new DateTimeImmutable();
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Employee;
use App\Entity\EmployeeWeekComment;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<EmployeeWeekComment>
*/
class EmployeeWeekCommentRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, EmployeeWeekComment::class);
}
public function findOneByEmployeeAndWeek(Employee $employee, DateTimeImmutable $weekStart): ?EmployeeWeekComment
{
return $this->findOneBy(['employee' => $employee, 'weekStartDate' => $weekStart]);
}
/**
* @param list<Employee> $employees
*
* @return array<int, EmployeeWeekComment> employee_id → comment
*/
public function findByWeekAndEmployees(DateTimeImmutable $weekStart, array $employees): array
{
if ([] === $employees) {
return [];
}
$rows = $this->createQueryBuilder('c')
->andWhere('c.weekStartDate = :weekStart')
->andWhere('c.employee IN (:employees)')
->setParameter('weekStart', $weekStart)
->setParameter('employees', $employees)
->innerJoin('c.employee', 'e')->addSelect('e')
->getQuery()->getResult()
;
$map = [];
foreach ($rows as $row) {
$eid = $row->getEmployee()?->getId();
if (null !== $eid) {
$map[$eid] = $row;
}
}
return $map;
}
}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Service\Contracts;
use App\Dto\Contracts\ContractPhase;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use DateTimeImmutable;
use LogicException;
final readonly class EmployeeContractPhaseResolver
{
private ?DateTimeImmutable $dataStartDate;
public function __construct(string $dataStartDate = '')
{
$trimmed = trim($dataStartDate);
if ('' === $trimmed) {
$this->dataStartDate = null;
return;
}
$parsed = DateTimeImmutable::createFromFormat('!Y-m-d', $trimmed);
$this->dataStartDate = $parsed instanceof DateTimeImmutable ? $parsed : null;
}
/**
* @return list<ContractPhase>
*/
public function resolvePhases(Employee $employee): array
{
$periods = $employee->getContractPeriods()->toArray();
usort(
$periods,
static fn (EmployeeContractPeriod $a, EmployeeContractPeriod $b): int => $a->getStartDate() <=> $b->getStartDate()
);
$today = new DateTimeImmutable('today');
$phases = [];
$group = [];
$signature = null;
foreach ($periods as $period) {
$currentSignature = $this->signature($period);
if (null !== $signature && $currentSignature !== $signature) {
$phases[] = $this->buildPhase($group, $today);
$group = [];
}
$group[] = $period;
$signature = $currentSignature;
}
if ([] !== $group) {
$phases[] = $this->buildPhase($group, $today);
}
// Hide phases entirely before the application's data start date: no usable
// work-hour or absence data exists before that date, so exposing them would
// confuse HR (e.g. legacy contract periods predating the software launch).
if (null !== $this->dataStartDate) {
$dataStart = $this->dataStartDate;
$phases = array_values(array_filter(
$phases,
static fn (ContractPhase $phase): bool => null === $phase->endDate || $phase->endDate >= $dataStart,
));
}
// Most recent first.
return array_reverse($phases);
}
private function signature(EmployeeContractPeriod $period): string
{
$contract = $period->getContract();
$type = $contract?->getType()->value ?? '';
$hours = $contract?->getWeeklyHours() ?? -1;
$driver = $period->getIsDriver() ? '1' : '0';
return sprintf('%s|%d|%s', $type, $hours, $driver);
}
/**
* @param non-empty-list<EmployeeContractPeriod> $group
*/
private function buildPhase(array $group, DateTimeImmutable $today): ContractPhase
{
$first = $group[0];
$last = end($group);
$endDate = $last->getEndDate();
$isCurrent = null === $endDate || $endDate >= $today;
$contract = $first->getContract();
return new ContractPhase(
id: (int) $first->getId(),
contractType: $contract?->getType() ?? throw new LogicException('Phase requires a contract type'),
weeklyHours: $contract?->getWeeklyHours(),
isDriver: $first->getIsDriver(),
startDate: $first->getStartDate(),
endDate: $endDate,
periodIds: array_map(static fn (EmployeeContractPeriod $p): int => (int) $p->getId(), $group),
isCurrent: $isCurrent,
contractNature: $first->getContractNatureEnum(),
);
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Service\Exercise;
use DateTimeImmutable;
final readonly class ExerciseYearResolver
{
/**
* Convert a date to its leave/RTT exercise year.
*
* - Forfait: calendar year (Jan→Dec) — returns $date.Y.
* - Non-forfait: leave year (Juin N-1 → Mai N) — returns $date.Y+1 if month >= 6, else $date.Y.
*/
public function forDate(DateTimeImmutable $date, bool $isForfait = false): int
{
if ($isForfait) {
return (int) $date->format('Y');
}
return (int) $date->format('n') >= 6
? (int) $date->format('Y') + 1
: (int) $date->format('Y');
}
}

View File

@@ -221,8 +221,9 @@ final readonly class RttRecoveryComputationService
continue;
}
$weekAnchorNature = $naturesByDate[$employeeId][$weekDays[0]] ?? ContractNature::CDI;
$weekAnchorContract = $employeeContractsByDate[$weekDays[0]] ?? null;
$weekAnchorDate = $this->resolveWeekAnchorDate($weekDays, $employeeContractsByDate);
$weekAnchorNature = $naturesByDate[$employeeId][$weekAnchorDate] ?? ContractNature::CDI;
$weekAnchorContract = $employeeContractsByDate[$weekAnchorDate] ?? null;
$isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode();
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract, $weekAnchorNature);
$weekContractType = ContractType::resolve(
@@ -387,6 +388,27 @@ final readonly class RttRecoveryComputationService
return $total;
}
/**
* Date d'ancrage de la semaine pour résoudre le type/nature de contrat : premier jour
* de la semaine couvert par un contrat. Évite qu'une semaine d'embauche en milieu de
* semaine (premiers jours hors contrat) soit classée CUSTOM — ce qui désactiverait à
* tort les bonus 25 %/50 % d'un contrat 35h/39h. Fallback sur le 1er jour si aucun jour
* n'est contracté (semaine entièrement hors contrat → 0 de toute façon).
*
* @param list<string> $weekDays
* @param array<string, ?Contract> $contractsByDate
*/
private function resolveWeekAnchorDate(array $weekDays, array $contractsByDate): string
{
foreach ($weekDays as $date) {
if (null !== ($contractsByDate[$date] ?? null)) {
return $date;
}
}
return $weekDays[0];
}
/**
* @param list<string> $days
* @param array<string, ?Contract> $contractsByDate
@@ -413,10 +435,17 @@ final readonly class RttRecoveryComputationService
{
$total = 0;
foreach ($days as $date) {
$isoDay = (int) new DateTimeImmutable($date)->format('N');
$contract = $contractsByDate[$date] ?? null;
$hours = $contract?->getWeeklyHours();
$startHours = (null !== $hours && $hours >= 39) ? 39 : 35;
$isoDay = (int) new DateTimeImmutable($date)->format('N');
$contract = $contractsByDate[$date] ?? null;
$hours = $contract?->getWeeklyHours();
// Days without an active contract (pre-hire, post-termination, contract
// gaps) must NOT contribute to the weekly 25% overtime threshold —
// otherwise hiring mid-week artificially inflates the threshold and
// erases legitimate overtime.
if (null === $hours || $hours <= 0) {
continue;
}
$startHours = $hours >= 39 ? 39 : 35;
$total += $this->resolveDailyReferenceMinutes($startHours, $isoDay);
}

View File

@@ -14,8 +14,10 @@ use App\Enum\TrackingMode;
use App\Repository\AbsenceRepository;
use App\Repository\WorkHourRepository;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\PublicHolidayServiceInterface;
use DateInterval;
use DateTimeImmutable;
use Throwable;
class YearlyHoursExportBuilder
{
@@ -25,6 +27,8 @@ class YearlyHoursExportBuilder
private EmployeeContractResolver $contractResolver,
private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
private PublicHolidayServiceInterface $publicHolidayService,
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
) {}
/**
@@ -56,6 +60,8 @@ class YearlyHoursExportBuilder
$absences = $this->absenceRepository->findForPrint($from, $to, $employees);
$contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
$workDaysMap = $this->contractResolver->resolveWorkDaysMinutesForEmployeesAndDays($employees, $days);
$holidayMap = $this->buildHolidayMap($from, $to);
$workHourMap = $this->buildWorkHourMap($workHours);
$absenceMap = $this->buildAbsenceMap($absences, $days);
@@ -71,6 +77,8 @@ class YearlyHoursExportBuilder
$driverMap[$employeeId] ?? [],
$workHourMap[$employeeId] ?? [],
$absenceData,
$workDaysMap[$employeeId] ?? [],
$holidayMap,
);
if ([] === $segments) {
@@ -205,6 +213,9 @@ class YearlyHoursExportBuilder
}
/**
* @param array<string, ?array<int, int>> $workDaysMinutesByDate
* @param array<string, string> $holidayMap
*
* @return list<array{mode: string, contractName: ?string, rows: list<array>}>
*/
private function buildSegments(
@@ -213,6 +224,8 @@ class YearlyHoursExportBuilder
array $driverByDate,
array $workHoursByDate,
array $absenceData,
array $workDaysMinutesByDate,
array $holidayMap,
): array {
$segments = [];
$currentMode = null;
@@ -222,7 +235,8 @@ class YearlyHoursExportBuilder
$firstDataDate = null;
foreach ($days as $date) {
$hasRow = null !== ($workHoursByDate[$date] ?? null)
|| ($absenceData['hasDayAbsence'][$date] ?? false);
|| ($absenceData['hasDayAbsence'][$date] ?? false)
|| isset($holidayMap[$date]);
if ($hasRow) {
$firstDataDate = $date;
@@ -241,14 +255,16 @@ class YearlyHoursExportBuilder
continue;
}
$contract = $contractsByDate[$date] ?? null;
$isDriver = $driverByDate[$date] ?? false;
$wh = $workHoursByDate[$date] ?? null;
$hasData = null !== $wh || ($absenceData['hasDayAbsence'][$date] ?? false);
$isoDay = (int) new DateTimeImmutable($date)->format('N');
$isWeekend = $isoDay >= 6;
$contract = $contractsByDate[$date] ?? null;
$isDriver = $driverByDate[$date] ?? false;
$wh = $workHoursByDate[$date] ?? null;
$hasData = null !== $wh || ($absenceData['hasDayAbsence'][$date] ?? false);
$holidayLabel = $holidayMap[$date] ?? null;
$isHoliday = null !== $holidayLabel;
$isoDay = (int) new DateTimeImmutable($date)->format('N');
$isWeekend = $isoDay >= 6;
if (!$hasData && !$isWeekend) {
if (!$hasData && !$isWeekend && !$isHoliday) {
continue;
}
@@ -275,10 +291,18 @@ class YearlyHoursExportBuilder
$creditedMinutes = $absenceData['credited'][$date] ?? 0;
$absenceLabel = $absenceData['labels'][$date] ?? null;
$hasAbsence = $absenceData['hasDayAbsence'][$date] ?? false;
$virtualMinutes = $this->holidayVirtualHoursResolver->resolveVirtualCredit(
$contract,
new DateTimeImmutable($date),
$hasAbsence,
$workDaysMinutesByDate[$date] ?? null,
);
$row = [
'date' => new DateTimeImmutable($date)->format('d/m/Y'),
'absenceLabel' => $absenceLabel,
'holidayLabel' => $holidayLabel,
'isWeekend' => $isWeekend,
];
@@ -297,6 +321,9 @@ class YearlyHoursExportBuilder
$nightMin = $wh?->getNightHoursMinutes() ?? 0;
$workshopMin = $wh?->getWorkshopHoursMinutes() ?? 0;
$totalMin = $dayMin + $nightMin + $workshopMin + $creditedMinutes;
if ($virtualMinutes > $totalMin) {
$totalMin = $virtualMinutes;
}
$row['dayHours'] = $this->formatMinutes($dayMin);
$row['nightHours'] = $this->formatMinutes($nightMin);
@@ -305,6 +332,10 @@ class YearlyHoursExportBuilder
} else {
$metrics = null !== $wh ? $this->computeMetrics($wh) : new WorkMetrics();
$metrics->addCreditedMinutes($creditedMinutes);
$totalMin = $metrics->totalMinutes;
if ($virtualMinutes > $totalMin) {
$totalMin = $virtualMinutes;
}
$row['morningFrom'] = $wh?->getMorningFrom() ?? '';
$row['morningTo'] = $wh?->getMorningTo() ?? '';
@@ -312,7 +343,7 @@ class YearlyHoursExportBuilder
$row['afternoonTo'] = $wh?->getAfternoonTo() ?? '';
$row['eveningFrom'] = $wh?->getEveningFrom() ?? '';
$row['eveningTo'] = $wh?->getEveningTo() ?? '';
$row['total'] = $this->formatMinutes($metrics->totalMinutes);
$row['total'] = $this->formatMinutes($totalMin);
}
$currentRows[] = $row;
@@ -329,6 +360,29 @@ class YearlyHoursExportBuilder
return $segments;
}
/**
* @return array<string, string> Y-m-d => label
*/
private function buildHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$map = [];
$startYear = (int) $from->format('Y');
$endYear = (int) $to->format('Y');
try {
for ($year = $startYear; $year <= $endYear; ++$year) {
$holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', (string) $year);
foreach ($holidays as $date => $label) {
$map[(string) $date] = (string) $label;
}
}
} catch (Throwable) {
return [];
}
return $map;
}
private function resolveSegmentMode(string $trackingMode, bool $isDriver): string
{
if ($isDriver) {

View File

@@ -7,6 +7,7 @@ namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\EmployeeLeaveSummary;
use App\Dto\Contracts\ContractPhase;
use App\Entity\Absence;
use App\Entity\ContractSuspension;
use App\Entity\Employee;
@@ -20,6 +21,8 @@ use App\Repository\EmployeeLeaveBalanceRepository;
use App\Repository\EmployeeRepository;
use App\Repository\WorkHourRepository;
use App\Security\EmployeeScopeService;
use App\Service\Contracts\EmployeeContractPhaseResolver;
use App\Service\Exercise\ExerciseYearResolver;
use App\Service\Leave\LeaveBalanceComputationService;
use App\Service\Leave\LongMaladieService;
use App\Service\Leave\SuspensionDaysCalculator;
@@ -35,6 +38,7 @@ use Throwable;
final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
{
private const int FORFAIT_TARGET_WORKED_DAYS = 218;
private const int FORFAIT_STANDARD_CP_DAYS = 25;
private const float CDI_NON_FORFAIT_STANDARD_ACQUIRED_DAYS = 25.0;
private const float CDI_NON_FORFAIT_STANDARD_ACQUIRED_SATURDAYS = 5.0;
private const float CDI_NON_FORFAIT_STANDARD_ACCRUAL_PER_MONTH = self::CDI_NON_FORFAIT_STANDARD_ACQUIRED_DAYS / 12.0;
@@ -45,6 +49,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
private const float CDI_NON_FORFAIT_4H_SATURDAY_ACCRUAL_PER_MONTH = 0.0;
private const float LONG_MALADIE_MONTHLY_ACCRUAL = 2.0;
private ?string $dataStartDate;
public function __construct(
private Security $security,
private RequestStack $requestStack,
@@ -58,7 +64,12 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
private PublicHolidayServiceInterface $publicHolidayService,
private SuspensionDaysCalculator $suspensionDaysCalculator,
private WorkHourRepository $workHourRepository,
) {}
private EmployeeContractPhaseResolver $phaseResolver,
private ExerciseYearResolver $exerciseYearResolver,
string $dataStartDate = '',
) {
$this->dataStartDate = '' !== $dataStartDate ? $dataStartDate : null;
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmployeeLeaveSummary
{
@@ -81,13 +92,15 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
throw new AccessDeniedHttpException('Employee outside your scope.');
}
$year = $this->resolveYear($employee);
$phase = $this->resolveTargetPhase($employee);
$year = $this->resolveYear($employee, $phase);
$summary = new EmployeeLeaveSummary();
$summary->year = $year;
$summary->ruleCode = LeaveRuleCode::UNSUPPORTED->value;
$summary = new EmployeeLeaveSummary();
$summary->year = $year;
$summary->ruleCode = LeaveRuleCode::UNSUPPORTED->value;
$summary->dataStartDate = $this->dataStartDate;
$yearSummary = $this->computeYearSummary($employee, $year);
$yearSummary = $this->computeYearSummary($employee, $year, 0.0, null, $phase);
if (null === $yearSummary) {
return $summary;
}
@@ -98,7 +111,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
// For forfait contracts, paid days reduce N-1 stock before taken-day attribution.
// Recompute with paidLeaveDays so taken days shift from N-1 to N when N-1 is consumed by payment.
if ($paidLeaveDays > 0.0) {
$yearSummary = $this->computeYearSummary($employee, $year, $paidLeaveDays);
$yearSummary = $this->computeYearSummary($employee, $year, $paidLeaveDays, null, $phase);
if (null === $yearSummary) {
return $summary;
}
@@ -119,7 +132,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$summary->previousYearRemainingDays = $yearSummary['previousYearRemainingDays'];
$summary->previousYearPaidDays = $paidLeaveDays;
[$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year);
[$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year, $phase);
// Forfait-only: leaves taken from N-1 stock do NOT decrement presence days.
// For non-forfait, previousYearTakenDays is always 0, so the budget has no effect.
$n1AbsencesBudget = $yearSummary['previousYearTakenDays'];
@@ -161,9 +174,20 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
* previousYearRemainingDays: float
* }
*/
public function computeYearSummary(Employee $employee, int $targetYear, float $paidLeaveDays = 0.0, ?DateTimeImmutable $asOfDate = null): ?array
public function computeYearSummary(Employee $employee, int $targetYear, float $paidLeaveDays = 0.0, ?DateTimeImmutable $asOfDate = null, ?ContractPhase $phase = null): ?array
{
$firstYear = max($this->resolveFirstComputationYear($employee), $targetYear - 1);
// Track whether a phase was provided explicitly. When the caller supplies $phase,
// we apply the phase-end cap on period bounds. When we fall back to resolveCurrentPhase
// (legacy callers without phase awareness, e.g. LeaveRecapRowBuilder), we preserve
// the pre-phase-cap behavior to avoid changing observable results for terminated
// employees (the resolved fallback phase would otherwise unduly cap `to`).
$applyPhaseEndCap = null !== $phase;
$phase ??= $this->resolveCurrentPhase($employee);
if (null === $phase) {
return null;
}
$firstYear = max($this->resolveFirstComputationYear($employee, $phase), $targetYear - 1);
if ($targetYear < $firstYear) {
$targetYear = $firstYear;
}
@@ -173,8 +197,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$targetSummary = null;
for ($year = $firstYear; $year <= $targetYear; ++$year) {
[$from, $to] = $this->resolvePeriodBounds($employee, $year);
$leavePolicy = $this->resolveLeavePolicy($employee, $from, $to);
[$from, $to] = $this->resolvePeriodBounds($employee, $year, $phase, $applyPhaseEndCap);
$leavePolicy = $this->resolveLeavePolicy($employee, $phase, $from, $to);
if (null === $leavePolicy) {
if ($year === $targetYear) {
return null;
@@ -218,8 +242,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
}
$effectiveAsOfDate = ($year === $targetYear) ? $asOfDate : null;
$accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee, $effectiveAsOfDate);
$takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee, $effectiveAsOfDate);
$accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee, $phase, $effectiveAsOfDate, $applyPhaseEndCap);
$takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee, $phase, $effectiveAsOfDate, $applyPhaseEndCap);
$suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace(
$this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to)
);
@@ -227,7 +251,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$longMaladiePeriods = [];
$longMaladieReductionFactor = 1.0;
if (LeaveRuleCode::CDI_CDD_NON_FORFAIT->value === $leavePolicy['ruleCode']
&& 4 !== $employee->getContract()?->getWeeklyHours()
&& 4 !== $phase->weeklyHours
&& null !== $accrualCalculationEnd
) {
$longMaladiePeriods = $this->longMaladieService->findReducedRatePeriods($employee, $effectiveFrom, $accrualCalculationEnd);
@@ -414,16 +438,29 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
return $earliest;
}
private function resolveYear(Employee $employee): int
private function resolveYear(Employee $employee, ContractPhase $phase): int
{
$raw = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? '');
$isForfait = ContractType::FORFAIT === $phase->contractType;
$raw = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? '');
$phaseIdRaw = $this->requestStack->getCurrentRequest()?->query->get('phaseId');
$phaseIdProvided = null !== $phaseIdRaw && '' !== (string) $phaseIdRaw;
if ('' === $raw) {
$today = new DateTimeImmutable('today');
if (ContractType::FORFAIT === $employee->getContract()?->getType()) {
return (int) $today->format('Y');
// When a phaseId is explicitly provided, default to the year derived from
// the phase's end date (or today if the phase is still current).
if ($phaseIdProvided) {
$reference = $phase->endDate ?? new DateTimeImmutable('today');
return $isForfait
? (int) $reference->format('Y')
: $this->resolveCurrentLeaveYear($reference);
}
return $this->resolveCurrentLeaveYear($today);
$today = new DateTimeImmutable('today');
return $isForfait
? (int) $today->format('Y')
: $this->resolveCurrentLeaveYear($today);
}
if (!preg_match('/^\d{4}$/', $raw)) {
@@ -435,9 +472,144 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.');
}
// When a phaseId is explicit, silently clamp the requested year to the
// first/last exercise covered by the phase.
if ($phaseIdProvided) {
$year = $this->clampYearToPhase($year, $phase, $isForfait);
}
return $year;
}
private function clampYearToPhase(int $year, ContractPhase $phase, bool $isForfait): int
{
$firstYear = $this->exerciseYearResolver->forDate($phase->startDate, $isForfait);
$lastYear = $phase->endDate instanceof DateTimeImmutable
? $this->exerciseYearResolver->forDate($phase->endDate, $isForfait)
: null;
if ($year < $firstYear) {
return $firstYear;
}
if (null !== $lastYear && $year > $lastYear) {
return $lastYear;
}
return $year;
}
private function resolveTargetPhase(Employee $employee): ContractPhase
{
$raw = $this->requestStack->getCurrentRequest()?->query->get('phaseId');
$phases = $this->phaseResolver->resolvePhases($employee);
if ([] === $phases) {
throw new UnprocessableEntityHttpException('Employee has no contract phase.');
}
if (null === $raw || '' === (string) $raw) {
// Phase courante par défaut = celle marquée isCurrent ou, à défaut, la plus récente.
foreach ($phases as $phase) {
if ($phase->isCurrent) {
return $phase;
}
}
return $phases[0];
}
if (!preg_match('/^\d+$/', (string) $raw)) {
throw new UnprocessableEntityHttpException('phaseId must be a positive integer.');
}
$phaseId = (int) $raw;
foreach ($phases as $phase) {
if ($phase->id === $phaseId) {
return $phase;
}
}
throw new UnprocessableEntityHttpException('phaseId does not match any phase of this employee.');
}
private function resolveCurrentPhase(Employee $employee): ?ContractPhase
{
$phases = $this->phaseResolver->resolvePhases($employee);
if ([] === $phases) {
return null;
}
foreach ($phases as $phase) {
if ($phase->isCurrent) {
return $phase;
}
}
return $phases[0];
}
/**
* Phase dont la date de début est la plus proche en deçà de celle de $phase
* (la phase qui précède immédiatement). Null si $phase est la première.
*/
private function resolvePhaseImmediatelyBefore(Employee $employee, ContractPhase $phase): ?ContractPhase
{
$prior = null;
foreach ($this->phaseResolver->resolvePhases($employee) as $candidate) {
if ($candidate->startDate >= $phase->startDate) {
continue;
}
if (null === $prior || $candidate->startDate > $prior->startDate) {
$prior = $candidate;
}
}
return $prior;
}
/**
* CP à reporter d'une phase non-forfait vers une entrée en FORFAIT : jours ouvrés
* NETS (acquis + en cours d'acquisition jours ouvrés posés) + samedis BRUTS (acquis,
* sans déduction des samedis posés). Retourne 0 si aucune phase précédente (nouvel
* embauché → cas 2) ou si la précédente est elle-même un FORFAIT (ré-embauche / double
* transition forfait — pas de report CP non-forfait à reprendre).
*
* Composition du retour (clés de computeYearSummary, branche CDI_CDD_NON_FORFAIT) :
* remainingDays : acquis (report N-1) restant après jours ouvrés posés
* accruingDays : généré de l'exercice restant, NET des jours posés en débordement
* (= remainingGenerated + remainingGeneratedSaturdays)
* remainingSaturdays : samedis acquis (report N-1) restants
* + takenSaturdays : ré-ajout des samedis posés (règle métier ci-dessous). Invariant :
* comme accruingDays a déjà déduit les samedis posés en débordement,
* ce ré-ajout laisse le solde samedi BRUT (généré + acquis), pas net.
*
* Règle (validée comptable) : seuls les congés en JOURS OUVRÉS déjà posés réduisent
* le report ; les SAMEDIS déjà posés ne le réduisent pas. computeYearSummary déduit
* tous les congés posés (samedis inclus), d'où le ré-ajout de takenSaturdays.
* Ex. Grégory : 12 acquis 5 jours ouvrés posés = 7 (le samedi posé reste crédité).
*
* Les jours fractionnés (fractionedDays, ajustement manuel ajouté par provide() à
* l'affichage) sont volontairement EXCLUS : on ne reporte que le solde CP acquis/généré
* de la phase précédente, pas les bonus de fractionnement.
*/
private function resolveCarriedCpFromPriorPhase(Employee $employee, ContractPhase $forfaitPhase): float
{
$prior = $this->resolvePhaseImmediatelyBefore($employee, $forfaitPhase);
if (null === $prior || ContractType::FORFAIT === $prior->contractType) {
return 0.0;
}
$reference = $prior->endDate ?? new DateTimeImmutable('today');
$priorYear = $this->exerciseYearResolver->forDate($reference, false);
$summary = $this->computeYearSummary($employee, $priorYear, 0.0, null, $prior);
if (null === $summary) {
return 0.0;
}
return $summary['remainingDays']
+ $summary['accruingDays']
+ $summary['remainingSaturdays']
+ $summary['takenSaturdays'];
}
/**
* @param list<ContractSuspension> $suspensions
* @param list<array{start: DateTimeImmutable, end: DateTimeImmutable}> $longMaladiePeriods
@@ -512,14 +684,21 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
int $year,
DateTimeImmutable $periodEnd,
Employee $employee,
?DateTimeImmutable $asOfDate = null
ContractPhase $phase,
?DateTimeImmutable $asOfDate = null,
bool $applyPhaseEndCap = true
): ?DateTimeImmutable {
$reference = $asOfDate ?? new DateTimeImmutable('today');
$currentYear = LeaveRuleCode::FORFAIT_218->value === $ruleCode
? (int) $reference->format('Y')
: $this->resolveCurrentLeaveYear($reference);
if ($year < $currentYear) {
// When viewing a closed phase explicitly, treat its end date as the reference cutoff:
// accrual is bounded to the phase end, never running to "today".
// Legacy callers (no explicit phase) skip this cap to preserve pre-phase behavior.
if ($applyPhaseEndCap && !$phase->isCurrent && null !== $phase->endDate) {
$end = $phase->endDate < $periodEnd ? $phase->endDate : $periodEnd;
} elseif ($year < $currentYear) {
$end = $periodEnd;
} elseif ($year > $currentYear) {
$end = null;
@@ -532,12 +711,17 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$end = $lastDayPreviousMonth < $periodEnd ? $lastDayPreviousMonth : $periodEnd;
}
// Cap at contract end date if the employee has left.
$contractEndRaw = $employee->getCurrentContractEndDate();
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
$contractEnd = $this->parseYmdDate($contractEndRaw);
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
$end = $contractEnd;
// Cap at contract end date if the employee has left (only meaningful when
// viewing the current phase; closed phases are already capped above).
// Legacy callers (no explicit phase) always evaluate this branch to mimic
// the pre-phase behavior, which relied on getCurrentContractEndDate().
if (!$applyPhaseEndCap || $phase->isCurrent) {
$contractEndRaw = $employee->getCurrentContractEndDate();
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
$contractEnd = $this->parseYmdDate($contractEndRaw);
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
$end = $contractEnd;
}
}
}
@@ -547,7 +731,9 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
private function resolveTakenCalculationEndDate(
DateTimeImmutable $periodEnd,
Employee $employee,
?DateTimeImmutable $asOfDate = null
ContractPhase $phase,
?DateTimeImmutable $asOfDate = null,
bool $applyPhaseEndCap = true
): ?DateTimeImmutable {
$end = $periodEnd;
@@ -555,12 +741,21 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$end = $asOfDate;
}
// Cap at contract end date if the employee has left.
$contractEndRaw = $employee->getCurrentContractEndDate();
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
$contractEnd = $this->parseYmdDate($contractEndRaw);
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
$end = $contractEnd;
// Closed phase: cap taken-absence accounting at the phase end.
// Skip for legacy callers (no explicit phase) to preserve pre-phase behavior.
if ($applyPhaseEndCap && !$phase->isCurrent && null !== $phase->endDate && $phase->endDate < $end) {
$end = $phase->endDate;
}
// Legacy callers (no explicit phase) always use the live contract end date,
// mirroring the pre-phase implementation.
if (!$applyPhaseEndCap || $phase->isCurrent) {
$contractEndRaw = $employee->getCurrentContractEndDate();
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
$contractEnd = $this->parseYmdDate($contractEndRaw);
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
$end = $contractEnd;
}
}
}
@@ -578,10 +773,41 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
* splitSaturdays: bool
* }
*/
private function resolveLeavePolicy(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): ?array
private function resolveLeavePolicy(Employee $employee, ContractPhase $phase, DateTimeImmutable $from, DateTimeImmutable $to): ?array
{
$type = $employee->getContract()?->getType();
$type = $phase->contractType;
if (ContractType::FORFAIT === $type) {
$year = (int) $from->format('Y'); // période forfait = année civile
// Entrée en FORFAIT en cours d'année : repos proratisés + CP nets reportés de
// la phase précédente, au lieu de max(0, businessDays 218) qui donnerait 0.
if ($this->isForfaitEntryYear($phase, $year)) {
$yearStart = new DateTimeImmutable(sprintf('%d-01-01 00:00:00', $year));
$yearEnd = new DateTimeImmutable(sprintf('%d-12-31 00:00:00', $year));
$rawYearHolidays = $this->buildRawPublicHolidayMap($yearStart, $yearEnd);
$businessDaysYear = $this->countBusinessDays($yearStart, $yearEnd, $rawYearHolidays);
$businessDaysPeriod = $this->countBusinessDays($from, $to, $rawYearHolidays);
$repoDays = $this->computeProratedForfaitRepoDays($businessDaysYear, $businessDaysPeriod);
$carriedCp = $this->resolveCarriedCpFromPriorPhase($employee, $phase);
// NB : le bonus week-end/férié travaillé (bonusDays du chemin année pleine)
// n'est volontairement PAS ajouté ici. L'acquis de l'année d'entrée = repos
// proratisés + CP reportés (règle comptable). À revoir si la RH veut créditer
// le travail week-end/férié posé pendant la période forfait partielle.
return [
'ruleCode' => LeaveRuleCode::FORFAIT_218->value,
'acquiredDays' => $repoDays + $carriedCp,
'acquiredSaturdays' => 0.0,
'accrualPerMonth' => 0.0,
'saturdayAccrualPerMonth' => 0.0,
'countOnlyCp' => false,
'splitSaturdays' => false,
];
}
// Année pleine : calcul 218 existant (INCHANGÉ).
// 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).
@@ -609,12 +835,13 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
];
}
$nature = ContractNature::tryFrom($employee->getCurrentContractNature());
// Resolve nature directly from the phase DTO (populated by EmployeeContractPhaseResolver).
$nature = $phase->contractNature;
if (ContractNature::CDI !== $nature && ContractNature::CDD !== $nature) {
return null;
}
$weeklyHours = $employee->getContract()?->getWeeklyHours();
$weeklyHours = $phase->weeklyHours;
if (4 === $weeklyHours) {
return [
'ruleCode' => LeaveRuleCode::CDI_CDD_NON_FORFAIT->value,
@@ -638,6 +865,37 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
];
}
/**
* Jours de repos forfait proratisés sur la fraction de jours ouvrés couverte.
*
* Repos année pleine = jours_ouvrés_année 218 (cible travaillée) 25 (CP standard).
* Pour 2026 : 252 218 25 = 9, proratisés au ratio jours_ouvrés_période / jours_ouvrés_année.
*/
private function computeProratedForfaitRepoDays(int $businessDaysYear, int $businessDaysPeriod): float
{
if ($businessDaysYear <= 0) {
return 0.0;
}
$repoDaysYear = max(0, $businessDaysYear - self::FORFAIT_TARGET_WORKED_DAYS - self::FORFAIT_STANDARD_CP_DAYS);
return $repoDaysYear * $businessDaysPeriod / $businessDaysYear;
}
/**
* Vrai si la phase FORFAIT démarre en cours de l'année civile consultée
* (donc avec une période partielle), faux pour une année pleine ou un démarrage le 1er janvier.
*/
private function isForfaitEntryYear(ContractPhase $phase, int $year): bool
{
if (ContractType::FORFAIT !== $phase->contractType) {
return false;
}
return (int) $phase->startDate->format('Y') === $year
&& '01-01' !== $phase->startDate->format('m-d');
}
/**
* @param null|array<string, string> $publicHolidays pre-built map (built if null)
*/
@@ -809,13 +1067,33 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
/**
* @return array{DateTimeImmutable, DateTimeImmutable}
*/
private function resolvePeriodBounds(Employee $employee, int $year): array
private function resolvePeriodBounds(Employee $employee, int $year, ContractPhase $phase, bool $applyPhaseEndCap = true): array
{
if (ContractType::FORFAIT === $employee->getContract()?->getType()) {
return $this->resolveForfaitYearBounds($employee, $year);
if (ContractType::FORFAIT === $phase->contractType) {
[$from, $to] = $this->resolveForfaitYearBounds($employee, $year, $phase);
// For FORFAIT, cap from at phase.startDate: the 218-day FORFAIT accrual
// is calendar-year scoped and only counts the FORFAIT portion of the year.
if ($phase->startDate > $from) {
$from = $phase->startDate;
}
} else {
[$from, $to] = $this->resolveLeavePeriodBounds($year);
// For non-forfait, do NOT cap from at phase.startDate: CP accrual is
// annual (Juin→Mai) and continuous across signature changes within the
// same leave rule (e.g. 35h → 39h, driver flag flip, weeklyHours bump).
// The contract-entry-date cap is handled by resolveEffectivePeriodStart().
}
return $this->resolveLeavePeriodBounds($year);
// End cap applies to both modes. Skipped when the phase was not explicitly
// provided (legacy callers) to preserve pre-phase-cap behavior for
// terminated employees.
if ($applyPhaseEndCap && null !== $phase->endDate && $phase->endDate < $to) {
$to = $phase->endDate;
}
return [$from, $to];
}
/**
@@ -833,24 +1111,29 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
/**
* @return array{DateTimeImmutable, DateTimeImmutable}
*/
private function resolveForfaitYearBounds(Employee $employee, int $year): array
private function resolveForfaitYearBounds(Employee $employee, int $year, ContractPhase $phase): array
{
$from = new DateTimeImmutable(sprintf('%d-01-01 00:00:00', $year));
$to = new DateTimeImmutable(sprintf('%d-12-31 00:00:00', $year));
$contractStartRaw = $employee->getCurrentContractStartDate();
if (null !== $contractStartRaw && '' !== trim($contractStartRaw)) {
$contractStart = $this->parseYmdDate($contractStartRaw);
if ($contractStart instanceof DateTimeImmutable && $contractStart > $from) {
$from = $contractStart;
// When viewing the current phase, prefer the live "current contract" dates
// for backward compat with existing tests/usage. Closed phases rely on the
// generic cap applied in resolvePeriodBounds().
if ($phase->isCurrent) {
$contractStartRaw = $employee->getCurrentContractStartDate();
if (null !== $contractStartRaw && '' !== trim($contractStartRaw)) {
$contractStart = $this->parseYmdDate($contractStartRaw);
if ($contractStart instanceof DateTimeImmutable && $contractStart > $from) {
$from = $contractStart;
}
}
}
$contractEndRaw = $employee->getCurrentContractEndDate();
if (null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
$contractEnd = $this->parseYmdDate($contractEndRaw);
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $to) {
$to = $contractEnd;
$contractEndRaw = $employee->getCurrentContractEndDate();
if (null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
$contractEnd = $this->parseYmdDate($contractEndRaw);
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $to) {
$to = $contractEnd;
}
}
}
@@ -872,16 +1155,19 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
return $month >= 6 ? $year + 1 : $year;
}
private function resolveFirstComputationYear(Employee $employee): int
private function resolveFirstComputationYear(Employee $employee, ContractPhase $phase): int
{
$isForfait = ContractType::FORFAIT === $employee->getContract()?->getType();
$isForfait = ContractType::FORFAIT === $phase->contractType;
$fallbackYear = $isForfait
? (int) new DateTimeImmutable('today')->format('Y')
: $this->resolveCurrentLeaveYear(new DateTimeImmutable('today'));
// Do not go before the exercice containing $phase->startDate.
$phaseFirstYear = $this->exerciseYearResolver->forDate($phase->startDate, $isForfait);
$history = $employee->getContractHistory();
if ([] === $history) {
return $fallbackYear;
return max($phaseFirstYear, $fallbackYear);
}
$oldestStartDate = null;
@@ -897,22 +1183,19 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
if (null === $oldestStartDate) {
$oldestBalanceYear = $this->leaveBalanceRepository->findEarliestYearForEmployee($employee);
$candidate = null === $oldestBalanceYear ? $fallbackYear : min($fallbackYear, $oldestBalanceYear);
return null === $oldestBalanceYear ? $fallbackYear : min($fallbackYear, $oldestBalanceYear);
return max($phaseFirstYear, $candidate);
}
$firstYear = $isForfait
? (int) $oldestStartDate->format('Y')
: ((int) $oldestStartDate->format('n') >= 6
? (int) $oldestStartDate->format('Y') + 1
: (int) $oldestStartDate->format('Y'));
$firstYear = $this->exerciseYearResolver->forDate($oldestStartDate, $isForfait);
$oldestBalanceYear = $this->leaveBalanceRepository->findEarliestYearForEmployee($employee);
if (null !== $oldestBalanceYear && $oldestBalanceYear < $firstYear) {
return $oldestBalanceYear;
$firstYear = $oldestBalanceYear;
}
return $firstYear;
return max($phaseFirstYear, $firstYear);
}
private function parseYmdDate(string $value): ?DateTimeImmutable

View File

@@ -12,8 +12,10 @@ use App\Entity\EmployeeRttPayment;
use App\Repository\EmployeeRepository;
use App\Repository\EmployeeRttPaymentRepository;
use App\Service\AuditLogger;
use DateTimeImmutable;
use App\Service\Contracts\EmployeeContractPhaseResolver;
use App\Service\Exercise\ExerciseYearResolver;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Clock\ClockInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
@@ -24,6 +26,9 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
private EmployeeRttPaymentRepository $rttPaymentRepository,
private EntityManagerInterface $entityManager,
private AuditLogger $auditLogger,
private EmployeeContractPhaseResolver $phaseResolver,
private ClockInterface $clock,
private ExerciseYearResolver $exerciseYearResolver,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EmployeeRttPaymentInput
@@ -48,6 +53,8 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
$year = $data->year ?? $this->resolveCurrentExerciseYear();
$this->assertYearAllowedForPayment($employee, $year);
$payment = $this->rttPaymentRepository->findOneByEmployeeYearMonth($employee, $year, $data->month);
if (null === $payment) {
@@ -83,10 +90,33 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
private function resolveCurrentExerciseYear(): int
{
$today = new DateTimeImmutable('today');
$year = (int) $today->format('Y');
$month = (int) $today->format('n');
return $this->exerciseYearResolver->forDate($this->clock->now());
}
return $month >= 6 ? $year + 1 : $year;
/**
* 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).
*/
private function assertYearAllowedForPayment(Employee $employee, int $year): void
{
$currentExerciseYear = $this->resolveCurrentExerciseYear();
if ($year === $currentExerciseYear) {
return;
}
$phases = $this->phaseResolver->resolvePhases($employee);
foreach ($phases as $phase) {
if ($phase->isCurrent || null === $phase->endDate) {
continue;
}
if ($year === $this->exerciseYearResolver->forDate($phase->endDate)) {
return;
}
}
throw new UnprocessableEntityHttpException(
'RTT payment is only allowed on the current exercise or the last exercise of a closed contract phase.'
);
}
}

View File

@@ -7,6 +7,7 @@ namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\EmployeeRttSummary;
use App\Dto\Contracts\ContractPhase;
use App\Dto\Rtt\EmployeeRttWeekSummary;
use App\Dto\Rtt\RttMonthPayment;
use App\Dto\Rtt\WeekRecoveryDetail;
@@ -17,6 +18,8 @@ use App\Repository\EmployeeRttBalanceRepository;
use App\Repository\EmployeeRttPaymentRepository;
use App\Repository\WorkHourRepository;
use App\Security\EmployeeScopeService;
use App\Service\Contracts\EmployeeContractPhaseResolver;
use App\Service\Exercise\ExerciseYearResolver;
use App\Service\Rtt\RttRecoveryComputationService;
use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
@@ -38,6 +41,8 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
private EmployeeRttPaymentRepository $rttPaymentRepository,
private RttRecoveryComputationService $rttRecoveryService,
private WorkHourRepository $workHourRepository,
private EmployeeContractPhaseResolver $phaseResolver,
private ExerciseYearResolver $exerciseYearResolver,
string $rttStartDate = '',
) {
$this->rttStartDate = '' !== $rttStartDate ? $rttStartDate : null;
@@ -64,12 +69,25 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
throw new AccessDeniedHttpException('Employee outside your scope.');
}
$year = $this->resolveYear();
$phase = $this->resolveTargetPhase($employee);
$year = $this->resolveYear($phase);
$today = new DateTimeImmutable('today');
$currentExerciseYear = $this->resolveCurrentExerciseYear($today);
[$periodFrom, $periodTo] = $this->rttRecoveryService->resolveExerciseBounds($year);
$weeks = $this->rttRecoveryService->buildWeeksForExercise($periodFrom, $periodTo);
$weekRanges = array_map(
// Cap periodTo at the phase endDate for closed phases so the RTT table does
// not extend past the date the phase ended.
// Do NOT cap periodFrom at phase.startDate: keep the full exercise
// displayed so weeks before the employee's hire (or before a past phase
// started) appear at 0, matching the previous behavior. Weeks outside the
// contract range contribute 0 minutes to the cumul naturally (no contract
// ⇒ no reference, no worked hours).
if (!$phase->isCurrent && null !== $phase->endDate && $phase->endDate < $periodTo) {
$periodTo = $phase->endDate;
}
$weeks = $this->rttRecoveryService->buildWeeksForExercise($periodFrom, $periodTo);
$weekRanges = array_map(
static fn (array $week): array => [
'weekNumber' => (int) $week['weekNumber'],
'start' => $week['start'],
@@ -96,6 +114,12 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
}
}
// For a closed phase: cap the week-computation limit at the phase end date,
// so weeks beyond the phase are not counted.
if (!$phase->isCurrent && null !== $phase->endDate && $phase->endDate < $limitDate) {
$limitDate = $phase->endDate;
}
$currentByWeekStart = $this->rttRecoveryService->computeRecoveryByWeek($employee, $weekRanges, $periodFrom, $periodTo, $limitDate);
[$carry, $carryMonth] = $this->resolveCarry($employee, $year);
@@ -110,14 +134,11 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
$summary->currentYearRecoveryMinutes = array_sum(array_map(static fn ($d) => $d->totalMinutes, $currentByWeekStart));
$summary->availableMinutes = $summary->carryFromPreviousYearMinutes + $summary->currentYearRecoveryMinutes;
// Pass rttStartDate only if it falls within this exercise
if (null !== $this->rttStartDate) {
$startDate = new DateTimeImmutable($this->rttStartDate);
if ($startDate >= $periodFrom && $startDate <= $periodTo) {
$summary->rttStartDate = $this->rttStartDate;
}
}
$summary->weeks = $this->buildWeekSummaries($weekRanges, $currentByWeekStart, $periodFrom, $periodTo);
// Always expose rttStartDate so the frontend can use it as a hard floor
// for the year selector. Frontend already uses month-level comparison
// to hide carry/report rows when the date is outside the exercise.
$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;
@@ -164,6 +185,18 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
$monthBuckets[$m]['bonus50'] += $payment->getBonus50Minutes();
}
$runningCumul = $summary->carryFromPreviousYearMinutes;
$prevMonth = null;
foreach ($summary->weeks as $week) {
if (null !== $prevMonth && $week->month !== $prevMonth && isset($monthBuckets[$prevMonth])) {
$b = $monthBuckets[$prevMonth];
$runningCumul -= $b['base25'] + $b['bonus25'] + $b['base50'] + $b['bonus50'];
}
$runningCumul += $week->totalMinutes;
$week->cumulativeBalanceMinutes = $runningCumul;
$prevMonth = $week->month;
}
$monthPayments = [];
$totalPaidMinutes = 0;
@@ -204,10 +237,21 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
];
}
private function resolveYear(): int
private function resolveYear(ContractPhase $phase): int
{
$raw = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? '');
$raw = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? '');
$phaseIdRaw = $this->requestStack->getCurrentRequest()?->query->get('phaseId');
$phaseIdProvided = null !== $phaseIdRaw && '' !== (string) $phaseIdRaw;
if ('' === $raw) {
// When a phaseId is explicitly provided, default to the exercise year derived from
// the phase's end date (or today if the phase is still current).
if ($phaseIdProvided) {
$reference = $phase->endDate ?? new DateTimeImmutable('today');
return $this->resolveCurrentExerciseYear($reference);
}
return $this->resolveCurrentExerciseYear(new DateTimeImmutable('today'));
}
if (!preg_match('/^\d{4}$/', $raw)) {
@@ -219,9 +263,64 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.');
}
// When a phaseId is explicit, silently clamp the requested year to the
// first/last exercise covered by the phase.
if ($phaseIdProvided) {
$year = $this->clampYearToPhase($year, $phase);
}
return $year;
}
private function clampYearToPhase(int $year, ContractPhase $phase): int
{
$firstYear = $this->exerciseYearResolver->forDate($phase->startDate);
$lastYear = $phase->endDate instanceof DateTimeImmutable
? $this->exerciseYearResolver->forDate($phase->endDate)
: null;
if ($year < $firstYear) {
return $firstYear;
}
if (null !== $lastYear && $year > $lastYear) {
return $lastYear;
}
return $year;
}
private function resolveTargetPhase(Employee $employee): ContractPhase
{
$raw = $this->requestStack->getCurrentRequest()?->query->get('phaseId');
$phases = $this->phaseResolver->resolvePhases($employee);
if ([] === $phases) {
throw new UnprocessableEntityHttpException('Employee has no contract phase.');
}
if (null === $raw || '' === (string) $raw) {
// Phase courante par défaut = celle marquée isCurrent ou, à défaut, la plus récente.
foreach ($phases as $phase) {
if ($phase->isCurrent) {
return $phase;
}
}
return $phases[0];
}
if (!preg_match('/^\d+$/', (string) $raw)) {
throw new UnprocessableEntityHttpException('phaseId must be a positive integer.');
}
$phaseId = (int) $raw;
foreach ($phases as $phase) {
if ($phase->id === $phaseId) {
return $phase;
}
}
throw new UnprocessableEntityHttpException('phaseId does not match any phase of this employee.');
}
private function resolveCurrentExerciseYear(DateTimeImmutable $today): int
{
$year = (int) $today->format('Y');

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Employee;
use App\Entity\EmployeeWeekComment;
use App\Service\AuditLogger;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
final readonly class EmployeeWeekCommentWriteProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private ProcessorInterface $removeProcessor,
private EntityManagerInterface $entityManager,
private AuditLogger $auditLogger,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof EmployeeWeekComment) {
return $data;
}
$employee = $data->getEmployee();
if ($operation instanceof DeleteOperationInterface) {
$this->auditLogger->log(
$employee,
'delete',
'week_comment',
$data->getId(),
sprintf('Commentaire semaine supprimé pour %s (semaine du %s)', $this->label($employee), $data->getWeekStartDate()?->format('d/m/Y') ?? '?'),
['old' => ['content' => $data->getContent()]],
$data->getWeekStartDate(),
);
$result = $this->removeProcessor->process($data, $operation, $uriVariables, $context);
$this->entityManager->flush();
return $result;
}
$weekStart = $data->getWeekStartDate();
if (null === $weekStart || '1' !== $weekStart->format('N')) {
throw new UnprocessableEntityHttpException('weekStartDate must be a Monday (ISO weekday 1).');
}
$prev = null;
if (null !== $data->getId()) {
$prev = $this->entityManager->getUnitOfWork()->getOriginalEntityData($data)['content'] ?? null;
$data->touchUpdatedAt();
}
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
if (null === $prev) {
$this->auditLogger->log($employee, 'create', 'week_comment', $data->getId(), sprintf('Commentaire semaine créé pour %s (semaine du %s)', $this->label($employee), $weekStart->format('d/m/Y')), ['new' => ['content' => $data->getContent()]], $weekStart);
} elseif ($prev !== $data->getContent()) {
$this->auditLogger->log($employee, 'update', 'week_comment', $data->getId(), sprintf('Commentaire semaine modifié pour %s (semaine du %s)', $this->label($employee), $weekStart->format('d/m/Y')), ['old' => ['content' => $prev], 'new' => ['content' => $data->getContent()]], $weekStart);
}
$this->entityManager->flush();
return $result;
}
private function label(mixed $e): string
{
return $e instanceof Employee ? trim(($e->getLastName() ?? '').' '.($e->getFirstName() ?? '')) : '?';
}
}

View File

@@ -363,7 +363,10 @@ class SalaryRecapPrintProvider implements ProviderInterface
if ($wh->getHasBreakfast()) {
++$driverBreakfast;
}
if ($wh->getHasLunch() || $wh->getHasDinner()) {
if ($wh->getHasLunch()) {
++$driverMeals;
}
if ($wh->getHasDinner()) {
++$driverMeals;
}
if ($wh->getHasOvernight()) {

View File

@@ -13,6 +13,7 @@ use App\Dto\WorkHours\WorkMetrics;
use App\Entity\Absence;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\EmployeeWeekComment;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Enum\ContractNature;
@@ -21,7 +22,9 @@ use App\Enum\TrackingMode;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Repository\EmployeeWeekCommentRepository;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\PublicHolidayServiceInterface;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\DailyReferenceMinutesResolver;
use App\Service\WorkHours\HolidayVirtualHoursResolver;
@@ -31,6 +34,7 @@ use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Throwable;
final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
{
@@ -45,6 +49,8 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
private EmployeeContractResolver $contractResolver,
private DailyReferenceMinutesResolver $dailyReferenceResolver,
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
private PublicHolidayServiceInterface $publicHolidayService,
private EmployeeWeekCommentRepository $weekCommentRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary
@@ -62,11 +68,13 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($weekStart, $weekEnd, $employees);
$absences = $this->absenceRepository->findForPrint($weekStart, $weekEnd, $employees);
$weekComments = $this->weekCommentRepository->findByWeekAndEmployees($weekStart, $employees);
$summary = new WorkHourWeeklySummary();
$summary->weekStart = $weekStart->format('Y-m-d');
$summary->weekEnd = $weekEnd->format('Y-m-d');
$summary->days = $days;
$summary->rows = $this->buildRows($employees, $workHours, $absences, $days, $anchorDate->format('Y-m-d'));
$summary->rows = $this->buildRows($employees, $workHours, $absences, $days, $anchorDate->format('Y-m-d'), $weekComments);
return $summary;
}
@@ -109,19 +117,21 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
}
/**
* @param list<Employee> $employees
* @param list<WorkHour> $workHours
* @param list<Absence> $absences
* @param list<string> $days
* @param list<Employee> $employees
* @param list<WorkHour> $workHours
* @param list<Absence> $absences
* @param list<string> $days
* @param array<int, EmployeeWeekComment> $weekComments
*
* @return list<WeeklySummaryRow>
*/
private function buildRows(array $employees, array $workHours, array $absences, array $days, string $anchorDateYmd): array
private function buildRows(array $employees, array $workHours, array $absences, array $days, string $anchorDateYmd, array $weekComments = []): array
{
$contractsByEmployeeDate = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
$contractNaturesByEmployeeDate = $this->contractResolver->resolveNaturesForEmployeesAndDays($employees, $days);
$isDriverByEmployeeDate = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
$workDaysByEmployeeDate = $this->contractResolver->resolveWorkDaysMinutesForEmployeesAndDays($employees, $days);
$holidayLabelsByDate = $this->buildHolidayLabelsForDays($days);
$metricsByEmployeeDate = [];
foreach ($workHours as $workHour) {
$employeeId = $workHour->getEmployee()?->getId();
@@ -324,6 +334,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
hasDinner: $hasDinner,
hasOvernight: $hasOvernight,
virtualHolidayMinutes: $virtualHolidayMinutes,
holidayLabel: $holidayLabelsByDate[$date] ?? null,
);
}
@@ -370,12 +381,46 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
weeklyOvernightCount: $weeklyOvernightCount,
hasContractForWeek: $hasContractForWeek,
contractNature: $weekAnchorContractNature->value,
comment: ($weekComments[$employeeId] ?? null)?->getContent(),
commentId: ($weekComments[$employeeId] ?? null)?->getId(),
);
}
return $rows;
}
/**
* @param list<string> $days
*
* @return array<string, string>
*/
private function buildHolidayLabelsForDays(array $days): array
{
if ([] === $days) {
return [];
}
$years = [];
foreach ($days as $day) {
$years[substr($day, 0, 4)] = true;
}
$map = [];
try {
foreach (array_keys($years) as $year) {
$holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', (string) $year);
foreach ($holidays as $date => $label) {
$map[(string) $date] = (string) $label;
}
}
} catch (Throwable) {
return [];
}
return $map;
}
private function computeMetrics(WorkHour $workHour): WorkMetrics
{
$ranges = [

View File

@@ -76,11 +76,14 @@
td { font-size: 9px; }
td.date { text-align: left; font-weight: bold; }
td.absence { text-align: left; color: #c00; }
td.absence .holiday { color: #0277bd; font-weight: 600; }
td.absence .holiday.with-absence { display: block; }
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.date { color: #333; }
tr.holiday td { background: #e1f5fe; }
.signature-footer {
page-break-inside: avoid;
@@ -165,9 +168,12 @@
</thead>
<tbody>
{% for row in segment.rows %}
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
<tr class="{{ row.isWeekend ? 'weekend' : (row.holidayLabel ? 'holiday' : '') }}">
<td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
<td class="absence">
{{ row.absenceLabel ?? '' }}
{% if row.holidayLabel %}<span class="holiday {{ row.absenceLabel ? 'with-absence' : '' }}">Férié : {{ row.holidayLabel }}</span>{% endif %}
</td>
<td class="presence">{{ row.presentMorning ? 'X' : '' }}</td>
<td class="presence">{{ row.presentAfternoon ? 'X' : '' }}</td>
<td class="total">{{ row.total }}</td>
@@ -189,9 +195,12 @@
</thead>
<tbody>
{% for row in segment.rows %}
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
<tr class="{{ row.isWeekend ? 'weekend' : (row.holidayLabel ? 'holiday' : '') }}">
<td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
<td class="absence">
{{ row.absenceLabel ?? '' }}
{% if row.holidayLabel %}<span class="holiday {{ row.absenceLabel ? 'with-absence' : '' }}">Férié : {{ row.holidayLabel }}</span>{% endif %}
</td>
<td class="time">{{ row.dayHours }}</td>
<td class="time">{{ row.nightHours }}</td>
<td class="time">{{ row.workshopHours }}</td>
@@ -217,9 +226,12 @@
</thead>
<tbody>
{% for row in segment.rows %}
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
<tr class="{{ row.isWeekend ? 'weekend' : (row.holidayLabel ? 'holiday' : '') }}">
<td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
<td class="absence">
{{ row.absenceLabel ?? '' }}
{% if row.holidayLabel %}<span class="holiday {{ row.absenceLabel ? 'with-absence' : '' }}">Férié : {{ row.holidayLabel }}</span>{% endif %}
</td>
<td class="time">{{ row.morningFrom }}</td>
<td class="time">{{ row.morningTo }}</td>
<td class="time">{{ row.afternoonFrom }}</td>

View File

@@ -65,11 +65,14 @@
td { font-size: 9px; }
td.date { text-align: left; font-weight: bold; }
td.absence { text-align: left; color: #c00; }
td.absence .holiday { color: #0277bd; font-weight: 600; }
td.absence .holiday.with-absence { display: block; }
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.date { color: #333; }
tr.holiday td { background: #e1f5fe; }
.signature-footer {
page-break-inside: avoid;
@@ -151,9 +154,12 @@
</thead>
<tbody>
{% for row in segment.rows %}
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
<tr class="{{ row.isWeekend ? 'weekend' : (row.holidayLabel ? 'holiday' : '') }}">
<td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
<td class="absence">
{{ row.absenceLabel ?? '' }}
{% if row.holidayLabel %}<span class="holiday {{ row.absenceLabel ? 'with-absence' : '' }}">Férié : {{ row.holidayLabel }}</span>{% endif %}
</td>
<td class="presence">{{ row.presentMorning ? 'X' : '' }}</td>
<td class="presence">{{ row.presentAfternoon ? 'X' : '' }}</td>
<td class="total">{{ row.total }}</td>
@@ -175,9 +181,12 @@
</thead>
<tbody>
{% for row in segment.rows %}
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
<tr class="{{ row.isWeekend ? 'weekend' : (row.holidayLabel ? 'holiday' : '') }}">
<td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
<td class="absence">
{{ row.absenceLabel ?? '' }}
{% if row.holidayLabel %}<span class="holiday {{ row.absenceLabel ? 'with-absence' : '' }}">Férié : {{ row.holidayLabel }}</span>{% endif %}
</td>
<td class="time">{{ row.dayHours }}</td>
<td class="time">{{ row.nightHours }}</td>
<td class="time">{{ row.workshopHours }}</td>
@@ -203,9 +212,12 @@
</thead>
<tbody>
{% for row in segment.rows %}
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
<tr class="{{ row.isWeekend ? 'weekend' : (row.holidayLabel ? 'holiday' : '') }}">
<td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
<td class="absence">
{{ row.absenceLabel ?? '' }}
{% if row.holidayLabel %}<span class="holiday {{ row.absenceLabel ? 'with-absence' : '' }}">Férié : {{ row.holidayLabel }}</span>{% endif %}
</td>
<td class="time">{{ row.morningFrom }}</td>
<td class="time">{{ row.morningTo }}</td>
<td class="time">{{ row.afternoonFrom }}</td>

View File

@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Contracts;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use App\Enum\ContractNature;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Service\Contracts\EmployeeContractPhaseResolver;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use ReflectionProperty;
/**
* @internal
*/
final class EmployeeContractPhaseResolverTest extends TestCase
{
public function testSinglePeriodYieldsSinglePhaseMarkedCurrent(): void
{
$employee = $this->buildEmployee([
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2020-06-01', 'end' => null],
]);
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
self::assertCount(1, $phases);
self::assertSame(ContractType::H39, $phases[0]->contractType);
self::assertTrue($phases[0]->isCurrent);
self::assertNull($phases[0]->endDate);
self::assertSame(ContractNature::CDI, $phases[0]->contractNature);
}
public function testThreeConsecutivePeriodsSameSignatureCollapseIntoSinglePhase(): void
{
$employee = $this->buildEmployee([
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2020-06-01', 'end' => '2021-05-31'],
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2021-06-01', 'end' => '2022-05-31'],
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2022-06-01', 'end' => null],
]);
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
self::assertCount(1, $phases);
self::assertCount(3, $phases[0]->periodIds);
self::assertSame('2020-06-01', $phases[0]->startDate->format('Y-m-d'));
self::assertNull($phases[0]->endDate);
}
public function testSwitchFromH39ToForfaitProducesTwoPhasesMostRecentFirst(): void
{
$employee = $this->buildEmployee([
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2020-06-01', 'end' => '2026-04-30'],
['type' => ContractType::FORFAIT, 'hours' => 39, 'driver' => false, 'start' => '2026-05-01', 'end' => null],
]);
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
self::assertCount(2, $phases);
self::assertSame(ContractType::FORFAIT, $phases[0]->contractType);
self::assertTrue($phases[0]->isCurrent);
self::assertSame(ContractType::H39, $phases[1]->contractType);
self::assertFalse($phases[1]->isCurrent);
self::assertSame('2026-04-30', $phases[1]->endDate?->format('Y-m-d'));
}
public function testInterimBetweenTwoH39PeriodsBreaksThePhases(): void
{
$employee = $this->buildEmployee([
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2020-06-01', 'end' => '2023-12-31'],
['type' => ContractType::INTERIM, 'hours' => null, 'driver' => false, 'start' => '2024-01-01', 'end' => '2024-04-30'],
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2024-05-01', 'end' => null],
]);
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
self::assertCount(3, $phases);
self::assertSame(ContractType::H39, $phases[0]->contractType);
self::assertSame(ContractType::INTERIM, $phases[1]->contractType);
self::assertSame(ContractType::H39, $phases[2]->contractType);
}
public function testCustomPhasesSplitOnWeeklyHoursChange(): void
{
$employee = $this->buildEmployee([
['type' => ContractType::CUSTOM, 'hours' => 28, 'driver' => false, 'start' => '2024-01-01', 'end' => '2024-12-31'],
['type' => ContractType::CUSTOM, 'hours' => 30, 'driver' => false, 'start' => '2025-01-01', 'end' => null],
]);
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
self::assertCount(2, $phases);
self::assertSame(30, $phases[0]->weeklyHours);
self::assertSame(28, $phases[1]->weeklyHours);
}
public function testPhasesSplitOnIsDriverChange(): void
{
$employee = $this->buildEmployee([
['type' => ContractType::H35, 'hours' => 35, 'driver' => false, 'start' => '2023-01-01', 'end' => '2024-12-31'],
['type' => ContractType::H35, 'hours' => 35, 'driver' => true, 'start' => '2025-01-01', 'end' => null],
]);
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
self::assertCount(2, $phases);
self::assertTrue($phases[0]->isDriver);
self::assertFalse($phases[1]->isDriver);
}
public function testPhasesEntirelyBeforeDataStartDateAreFilteredOut(): void
{
// H35 phase ends before 2026-02-23 → must be hidden; H39 phase spans the date → kept.
$employee = $this->buildEmployee([
['type' => ContractType::H35, 'hours' => 35, 'driver' => false, 'start' => '2014-07-01', 'end' => '2025-10-31'],
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2025-11-01', 'end' => null],
]);
$phases = new EmployeeContractPhaseResolver('2026-02-23')->resolvePhases($employee);
self::assertCount(1, $phases);
self::assertSame(ContractType::H39, $phases[0]->contractType);
}
public function testPhaseEndingExactlyOnDataStartDateIsKept(): void
{
// Edge case: a phase whose endDate equals the data start date is kept
// (the inequality is `>= $dataStart`, not strict).
$employee = $this->buildEmployee([
['type' => ContractType::H35, 'hours' => 35, 'driver' => false, 'start' => '2020-01-01', 'end' => '2026-02-23'],
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2026-02-24', 'end' => null],
]);
$phases = new EmployeeContractPhaseResolver('2026-02-23')->resolvePhases($employee);
self::assertCount(2, $phases);
}
public function testNoFilteringWhenDataStartDateIsEmpty(): void
{
$employee = $this->buildEmployee([
['type' => ContractType::H35, 'hours' => 35, 'driver' => false, 'start' => '2014-07-01', 'end' => '2020-12-31'],
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2021-01-01', 'end' => null],
]);
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
self::assertCount(2, $phases);
}
public function testInvalidDataStartDateStringIsTreatedAsNull(): void
{
$employee = $this->buildEmployee([
['type' => ContractType::H35, 'hours' => 35, 'driver' => false, 'start' => '2014-07-01', 'end' => '2020-12-31'],
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2021-01-01', 'end' => null],
]);
$phases = new EmployeeContractPhaseResolver('not-a-date')->resolvePhases($employee);
self::assertCount(2, $phases);
}
/**
* @param list<array{type: ContractType, hours: ?int, driver: bool, start: string, end: ?string}> $periodsSpec
*/
private function buildEmployee(array $periodsSpec): Employee
{
$employee = new Employee();
$id = 0;
foreach ($periodsSpec as $spec) {
$contract = new Contract();
$contract->setName($spec['type']->value);
$contract->setTrackingMode(
ContractType::FORFAIT === $spec['type'] ? TrackingMode::PRESENCE->value : TrackingMode::TIME->value
);
$contract->setWeeklyHours($spec['hours']);
$period = new EmployeeContractPeriod();
$reflection = new ReflectionProperty(EmployeeContractPeriod::class, 'id');
$reflection->setValue($period, ++$id);
$period->setEmployee($employee);
$period->setContract($contract);
$period->setStartDate(new DateTimeImmutable($spec['start']));
$period->setEndDate(null !== $spec['end'] ? new DateTimeImmutable($spec['end']) : null);
$period->setContractNature(ContractNature::CDI);
$period->setIsDriver($spec['driver']);
$employee->getContractPeriods()->add($period);
}
return $employee;
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Exercise;
use App\Service\Exercise\ExerciseYearResolver;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class ExerciseYearResolverTest extends TestCase
{
public function testNonForfaitJuneMapsToNextYear(): void
{
$resolver = new ExerciseYearResolver();
self::assertSame(2026, $resolver->forDate(new DateTimeImmutable('2025-06-01')));
self::assertSame(2026, $resolver->forDate(new DateTimeImmutable('2025-06-30')));
}
public function testNonForfaitMayMapsToSameYear(): void
{
$resolver = new ExerciseYearResolver();
self::assertSame(2025, $resolver->forDate(new DateTimeImmutable('2025-05-01')));
self::assertSame(2025, $resolver->forDate(new DateTimeImmutable('2025-05-31')));
}
public function testNonForfaitDecemberMapsToNextYear(): void
{
$resolver = new ExerciseYearResolver();
self::assertSame(2026, $resolver->forDate(new DateTimeImmutable('2025-12-31')));
}
public function testNonForfaitJanuaryMapsToSameYear(): void
{
$resolver = new ExerciseYearResolver();
self::assertSame(2025, $resolver->forDate(new DateTimeImmutable('2025-01-15')));
}
public function testForfaitReturnsCalendarYearRegardlessOfMonth(): void
{
$resolver = new ExerciseYearResolver();
self::assertSame(2025, $resolver->forDate(new DateTimeImmutable('2025-01-15'), true));
self::assertSame(2025, $resolver->forDate(new DateTimeImmutable('2025-06-01'), true));
self::assertSame(2025, $resolver->forDate(new DateTimeImmutable('2025-12-31'), true));
}
public function testForfaitFlagDefaultsToFalse(): void
{
$resolver = new ExerciseYearResolver();
// June without explicit flag must follow non-forfait rule (year + 1).
self::assertSame(2026, $resolver->forDate(new DateTimeImmutable('2025-06-01')));
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Rtt;
use App\Entity\Contract;
use App\Service\Rtt\RttRecoveryComputationService;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
/**
* The service constructor takes several final-class collaborators that PHPUnit cannot
* double. Pure helpers are exercised via newInstanceWithoutConstructor + reflection.
*
* @internal
*/
final class RttRecoveryComputationServiceTest extends TestCase
{
public function testResolveWeekAnchorDateReturnsFirstContractedDayWhenWeekStartsBeforeHire(): void
{
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
$contract = new Contract();
$weekDays = ['2026-03-16', '2026-03-17', '2026-03-18', '2026-03-19', '2026-03-20', '2026-03-21', '2026-03-22'];
$contractsByDate = [
'2026-03-16' => null,
'2026-03-17' => null,
'2026-03-18' => null,
'2026-03-19' => $contract,
'2026-03-20' => $contract,
'2026-03-21' => $contract,
'2026-03-22' => $contract,
];
$anchor = $this->invokePrivate($service, 'resolveWeekAnchorDate', $weekDays, $contractsByDate);
self::assertSame('2026-03-19', $anchor);
}
public function testResolveWeekAnchorDateReturnsFirstDayWhenItIsContracted(): void
{
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
$contract = new Contract();
$weekDays = ['2026-03-23', '2026-03-24', '2026-03-25'];
$contractsByDate = [
'2026-03-23' => $contract,
'2026-03-24' => $contract,
'2026-03-25' => $contract,
];
$anchor = $this->invokePrivate($service, 'resolveWeekAnchorDate', $weekDays, $contractsByDate);
self::assertSame('2026-03-23', $anchor);
}
public function testResolveWeekAnchorDateFallsBackToFirstDayWhenNoContract(): void
{
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
$weekDays = ['2026-03-16', '2026-03-17'];
$contractsByDate = ['2026-03-16' => null, '2026-03-17' => null];
$anchor = $this->invokePrivate($service, 'resolveWeekAnchorDate', $weekDays, $contractsByDate);
self::assertSame('2026-03-16', $anchor);
}
private function invokePrivate(object $obj, string $method, mixed ...$args): mixed
{
return new ReflectionClass($obj::class)->getMethod($method)->invoke($obj, ...$args);
}
}

View File

@@ -4,16 +4,33 @@ declare(strict_types=1);
namespace App\Tests\State;
use App\Dto\Contracts\ContractPhase;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use App\Enum\ContractNature;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Service\Contracts\EmployeeContractPhaseResolver;
use App\Service\Exercise\ExerciseYearResolver;
use App\State\EmployeeLeaveSummaryProvider;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use ReflectionProperty;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* @internal
*/
final class EmployeeLeaveSummaryProviderTest extends TestCase
{
// -----------------------------------------------------------------------
// Existing tests (unchanged) — verify accrual prorating arithmetic.
// -----------------------------------------------------------------------
public function testComputeAccruedDaysFromStartProratesPartialFirstMonth(): void
{
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
@@ -68,4 +85,493 @@ final class EmployeeLeaveSummaryProviderTest extends TestCase
self::assertEqualsWithDelta(25.0 / 12.0, $result, 0.0001);
}
public function testComputeProratedForfaitRepoDaysGregoryCase(): void
{
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
// 2026 : 252 jours ouvrés/an, 168 sur la période 01/05→31/12.
// repos année = 252 - 218 - 25 = 9 ; proratisé = 9 × 168/252 = 6.0
$result = $this->invokePrivate($provider, 'computeProratedForfaitRepoDays', 252, 168);
self::assertEqualsWithDelta(6.0, $result, 0.001);
}
public function testComputeProratedForfaitRepoDaysFullYearEquals9(): void
{
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
// Année pleine : 9 × 252/252 = 9.0
$result = $this->invokePrivate($provider, 'computeProratedForfaitRepoDays', 252, 252);
self::assertEqualsWithDelta(9.0, $result, 0.001);
}
public function testComputeProratedForfaitRepoDaysClampsNegativeToZero(): void
{
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
// Année avec trop peu de jours ouvrés (240 - 218 - 25 < 0) → 0
$result = $this->invokePrivate($provider, 'computeProratedForfaitRepoDays', 240, 160);
self::assertSame(0.0, $result);
}
public function testComputeProratedForfaitRepoDaysZeroYearGuard(): void
{
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
self::assertSame(0.0, $this->invokePrivate($provider, 'computeProratedForfaitRepoDays', 0, 0));
}
// -----------------------------------------------------------------------
// Phase resolution tests (Task 3 — phaseId support).
// The repository / service dependencies are typed against final classes
// which PHPUnit cannot double, so phase resolution is exercised via
// reflection on private methods to avoid instantiating the full DI graph.
// -----------------------------------------------------------------------
public function testResolveTargetPhasePicksH39PhaseFromPhaseId(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
$h39Phase = $phases[1]; // oldest = 39h
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id]);
$resolved = $this->invokePrivate($provider, 'resolveTargetPhase', $employee);
self::assertInstanceOf(ContractPhase::class, $resolved);
self::assertSame($h39Phase->id, $resolved->id);
self::assertSame(ContractType::H39, $resolved->contractType);
self::assertFalse($resolved->isCurrent);
}
public function testResolveTargetPhaseDefaultsToCurrentPhaseWhenPhaseIdAbsent(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
$currentPhase = $phases[0]; // most recent = FORFAIT
$provider = $this->buildProvider([]);
$resolved = $this->invokePrivate($provider, 'resolveTargetPhase', $employee);
self::assertSame($currentPhase->id, $resolved->id);
self::assertSame(ContractType::FORFAIT, $resolved->contractType);
self::assertTrue($resolved->isCurrent);
}
public function testPastH39PhaseAppliesNonForfaitRuleCodeEvenWhenCurrentIsForfait(): void
{
// Verifies resolveLeavePolicy uses the phase's contractType (not the current contract).
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
$h39Phase = $phases[1];
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id]);
$from = new DateTimeImmutable('2025-06-01');
$to = new DateTimeImmutable('2026-04-30');
$leavePolicy = $this->invokePrivate($provider, 'resolveLeavePolicy', $employee, $h39Phase, $from, $to);
self::assertNotNull($leavePolicy);
self::assertSame('CDI_CDD_NON_FORFAIT', $leavePolicy['ruleCode']);
self::assertSame(25.0, $leavePolicy['acquiredDays']);
self::assertEqualsWithDelta(25.0 / 12.0, $leavePolicy['accrualPerMonth'], 0.0001);
}
public function testResolvePeriodBoundsCapsAtPhaseEndDate(): void
{
// 39h phase (June 2020 → April 30 2026). Exercise 2026 spans June 2025 → May 31 2026.
// The phase cap should clip the upper bound to April 30 2026.
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
$h39Phase = $phases[1];
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2026']);
[$from, $to] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2026, $h39Phase);
self::assertSame('2025-06-01', $from->format('Y-m-d'));
self::assertSame('2026-04-30', $to->format('Y-m-d'));
}
public function testTransitionExerciseOnH39PhaseAccruesAround22Point9Days(): void
{
// 11 full months of accrual at 25/12 ≈ 22.917 days.
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
$h39Phase = $phases[1];
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2026']);
$method = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->getMethod('computeAccruedDaysFromStart');
// Period bounds for exercise 2026 on H39 phase = June 1 2025 → April 30 2026.
[$from, $to] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2026, $h39Phase);
$acquired = $method->invoke($provider, 25.0, 25.0 / 12.0, $from, $to);
self::assertEqualsWithDelta(22.92, $acquired, 0.1);
}
public function testIsForfaitEntryYearTrueOnStartYear(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$forfaitPhase = new EmployeeContractPhaseResolver()->resolvePhases($employee)[0];
$provider = $this->buildProvider();
self::assertTrue($this->invokePrivate($provider, 'isForfaitEntryYear', $forfaitPhase, 2026));
}
public function testIsForfaitEntryYearFalseOnSubsequentFullYear(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$forfaitPhase = new EmployeeContractPhaseResolver()->resolvePhases($employee)[0];
$provider = $this->buildProvider();
self::assertFalse($this->invokePrivate($provider, 'isForfaitEntryYear', $forfaitPhase, 2027));
}
public function testIsForfaitEntryYearFalseWhenForfaitStartsJan1(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2025-12-31', '2026-01-01');
$forfaitPhase = new EmployeeContractPhaseResolver()->resolvePhases($employee)[0];
$provider = $this->buildProvider();
// Forfait démarrant un 1er janvier = année pleine, pas une entrée en cours d'année.
self::assertFalse($this->invokePrivate($provider, 'isForfaitEntryYear', $forfaitPhase, 2026));
}
public function testIsForfaitEntryYearFalseForNonForfaitPhase(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$h39Phase = new EmployeeContractPhaseResolver()->resolvePhases($employee)[1];
$provider = $this->buildProvider();
self::assertFalse($this->invokePrivate($provider, 'isForfaitEntryYear', $h39Phase, 2026));
}
public function testResolvePhaseImmediatelyBeforeReturnsPriorH39Phase(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
$forfaitPhase = $phases[0]; // current FORFAIT
$h39Phase = $phases[1];
$provider = $this->buildProvider();
$prior = $this->invokePrivate($provider, 'resolvePhaseImmediatelyBefore', $employee, $forfaitPhase);
self::assertNotNull($prior);
self::assertSame($h39Phase->id, $prior->id);
self::assertSame(ContractType::H39, $prior->contractType);
}
public function testResolvePhaseImmediatelyBeforeReturnsNullForFirstPhase(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
$firstPhase = $phases[1]; // the H39 (earliest)
$provider = $this->buildProvider();
self::assertNull($this->invokePrivate($provider, 'resolvePhaseImmediatelyBefore', $employee, $firstPhase));
}
public function testNonForfaitPhaseStartingMidExerciseUsesFullExerciseFromAsStart(): void
{
// Scenario: 35h CDI from 2014-07-01 to 2025-10-31, then 39h CDI from 2025-11-01.
// Both phases are non-forfait (same leave rule CDI_CDD_NON_FORFAIT).
// Viewing exercise 2026 on the current 39h phase, accrual must run from the
// exercise start (June 1, 2025), NOT from the phase start (November 1, 2025).
// Otherwise the 5 months of June-October under 35h would be lost from the
// annual CP accrual, which is wrong (CP exercise is annual, not per-phase).
$employee = $this->buildH35ToH39Transition('2014-07-01', '2025-10-31', '2025-11-01');
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
$h39Phase = $phases[0]; // current
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2026']);
[$from, $to] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2026, $h39Phase);
self::assertSame('2025-06-01', $from->format('Y-m-d'));
self::assertSame('2026-05-31', $to->format('Y-m-d'));
}
public function testForfaitPhaseStartingMidYearCapsFromAtPhaseStart(): void
{
// Scenario: 39h CDI ends 2026-04-30, FORFAIT from 2026-05-01.
// Viewing year 2026 on the FORFAIT phase, the period must be capped at
// phase start (May 1) so that only the FORFAIT portion of the calendar
// year is counted.
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
$forfaitPhase = $phases[0]; // current FORFAIT
$provider = $this->buildProvider(['phaseId' => (string) $forfaitPhase->id, 'year' => '2026']);
[$from, $to] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2026, $forfaitPhase);
self::assertSame('2026-05-01', $from->format('Y-m-d'));
self::assertSame('2026-12-31', $to->format('Y-m-d'));
}
public function testYearOutsidePhaseRangeIsSilentlyClampedToPhaseLastExercise(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
$h39Phase = $phases[1];
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2030']);
$year = $this->invokePrivate($provider, 'resolveYear', $employee, $h39Phase);
self::assertSame(2026, $year);
}
public function testYearBeforePhaseIsClampedToPhaseFirstExercise(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
$h39Phase = $phases[1];
// Phase starts 2020-06-01 → first exercise (non-forfait) = 2021 (since month >=6 = year+1).
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2010']);
$year = $this->invokePrivate($provider, 'resolveYear', $employee, $h39Phase);
self::assertSame(2021, $year);
}
public function testInvalidPhaseIdReturns422(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$provider = $this->buildProvider(['phaseId' => '99999']);
$this->expectException(UnprocessableEntityHttpException::class);
$this->invokePrivate($provider, 'resolveTargetPhase', $employee);
}
public function testNonNumericPhaseIdReturns422(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$provider = $this->buildProvider(['phaseId' => 'abc']);
$this->expectException(UnprocessableEntityHttpException::class);
$this->invokePrivate($provider, 'resolveTargetPhase', $employee);
}
public function testDefaultYearForPhaseIdOnClosedPhaseUsesPhaseEndDate(): void
{
// No `year` param + explicit phaseId → default year is derived from $phase->endDate.
// H39 phase ends 2026-04-30 → non-forfait exercise containing that date = 2026.
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
$h39Phase = $phases[1];
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id]);
$year = $this->invokePrivate($provider, 'resolveYear', $employee, $h39Phase);
self::assertSame(2026, $year);
}
public function testNoQueryParamsKeepsLegacyYearDefaulting(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
$currentPhase = $phases[0];
$provider = $this->buildProvider([]);
$year = $this->invokePrivate($provider, 'resolveYear', $employee, $currentPhase);
// Today is 2026-05-19, FORFAIT phase → year is the current calendar year (2026).
self::assertSame(2026, $year);
}
// -----------------------------------------------------------------------
// Regression: terminated-employee path through `computeYearSummary` without
// an explicit phase (legacy callers: LeaveRecapRowBuilder,
// DumpVerificationSnapshotCommand). Before the phase-aware refactor, the
// period bounds were NOT capped at the contract end for terminated
// employees (because Employee::getCurrentContractEndDate() returns null
// when no period covers "today"). The new code resolves a fallback phase
// whose `isCurrent` is false, which would otherwise cap `to` at the phase
// end — a behavior change for legacy callers. The flag `applyPhaseEndCap`
// toggles this cap so legacy callers get the pre-refactor behavior.
// -----------------------------------------------------------------------
public function testTerminatedEmployeeWithoutExplicitPhaseSkipsPhaseEndCap(): void
{
// Terminated employee: H39 phase ending 2024-12-31 (well in the past).
$employee = $this->buildTerminatedEmployee('2020-06-01', '2024-12-31');
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
self::assertCount(1, $phases);
$phase = $phases[0];
self::assertFalse($phase->isCurrent, 'Sanity: terminated phase must not be flagged as current.');
$provider = $this->buildProvider([]);
// applyPhaseEndCap=false → mimics legacy callers (no explicit phase):
// the upper bound MUST stay at the natural leave-year end (May 31).
[$fromLegacy, $toLegacy] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2025, $phase, false);
self::assertSame('2024-06-01', $fromLegacy->format('Y-m-d'));
self::assertSame('2025-05-31', $toLegacy->format('Y-m-d'));
// applyPhaseEndCap=true → explicit-phase callers get the cap at phase end.
[$fromCap, $toCap] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2025, $phase, true);
self::assertSame('2024-06-01', $fromCap->format('Y-m-d'));
self::assertSame('2024-12-31', $toCap->format('Y-m-d'));
}
// -----------------------------------------------------------------------
// Test harness helpers.
// -----------------------------------------------------------------------
/**
* Build a terminated-employee fixture: a single H39 period ending before today.
*/
private function buildTerminatedEmployee(string $start, string $end): Employee
{
$employee = new Employee();
$this->setEntityId($employee, 2);
$contract = new Contract();
$contract->setName('39H');
$contract->setTrackingMode(TrackingMode::TIME->value);
$contract->setWeeklyHours(39);
$period = new EmployeeContractPeriod();
$this->setEntityId($period, 10);
$period->setEmployee($employee);
$period->setContract($contract);
$period->setStartDate(new DateTimeImmutable($start));
$period->setEndDate(new DateTimeImmutable($end));
$period->setContractNature(ContractNature::CDI);
$period->setIsDriver(false);
$employee->getContractPeriods()->add($period);
return $employee;
}
/**
* Build a two-period employee transitioning from H39 to FORFAIT.
*/
private function buildH35ToH39Transition(string $h35Start, string $h35End, string $h39Start): Employee
{
$employee = new Employee();
$this->setEntityId($employee, 1);
$h35Contract = new Contract();
$h35Contract->setName('35H');
$h35Contract->setTrackingMode(TrackingMode::TIME->value);
$h35Contract->setWeeklyHours(35);
$h39Contract = new Contract();
$h39Contract->setName('39H');
$h39Contract->setTrackingMode(TrackingMode::TIME->value);
$h39Contract->setWeeklyHours(39);
$h35Period = new EmployeeContractPeriod();
$this->setEntityId($h35Period, 1);
$h35Period->setEmployee($employee);
$h35Period->setContract($h35Contract);
$h35Period->setStartDate(new DateTimeImmutable($h35Start));
$h35Period->setEndDate(new DateTimeImmutable($h35End));
$h35Period->setContractNature(ContractNature::CDI);
$h35Period->setIsDriver(false);
$h39Period = new EmployeeContractPeriod();
$this->setEntityId($h39Period, 2);
$h39Period->setEmployee($employee);
$h39Period->setContract($h39Contract);
$h39Period->setStartDate(new DateTimeImmutable($h39Start));
$h39Period->setEndDate(null);
$h39Period->setContractNature(ContractNature::CDI);
$h39Period->setIsDriver(false);
$employee->getContractPeriods()->add($h35Period);
$employee->getContractPeriods()->add($h39Period);
return $employee;
}
private function buildEmployeeWithTransition(string $h39Start, string $h39End, string $forfaitStart): Employee
{
$employee = new Employee();
$this->setEntityId($employee, 1);
$h39Contract = new Contract();
$h39Contract->setName('39H');
$h39Contract->setTrackingMode(TrackingMode::TIME->value);
$h39Contract->setWeeklyHours(39);
$forfaitContract = new Contract();
$forfaitContract->setName('Forfait');
$forfaitContract->setTrackingMode(TrackingMode::PRESENCE->value);
$forfaitContract->setWeeklyHours(null);
$h39Period = new EmployeeContractPeriod();
$this->setEntityId($h39Period, 1);
$h39Period->setEmployee($employee);
$h39Period->setContract($h39Contract);
$h39Period->setStartDate(new DateTimeImmutable($h39Start));
$h39Period->setEndDate(new DateTimeImmutable($h39End));
$h39Period->setContractNature(ContractNature::CDI);
$h39Period->setIsDriver(false);
$forfaitPeriod = new EmployeeContractPeriod();
$this->setEntityId($forfaitPeriod, 2);
$forfaitPeriod->setEmployee($employee);
$forfaitPeriod->setContract($forfaitContract);
$forfaitPeriod->setStartDate(new DateTimeImmutable($forfaitStart));
$forfaitPeriod->setEndDate(null);
$forfaitPeriod->setContractNature(ContractNature::CDI);
$forfaitPeriod->setIsDriver(false);
$employee->getContractPeriods()->add($h39Period);
$employee->getContractPeriods()->add($forfaitPeriod);
return $employee;
}
/**
* Build an uninitialized provider with a RequestStack pre-loaded with the given query.
*
* The provider's repository/service dependencies are typed against final classes
* (EmployeeRepository, LeaveBalanceComputationService, etc.) which PHPUnit cannot
* double. We bypass full instantiation by using newInstanceWithoutConstructor and
* only setting the properties that the tested private methods actually read:
* `requestStack` and `phaseResolver`. Tests targeting heavier code paths exercise
* private methods directly (resolveTargetPhase, resolvePeriodBounds, etc.).
*
* @param array<string, string> $request query parameters (year, phaseId, ...)
*/
private function buildProvider(array $request = []): EmployeeLeaveSummaryProvider
{
$requestStack = new RequestStack();
$requestStack->push(new Request(query: $request));
$reflection = new ReflectionClass(EmployeeLeaveSummaryProvider::class);
$provider = $reflection->newInstanceWithoutConstructor();
$this->setReadonlyProperty($provider, 'requestStack', $requestStack);
$this->setReadonlyProperty($provider, 'phaseResolver', new EmployeeContractPhaseResolver());
$this->setReadonlyProperty($provider, 'exerciseYearResolver', new ExerciseYearResolver());
$this->setReadonlyProperty($provider, 'dataStartDate', null);
return $provider;
}
private function invokePrivate(object $obj, string $method, mixed ...$args): mixed
{
$reflection = new ReflectionClass($obj::class);
$m = $reflection->getMethod($method);
return $m->invoke($obj, ...$args);
}
private function setReadonlyProperty(object $obj, string $property, mixed $value): void
{
$reflection = new ReflectionProperty($obj::class, $property);
$reflection->setValue($obj, $value);
}
private function setEntityId(object $entity, int $id): void
{
$reflection = new ReflectionProperty($entity::class, 'id');
$reflection->setValue($entity, $id);
}
}

View File

@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace App\Tests\State;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use App\Enum\ContractNature;
use App\Enum\TrackingMode;
use App\Service\Contracts\EmployeeContractPhaseResolver;
use App\Service\Exercise\ExerciseYearResolver;
use App\State\EmployeeRttPaymentProcessor;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use Psr\Clock\ClockInterface;
use ReflectionClass;
use ReflectionProperty;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* @internal
*
* Exercises the year-acceptance guard of EmployeeRttPaymentProcessor.
*
* The processor depends on final repositories (EmployeeRepository,
* EmployeeRttPaymentRepository) which PHPUnit cannot double. The guard logic
* lives in a private helper (assertYearAllowedForPayment) tested directly via
* reflection — same pattern used in EmployeeRttSummaryProviderTest.
*/
final class EmployeeRttPaymentProcessorTest extends TestCase
{
public function testPaymentAllowedOnCurrentExercise(): void
{
// Today = 2026-05-19 (env clock) → current exercise = 2026.
$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, 2026);
// No exception → guard accepts current exercise.
self::assertTrue(true);
}
public function testPaymentAllowedOnLastExerciseOfClosedPhase(): void
{
// Phase 39h closed 2026-04-30, FORFAIT from 2026-05-01.
// Exercise 2026 (Juin 2025 → Mai 2026) contains the H39 phase end date.
// Payment must be allowed on exercise 2026 even when current exercise is 2027.
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$processor = $this->buildProcessorWithClock(new DateTimeImmutable('2027-01-15'));
$this->invokePrivate($processor, 'assertYearAllowedForPayment', $employee, 2026);
self::assertTrue(true);
}
public function testPaymentRejectedOnEarlierExerciseOfClosedPhase(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$processor = $this->buildProcessorWithClock(new DateTimeImmutable('2027-01-15'));
$this->expectException(UnprocessableEntityHttpException::class);
$this->invokePrivate($processor, 'assertYearAllowedForPayment', $employee, 2024);
}
public function testPaymentRejectedOnFutureExercise(): void
{
$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, 2030);
}
// -----------------------------------------------------------------------
// Test harness helpers.
// -----------------------------------------------------------------------
/**
* Build a two-period employee transitioning from H39 to FORFAIT.
*/
private function buildEmployeeWithTransition(string $h39Start, string $h39End, string $forfaitStart): Employee
{
$employee = new Employee();
$this->setEntityId($employee, 1);
$h39Contract = new Contract();
$h39Contract->setName('39H');
$h39Contract->setTrackingMode(TrackingMode::TIME->value);
$h39Contract->setWeeklyHours(39);
$forfaitContract = new Contract();
$forfaitContract->setName('Forfait');
$forfaitContract->setTrackingMode(TrackingMode::PRESENCE->value);
$forfaitContract->setWeeklyHours(null);
$h39Period = new EmployeeContractPeriod();
$this->setEntityId($h39Period, 1);
$h39Period->setEmployee($employee);
$h39Period->setContract($h39Contract);
$h39Period->setStartDate(new DateTimeImmutable($h39Start));
$h39Period->setEndDate(new DateTimeImmutable($h39End));
$h39Period->setContractNature(ContractNature::CDI);
$h39Period->setIsDriver(false);
$forfaitPeriod = new EmployeeContractPeriod();
$this->setEntityId($forfaitPeriod, 2);
$forfaitPeriod->setEmployee($employee);
$forfaitPeriod->setContract($forfaitContract);
$forfaitPeriod->setStartDate(new DateTimeImmutable($forfaitStart));
$forfaitPeriod->setEndDate(null);
$forfaitPeriod->setContractNature(ContractNature::CDI);
$forfaitPeriod->setIsDriver(false);
$employee->getContractPeriods()->add($h39Period);
$employee->getContractPeriods()->add($forfaitPeriod);
return $employee;
}
/**
* Build an uninitialized processor with a fixed clock. The repositories are
* declared on final classes that PHPUnit cannot double, so we bypass full
* instantiation via newInstanceWithoutConstructor and only seed the
* properties the tested private guard reads: phaseResolver + clock.
*/
private function buildProcessorWithClock(DateTimeImmutable $today): EmployeeRttPaymentProcessor
{
$reflection = new ReflectionClass(EmployeeRttPaymentProcessor::class);
$processor = $reflection->newInstanceWithoutConstructor();
$clock = new readonly class($today) implements ClockInterface {
public function __construct(private DateTimeImmutable $now) {}
public function now(): DateTimeImmutable
{
return $this->now;
}
};
$this->setReadonlyProperty($processor, 'phaseResolver', new EmployeeContractPhaseResolver());
$this->setReadonlyProperty($processor, 'clock', $clock);
$this->setReadonlyProperty($processor, 'exerciseYearResolver', new ExerciseYearResolver());
return $processor;
}
private function invokePrivate(object $obj, string $method, mixed ...$args): mixed
{
$reflection = new ReflectionClass($obj::class);
$m = $reflection->getMethod($method);
return $m->invoke($obj, ...$args);
}
private function setReadonlyProperty(object $obj, string $property, mixed $value): void
{
$reflection = new ReflectionProperty($obj::class, $property);
$reflection->setValue($obj, $value);
}
private function setEntityId(object $entity, int $id): void
{
$reflection = new ReflectionProperty($entity::class, 'id');
$reflection->setValue($entity, $id);
}
}

View File

@@ -0,0 +1,293 @@
<?php
declare(strict_types=1);
namespace App\Tests\State;
use App\Dto\Contracts\ContractPhase;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use App\Enum\ContractNature;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Service\Contracts\EmployeeContractPhaseResolver;
use App\Service\Exercise\ExerciseYearResolver;
use App\State\EmployeeRttSummaryProvider;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use ReflectionProperty;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* @internal
*/
final class EmployeeRttSummaryProviderTest extends TestCase
{
// -----------------------------------------------------------------------
// Phase resolution tests (Task 4 — phaseId support).
// The repository / service dependencies are typed against final classes
// which PHPUnit cannot double, so phase resolution is exercised via
// reflection on private methods to avoid instantiating the full DI graph.
// -----------------------------------------------------------------------
public function testResolveTargetPhasePicksH39PhaseFromPhaseId(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
$h39Phase = $phases[1]; // oldest = 39h
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id]);
$resolved = $this->invokePrivate($provider, 'resolveTargetPhase', $employee);
self::assertInstanceOf(ContractPhase::class, $resolved);
self::assertSame($h39Phase->id, $resolved->id);
self::assertSame(ContractType::H39, $resolved->contractType);
self::assertFalse($resolved->isCurrent);
}
public function testResolveTargetPhaseDefaultsToCurrentPhaseWhenPhaseIdAbsent(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
$currentPhase = $phases[0]; // most recent = FORFAIT
$provider = $this->buildProvider([]);
$resolved = $this->invokePrivate($provider, 'resolveTargetPhase', $employee);
self::assertSame($currentPhase->id, $resolved->id);
self::assertTrue($resolved->isCurrent);
}
public function testPastH39PhaseRttSummaryIsCappedAtPhaseEndDate(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
$h39Phase = $phases[1];
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2026']);
$year = $this->invokePrivate($provider, 'resolveYear', $h39Phase);
self::assertSame(2026, $year);
// The phase-cap branch in provide() narrows periodTo to the phase end date.
// Reproduce that logic to validate the resulting window.
$periodFrom = new DateTimeImmutable('2025-06-01');
$periodTo = new DateTimeImmutable('2026-05-31');
if (!$h39Phase->isCurrent && null !== $h39Phase->endDate && $h39Phase->endDate < $periodTo) {
$periodTo = $h39Phase->endDate;
}
if ($h39Phase->startDate > $periodFrom) {
$periodFrom = $h39Phase->startDate;
}
self::assertSame('2025-06-01', $periodFrom->format('Y-m-d'));
self::assertSame('2026-04-30', $periodTo->format('Y-m-d'));
}
public function testYearOutsidePhaseRangeIsSilentlyClampedToPhaseLastExercise(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
$h39Phase = $phases[1];
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2030']);
$year = $this->invokePrivate($provider, 'resolveYear', $h39Phase);
// Phase ends 2026-04-30 → exercice (Juin-Mai) containing it = 2026.
self::assertSame(2026, $year);
}
public function testYearBeforePhaseIsClampedToPhaseFirstExercise(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
$h39Phase = $phases[1];
// Phase starts 2020-06-01 → first exercise (Juin-Mai) = 2021.
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2010']);
$year = $this->invokePrivate($provider, 'resolveYear', $h39Phase);
self::assertSame(2021, $year);
}
public function testInvalidPhaseIdReturns422(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$provider = $this->buildProvider(['phaseId' => '99999']);
$this->expectException(UnprocessableEntityHttpException::class);
$this->invokePrivate($provider, 'resolveTargetPhase', $employee);
}
public function testNonNumericPhaseIdReturns422(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$provider = $this->buildProvider(['phaseId' => 'abc']);
$this->expectException(UnprocessableEntityHttpException::class);
$this->invokePrivate($provider, 'resolveTargetPhase', $employee);
}
public function testDefaultYearForPhaseIdOnClosedPhaseUsesPhaseEndDate(): void
{
// No `year` param + explicit phaseId → default year is derived from $phase->endDate.
// H39 phase ends 2026-04-30 → RTT exercise containing that date = 2026.
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
$h39Phase = $phases[1];
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id]);
$year = $this->invokePrivate($provider, 'resolveYear', $h39Phase);
self::assertSame(2026, $year);
}
public function testNoQueryParamsKeepsLegacyYearDefaulting(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
$currentPhase = $phases[0];
$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);
}
public function testInvalidYearFormatReturns422(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
$currentPhase = $phases[0];
$provider = $this->buildProvider(['year' => '20XX']);
$this->expectException(UnprocessableEntityHttpException::class);
$this->invokePrivate($provider, 'resolveYear', $currentPhase);
}
public function testYearOutsideBoundsReturns422(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
$currentPhase = $phases[0];
$provider = $this->buildProvider(['year' => '1900']);
$this->expectException(UnprocessableEntityHttpException::class);
$this->invokePrivate($provider, 'resolveYear', $currentPhase);
}
public function testYearWithoutPhaseIdIsNotClamped(): void
{
// No `phaseId` → legacy callers must keep their requested year as-is,
// even if it falls outside the current phase range.
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
$currentPhase = $phases[0];
$provider = $this->buildProvider(['year' => '2030']);
$year = $this->invokePrivate($provider, 'resolveYear', $currentPhase);
self::assertSame(2030, $year);
}
// -----------------------------------------------------------------------
// Test harness helpers.
// -----------------------------------------------------------------------
/**
* Build a two-period employee transitioning from H39 to FORFAIT.
*/
private function buildEmployeeWithTransition(string $h39Start, string $h39End, string $forfaitStart): Employee
{
$employee = new Employee();
$this->setEntityId($employee, 1);
$h39Contract = new Contract();
$h39Contract->setName('39H');
$h39Contract->setTrackingMode(TrackingMode::TIME->value);
$h39Contract->setWeeklyHours(39);
$forfaitContract = new Contract();
$forfaitContract->setName('Forfait');
$forfaitContract->setTrackingMode(TrackingMode::PRESENCE->value);
$forfaitContract->setWeeklyHours(null);
$h39Period = new EmployeeContractPeriod();
$this->setEntityId($h39Period, 1);
$h39Period->setEmployee($employee);
$h39Period->setContract($h39Contract);
$h39Period->setStartDate(new DateTimeImmutable($h39Start));
$h39Period->setEndDate(new DateTimeImmutable($h39End));
$h39Period->setContractNature(ContractNature::CDI);
$h39Period->setIsDriver(false);
$forfaitPeriod = new EmployeeContractPeriod();
$this->setEntityId($forfaitPeriod, 2);
$forfaitPeriod->setEmployee($employee);
$forfaitPeriod->setContract($forfaitContract);
$forfaitPeriod->setStartDate(new DateTimeImmutable($forfaitStart));
$forfaitPeriod->setEndDate(null);
$forfaitPeriod->setContractNature(ContractNature::CDI);
$forfaitPeriod->setIsDriver(false);
$employee->getContractPeriods()->add($h39Period);
$employee->getContractPeriods()->add($forfaitPeriod);
return $employee;
}
/**
* Build an uninitialized provider with a RequestStack pre-loaded with the given query.
*
* The provider's repository/service dependencies are typed against final classes
* (EmployeeRepository, RttRecoveryComputationService, etc.) which PHPUnit cannot
* double. We bypass full instantiation by using newInstanceWithoutConstructor and
* only setting the properties that the tested private methods actually read:
* `requestStack` and `phaseResolver`.
*
* @param array<string, string> $request query parameters (year, phaseId, ...)
*/
private function buildProvider(array $request = []): EmployeeRttSummaryProvider
{
$requestStack = new RequestStack();
$requestStack->push(new Request(query: $request));
$reflection = new ReflectionClass(EmployeeRttSummaryProvider::class);
$provider = $reflection->newInstanceWithoutConstructor();
$this->setReadonlyProperty($provider, 'requestStack', $requestStack);
$this->setReadonlyProperty($provider, 'phaseResolver', new EmployeeContractPhaseResolver());
$this->setReadonlyProperty($provider, 'exerciseYearResolver', new ExerciseYearResolver());
$this->setReadonlyProperty($provider, 'rttStartDate', null);
return $provider;
}
private function invokePrivate(object $obj, string $method, mixed ...$args): mixed
{
$reflection = new ReflectionClass($obj::class);
$m = $reflection->getMethod($method);
return $m->invoke($obj, ...$args);
}
private function setReadonlyProperty(object $obj, string $property, mixed $value): void
{
$reflection = new ReflectionProperty($obj::class, $property);
$reflection->setValue($obj, $value);
}
private function setEntityId(object $entity, int $id): void
{
$reflection = new ReflectionProperty($entity::class, 'id');
$reflection->setValue($entity, $id);
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Tests\State;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Employee;
use App\Entity\EmployeeWeekComment;
use App\Service\AuditLogger;
use App\State\EmployeeWeekCommentWriteProcessor;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\UnitOfWork;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* @internal
*/
final class EmployeeWeekCommentWriteProcessorTest extends TestCase
{
public function testRejectsNonMondayWeekStart(): void
{
$processor = new EmployeeWeekCommentWriteProcessor(
$this->createStub(ProcessorInterface::class),
$this->createStub(ProcessorInterface::class),
$this->createStub(EntityManagerInterface::class),
$this->createStub(AuditLogger::class),
);
$comment = new EmployeeWeekComment()
->setEmployee(new Employee()->setFirstName('A')->setLastName('B'))
->setWeekStartDate(new DateTimeImmutable('2026-04-14'))
->setContent('test')
;
$this->expectException(UnprocessableEntityHttpException::class);
$processor->process($comment, new Post());
}
public function testAcceptsMondayAndAuditsCreate(): void
{
$persist = $this->createMock(ProcessorInterface::class);
$persist->expects(self::once())->method('process');
$em = $this->createMock(EntityManagerInterface::class);
$em->method('getUnitOfWork')->willReturn($this->createStub(UnitOfWork::class));
$em->expects(self::once())->method('flush');
$auditor = $this->createMock(AuditLogger::class);
$auditor->expects(self::once())->method('log')->with(self::anything(), 'create', 'week_comment');
$processor = new EmployeeWeekCommentWriteProcessor($persist, $this->createStub(ProcessorInterface::class), $em, $auditor);
$processor->process(
new EmployeeWeekComment()->setEmployee(new Employee()->setFirstName('A')->setLastName('B'))->setWeekStartDate(new DateTimeImmutable('2026-04-13'))->setContent('x'),
new Post()
);
}
public function testDeleteAudits(): void
{
$remove = $this->createMock(ProcessorInterface::class);
$remove->expects(self::once())->method('process');
$em = $this->createMock(EntityManagerInterface::class);
$em->expects(self::once())->method('flush');
$auditor = $this->createMock(AuditLogger::class);
$auditor->expects(self::once())->method('log')->with(self::anything(), 'delete', 'week_comment');
$processor = new EmployeeWeekCommentWriteProcessor($this->createStub(ProcessorInterface::class), $remove, $em, $auditor);
$processor->process(
new EmployeeWeekComment()->setEmployee(new Employee()->setFirstName('A')->setLastName('B'))->setWeekStartDate(new DateTimeImmutable('2026-04-13'))->setContent('x'),
new Delete()
);
}
}

View File

@@ -15,6 +15,7 @@ use App\Enum\HalfDay;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Repository\EmployeeWeekCommentRepository;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\PublicHolidayServiceInterface;
use App\Service\WorkHours\AbsenceSegmentsResolver;
@@ -66,6 +67,8 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
$this->buildResolverStub(),
new DailyReferenceMinutesResolver(),
$this->buildHolidayResolver(),
$this->buildHolidayService(),
$this->buildWeekCommentRepoStub(),
);
$this->expectException(AccessDeniedHttpException::class);
@@ -128,6 +131,8 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
$this->buildWeeklyResolverStub($employees),
new DailyReferenceMinutesResolver(),
$this->buildHolidayResolver(),
$this->buildHolidayService(),
$this->buildWeekCommentRepoStub(),
);
$result = $provider->provide(new Get());
@@ -178,16 +183,29 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
$property->setValue($entity, $id);
}
private function buildWeekCommentRepoStub(): EmployeeWeekCommentRepository
{
$r = $this->createStub(EmployeeWeekCommentRepository::class);
$r->method('findByWeekAndEmployees')->willReturn([]);
return $r;
}
private function buildHolidayResolver(array $holidayMap = []): HolidayVirtualHoursResolver
{
return new HolidayVirtualHoursResolver(
new DailyReferenceMinutesResolver(),
$this->buildHolidayService($holidayMap),
$this->createStub(EmployeeContractResolver::class),
);
}
private function buildHolidayService(array $holidayMap = []): PublicHolidayServiceInterface
{
$service = $this->createStub(PublicHolidayServiceInterface::class);
$service->method('getHolidaysDayByYears')->willReturn($holidayMap);
return new HolidayVirtualHoursResolver(
new DailyReferenceMinutesResolver(),
$service,
$this->createStub(EmployeeContractResolver::class),
);
return $service;
}
private function buildResolverStub(): EmployeeContractResolver