Compare commits

...

10 Commits

Author SHA1 Message Date
tristan ca8468c95d fix : le cron de bascule recalcule la vraie clôture au lieu du placeholder
LeaveRolloverCommand::resolveCarry se fiait au closing_days stocké quand
une ligne existait pour l'exercice précédent. Or closing_days n'est jamais
recalculé après création (toujours = opening, ou 0 sur un bootstrap), donc
le report propageait l'ouverture sans créditer l'acquisition de l'année.
Cas Aurore : bascule 2026->2027 aurait reporté 0 au lieu de 31.

resolveCarry calcule désormais toujours la clôture réelle via
computeDynamicClosingForYear (bootstrap-aware, intègre acquisition + samedis
+ fractionnés − pris), puis fige ce résultat dans closing_days de l'exercice
qui se termine. Vérifié sur données réelles : report 2027 d'Aurore = 31,00 j
/ 5,00 samedis (au lieu de 0).

Corrections manuelles préservées : le cron reste idempotent (ne réécrit pas
une ligne existante) et le bon levier de correction devient opening_days
(propagé par le recalcul), pas closing_days.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 11:46:17 +02:00
tristan 9635cf15ff chore : ignorer le dump local sirh.sql
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 11:43:42 +02:00
tristan 7c4dde9fd9 feat : retirer l'exercice suivant du sélecteur RTT (Congés uniquement)
Consulter un exercice RTT à venir n'a pas de sens (heures non saisies,
rien à payer). La borne haute du sélecteur RTT redevient l'exercice
courant sur une phase ouverte ; l'onglet Congés conserve l'exercice
suivant. Docs et verrouillage RTT réalignés (passé uniquement).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 11:20:54 +02:00
tristan 2418836cd1 fix : ancrer la clôture dynamique des congés sur le solde bootstrap
computeDynamicClosingForYear (qui produit le report d'ouverture de
l'exercice suivant) ignorait la table employee_leave_balances et
recalculait depuis l'embauche, sans absences historiques. Pour un
exercice consulté en avance, il cumulait donc une année pleine
d'acquisition par exercice antérieur à la mise en service.

Cas Aurore (CDI depuis 2022, bootstrap 2026 = report 32 / pris 24) :
report d'ouverture 2027 affiché à 88,39 j au lieu de 31. La vue courante
était juste car le provider, lui, lit déjà le bootstrap.

La clôture dynamique applique désormais la même règle que
EmployeeLeaveSummaryProvider::computeYearSummary : si une ligne bootstrap
existe pour l'exercice, on part de opening_days/opening_saturdays et on
ajoute l'offset taken_days/taken_saturdays, au lieu du report dynamique
accumulé. Vérifié sur données réelles : 88,39 -> 31,00 j.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 11:06:18 +02:00
tristan 7a86669878 docs : préciser que le verrouillage des éditions vaut aussi pour l'exercice futur
Suite à l'ajout de l'exercice suivant dans les sélecteurs, isHistoricalYear
(selectedYear !== currentYear) s'applique aussi à l'exercice futur. Les docs
parlaient uniquement d'exercice passé/antérieur ; clarifié pour couvrir passé
ET futur.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 10:25:09 +02:00
tristan a7fed83dda docs : sélecteurs Congés/RTT proposent l'exercice suivant
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 10:17:40 +02:00
tristan 9a65890bfd feat : proposer l'exercice suivant dans les sélecteurs Congés et RTT
Sur une phase de contrat ouverte, la borne haute des sélecteurs d'exercice
(availableLeaveYears / availableRttYears) passe de l'exercice courant à
l'exercice suivant (courant + 1), pour consulter en avance les congés/RTT
posés sur l'exercice à venir. Phase clôturée : borne inchangée (fin de phase).
Sélection par défaut et verrouillage des éditions inchangés.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 10:13:33 +02:00
tristan 41777fbd72 docs : plan d'implémentation exercice suivant Congés/RTT (SIRH-32)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 10:11:26 +02:00
tristan cfbea730d7 docs : spec exercice suivant sur sélecteurs Congés et RTT (SIRH-32)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 10:06:16 +02:00
tristan cb2db678ba fix : sélecteur d'exercice vide par défaut sur l'onglet Congés
Le watch sur le changement de phase (useEmployeeLeave/useEmployeeRtt)
remettait selectedYear à null pendant l'await du chargement eager du
récap congés, à la résolution initiale de la phase (undefined → phase
courante). Comme le chargement eager marquait ensuite leaveDataLoaded=true
et que le watch de useEmployeeDetailPage ignore la résolution initiale
(oldId === undefined), l'année restait à null et le <select> s'affichait
vide. L'onglet RTT y échappait car chargé en lazy.

Ajoute le garde oldId === undefined dans les watchs des deux composables,
même convention que useEmployeeDetailPage : la résolution initiale est
ignorée, seul un vrai changement de phase réinitialise l'année.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 10:05:25 +02:00
12 changed files with 487 additions and 45 deletions
+3
View File
@@ -29,3 +29,6 @@ docker/.env.docker.local
###> lexik/jwt-authentication-bundle ###
/config/jwt/*.pem
###< lexik/jwt-authentication-bundle ###
# Local DB dump (not versioned)
/sirh.sql
+3 -3
View File
@@ -73,14 +73,14 @@
## Onglet Congés (fiche employé)
- Calendrier annuel des congés (`frontend/components/employees/LeaveTab.vue`) — période = Janvier→Décembre pour FORFAIT, Juin(N-1)→Mai(N) pour les autres contrats. Règle pilotée par le **contrat courant** (cf. `EmployeeLeaveSummaryProvider::resolveYear`), même quand on consulte une année passée.
- **Sélecteur d'année** en pied de calendrier (zone scrollable, à gauche). Plage : de l'exercice courant jusqu'à `max(floor_contrat, floor_data_start_date)``floor_contrat` = premier exercice avec contrat ouvert (`employee.contractHistory[].startDate`) ; `floor_data_start_date` = exercice contenant `RTT_START_DATE` (env, ex. `2026-02-23` → exercice 2026). Le double plancher empêche de remonter avant la mise en service du logiciel. Format : `2026` pour FORFAIT, `Juin 2025 → Mai 2026` sinon.
- **Sélecteur d'année** en pied de calendrier (zone scrollable, à gauche). Plage : de l'exercice **suivant** (exercice courant + 1 sur une phase ouverte ; exercice de fin de phase si clôturée) jusqu'à `max(floor_contrat, floor_data_start_date)``floor_contrat` = premier exercice avec contrat ouvert (`employee.contractHistory[].startDate`) ; `floor_data_start_date` = exercice contenant `RTT_START_DATE` (env, ex. `2026-02-23` → exercice 2026). Le double plancher empêche de remonter avant la mise en service du logiciel. Format : `2026` pour FORFAIT, `Juin 2025 → Mai 2026` sinon.
- Changement d'année → recharge complète de l'onglet via `useEmployeeLeave.setSelectedLeaveYear(year)` (reload de `getEmployeeLeaveSummary?year=YYYY` + `listAbsences` + `listPublicHolidays`). Backend : filtre `?year=YYYY` validé 2000-2100, et `EmployeeLeaveSummary` expose `dataStartDate` (env `RTT_START_DATE`, injecté via `services.yaml`).
- Sur un exercice passé (`selectedYear !== currentYear`), les boutons crayon **Jours fractionnés** et **Année N-1 payés** sont **désactivés** : pas d'édition rétroactive des stocks de report.
- Sur un exercice **autre que l'exercice courant** (`selectedYear !== currentYear`, passé ou futur), les boutons crayon **Jours fractionnés** et **Année N-1 payés** sont **désactivés** : pas d'édition rétroactive des stocks de report (ni d'édition anticipée sur un exercice futur).
- Doc : `doc/leave-tab.md`.
## Onglet RTT (fiche employé)
- Tableau hebdomadaire (`frontend/components/employees/RttTab.vue`) — exercice fixe Juin(N-1)→Mai(N). Onglet **masqué pour les FORFAIT** (`showRttTab`).
- **Sélecteur d'année** sous le tableau dans la zone scrollable. Même mécanique que l'onglet Congés (double plancher) : `max(floor_contrat, floor_rttStartDate)`. Format unique : `Juin 2025 → Mai 2026`.
- **Sélecteur d'année** sous le tableau dans la zone scrollable. Double plancher `max(floor_contrat, floor_rttStartDate)`. Borne haute = exercice courant : **contrairement à l'onglet Congés, le RTT ne propose PAS l'exercice suivant** (consulter un exercice RTT à venir — heures non saisies, rien à payer — n'a pas de sens ; cf. `availableRttYears`). Format unique : `Juin 2025 → Mai 2026`.
- Changement d'année → recharge via `useEmployeeRtt.setSelectedRttYear(year)` (`getEmployeeRttSummary?year=YYYY`). `EmployeeRttSummary.rttStartDate` est déjà exposé (champ existant) — il sert à la fois au floor du sélecteur et au masquage des lignes Report avant la mise en service.
- Sur un exercice passé, le bouton **+ Payer les RTT** est désactivé (pas de paiement rétroactif).
- Doc : `doc/rtt-tab.md`.
+13 -5
View File
@@ -68,7 +68,8 @@ Etat implementation:
- la table est creee
- le calcul de synthese conges lit en priorite `opening_days/opening_saturdays` de cette table quand une ligne existe pour `(employee, rule_code, year)`
- si aucune ligne n'existe, le calcul reste base sur le report dynamique N-1
- la commande `app:leave:rollover` calcule aussi le report dynamique N-1 si la ligne N-1 n'est pas encore persistée (pas de reset a 0 par defaut)
- **le report dynamique (`LeaveBalanceComputationService::computeDynamicClosingForYear`, qui alimente le solde d'ouverture de l'exercice suivant) ancre lui aussi sur cette table** : pour chaque exercice de sa boucle, si une ligne bootstrap existe il part de `opening_days/opening_saturdays` (et ajoute l'offset `taken_days/taken_saturdays`) au lieu de recalculer depuis l'embauche. Sans cet ancrage, la clôture d'un exercice consulté en avance (ex. exercice suivant) cumulerait une année pleine d'acquisition par exercice antérieur à la mise en service — aucune absence historique n'étant saisie (cas Aurore : 88 jours au lieu de 31).
- la commande `app:leave:rollover` recalcule **toujours** le report via `computeDynamicClosingForYear(N-1)` (et ne se fie plus au `closing_days` stocké, qui n'est qu'un placeholder = `opening`), puis fige ce résultat dans le `closing_days` de l'exercice qui se termine ; voir § 6
### Definition des colonnes
@@ -120,12 +121,19 @@ Date d'effet:
- non forfait: au `1er juin`
Traitement par employe:
1. lire l'exercice precedent
2. determiner le report:
1. determiner le report de l'exercice precedent:
- si cloture `paidLeaveSettled=true` sur la periode precedente => report `0`
- sinon report = `closing` exercice precedent
- sinon report = **cloture reelle recalculee** via `computeDynamicClosingForYear(exercicePrecedent)` (acquisition + samedis + fractionnes pris, ancree sur l'`opening_days` bootstrap de chaque exercice). On **ne se fie PAS** au `closing_days` stocke : il n'est jamais recalcule apres creation (toujours egal a l'`opening`), donc s'y fier propagerait l'ouverture sans jamais crediter l'acquisition de l'annee (cas Aurore : report 0 au lieu de 31).
2. **figer** ce report dans `closing_days/closing_saturdays` de la ligne de l'exercice qui se termine (la colonne contient enfin un vrai solde de cloture, auditable).
3. creer la ligne du nouvel exercice avec ce report en `opening_*`
4. initialiser `accrued/taken/closing` pour le nouvel exercice
4. initialiser `accrued/taken/closing` pour le nouvel exercice (= `opening` a la creation)
### Correction manuelle d'un solde (RH / comptable)
Le verrouillage (`is_locked`) n'est pas utilise ; les corrections se font directement en BDD. Deux garde-fous rendent cela sur :
- **Idempotence** : le cron ne cree la ligne d'un exercice que si elle n'existe pas (les lignes existantes sont ignorees). Une ligne corrigee a la main n'est donc **jamais** ecrasee par un passage ulterieur du cron (meme avec `--force`).
- **Le bon levier est `opening_days`, pas `closing_days`** : `computeDynamicClosingForYear` part de l'`opening_days` de chaque exercice comme ancre. Corriger l'`opening_days` d'un exercice (ou la donnee de fond : absence, fractionne, paye) se propage automatiquement aux reports des exercices suivants. Editer un `closing_days` d'un exercice **pas encore bascule** est inutile (il sera recalcule a la bascule) ; une fois la ligne suivante creee, plus rien n'y touche.
## 7) Donnees a fournir au go-live
+5 -5
View File
@@ -24,12 +24,12 @@ Cette règle suit `EmployeeLeaveSummaryProvider::resolveYear()` côté backend :
Position : **en bas du calendrier**, à gauche, à l'intérieur de la zone scrollable. Il scrolle donc avec les mois et apparaît sous la grille.
Plage proposée :
- du plus récent (= année courante) au plus ancien ;
- du plus récent au plus ancien. La borne haute est l'exercice **suivant** (exercice courant + 1) lorsque la phase de contrat est ouverte, afin de consulter en avance les congés posés sur l'exercice à venir ; pour une phase clôturée, la borne haute reste l'exercice de fin de phase ;
- **double plancher** : l'année minimum est `max(floor_historique_contrat, floor_data_start_date)`
- **floor_historique_contrat** : dérivé de `employee.contractHistory[].startDate` — premier exercice où l'employé avait un contrat ouvert
- **floor_data_start_date** : dérivé de l'env `RTT_START_DATE` (date de mise en service du logiciel, ex. `2026-02-23` → exercice 2026 / année forfait 2026). Aucune donnée historique n'existe avant cette date, donc on ne propose pas d'années antérieures même si le contrat de l'employé est plus ancien.
- la valeur est exposée par l'API `GET /employees/{id}/leave-summary` via le champ `dataStartDate` (peuplé depuis l'env serveur).
- en cas d'historique manquant **et** d'env absente, la plage se réduit à l'année courante.
- en cas d'historique manquant **et** d'env absente, la plage se réduit à l'exercice courant et à l'exercice suivant.
Format des libellés :
- FORFAIT : `2026`, `2025`, `2024`
@@ -39,13 +39,13 @@ 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
## Verrouillage des éditions hors exercice courant
Quand `selectedYear !== currentYear` (consultation d'une année antérieure) :
Quand `selectedYear !== currentYear` (consultation d'une année **différente de l'exercice courant**, passée ou future) :
- le bouton crayon **Jours fractionnés** (non-FORFAIT) est désactivé ;
- le bouton crayon **Année N-1 payés** (FORFAIT) est désactivé.
Justification : modifier rétroactivement les stocks de report ou les jours fractionnés d'un exercice clos décalerait silencieusement les soldes de toutes les années postérieures. La consultation reste possible, l'édition non.
Justification : modifier les stocks de report ou les jours fractionnés d'un exercice clos décalerait silencieusement les soldes des années postérieures ; les éditer sur un exercice futur (pas encore démarré) n'aurait pas de sens. La consultation reste possible, l'édition non.
## Sélecteur de phase de contrat
+2 -2
View File
@@ -21,7 +21,7 @@ Toujours **Juin (Y-1) → Mai (Y)**. Le champ `EmployeeRttSummary.year` correspo
Position : sous la table, à l'intérieur de la zone scrollable, à gauche.
Plage proposée :
- du plus récent (= exercice courant) au plus ancien ;
- du plus récent (= exercice courant) au plus ancien. Contrairement à l'onglet Congés, le RTT **ne propose pas** l'exercice suivant (consulter un exercice RTT à venir n'a pas de sens) ; pour une phase clôturée, la borne haute reste l'exercice de fin de phase ;
- **double plancher** : `max(floor_historique_contrat, floor_data_start_date)`
- **floor_historique_contrat** : dérivé de `employee.contractHistory[].startDate` — premier exercice où l'employé avait un contrat ouvert
- **floor_data_start_date** : exercice contenant `RTT_START_DATE` (env, ex. `2026-02-23` → exercice 2026)
@@ -32,7 +32,7 @@ Comportement :
- changer d'exercice recharge `getEmployeeRttSummary?year=YYYY` (le backend valide 20002100) ;
- la table redéploie les semaines de l'exercice sélectionné, navigation par mois conservée.
## Verrouillage des édition sur exercices passés
## Verrouillage des éditions sur exercices passés
Quand `selectedYear !== currentYear` (consultation d'un exercice antérieur), le bouton **+ Payer les RTT** est désactivé. Justification : un paiement rétroactif sur un exercice clos décalerait les soldes courants et le report N-1 calculé.
@@ -0,0 +1,267 @@
# Exercice suivant dans les sélecteurs Congés et RTT — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Faire apparaître toujours l'exercice **suivant** (exercice courant + 1) dans les sélecteurs d'exercice des onglets Congés et RTT de la fiche employé, pour une phase de contrat ouverte.
**Architecture :** Changement **frontend uniquement**. Le backend calcule déjà l'exercice suivant pour une phase ouverte (`clampYearToPhase` ne plafonne pas vers le haut quand `phase.endDate` est nul). On déplace la borne haute (`maxYear`) des deux `computed` `availableLeaveYears` / `availableRttYears` de « exercice courant » à « exercice courant + 1 » lorsque la phase est ouverte ; la borne reste l'exercice de fin de phase pour une phase clôturée. La borne basse, la sélection par défaut (exercice courant) et le verrouillage des éditions (`isHistoricalYear`) sont inchangés.
**Tech Stack :** Nuxt 4 / Vue 3 / TypeScript (composables). Backend Symfony inchangé (les tests PHPUnit doivent rester verts). Pas de harnais de test frontend dans le projet → vérification manuelle via `make dev-nuxt`.
**Référence spec :** `docs/superpowers/specs/2026-05-26-exercice-suivant-conges-rtt-design.md`
---
## File Structure
- `frontend/composables/useEmployeeLeave.ts` — borne haute du sélecteur Congés (`availableLeaveYears`).
- `frontend/composables/useEmployeeRtt.ts` — borne haute du sélecteur RTT (`availableRttYears`).
- `doc/leave-tab.md` — doc fonctionnelle, section « Sélecteur d'année ».
- `doc/rtt-tab.md` — doc fonctionnelle, section « Sélecteur d'année ».
- `CLAUDE.md` — bullets « Sélecteur d'année » des sections Congés et RTT.
- `frontend/data/documentation-content.ts` — documentation in-app (2 paragraphes).
Aucun fichier créé ; 6 fichiers modifiés.
---
### Task 1 : Borne haute = exercice suivant sur les deux composables
**Files:**
- Modify: `frontend/composables/useEmployeeLeave.ts` (computed `availableLeaveYears`)
- Modify: `frontend/composables/useEmployeeRtt.ts` (computed `availableRttYears`)
- [ ] **Step 1 : Modifier `availableLeaveYears` dans `useEmployeeLeave.ts`**
Remplacer ce bloc (déclaration de `phaseEndYear`) :
```ts
// Plage = exercices intersectant la phase.
const phaseStartYear = computeLeaveYearForDate(new Date(`${phase.startDate}T00:00:00`))
const phaseEndYear = phase.endDate
? computeLeaveYearForDate(new Date(`${phase.endDate}T00:00:00`))
: currentLeaveYear.value
```
par :
```ts
// Plage = exercices intersectant la phase.
const phaseStartYear = computeLeaveYearForDate(new Date(`${phase.startDate}T00:00:00`))
// Borne haute : fin de phase si clôturée ; sinon l'exercice SUIVANT (courant + 1),
// pour pouvoir consulter en avance les congés posés sur l'exercice à venir.
const maxYear = phase.endDate
? computeLeaveYearForDate(new Date(`${phase.endDate}T00:00:00`))
: currentLeaveYear.value + 1
```
Puis, plus bas, supprimer la ligne devenue redondante en remplaçant :
```ts
const minYear = dataFloor !== null ? Math.max(phaseStartYear, dataFloor) : phaseStartYear
const maxYear = phaseEndYear
```
par :
```ts
const minYear = dataFloor !== null ? Math.max(phaseStartYear, dataFloor) : phaseStartYear
```
(La variable `maxYear` est désormais déclarée plus haut ; la boucle `for (let y = maxYear; ...)` qui suit est inchangée.)
- [ ] **Step 2 : Modifier `availableRttYears` dans `useEmployeeRtt.ts`**
Remplacer ce bloc :
```ts
// Plage = exercices intersectant la phase.
const phaseStartYear = computeRttYearForDate(new Date(`${phase.startDate}T00:00:00`))
const phaseEndYear = phase.endDate
? computeRttYearForDate(new Date(`${phase.endDate}T00:00:00`))
: currentRttYear.value
```
par :
```ts
// Plage = exercices intersectant la phase.
const phaseStartYear = computeRttYearForDate(new Date(`${phase.startDate}T00:00:00`))
// Borne haute : fin de phase si clôturée ; sinon l'exercice SUIVANT (courant + 1),
// pour rester cohérent avec le sélecteur de l'onglet Congés.
const maxYear = phase.endDate
? computeRttYearForDate(new Date(`${phase.endDate}T00:00:00`))
: currentRttYear.value + 1
```
Puis remplacer :
```ts
const minYear = dataFloor !== null ? Math.max(phaseStartYear, dataFloor) : phaseStartYear
const maxYear = phaseEndYear
```
par :
```ts
const minYear = dataFloor !== null ? Math.max(phaseStartYear, dataFloor) : phaseStartYear
```
- [ ] **Step 3 : Vérifier qu'il ne reste aucune référence à `phaseEndYear`**
Run: `grep -rn "phaseEndYear" frontend/composables/`
Expected: aucune sortie (la variable a été supprimée des deux fichiers).
- [ ] **Step 4 : Vérification manuelle dans le dev server**
Run: `make dev-nuxt` puis, dans le navigateur, ouvrir la fiche d'un employé **non-forfait avec phase courante ouverte**.
Attendu :
- Onglet **Congés** → le menu déroulant en pied de calendrier propose l'exercice **suivant** en première position (ex. aujourd'hui exercice `Juin 2025 → Mai 2026` → l'option `Juin 2026 → Mai 2027` est présente), et l'onglet s'ouvre **par défaut** sur l'exercice courant.
- Onglet **RTT** → idem, l'exercice suivant est proposé.
- Sélectionner l'exercice suivant : les boutons **Jours fractionnés** / **Année N-1 payés** (Congés) et **+ Payer les RTT** (RTT) sont **désactivés** (car `isHistoricalYear` = vrai), et aucun bandeau « Vous consultez l'historique » n'apparaît (ce bandeau dépend de la phase, pas de l'année).
- Ouvrir la fiche d'un employé ayant une **phase clôturée** (sélecteur « Vue contrat ») : le sélecteur d'exercice de cette phase **ne propose pas** d'exercice au-delà de la fin de phase (comportement inchangé).
- [ ] **Step 5 : Commit**
```bash
git add frontend/composables/useEmployeeLeave.ts frontend/composables/useEmployeeRtt.ts
git commit -m "feat : proposer l'exercice suivant dans les sélecteurs Congés et RTT
Sur une phase de contrat ouverte, la borne haute des sélecteurs d'exercice
(availableLeaveYears / availableRttYears) passe de l'exercice courant à
l'exercice suivant (courant + 1), pour consulter en avance les congés/RTT
posés sur l'exercice à venir. Phase clôturée : borne inchangée (fin de phase).
Sélection par défaut et verrouillage des éditions inchangés.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
(Le hook pre-commit lance PHPUnit ; les 151 tests doivent rester verts — aucun changement backend.)
---
### Task 2 : Mettre à jour la documentation
**Files:**
- Modify: `doc/leave-tab.md` (section « Sélecteur d'année »)
- Modify: `doc/rtt-tab.md` (section « Sélecteur d'année »)
- Modify: `CLAUDE.md` (bullets sélecteur Congés et RTT)
- Modify: `frontend/data/documentation-content.ts` (2 paragraphes)
- [ ] **Step 1 : `doc/leave-tab.md`**
Dans la section « ## Sélecteur d'année » (vers la ligne 26 « Plage proposée : »), remplacer la première puce :
```markdown
- du plus récent (= exercice courant) au plus ancien ;
```
> Remarque : si cette puce n'existe pas telle quelle dans ce fichier, ajouter à la place, juste après la ligne `Plage proposée :`, la puce ci-dessous.
par :
```markdown
- du plus récent au plus ancien. La borne haute est l'exercice **suivant** (exercice courant + 1) lorsque la phase de contrat est ouverte, afin de consulter en avance les congés posés sur l'exercice à venir ; pour une phase clôturée, la borne haute reste l'exercice de fin de phase ;
```
- [ ] **Step 2 : `doc/rtt-tab.md`**
Dans la section « ## Sélecteur d'année », remplacer la puce (ligne 24) :
```markdown
- du plus récent (= exercice courant) au plus ancien ;
```
par :
```markdown
- du plus récent au plus ancien. La borne haute est l'exercice **suivant** (exercice courant + 1) sur une phase ouverte (cohérent avec l'onglet Congés) ; pour une phase clôturée, elle reste l'exercice de fin de phase ;
```
- [ ] **Step 3 : `CLAUDE.md` — section Onglet Congés (ligne 76)**
Remplacer le segment :
```
Plage : de l'exercice courant jusqu'à `max(floor_contrat, floor_data_start_date)`
```
par :
```
Plage : de l'exercice **suivant** (exercice courant + 1 sur une phase ouverte ; exercice de fin de phase si clôturée) jusqu'à `max(floor_contrat, floor_data_start_date)`
```
- [ ] **Step 4 : `CLAUDE.md` — section Onglet RTT (ligne 83)**
Remplacer la phrase :
```
Même mécanique que l'onglet Congés (double plancher) : `max(floor_contrat, floor_rttStartDate)`.
```
par :
```
Même mécanique que l'onglet Congés : borne haute = exercice suivant (courant + 1) sur phase ouverte, double plancher `max(floor_contrat, floor_rttStartDate)`.
```
- [ ] **Step 5 : `frontend/data/documentation-content.ts` — paragraphe Congés (ligne 483)**
Remplacer le contenu du paragraphe :
```ts
{ type: 'paragraph', content: 'Un sélecteur d\'année est disponible en bas du calendrier (zone scrollable, à gauche). Il permet de consulter les exercices passés. La plage proposée part de l\'exercice courant et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel — il est inutile de remonter plus loin, aucune donnée n\'a été saisie avant.' },
```
par :
```ts
{ type: 'paragraph', content: 'Un sélecteur d\'année est disponible en bas du calendrier (zone scrollable, à gauche). Il permet de consulter l\'exercice suivant ainsi que les exercices passés. La plage proposée part de l\'exercice suivant (l\'exercice à venir, pour consulter en avance les congés déjà posés) et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel — il est inutile de remonter plus loin, aucune donnée n\'a été saisie avant.' },
```
- [ ] **Step 6 : `frontend/data/documentation-content.ts` — paragraphe RTT (ligne 539)**
Remplacer le contenu du paragraphe :
```ts
{ type: 'paragraph', content: 'Un sélecteur d\'exercice est disponible en bas du tableau RTT (zone scrollable, à gauche). Il permet de consulter les exercices passés (Juin → Mai). La plage proposée part de l\'exercice courant et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel.' },
```
par :
```ts
{ type: 'paragraph', content: 'Un sélecteur d\'exercice est disponible en bas du tableau RTT (zone scrollable, à gauche). Il permet de consulter l\'exercice suivant ainsi que les exercices passés (Juin → Mai). La plage proposée part de l\'exercice suivant et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel.' },
```
- [ ] **Step 7 : Vérifier la cohérence des chaînes éditées**
Run: `grep -n "exercice suivant" frontend/data/documentation-content.ts doc/leave-tab.md doc/rtt-tab.md CLAUDE.md`
Expected: les 6 emplacements modifiés ci-dessus apparaissent (2 dans documentation-content.ts, 1 dans chaque doc, 2 dans CLAUDE.md).
- [ ] **Step 8 : Commit**
```bash
git add doc/leave-tab.md doc/rtt-tab.md CLAUDE.md frontend/data/documentation-content.ts
git commit -m "docs : sélecteurs Congés/RTT proposent l'exercice suivant
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
## Self-Review
**1. Couverture de la spec :**
- « Borne haute = exercice courant + 1 sur phase ouverte, fin de phase si clôturée » → Task 1, Steps 1-2. ✓
- « Forfait (année civile) et non-forfait » → le `+1` porte sur le numéro d'exercice produit par `computeLeaveYearForDate`/`computeRttYearForDate`, donc valable pour les deux règles. ✓
- « Sélection par défaut inchangée » → aucun changement à `initSelected*Year` ; vérifié en Step 4. ✓
- « Verrouillage des éditions / pas de bandeau passé » → aucun changement à `isHistoricalYear` ; vérifié en Step 4. ✓
- « Périmètre Congés + RTT » → Task 1 touche les deux composables. ✓
- « Docs : leave-tab.md, rtt-tab.md, CLAUDE.md, documentation-content.ts » → Task 2. ✓
- « Tests backend restent verts » → hook pre-commit, Task 1 Step 5. ✓
**2. Placeholders :** aucun « TBD/TODO » ; tout le code et toutes les chaînes sont fournis. La Step 1 de Task 2 prévoit le cas où l'ancre exacte diffère (instruction de repli explicite).
**3. Cohérence des types/noms :** la variable `maxYear` est désormais déclarée en amont dans les deux composables ; la ligne `const maxYear = phaseEndYear` est supprimée ; `phaseEndYear` n'existe plus (vérifié Step 3). La boucle `for (let y = maxYear; y >= minYear; ...)` reste valide.
@@ -0,0 +1,118 @@
# Proposer toujours l'exercice suivant dans les sélecteurs Congés et RTT
**Date** : 2026-05-26
**Ticket** : SIRH-32
**Statut** : design validé
## Contexte
Les onglets **Congés** et **RTT** de la fiche employé proposent un sélecteur
d'exercice (`availableLeaveYears` / `availableRttYears`) dont la borne haute est
plafonnée à l'**exercice courant**. La RH a commencé à poser des congés sur
l'**exercice suivant**, mais ne peut pas le consulter dans la fiche employé :
l'exercice suivant n'apparaît pas dans le menu déroulant.
On veut que le sélecteur propose **toujours** l'exercice suivant pour une phase
de contrat ouverte, afin que ce besoin ne ressurgisse jamais.
## Faisabilité — déjà supportée côté backend
Aucun changement backend n'est nécessaire :
- `EmployeeLeaveSummaryProvider::clampYearToPhase` et son équivalent RTT ne
plafonnent **pas** vers le haut quand la phase est ouverte (`phase.endDate`
nul → `lastYear = null`). Une requête `?year=<exercice+1>&phaseId=<phase ouverte>`
est donc déjà calculée correctement.
- La validation `year` (20002100) couvre largement l'exercice suivant.
Le seul blocage est le **frontend**, qui calcule `maxYear = exercice courant`.
## Le changement
Frontend uniquement. Dans `frontend/composables/useEmployeeLeave.ts`
(`availableLeaveYears`) et `frontend/composables/useEmployeeRtt.ts`
(`availableRttYears`), la borne haute devient :
- **Phase ouverte** (pas de `phase.endDate`) :
`maxYear = exercice courant + 1`.
- **Phase clôturée** (`phase.endDate` présent) : **inchangé**
`maxYear = exercice de fin de phase` (on ne propose pas au-delà d'une phase
terminée).
Le `+1` porte sur le **numéro d'exercice**, donc il est correct pour le forfait
(année civile) comme pour le non-forfait (Juin N-1 → Mai N), via
`computeLeaveYearForDate` / `computeRttYearForDate`.
### Pseudo-code de la borne
```
maxYear = phase.endDate
? computeYearForDate(phase.endDate) // phase clôturée : cap à la fin de phase
: currentYear + 1 // phase ouverte : on propose l'exercice suivant
```
La borne basse (`minYear = max(phaseStartYear, dataFloor)`) est **inchangée**.
## Comportements conservés
- **Sélection par défaut** : inchangée. L'onglet s'ouvre toujours sur l'exercice
**courant** ; l'exercice suivant est seulement disponible dans le menu (pas de
saut automatique sur le futur). `initSelected*Year` continue d'initialiser sur
`current*Year`, qui reste dans la plage `[minYear ; maxYear]`.
- **Verrouillage des éditions** : `isHistoricalYear` (`selectedYear !== currentYear`)
reste tel quel. Sur l'exercice suivant, les boutons **Jours fractionnés**,
**Année N-1 payés** (onglet Congés) et **+ Payer les RTT** (onglet RTT) sont
**désactivés** — souhaitable : pas d'édition de stocks ni de paiement sur un
exercice pas encore démarré.
- **Aucune mention « passé » trompeuse** : le bandeau « Vous consultez
l'historique » est piloté par la phase (`isViewingPastPhase`), pas par l'année
sélectionnée ; sélectionner un exercice futur ne l'affiche pas.
## Affichage des congés posés sur l'exercice suivant (réponse Q1)
Le header congés (grille de compteurs de l'onglet + libellé présence du header
de fiche) reflète **le récap de l'exercice sélectionné**. Chaque récap est
calculé sur sa fenêtre `[from, to]` ; les absences/jours pris ne sont comptés que
dans cette fenêtre.
- **Sur l'exercice courant** (vue par défaut) : les congés posés sur l'exercice
suivant **n'apparaissent pas** dans les compteurs — comportement correct.
- **Sur l'exercice suivant** (sélectionné) : ils s'affichent (calendrier +
compteur « Pris »).
### Caveat fonctionnel
Sur l'exercice suivant, les compteurs **report / Année N-1 / reste** sont
**provisoires** jusqu'à la clôture de l'exercice courant (ils en dépendent). En
revanche, le **« Pris » et le calendrier** des congés posés sont exacts. À
communiquer à la RH.
## Périmètre
- Onglet **Congés** et onglet **RTT** (les deux sélecteurs partagent la même
mécanique).
- Forfait (année civile) et non-forfait (Juin→Mai).
## Hors périmètre
- Aucune modification de la mécanique de saisie d'absences (la RH pose déjà des
congés sur l'exercice suivant via les écrans Calendrier / Heures, indépendamment
de ce sélecteur).
- Pas de proposition de plusieurs exercices futurs (un seul : N+1).
- Pas d'activation des éditions de stocks/paiement sur l'exercice futur.
## Documentation à mettre à jour (règle obligatoire CLAUDE.md)
- `doc/leave-tab.md` — plage du sélecteur.
- `doc/rtt-tab.md` — plage du sélecteur.
- `CLAUDE.md` — sections « Onglet Congés » et « Onglet RTT » (description de la
plage `max(...)` → borne haute `exercice courant + 1` sur phase ouverte).
- `frontend/data/documentation-content.ts` — documentation in-app.
## Tests
Pas de harnais de test frontend dans le projet (backend PHPUnit uniquement). La
modification est de la logique de calcul de plage dans deux `computed` :
vérification manuelle (dev Nuxt) que l'exercice suivant apparaît dans les deux
sélecteurs pour une phase ouverte, et n'apparaît pas pour une phase clôturée. Les
tests backend existants doivent rester verts (aucun changement backend).
+12 -5
View File
@@ -51,9 +51,11 @@ export const useEmployeeLeave = (
// Plage = exercices intersectant la phase.
const phaseStartYear = computeLeaveYearForDate(new Date(`${phase.startDate}T00:00:00`))
const phaseEndYear = phase.endDate
// Borne haute : fin de phase si clôturée ; sinon l'exercice SUIVANT (courant + 1),
// pour pouvoir consulter en avance les congés posés sur l'exercice à venir.
const maxYear = phase.endDate
? computeLeaveYearForDate(new Date(`${phase.endDate}T00:00:00`))
: currentLeaveYear.value
: currentLeaveYear.value + 1
// Hard floor : data-start-date (env RTT_START_DATE) — le logiciel n'a pas
// d'historique avant cette date, inutile de proposer des années antérieures.
@@ -67,7 +69,6 @@ export const useEmployeeLeave = (
}
const minYear = dataFloor !== null ? Math.max(phaseStartYear, dataFloor) : phaseStartYear
const maxYear = phaseEndYear
const years: LeaveYearOption[] = []
for (let y = maxYear; y >= minYear; y -= 1) {
@@ -124,8 +125,14 @@ export const useEmployeeLeave = (
selectedLeaveYear.value = null
}
watch(() => selectedPhase.value?.id, () => {
// Reset l'année car la plage a peut-être changé.
watch(() => selectedPhase.value?.id, (newId, oldId) => {
// Ignore la résolution initiale (undefined → phase courante au montage) :
// le chargement eager du récap initialise déjà l'année sélectionnée. Sans ce
// garde, ce watch (asynchrone) s'exécute PENDANT l'await du chargement eager et
// remet selectedLeaveYear à null, ce qui laisse le sélecteur d'exercice vide.
// Même convention que le watch de useEmployeeDetailPage.
if (oldId === undefined || newId === oldId) return
// Changement de phase réel : reset l'année car la plage a peut-être changé.
selectedLeaveYear.value = null
leaveDataLoaded.value = false
// Le rechargement effectif est piloté par useEmployeeDetailPage.
+10 -4
View File
@@ -35,7 +35,10 @@ export const useEmployeeRtt = (
// Plage = exercices intersectant la phase.
const phaseStartYear = computeRttYearForDate(new Date(`${phase.startDate}T00:00:00`))
const phaseEndYear = phase.endDate
// Borne haute : fin de phase si clôturée ; sinon l'exercice courant.
// Contrairement à l'onglet Congés, on NE propose PAS l'exercice suivant en RTT :
// consulter un exercice RTT à venir (heures non saisies, rien à payer) n'a pas de sens.
const maxYear = phase.endDate
? computeRttYearForDate(new Date(`${phase.endDate}T00:00:00`))
: currentRttYear.value
@@ -50,7 +53,6 @@ export const useEmployeeRtt = (
}
const minYear = dataFloor !== null ? Math.max(phaseStartYear, dataFloor) : phaseStartYear
const maxYear = phaseEndYear
const years: RttYearOption[] = []
for (let y = maxYear; y >= minYear; y -= 1) {
@@ -95,8 +97,12 @@ export const useEmployeeRtt = (
selectedRttYear.value = null
}
watch(() => selectedPhase.value?.id, () => {
// Reset l'année car la plage a peut-être changé.
watch(() => selectedPhase.value?.id, (newId, oldId) => {
// Ignore la résolution initiale (undefined → phase courante au montage) :
// l'initialisation de l'année est pilotée par loadRttData. Même convention que
// le watch de useEmployeeDetailPage (évite un reset concurrent du sélecteur).
if (oldId === undefined || newId === oldId) return
// Changement de phase réel : reset l'année car la plage a peut-être changé.
selectedRttYear.value = null
rttDataLoaded.value = false
// Le rechargement effectif est piloté par useEmployeeDetailPage.
+3 -2
View File
@@ -480,7 +480,8 @@ export const documentationSections: DocSection[] = [
blocks: [
{ type: 'paragraph', content: 'L\'onglet "Congés" sur la fiche employé affiche un calendrier annuel des congés posés (12 mois en grille 4×3) ainsi que les compteurs (acquis, pris, reste, en cours d\'acquisition, N-1 ou samedis selon le contrat).' },
{ type: 'paragraph', content: 'La période affichée dépend du type de contrat actuel : Janvier → Décembre pour FORFAIT, Juin (N-1) → Mai (N) pour les autres contrats.' },
{ type: 'paragraph', content: 'Un sélecteur d\'année est disponible en bas du calendrier (zone scrollable, à gauche). Il permet de consulter les exercices passés. La plage proposée part de l\'exercice courant et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel — il est inutile de remonter plus loin, aucune donnée n\'a été saisie avant.' },
{ type: 'paragraph', content: 'Un sélecteur d\'année est disponible en bas du calendrier (zone scrollable, à gauche). Il permet de consulter l\'exercice suivant ainsi que les exercices passés. La plage proposée part de l\'exercice suivant (l\'exercice à venir, pour consulter en avance les congés déjà posés) et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel — il est inutile de remonter plus loin, aucune donnée n\'a été saisie avant.' },
{ type: 'note', content: 'Sur l\'exercice suivant, le calendrier et les congés déjà posés sont exacts, mais les compteurs « Année acquis » et report N-1 sont provisoires : ils dépendent de la clôture de l\'exercice courant et ne se figeront qu\'à cette clôture.' },
{ type: 'note', content: 'Sur un exercice passé, les boutons d\'édition "Jours fractionnés" et "Année N-1 payés" sont désactivés. La consultation reste possible, mais on n\'autorise pas la modification rétroactive d\'un exercice clos.' },
],
},
@@ -536,7 +537,7 @@ export const documentationSections: DocSection[] = [
title: 'Consulter un exercice passé',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Un sélecteur d\'exercice est disponible en bas du tableau RTT (zone scrollable, à gauche). Il permet de consulter les exercices passés (Juin → Mai). La plage proposée part de l\'exercice courant et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel.' },
{ type: 'paragraph', content: 'Un sélecteur d\'exercice est disponible en bas du tableau RTT (zone scrollable, à gauche). Il permet de consulter les exercices passés (Juin → Mai). La plage proposée part de l\'exercice courant et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel. Contrairement à l\'onglet Congés, l\'onglet RTT ne propose pas l\'exercice suivant.' },
{ type: 'note', content: 'Sur un exercice passé, le bouton « + Payer les RTT » est désactivé. Aucun paiement rétroactif n\'est autorisé pour préserver la cohérence du report N-1.' },
],
},
+23 -12
View File
@@ -188,24 +188,35 @@ final class LeaveRolloverCommand extends Command
private function resolveCarry(Employee $employee, LeaveRuleCode $ruleCode, int $targetYear): array
{
$previousYear = $targetYear - 1;
$previous = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $previousYear);
if (null !== $previous) {
$carryDays = $previous->getClosingDays() + $previous->getFractionedDays();
$carrySaturdays = LeaveRuleCode::CDI_CDD_NON_FORFAIT === $ruleCode
? $previous->getClosingSaturdays()
: 0.0;
} else {
[$carryDays, $carrySaturdays] = $this->leaveBalanceComputationService
->computeDynamicClosingForYear($employee, $ruleCode, $previousYear)
;
}
[$from, $to] = $this->leaveBalanceComputationService->resolvePeriodBounds($ruleCode, $previousYear);
$hasSettlement = $this->leaveBalanceComputationService
->hasPaidLeaveSettledClosureBetween($employee, $from, $to)
;
if ($hasSettlement) {
return [0.0, 0.0];
$carryDays = 0.0;
$carrySaturdays = 0.0;
} else {
// Compute the REAL closing of the ending exercise. computeDynamicClosingForYear
// is bootstrap-aware (it anchors on the persisted opening balance of each year)
// and already folds in accrual, taken absences and fractioned days. We must NOT
// trust the stored closing_days: it is only ever written equal to the opening at
// row creation (placeholder), so trusting it would propagate the opening and
// ignore the year's accrual (cas Aurore : report 0 au lieu de 31).
[$carryDays, $carrySaturdays] = $this->leaveBalanceComputationService
->computeDynamicClosingForYear($employee, $ruleCode, $previousYear)
;
}
// Freeze the computed closing on the ending exercise's row so the column finally
// holds a real, auditable value. The cron is idempotent — it never reaches here for
// an already-rolled target year (existing rows are skipped upstream) — so a row that
// was corrected manually in the DB afterwards is never overwritten.
$previous = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $previousYear);
if (null !== $previous) {
$previous->setClosingDays($carryDays);
$previous->setClosingSaturdays($carrySaturdays);
}
return [$carryDays, $carrySaturdays];
@@ -51,9 +51,20 @@ final readonly class LeaveBalanceComputationService
for ($year = $firstYear; $year <= $targetYear; ++$year) {
[$from, $to] = $this->resolvePeriodBounds($ruleCode, $year);
// Bootstrap anchor: a manually-entered opening balance (production data
// bootstrap) is the source of truth for the carry of that year — exactly
// like EmployeeLeaveSummaryProvider::computeYearSummary for the live view.
// Without it, the closing would be recomputed from the contract start with no
// historical absences, inflating the carry by one full year of accrual for
// every exercise predating the software (cas Aurore : 88 au lieu de 31).
$openingBalance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $year);
$carryDays = 0.0;
$carrySaturdays = 0.0;
if ($year > $firstYear) {
if (null !== $openingBalance) {
$carryDays = $openingBalance->getOpeningDays();
$carrySaturdays = LeaveRuleCode::CDI_CDD_NON_FORFAIT === $ruleCode ? $openingBalance->getOpeningSaturdays() : 0.0;
} elseif ($year > $firstYear) {
[$previousFrom, $previousTo] = $this->resolvePeriodBounds($ruleCode, $year - 1);
$hasSettlementOnPreviousYear = $this->periodRepository->hasPaidLeaveSettledClosureBetween($employee, $previousFrom, $previousTo);
if (!$hasSettlementOnPreviousYear) {
@@ -63,7 +74,10 @@ final readonly class LeaveBalanceComputationService
}
$effectiveFrom = $this->resolveEffectivePeriodStart($employee, $from, $to);
if ($effectiveFrom > $from) {
// A shifted start (new hire / settled closure) zeroes the dynamic carry, but
// an explicit bootstrap opening balance must be preserved (it already reflects
// the real situation at the bootstrap date).
if ($effectiveFrom > $from && null === $openingBalance) {
$carryDays = 0.0;
$carrySaturdays = 0.0;
}
@@ -74,11 +88,14 @@ final readonly class LeaveBalanceComputationService
// Business days for forfait must use the RAW holiday list (excluded holidays
// like "Lundi de Pentecôte" / journée de solidarité still count as non-working
// days for the 218-day legal target).
$totalBusinessDays = $this->countBusinessDaysInRange($from, $to, $this->buildRawPublicHolidayMap($from, $to));
$baseAcquiredDays = (float) max(0, $totalBusinessDays - self::FORFAIT_TARGET_WORKED_DAYS);
$acquiredDays = $carryDays + $baseAcquiredDays + $fractionedDays;
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
[$takenDays] = $this->computeTakenAbsences($absences, $effectiveFrom, $to, false, false);
$totalBusinessDays = $this->countBusinessDaysInRange($from, $to, $this->buildRawPublicHolidayMap($from, $to));
$baseAcquiredDays = (float) max(0, $totalBusinessDays - self::FORFAIT_TARGET_WORKED_DAYS);
$acquiredDays = $carryDays + $baseAcquiredDays + $fractionedDays;
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
[$takenDays] = $this->computeTakenAbsences($absences, $effectiveFrom, $to, false, false);
if (null !== $openingBalance) {
$takenDays += $openingBalance->getTakenDays();
}
$previousRemainingDays = max(0.0, $acquiredDays - $takenDays);
$previousRemainingSaturdays = 0.0;
@@ -120,6 +137,10 @@ final readonly class LeaveBalanceComputationService
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
[$takenDays, $takenSaturdays] = $this->computeTakenAbsences($absences, $effectiveFrom, $to, true, true);
if (null !== $openingBalance) {
$takenDays += $openingBalance->getTakenDays();
$takenSaturdays += $openingBalance->getTakenSaturdays();
}
$acquiredWithFractioned = $carryDays + $fractionedDays;
$takenFromAcquired = min(max(0.0, $acquiredWithFractioned), $takenDays);