diff --git a/.gitignore b/.gitignore
index 260ef0b..ad2eb97 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/CLAUDE.md b/CLAUDE.md
index 1fdebaa..2f171c3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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`.
diff --git a/doc/leave-rollover.md b/doc/leave-rollover.md
index 3b26087..ad2831f 100644
--- a/doc/leave-rollover.md
+++ b/doc/leave-rollover.md
@@ -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
diff --git a/doc/leave-tab.md b/doc/leave-tab.md
index b01e92a..13baf56 100644
--- a/doc/leave-tab.md
+++ b/doc/leave-tab.md
@@ -24,12 +24,12 @@ Cette règle suit `EmployeeLeaveSummaryProvider::resolveYear()` côté backend :
Position : **en bas du calendrier**, à gauche, à l'intérieur de la zone scrollable. Il scrolle donc avec les mois et apparaît sous la grille.
Plage proposée :
-- du plus récent (= année courante) au plus ancien ;
+- du plus récent au plus ancien. La borne haute est l'exercice **suivant** (exercice courant + 1) lorsque la phase de contrat est ouverte, afin de consulter en avance les congés posés sur l'exercice à venir ; pour une phase clôturée, la borne haute reste l'exercice de fin de phase ;
- **double plancher** : l'année minimum est `max(floor_historique_contrat, floor_data_start_date)`
- **floor_historique_contrat** : dérivé de `employee.contractHistory[].startDate` — premier exercice où l'employé avait un contrat ouvert
- **floor_data_start_date** : dérivé de l'env `RTT_START_DATE` (date de mise en service du logiciel, ex. `2026-02-23` → exercice 2026 / année forfait 2026). Aucune donnée historique n'existe avant cette date, donc on ne propose pas d'années antérieures même si le contrat de l'employé est plus ancien.
- la valeur est exposée par l'API `GET /employees/{id}/leave-summary` via le champ `dataStartDate` (peuplé depuis l'env serveur).
-- en cas d'historique manquant **et** d'env absente, la plage se réduit à l'année courante.
+- en cas d'historique manquant **et** d'env absente, la plage se réduit à l'exercice courant et à l'exercice suivant.
Format des libellés :
- FORFAIT : `2026`, `2025`, `2024`…
@@ -39,13 +39,24 @@ Comportement :
- changer d'année recharge l'intégralité de l'onglet (`getEmployeeLeaveSummary?year=YYYY` + `listAbsences` + `listPublicHolidays`) ;
- les compteurs du bandeau reflètent l'année sélectionnée.
-## Verrouillage des éditions sur années passées
+## Compteurs du bandeau
-Quand `selectedYear !== currentYear` (consultation d'une année antérieure) :
+- **Acquis** : jours de report N-1 + jours acquis sur l'exercice courant.
+- **Pris** : jours de congés posés et validés sur l'exercice.
+- **Reste** : acquis − pris.
+- **En cours d'acquisition** (non-forfait) : affiché au format `net / brut`.
+ - `net` (`accruingDays`) : généré de l'exercice restant, déduit des congés posés en anticipé (au-delà du report acquis).
+ - `brut` (`accruingDaysTotal` = `generatedDays + generatedSaturdays`) : total généré sur l'exercice à ce jour, avant cette déduction.
+ - La RH voit ainsi le total réellement acquis même si une partie a déjà été consommée en anticipé. Forfait : pas d'en-cours (affiche `0`, sans fraction).
+- **N-1** (non-forfait) ou **Samedis** (FORFAIT) : solde de l'exercice précédent / jours de repos samedis.
+
+## Verrouillage des éditions hors exercice courant
+
+Quand `selectedYear !== currentYear` (consultation d'une année **différente de l'exercice courant**, passée ou future) :
- le bouton crayon **Jours fractionnés** (non-FORFAIT) est désactivé ;
- le bouton crayon **Année N-1 payés** (FORFAIT) est désactivé.
-Justification : modifier rétroactivement les stocks de report ou les jours fractionnés d'un exercice clos décalerait silencieusement les soldes de toutes les années postérieures. La consultation reste possible, l'édition non.
+Justification : modifier les stocks de report ou les jours fractionnés d'un exercice clos décalerait silencieusement les soldes des années postérieures ; les éditer sur un exercice futur (pas encore démarré) n'aurait pas de sens. La consultation reste possible, l'édition non.
## Sélecteur de phase de contrat
diff --git a/doc/rtt-tab.md b/doc/rtt-tab.md
index 059a43b..4eb9908 100644
--- a/doc/rtt-tab.md
+++ b/doc/rtt-tab.md
@@ -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 2000–2100) ;
- 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é.
diff --git a/docs/superpowers/plans/2026-05-26-en-cours-acquisition-net-brut.md b/docs/superpowers/plans/2026-05-26-en-cours-acquisition-net-brut.md
new file mode 100644
index 0000000..c4c7629
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-26-en-cours-acquisition-net-brut.md
@@ -0,0 +1,321 @@
+# En-cours d'acquisition « net / brut » — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Sur l'onglet Congés de la fiche employé, afficher l'en-cours d'acquisition au format `{net} / {brut généré à ce jour}` pour les non-forfait, afin que la RH voie le total acquis même quand des congés ont été pris en anticipé.
+
+**Architecture :** Exposition d'une valeur **déjà calculée** côté backend (`generatedDays + generatedSaturdays`) via un nouveau champ `accruingDaysTotal` sur `EmployeeLeaveSummary`, puis affichage en fraction côté Nuxt. Aucune nouvelle règle métier ; `accruingDays` (net) reste le numérateur inchangé.
+
+**Tech Stack :** Backend Symfony / API Platform (State Provider + ApiResource DTO). Frontend Nuxt 4 / Vue 3 / TypeScript. Tests : PHPUnit (backend) ; pas de harnais frontend → vérification manuelle.
+
+**Référence spec :** `docs/superpowers/specs/2026-05-26-en-cours-acquisition-net-brut-design.md`
+
+---
+
+## File Structure
+
+- `src/State/EmployeeLeaveSummaryProvider.php` — calcule `accruingDaysTotal` dans `computeYearSummary` et le recopie sur le DTO.
+- `src/ApiResource/EmployeeLeaveSummary.php` — nouvelle propriété exposée `accruingDaysTotal`.
+- `frontend/services/dto/employee-leave-summary.ts` — champ TS `accruingDaysTotal`.
+- `frontend/components/employees/LeaveTab.vue` — affichage `net / brut` (non-forfait).
+- `doc/leave-tab.md` + `frontend/data/documentation-content.ts` — documentation.
+
+Aucun fichier créé ; 6 fichiers modifiés.
+
+---
+
+### Task 1 : Backend — exposer `accruingDaysTotal`
+
+**Files:**
+- Modify: `src/State/EmployeeLeaveSummaryProvider.php`
+- Modify: `src/ApiResource/EmployeeLeaveSummary.php`
+
+- [ ] **Step 1 : Calculer `accruingDaysTotal` dans les deux branches de `computeYearSummary`**
+
+Dans `src/State/EmployeeLeaveSummaryProvider.php`, branche non-forfait, remplacer :
+
+```php
+ $acquiredDays = $carryDays;
+ $accruingDays = $remainingGenerated + $remainingGeneratedSaturdays;
+```
+
+par :
+
+```php
+ $acquiredDays = $carryDays;
+ $accruingDays = $remainingGenerated + $remainingGeneratedSaturdays;
+ // Brut généré à ce jour, AVANT imputation des congés pris en anticipé
+ // (dénominateur de l'affichage « net / brut » sur l'onglet Congés).
+ $accruingDaysTotal = $generatedDays + $generatedSaturdays;
+```
+
+Puis, branche forfait, remplacer :
+
+```php
+ $acquiredDays = $leavePolicy['acquiredDays'];
+ $accruingDays = 0.0;
+```
+
+par :
+
+```php
+ $acquiredDays = $leavePolicy['acquiredDays'];
+ $accruingDays = 0.0;
+ $accruingDaysTotal = 0.0;
+```
+
+- [ ] **Step 2 : Ajouter la clé au tableau `targetSummary`**
+
+Toujours dans `computeYearSummary`, remplacer :
+
+```php
+ 'accruingDays' => $accruingDays,
+```
+
+par :
+
+```php
+ 'accruingDays' => $accruingDays,
+ 'accruingDaysTotal' => $accruingDaysTotal,
+```
+
+- [ ] **Step 3 : Déclarer la clé dans le PHPDoc de retour**
+
+Dans le bloc `@return null|array{ ... }` de `computeYearSummary`, remplacer :
+
+```php
+ * accruingDays: float,
+```
+
+par :
+
+```php
+ * accruingDays: float,
+ * accruingDaysTotal: float,
+```
+
+- [ ] **Step 4 : Recopier la valeur sur le DTO dans `provide()`**
+
+Remplacer :
+
+```php
+ $summary->accruingDays = $yearSummary['accruingDays'];
+```
+
+par :
+
+```php
+ $summary->accruingDays = $yearSummary['accruingDays'];
+ $summary->accruingDaysTotal = $yearSummary['accruingDaysTotal'];
+```
+
+- [ ] **Step 5 : Ajouter la propriété sur l'ApiResource**
+
+Dans `src/ApiResource/EmployeeLeaveSummary.php`, remplacer :
+
+```php
+ public float $accruingDays = 0.0;
+```
+
+par :
+
+```php
+ public float $accruingDays = 0.0;
+ /** Brut généré sur l'exercice à ce jour (= accruingDays + congés pris en anticipé). Dénominateur de l'affichage « net / brut ». */
+ public float $accruingDaysTotal = 0.0;
+```
+
+- [ ] **Step 6 : Lancer la suite PHPUnit (non-régression)**
+
+Run: `docker exec -t -u www-data php-sirh-fpm php vendor/bin/phpunit`
+Expected: `OK (151 tests, ...)` — vert. (Le champ est une exposition pure ; aucun test existant ne doit casser. Le service n'est pas unit-testable en isolation à cause des dépôts `final`, cf. note spec.)
+
+- [ ] **Step 7 : Vérification sur données réelles (jetable, non commitée)**
+
+Créer `src/Command/TmpVerifyAccruingCommand.php` :
+
+```php
+em->getRepository(Employee::class)->findAll() as $e) {
+ $s = $m->invoke($this->provider, $e, 2026, 0.0, null, null);
+ if (null === $s || 'CDI_CDD_NON_FORFAIT' !== $s['ruleCode']) {
+ continue;
+ }
+ $output->writeln(sprintf(
+ '#%d %s : en-cours net=%.2f / brut=%.2f',
+ $e->getId(),
+ $e->getLastName(),
+ $s['accruingDays'],
+ $s['accruingDaysTotal'],
+ ));
+ }
+
+ return Command::SUCCESS;
+ }
+}
+```
+
+Run: `docker exec -t php-sirh-fpm php /var/www/html/bin/console app:tmp-verify-accruing --env=dev`
+Expected: chaque ligne affiche `net=… / brut=…` avec `net ≤ brut`. Pour un salarié sans congé anticipé, `net == brut` ; pour un salarié ayant débordé, `net < brut`.
+
+Puis supprimer le fichier :
+
+```bash
+rm src/Command/TmpVerifyAccruingCommand.php
+```
+
+- [ ] **Step 8 : Commit**
+
+```bash
+git add src/State/EmployeeLeaveSummaryProvider.php src/ApiResource/EmployeeLeaveSummary.php
+git commit -m "feat : exposer accruingDaysTotal (brut généré) sur le récap congés
+
+Co-Authored-By: Claude Opus 4.7 (1M context) En cours d'acquisition :
+ {{ formatCount(summary?.accruingDays) }} Jours
+ En cours d'acquisition :
+ {{ formatCount(summary?.accruingDays) }} / {{ formatCount(summary?.accruingDaysTotal) }} Jours
+ {{ formatCount(summary?.accruingDays) }} Jours
+
En cours d'acquisition : - {{ formatCount(summary?.accruingDays) }} Jours + {{ isForfaitRule ? formatCount(summary?.accruingDays) : `${formatCount(summary?.accruingDays)} / ${formatCount(summary?.accruingDaysTotal)}` }}
Samedi acquis : {{ formatCount(summary?.acquiredSaturdays) }} Jours diff --git a/frontend/composables/useEmployeeLeave.ts b/frontend/composables/useEmployeeLeave.ts index d19ea5d..992b0f9 100644 --- a/frontend/composables/useEmployeeLeave.ts +++ b/frontend/composables/useEmployeeLeave.ts @@ -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. diff --git a/frontend/composables/useEmployeeRtt.ts b/frontend/composables/useEmployeeRtt.ts index 0a4ed6a..5fe4a55 100644 --- a/frontend/composables/useEmployeeRtt.ts +++ b/frontend/composables/useEmployeeRtt.ts @@ -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. diff --git a/frontend/data/documentation-content.ts b/frontend/data/documentation-content.ts index 35379c0..80d296b 100644 --- a/frontend/data/documentation-content.ts +++ b/frontend/data/documentation-content.ts @@ -480,7 +480,9 @@ export const documentationSections: DocSection[] = [ blocks: [ { type: 'paragraph', content: 'L\'onglet "Congés" sur la fiche employé affiche un calendrier annuel des congés posés (12 mois en grille 4×3) ainsi que les compteurs (acquis, pris, reste, en cours d\'acquisition, N-1 ou samedis selon le contrat).' }, { type: 'paragraph', content: 'La période affichée dépend du type de contrat actuel : Janvier → Décembre pour FORFAIT, Juin (N-1) → Mai (N) pour les autres contrats.' }, - { type: 'paragraph', content: 'Un sélecteur d\'année est disponible en bas du calendrier (zone scrollable, à gauche). Il permet de consulter les exercices passés. La plage proposée part de l\'exercice courant et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel — il est inutile de remonter plus loin, aucune donnée n\'a été saisie avant.' }, + { type: 'note', content: 'La case « En cours d\'acquisition » affiche deux valeurs : à gauche les jours encore à acquérir (déduction faite des congés déjà posés en anticipé), à droite le total brut acquis sur l\'exercice à ce jour. Exemple : « 14,50 / 17,50 » signifie 17,50 jours acquis dont 3 déjà pris en anticipé.' }, + { type: 'paragraph', content: 'Un sélecteur d\'année est disponible en bas du calendrier (zone scrollable, à gauche). Il permet de consulter l\'exercice suivant ainsi que les exercices passés. La plage proposée part de l\'exercice suivant (l\'exercice à venir, pour consulter en avance les congés déjà posés) et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel — il est inutile de remonter plus loin, aucune donnée n\'a été saisie avant.' }, + { type: 'note', content: 'Sur l\'exercice suivant, le calendrier et les congés déjà posés sont exacts, mais les compteurs « Année acquis » et report N-1 sont provisoires : ils dépendent de la clôture de l\'exercice courant et ne se figeront qu\'à cette clôture.' }, { type: 'note', content: 'Sur un exercice passé, les boutons d\'édition "Jours fractionnés" et "Année N-1 payés" sont désactivés. La consultation reste possible, mais on n\'autorise pas la modification rétroactive d\'un exercice clos.' }, ], }, @@ -536,7 +538,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.' }, ], }, diff --git a/frontend/services/dto/employee-leave-summary.ts b/frontend/services/dto/employee-leave-summary.ts index 5da16b5..1b38796 100644 --- a/frontend/services/dto/employee-leave-summary.ts +++ b/frontend/services/dto/employee-leave-summary.ts @@ -10,6 +10,7 @@ export type EmployeeLeaveSummary = { takenSaturdays: number fractionedDays: number accruingDays: number + accruingDaysTotal: number previousYearAcquiredDays: number previousYearTakenDays: number previousYearRemainingDays: number diff --git a/src/ApiResource/EmployeeLeaveSummary.php b/src/ApiResource/EmployeeLeaveSummary.php index a3f6338..d538782 100644 --- a/src/ApiResource/EmployeeLeaveSummary.php +++ b/src/ApiResource/EmployeeLeaveSummary.php @@ -20,17 +20,20 @@ use App\State\EmployeeLeaveSummaryProvider; )] final class EmployeeLeaveSummary { - public int $year = 0; - public bool $isSupported = false; - public string $ruleCode = ''; - public float $acquiredDays = 0.0; - public float $remainingDays = 0.0; - public float $takenDays = 0.0; - public float $acquiredSaturdays = 0.0; - public float $remainingSaturdays = 0.0; - public float $takenSaturdays = 0.0; - public float $fractionedDays = 0.0; - public float $accruingDays = 0.0; + public int $year = 0; + public bool $isSupported = false; + public string $ruleCode = ''; + public float $acquiredDays = 0.0; + public float $remainingDays = 0.0; + public float $takenDays = 0.0; + public float $acquiredSaturdays = 0.0; + public float $remainingSaturdays = 0.0; + public float $takenSaturdays = 0.0; + public float $fractionedDays = 0.0; + public float $accruingDays = 0.0; + + /** Brut généré sur l'exercice à ce jour (= accruingDays + congés pris en anticipé). Dénominateur de l'affichage « net / brut ». */ + public float $accruingDaysTotal = 0.0; public float $previousYearAcquiredDays = 0.0; public float $previousYearTakenDays = 0.0; public float $previousYearRemainingDays = 0.0; diff --git a/src/Command/LeaveRolloverCommand.php b/src/Command/LeaveRolloverCommand.php index 59f5ef7..8152937 100644 --- a/src/Command/LeaveRolloverCommand.php +++ b/src/Command/LeaveRolloverCommand.php @@ -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]; diff --git a/src/Service/Leave/LeaveBalanceComputationService.php b/src/Service/Leave/LeaveBalanceComputationService.php index f716b7d..2108813 100644 --- a/src/Service/Leave/LeaveBalanceComputationService.php +++ b/src/Service/Leave/LeaveBalanceComputationService.php @@ -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); diff --git a/src/State/EmployeeLeaveSummaryProvider.php b/src/State/EmployeeLeaveSummaryProvider.php index 0ef7688..6195080 100644 --- a/src/State/EmployeeLeaveSummaryProvider.php +++ b/src/State/EmployeeLeaveSummaryProvider.php @@ -123,6 +123,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface $summary->acquiredSaturdays = $yearSummary['acquiredSaturdays']; $summary->fractionedDays = $fractionedDays; $summary->accruingDays = $yearSummary['accruingDays']; + $summary->accruingDaysTotal = $yearSummary['accruingDaysTotal']; $summary->takenDays = $yearSummary['takenDays']; $summary->takenSaturdays = $yearSummary['takenSaturdays']; $summary->remainingDays = $yearSummary['remainingDays'] + $fractionedDays; @@ -186,6 +187,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface * acquiredDays: float, * acquiredSaturdays: float, * accruingDays: float, + * accruingDaysTotal: float, * takenDays: float, * takenSaturdays: float, * remainingDays: float, @@ -336,8 +338,11 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface $remainingSaturdaysToImpute = max(0.0, $takenSaturdays - $takenFromAcquiredSaturdays); $remainingGeneratedSaturdays = $generatedSaturdays - $remainingSaturdaysToImpute; - $acquiredDays = $carryDays; - $accruingDays = $remainingGenerated + $remainingGeneratedSaturdays; + $acquiredDays = $carryDays; + $accruingDays = $remainingGenerated + $remainingGeneratedSaturdays; + // Brut généré à ce jour, AVANT imputation des congés pris en anticipé + // (dénominateur de l'affichage « net / brut » sur l'onglet Congés). + $accruingDaysTotal = $generatedDays + $generatedSaturdays; $remainingDays = $remainingAcquired; $acquiredSaturdays = $carrySaturdays; $remainingSaturdays = max(0.0, $remainingAcquiredSaturdays); @@ -359,6 +364,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface $acquiredDays = $leavePolicy['acquiredDays']; $accruingDays = 0.0; + $accruingDaysTotal = 0.0; $remainingDays = max(0.0, $acquiredDays - $takenFromCurrent); $acquiredSaturdays = 0.0; $remainingSaturdays = 0.0; @@ -373,6 +379,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface 'acquiredDays' => $acquiredDays, 'acquiredSaturdays' => $acquiredSaturdays, 'accruingDays' => $accruingDays, + 'accruingDaysTotal' => $accruingDaysTotal, 'takenDays' => $takenDays, 'takenSaturdays' => $takenSaturdays, 'remainingDays' => $remainingDays,