[#SIRH-32] Ajouter l'exercice 2026/2027 dans les congés/RTT (#20)
Auto Tag Develop / tag (push) Successful in 9s

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

## Description de la PR

## Modification du .env

## Check list

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

Reviewed-on: #20
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #20.
This commit is contained in:
2026-05-26 14:09:02 +00:00
committed by Autin
parent 25083f00c8
commit cf2e12c8ba
18 changed files with 939 additions and 59 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
+16 -5
View File
@@ -24,12 +24,12 @@ Cette règle suit `EmployeeLeaveSummaryProvider::resolveYear()` côté backend :
Position : **en bas du calendrier**, à gauche, à l'intérieur de la zone scrollable. Il scrolle donc avec les mois et apparaît sous la grille.
Plage proposée :
- du plus récent (= année courante) au plus ancien ;
- du plus récent au plus ancien. La borne haute est l'exercice **suivant** (exercice courant + 1) lorsque la phase de contrat est ouverte, afin de consulter en avance les congés posés sur l'exercice à venir ; pour une phase clôturée, la borne haute reste l'exercice de fin de phase ;
- **double plancher** : l'année minimum est `max(floor_historique_contrat, floor_data_start_date)`
- **floor_historique_contrat** : dérivé de `employee.contractHistory[].startDate` — premier exercice où l'employé avait un contrat ouvert
- **floor_data_start_date** : dérivé de l'env `RTT_START_DATE` (date de mise en service du logiciel, ex. `2026-02-23` → exercice 2026 / année forfait 2026). Aucune donnée historique n'existe avant cette date, donc on ne propose pas d'années antérieures même si le contrat de l'employé est plus ancien.
- la valeur est exposée par l'API `GET /employees/{id}/leave-summary` via le champ `dataStartDate` (peuplé depuis l'env serveur).
- en cas d'historique manquant **et** d'env absente, la plage se réduit à l'année courante.
- en cas d'historique manquant **et** d'env absente, la plage se réduit à l'exercice courant et à l'exercice suivant.
Format des libellés :
- FORFAIT : `2026`, `2025`, `2024`
@@ -39,13 +39,24 @@ Comportement :
- changer d'année recharge l'intégralité de l'onglet (`getEmployeeLeaveSummary?year=YYYY` + `listAbsences` + `listPublicHolidays`) ;
- les compteurs du bandeau reflètent l'année sélectionnée.
## Verrouillage des éditions sur années passées
## Compteurs du bandeau
Quand `selectedYear !== currentYear` (consultation d'une année antérieure) :
- **Acquis** : jours de report N-1 + jours acquis sur l'exercice courant.
- **Pris** : jours de congés posés et validés sur l'exercice.
- **Reste** : acquis pris.
- **En cours d'acquisition** (non-forfait) : affiché au format `net / brut`.
- `net` (`accruingDays`) : généré de l'exercice restant, déduit des congés posés en anticipé (au-delà du report acquis).
- `brut` (`accruingDaysTotal` = `generatedDays + generatedSaturdays`) : total généré sur l'exercice à ce jour, avant cette déduction.
- La RH voit ainsi le total réellement acquis même si une partie a déjà été consommée en anticipé. Forfait : pas d'en-cours (affiche `0`, sans fraction).
- **N-1** (non-forfait) ou **Samedis** (FORFAIT) : solde de l'exercice précédent / jours de repos samedis.
## Verrouillage des éditions hors exercice courant
Quand `selectedYear !== currentYear` (consultation d'une année **différente de l'exercice courant**, passée ou future) :
- le bouton crayon **Jours fractionnés** (non-FORFAIT) est désactivé ;
- le bouton crayon **Année N-1 payés** (FORFAIT) est désactivé.
Justification : modifier rétroactivement les stocks de report ou les jours fractionnés d'un exercice clos décalerait silencieusement les soldes de toutes les années postérieures. La consultation reste possible, l'édition non.
Justification : modifier les stocks de report ou les jours fractionnés d'un exercice clos décalerait silencieusement les soldes des années postérieures ; les éditer sur un exercice futur (pas encore démarré) n'aurait pas de sens. La consultation reste possible, l'édition non.
## Sélecteur de phase de contrat
+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,321 @@
# En-cours d'acquisition « net / brut » — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Sur l'onglet Congés de la fiche employé, afficher l'en-cours d'acquisition au format `{net} / {brut généré à ce jour}` pour les non-forfait, afin que la RH voie le total acquis même quand des congés ont été pris en anticipé.
**Architecture :** Exposition d'une valeur **déjà calculée** côté backend (`generatedDays + generatedSaturdays`) via un nouveau champ `accruingDaysTotal` sur `EmployeeLeaveSummary`, puis affichage en fraction côté Nuxt. Aucune nouvelle règle métier ; `accruingDays` (net) reste le numérateur inchangé.
**Tech Stack :** Backend Symfony / API Platform (State Provider + ApiResource DTO). Frontend Nuxt 4 / Vue 3 / TypeScript. Tests : PHPUnit (backend) ; pas de harnais frontend → vérification manuelle.
**Référence spec :** `docs/superpowers/specs/2026-05-26-en-cours-acquisition-net-brut-design.md`
---
## File Structure
- `src/State/EmployeeLeaveSummaryProvider.php` — calcule `accruingDaysTotal` dans `computeYearSummary` et le recopie sur le DTO.
- `src/ApiResource/EmployeeLeaveSummary.php` — nouvelle propriété exposée `accruingDaysTotal`.
- `frontend/services/dto/employee-leave-summary.ts` — champ TS `accruingDaysTotal`.
- `frontend/components/employees/LeaveTab.vue` — affichage `net / brut` (non-forfait).
- `doc/leave-tab.md` + `frontend/data/documentation-content.ts` — documentation.
Aucun fichier créé ; 6 fichiers modifiés.
---
### Task 1 : Backend — exposer `accruingDaysTotal`
**Files:**
- Modify: `src/State/EmployeeLeaveSummaryProvider.php`
- Modify: `src/ApiResource/EmployeeLeaveSummary.php`
- [ ] **Step 1 : Calculer `accruingDaysTotal` dans les deux branches de `computeYearSummary`**
Dans `src/State/EmployeeLeaveSummaryProvider.php`, branche non-forfait, remplacer :
```php
$acquiredDays = $carryDays;
$accruingDays = $remainingGenerated + $remainingGeneratedSaturdays;
```
par :
```php
$acquiredDays = $carryDays;
$accruingDays = $remainingGenerated + $remainingGeneratedSaturdays;
// Brut généré à ce jour, AVANT imputation des congés pris en anticipé
// (dénominateur de l'affichage « net / brut » sur l'onglet Congés).
$accruingDaysTotal = $generatedDays + $generatedSaturdays;
```
Puis, branche forfait, remplacer :
```php
$acquiredDays = $leavePolicy['acquiredDays'];
$accruingDays = 0.0;
```
par :
```php
$acquiredDays = $leavePolicy['acquiredDays'];
$accruingDays = 0.0;
$accruingDaysTotal = 0.0;
```
- [ ] **Step 2 : Ajouter la clé au tableau `targetSummary`**
Toujours dans `computeYearSummary`, remplacer :
```php
'accruingDays' => $accruingDays,
```
par :
```php
'accruingDays' => $accruingDays,
'accruingDaysTotal' => $accruingDaysTotal,
```
- [ ] **Step 3 : Déclarer la clé dans le PHPDoc de retour**
Dans le bloc `@return null|array{ ... }` de `computeYearSummary`, remplacer :
```php
* accruingDays: float,
```
par :
```php
* accruingDays: float,
* accruingDaysTotal: float,
```
- [ ] **Step 4 : Recopier la valeur sur le DTO dans `provide()`**
Remplacer :
```php
$summary->accruingDays = $yearSummary['accruingDays'];
```
par :
```php
$summary->accruingDays = $yearSummary['accruingDays'];
$summary->accruingDaysTotal = $yearSummary['accruingDaysTotal'];
```
- [ ] **Step 5 : Ajouter la propriété sur l'ApiResource**
Dans `src/ApiResource/EmployeeLeaveSummary.php`, remplacer :
```php
public float $accruingDays = 0.0;
```
par :
```php
public float $accruingDays = 0.0;
/** Brut généré sur l'exercice à ce jour (= accruingDays + congés pris en anticipé). Dénominateur de l'affichage « net / brut ». */
public float $accruingDaysTotal = 0.0;
```
- [ ] **Step 6 : Lancer la suite PHPUnit (non-régression)**
Run: `docker exec -t -u www-data php-sirh-fpm php vendor/bin/phpunit`
Expected: `OK (151 tests, ...)` — vert. (Le champ est une exposition pure ; aucun test existant ne doit casser. Le service n'est pas unit-testable en isolation à cause des dépôts `final`, cf. note spec.)
- [ ] **Step 7 : Vérification sur données réelles (jetable, non commitée)**
Créer `src/Command/TmpVerifyAccruingCommand.php` :
```php
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Employee;
use App\State\EmployeeLeaveSummaryProvider;
use Doctrine\ORM\EntityManagerInterface;
use ReflectionMethod;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'app:tmp-verify-accruing')]
final class TmpVerifyAccruingCommand extends Command
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly EmployeeLeaveSummaryProvider $provider,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$m = new ReflectionMethod(EmployeeLeaveSummaryProvider::class, 'computeYearSummary');
foreach ($this->em->getRepository(Employee::class)->findAll() as $e) {
$s = $m->invoke($this->provider, $e, 2026, 0.0, null, null);
if (null === $s || 'CDI_CDD_NON_FORFAIT' !== $s['ruleCode']) {
continue;
}
$output->writeln(sprintf(
'#%d %s : en-cours net=%.2f / brut=%.2f',
$e->getId(),
$e->getLastName(),
$s['accruingDays'],
$s['accruingDaysTotal'],
));
}
return Command::SUCCESS;
}
}
```
Run: `docker exec -t php-sirh-fpm php /var/www/html/bin/console app:tmp-verify-accruing --env=dev`
Expected: chaque ligne affiche `net=… / brut=…` avec `net ≤ brut`. Pour un salarié sans congé anticipé, `net == brut` ; pour un salarié ayant débordé, `net < brut`.
Puis supprimer le fichier :
```bash
rm src/Command/TmpVerifyAccruingCommand.php
```
- [ ] **Step 8 : Commit**
```bash
git add src/State/EmployeeLeaveSummaryProvider.php src/ApiResource/EmployeeLeaveSummary.php
git commit -m "feat : exposer accruingDaysTotal (brut généré) sur le récap congés
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 2 : Frontend — afficher « net / brut »
**Files:**
- Modify: `frontend/services/dto/employee-leave-summary.ts`
- Modify: `frontend/components/employees/LeaveTab.vue`
- [ ] **Step 1 : Ajouter le champ au DTO TypeScript**
Dans `frontend/services/dto/employee-leave-summary.ts`, remplacer :
```ts
accruingDays: number
```
par :
```ts
accruingDays: number
accruingDaysTotal: number
```
- [ ] **Step 2 : Afficher la fraction dans la case « En cours d'acquisition »**
Dans `frontend/components/employees/LeaveTab.vue`, remplacer :
```vue
<p class="col-start-4 p-[10px] border-b border-primary-500"><strong class="uppercase font-semibold">En cours d'acquisition :</strong>
{{ formatCount(summary?.accruingDays) }} Jours
</p>
```
par :
```vue
<p class="col-start-4 p-[10px] border-b border-primary-500"><strong class="uppercase font-semibold">En cours d'acquisition :</strong>
<template v-if="!isForfaitRule">{{ formatCount(summary?.accruingDays) }} / {{ formatCount(summary?.accruingDaysTotal) }} Jours</template>
<template v-else>{{ formatCount(summary?.accruingDays) }} Jours</template>
</p>
```
- [ ] **Step 3 : Vérification manuelle (dev server)**
Run: `make dev-nuxt` puis ouvrir la fiche d'un employé **non-forfait**.
Attendu :
- La case « En cours d'acquisition » affiche deux nombres séparés par `/` (ex. `14,50 / 17,50` ou `17,50 / 17,50` si aucun congé anticipé).
- Sur un employé **forfait**, la case affiche un seul nombre (`0`), inchangé.
(Ne pas lancer `npm run build`.)
- [ ] **Step 4 : Commit**
```bash
git add frontend/services/dto/employee-leave-summary.ts frontend/components/employees/LeaveTab.vue
git commit -m "feat : afficher l'en-cours d'acquisition au format net / brut (onglet Congés)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 3 : Documentation
**Files:**
- Modify: `doc/leave-tab.md`
- Modify: `frontend/data/documentation-content.ts`
- [ ] **Step 1 : `doc/leave-tab.md`**
Repérer la section décrivant les compteurs du header (recherche : `grep -n "acquisition\|En cours\|compteur" doc/leave-tab.md`). Ajouter (ou compléter la puce correspondante) avec ce texte :
```markdown
- **En cours d'acquisition** (non-forfait) : affiché au format `net / brut`.
- `net` (`accruingDays`) : généré de l'exercice restant, déduit des congés posés en anticipé (au-delà du report acquis).
- `brut` (`accruingDaysTotal` = `generatedDays + generatedSaturdays`) : total généré sur l'exercice à ce jour, avant cette déduction.
- La RH voit ainsi le total réellement acquis même si une partie a déjà été consommée en anticipé. Forfait : pas d'en-cours (affiche `0`, sans fraction).
```
- [ ] **Step 2 : `frontend/data/documentation-content.ts`**
Repérer le paragraphe de l'article « Onglet Congés » décrivant les compteurs (recherche : `grep -n "en cours d.acquisition\|En cours\|acquis" frontend/data/documentation-content.ts`). Ajouter un bloc `note` dans le tableau `blocks` de cet article :
```ts
{ type: 'note', content: 'La case « En cours d\'acquisition » affiche deux valeurs : à gauche les jours encore à acquérir (déduction faite des congés déjà posés en anticipé), à droite le total brut acquis sur l\'exercice à ce jour. Exemple : « 14,50 / 17,50 » signifie 17,50 jours acquis dont 3 déjà pris en anticipé.' },
```
> Insérer ce bloc juste après le paragraphe qui présente les compteurs de l'exercice de congés (celui mentionnant l'exercice Juin→Mai / les jours acquis). Respecter l'indentation existante (10 espaces) et l'échappement des apostrophes (`\'`).
- [ ] **Step 3 : Vérifier la cohérence**
Run: `grep -rn "accruingDaysTotal\|net / brut\|14,50 / 17,50" doc/leave-tab.md frontend/data/documentation-content.ts`
Expected : la doc fonctionnelle mentionne le format `net / brut` et la doc in-app contient la note d'exemple.
- [ ] **Step 4 : Commit**
```bash
git add doc/leave-tab.md frontend/data/documentation-content.ts
git commit -m "docs : en-cours d'acquisition affiché net / brut sur l'onglet Congés
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
## Self-Review
**1. Couverture de la spec :**
- Nouveau champ `accruingDaysTotal` = `generatedDays + generatedSaturdays` (non-forfait), `0` (forfait) → Task 1 Steps 1-2. ✓
- Exposition DTO PHP + recopie provider → Task 1 Steps 3-5. ✓
- DTO TS → Task 2 Step 1. ✓
- Affichage `net / brut` non-forfait, inchangé forfait → Task 2 Step 2. ✓
- Docs `doc/leave-tab.md` + in-app → Task 3. ✓
- Invariant `accruingDays ≤ accruingDaysTotal` → vérifié en Task 1 Step 7. ✓
- Hors périmètre (RTT, récap, header) → aucun fichier de ces zones touché. ✓
**2. Placeholders :** aucun « TBD/TODO » ; tout le code est fourni. Les Steps 1-2 de Task 3 demandent un `grep` pour localiser l'ancre exacte (le texte à insérer est fourni intégralement) car la position dans `documentation-content.ts` dépend de l'article ; c'est une instruction d'insertion, pas un placeholder de contenu.
**3. Cohérence des types/noms :** `accruingDaysTotal` (float PHP / number TS) utilisé identiquement dans le provider, le tableau `targetSummary`, le PHPDoc, l'ApiResource, le DTO TS et le template. `accruingDays` (numérateur) reste inchangé. La variable `$accruingDaysTotal` est définie dans les deux branches avant la construction de `targetSummary` (comme `$accruingDays`).
@@ -0,0 +1,267 @@
# Exercice suivant dans les sélecteurs Congés et RTT — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Faire apparaître toujours l'exercice **suivant** (exercice courant + 1) dans les sélecteurs d'exercice des onglets Congés et RTT de la fiche employé, pour une phase de contrat ouverte.
**Architecture :** Changement **frontend uniquement**. Le backend calcule déjà l'exercice suivant pour une phase ouverte (`clampYearToPhase` ne plafonne pas vers le haut quand `phase.endDate` est nul). On déplace la borne haute (`maxYear`) des deux `computed` `availableLeaveYears` / `availableRttYears` de « exercice courant » à « exercice courant + 1 » lorsque la phase est ouverte ; la borne reste l'exercice de fin de phase pour une phase clôturée. La borne basse, la sélection par défaut (exercice courant) et le verrouillage des éditions (`isHistoricalYear`) sont inchangés.
**Tech Stack :** Nuxt 4 / Vue 3 / TypeScript (composables). Backend Symfony inchangé (les tests PHPUnit doivent rester verts). Pas de harnais de test frontend dans le projet → vérification manuelle via `make dev-nuxt`.
**Référence spec :** `docs/superpowers/specs/2026-05-26-exercice-suivant-conges-rtt-design.md`
---
## File Structure
- `frontend/composables/useEmployeeLeave.ts` — borne haute du sélecteur Congés (`availableLeaveYears`).
- `frontend/composables/useEmployeeRtt.ts` — borne haute du sélecteur RTT (`availableRttYears`).
- `doc/leave-tab.md` — doc fonctionnelle, section « Sélecteur d'année ».
- `doc/rtt-tab.md` — doc fonctionnelle, section « Sélecteur d'année ».
- `CLAUDE.md` — bullets « Sélecteur d'année » des sections Congés et RTT.
- `frontend/data/documentation-content.ts` — documentation in-app (2 paragraphes).
Aucun fichier créé ; 6 fichiers modifiés.
---
### Task 1 : Borne haute = exercice suivant sur les deux composables
**Files:**
- Modify: `frontend/composables/useEmployeeLeave.ts` (computed `availableLeaveYears`)
- Modify: `frontend/composables/useEmployeeRtt.ts` (computed `availableRttYears`)
- [ ] **Step 1 : Modifier `availableLeaveYears` dans `useEmployeeLeave.ts`**
Remplacer ce bloc (déclaration de `phaseEndYear`) :
```ts
// Plage = exercices intersectant la phase.
const phaseStartYear = computeLeaveYearForDate(new Date(`${phase.startDate}T00:00:00`))
const phaseEndYear = phase.endDate
? computeLeaveYearForDate(new Date(`${phase.endDate}T00:00:00`))
: currentLeaveYear.value
```
par :
```ts
// Plage = exercices intersectant la phase.
const phaseStartYear = computeLeaveYearForDate(new Date(`${phase.startDate}T00:00:00`))
// Borne haute : fin de phase si clôturée ; sinon l'exercice SUIVANT (courant + 1),
// pour pouvoir consulter en avance les congés posés sur l'exercice à venir.
const maxYear = phase.endDate
? computeLeaveYearForDate(new Date(`${phase.endDate}T00:00:00`))
: currentLeaveYear.value + 1
```
Puis, plus bas, supprimer la ligne devenue redondante en remplaçant :
```ts
const minYear = dataFloor !== null ? Math.max(phaseStartYear, dataFloor) : phaseStartYear
const maxYear = phaseEndYear
```
par :
```ts
const minYear = dataFloor !== null ? Math.max(phaseStartYear, dataFloor) : phaseStartYear
```
(La variable `maxYear` est désormais déclarée plus haut ; la boucle `for (let y = maxYear; ...)` qui suit est inchangée.)
- [ ] **Step 2 : Modifier `availableRttYears` dans `useEmployeeRtt.ts`**
Remplacer ce bloc :
```ts
// Plage = exercices intersectant la phase.
const phaseStartYear = computeRttYearForDate(new Date(`${phase.startDate}T00:00:00`))
const phaseEndYear = phase.endDate
? computeRttYearForDate(new Date(`${phase.endDate}T00:00:00`))
: currentRttYear.value
```
par :
```ts
// Plage = exercices intersectant la phase.
const phaseStartYear = computeRttYearForDate(new Date(`${phase.startDate}T00:00:00`))
// Borne haute : fin de phase si clôturée ; sinon l'exercice SUIVANT (courant + 1),
// pour rester cohérent avec le sélecteur de l'onglet Congés.
const maxYear = phase.endDate
? computeRttYearForDate(new Date(`${phase.endDate}T00:00:00`))
: currentRttYear.value + 1
```
Puis remplacer :
```ts
const minYear = dataFloor !== null ? Math.max(phaseStartYear, dataFloor) : phaseStartYear
const maxYear = phaseEndYear
```
par :
```ts
const minYear = dataFloor !== null ? Math.max(phaseStartYear, dataFloor) : phaseStartYear
```
- [ ] **Step 3 : Vérifier qu'il ne reste aucune référence à `phaseEndYear`**
Run: `grep -rn "phaseEndYear" frontend/composables/`
Expected: aucune sortie (la variable a été supprimée des deux fichiers).
- [ ] **Step 4 : Vérification manuelle dans le dev server**
Run: `make dev-nuxt` puis, dans le navigateur, ouvrir la fiche d'un employé **non-forfait avec phase courante ouverte**.
Attendu :
- Onglet **Congés** → le menu déroulant en pied de calendrier propose l'exercice **suivant** en première position (ex. aujourd'hui exercice `Juin 2025 → Mai 2026` → l'option `Juin 2026 → Mai 2027` est présente), et l'onglet s'ouvre **par défaut** sur l'exercice courant.
- Onglet **RTT** → idem, l'exercice suivant est proposé.
- Sélectionner l'exercice suivant : les boutons **Jours fractionnés** / **Année N-1 payés** (Congés) et **+ Payer les RTT** (RTT) sont **désactivés** (car `isHistoricalYear` = vrai), et aucun bandeau « Vous consultez l'historique » n'apparaît (ce bandeau dépend de la phase, pas de l'année).
- Ouvrir la fiche d'un employé ayant une **phase clôturée** (sélecteur « Vue contrat ») : le sélecteur d'exercice de cette phase **ne propose pas** d'exercice au-delà de la fin de phase (comportement inchangé).
- [ ] **Step 5 : Commit**
```bash
git add frontend/composables/useEmployeeLeave.ts frontend/composables/useEmployeeRtt.ts
git commit -m "feat : proposer l'exercice suivant dans les sélecteurs Congés et RTT
Sur une phase de contrat ouverte, la borne haute des sélecteurs d'exercice
(availableLeaveYears / availableRttYears) passe de l'exercice courant à
l'exercice suivant (courant + 1), pour consulter en avance les congés/RTT
posés sur l'exercice à venir. Phase clôturée : borne inchangée (fin de phase).
Sélection par défaut et verrouillage des éditions inchangés.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
(Le hook pre-commit lance PHPUnit ; les 151 tests doivent rester verts — aucun changement backend.)
---
### Task 2 : Mettre à jour la documentation
**Files:**
- Modify: `doc/leave-tab.md` (section « Sélecteur d'année »)
- Modify: `doc/rtt-tab.md` (section « Sélecteur d'année »)
- Modify: `CLAUDE.md` (bullets sélecteur Congés et RTT)
- Modify: `frontend/data/documentation-content.ts` (2 paragraphes)
- [ ] **Step 1 : `doc/leave-tab.md`**
Dans la section « ## Sélecteur d'année » (vers la ligne 26 « Plage proposée : »), remplacer la première puce :
```markdown
- du plus récent (= exercice courant) au plus ancien ;
```
> Remarque : si cette puce n'existe pas telle quelle dans ce fichier, ajouter à la place, juste après la ligne `Plage proposée :`, la puce ci-dessous.
par :
```markdown
- du plus récent au plus ancien. La borne haute est l'exercice **suivant** (exercice courant + 1) lorsque la phase de contrat est ouverte, afin de consulter en avance les congés posés sur l'exercice à venir ; pour une phase clôturée, la borne haute reste l'exercice de fin de phase ;
```
- [ ] **Step 2 : `doc/rtt-tab.md`**
Dans la section « ## Sélecteur d'année », remplacer la puce (ligne 24) :
```markdown
- du plus récent (= exercice courant) au plus ancien ;
```
par :
```markdown
- du plus récent au plus ancien. La borne haute est l'exercice **suivant** (exercice courant + 1) sur une phase ouverte (cohérent avec l'onglet Congés) ; pour une phase clôturée, elle reste l'exercice de fin de phase ;
```
- [ ] **Step 3 : `CLAUDE.md` — section Onglet Congés (ligne 76)**
Remplacer le segment :
```
Plage : de l'exercice courant jusqu'à `max(floor_contrat, floor_data_start_date)`
```
par :
```
Plage : de l'exercice **suivant** (exercice courant + 1 sur une phase ouverte ; exercice de fin de phase si clôturée) jusqu'à `max(floor_contrat, floor_data_start_date)`
```
- [ ] **Step 4 : `CLAUDE.md` — section Onglet RTT (ligne 83)**
Remplacer la phrase :
```
Même mécanique que l'onglet Congés (double plancher) : `max(floor_contrat, floor_rttStartDate)`.
```
par :
```
Même mécanique que l'onglet Congés : borne haute = exercice suivant (courant + 1) sur phase ouverte, double plancher `max(floor_contrat, floor_rttStartDate)`.
```
- [ ] **Step 5 : `frontend/data/documentation-content.ts` — paragraphe Congés (ligne 483)**
Remplacer le contenu du paragraphe :
```ts
{ type: 'paragraph', content: 'Un sélecteur d\'année est disponible en bas du calendrier (zone scrollable, à gauche). Il permet de consulter les exercices passés. La plage proposée part de l\'exercice courant et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel — il est inutile de remonter plus loin, aucune donnée n\'a été saisie avant.' },
```
par :
```ts
{ type: 'paragraph', content: 'Un sélecteur d\'année est disponible en bas du calendrier (zone scrollable, à gauche). Il permet de consulter l\'exercice suivant ainsi que les exercices passés. La plage proposée part de l\'exercice suivant (l\'exercice à venir, pour consulter en avance les congés déjà posés) et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel — il est inutile de remonter plus loin, aucune donnée n\'a été saisie avant.' },
```
- [ ] **Step 6 : `frontend/data/documentation-content.ts` — paragraphe RTT (ligne 539)**
Remplacer le contenu du paragraphe :
```ts
{ type: 'paragraph', content: 'Un sélecteur d\'exercice est disponible en bas du tableau RTT (zone scrollable, à gauche). Il permet de consulter les exercices passés (Juin → Mai). La plage proposée part de l\'exercice courant et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel.' },
```
par :
```ts
{ type: 'paragraph', content: 'Un sélecteur d\'exercice est disponible en bas du tableau RTT (zone scrollable, à gauche). Il permet de consulter l\'exercice suivant ainsi que les exercices passés (Juin → Mai). La plage proposée part de l\'exercice suivant et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel.' },
```
- [ ] **Step 7 : Vérifier la cohérence des chaînes éditées**
Run: `grep -n "exercice suivant" frontend/data/documentation-content.ts doc/leave-tab.md doc/rtt-tab.md CLAUDE.md`
Expected: les 6 emplacements modifiés ci-dessus apparaissent (2 dans documentation-content.ts, 1 dans chaque doc, 2 dans CLAUDE.md).
- [ ] **Step 8 : Commit**
```bash
git add doc/leave-tab.md doc/rtt-tab.md CLAUDE.md frontend/data/documentation-content.ts
git commit -m "docs : sélecteurs Congés/RTT proposent l'exercice suivant
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
## Self-Review
**1. Couverture de la spec :**
- « Borne haute = exercice courant + 1 sur phase ouverte, fin de phase si clôturée » → Task 1, Steps 1-2. ✓
- « Forfait (année civile) et non-forfait » → le `+1` porte sur le numéro d'exercice produit par `computeLeaveYearForDate`/`computeRttYearForDate`, donc valable pour les deux règles. ✓
- « Sélection par défaut inchangée » → aucun changement à `initSelected*Year` ; vérifié en Step 4. ✓
- « Verrouillage des éditions / pas de bandeau passé » → aucun changement à `isHistoricalYear` ; vérifié en Step 4. ✓
- « Périmètre Congés + RTT » → Task 1 touche les deux composables. ✓
- « Docs : leave-tab.md, rtt-tab.md, CLAUDE.md, documentation-content.ts » → Task 2. ✓
- « Tests backend restent verts » → hook pre-commit, Task 1 Step 5. ✓
**2. Placeholders :** aucun « TBD/TODO » ; tout le code et toutes les chaînes sont fournis. La Step 1 de Task 2 prévoit le cas où l'ancre exacte diffère (instruction de repli explicite).
**3. Cohérence des types/noms :** la variable `maxYear` est désormais déclarée en amont dans les deux composables ; la ligne `const maxYear = phaseEndYear` est supprimée ; `phaseEndYear` n'existe plus (vérifié Step 3). La boucle `for (let y = maxYear; y >= minYear; ...)` reste valide.
@@ -0,0 +1,94 @@
# Afficher l'en-cours d'acquisition « net / brut » sur l'onglet Congés
**Date** : 2026-05-26
**Ticket** : SIRH-32 (retour RH)
**Statut** : design validé
## Contexte
Sur l'onglet **Congés** de la fiche employé, la case **« En cours d'acquisition »**
affiche un seul nombre. Ce nombre est l'en-cours **net** : quand un salarié pose
des congés **en anticipé** (au-delà de son report acquis), ces jours sont imputés
sur la génération de l'exercice et **réduisent** l'en-cours affiché.
La RH a besoin de voir aussi le **total brut généré** sur l'exercice à ce jour,
afin de connaître ce qui a réellement été acquis indépendamment de ce qui a déjà
été consommé en anticipé.
Nouveau format demandé : `{net} / {brut}` — ex. `14,50 / 17,50`.
## Définitions (non-forfait, `CDI_CDD_NON_FORFAIT`)
Dans `EmployeeLeaveSummaryProvider::computeYearSummary` :
- `generatedDays` / `generatedSaturdays` = acquisition **brute** de l'exercice à
ce jour (prorata mensuel, capée à aujourd'hui pour l'exercice courant).
- `remainingToImpute` (+ samedis) = congés pris au-delà du report acquis, imputés
sur la génération.
- `accruingDays` (champ existant, **numérateur**) =
`(generatedDays remainingToImpute) + (generatedSaturdays remainingSaturdaysToImpute)`
→ en-cours **net**.
- **Nouveau** : `accruingDaysTotal` (**dénominateur**) = `generatedDays + generatedSaturdays`
→ brut généré à ce jour, avant imputation des congés anticipés.
Invariant : `accruingDays ≤ accruingDaysTotal`. La différence = jours pris en
anticipé imputés sur la génération.
Côté **forfait** (`FORFAIT_218`) : pas d'acquisition « en cours »
(`accruingDays = 0`) → `accruingDaysTotal = 0`.
## Changements
### 1. Backend
- `EmployeeLeaveSummaryProvider::computeYearSummary` : ajouter
`accruingDaysTotal` au tableau retourné.
- Branche non-forfait : `generatedDays + generatedSaturdays`.
- Branche forfait : `0.0`.
- `App\ApiResource\EmployeeLeaveSummary` : nouvelle propriété publique
`float $accruingDaysTotal = 0.0`.
- Le provider recopie `yearSummary['accruingDaysTotal']` dans
`$summary->accruingDaysTotal` (à côté de `accruingDays`). Sur retour anticipé
(`yearSummary === null`), la valeur reste `0.0` (comme `accruingDays`).
### 2. Frontend
- `frontend/services/dto/employee-leave-summary.ts` : ajouter
`accruingDaysTotal: number`.
- `frontend/components/employees/LeaveTab.vue`, case « En cours d'acquisition » :
- **Non-forfait** : afficher `{{ formatCount(accruingDays) }} / {{ formatCount(accruingDaysTotal) }}`.
- **Forfait** : inchangé (afficher `accruingDays` seul, soit `0`).
- Réutiliser `isForfaitRule` (déjà présent) pour la condition.
### 3. Documentation
- `doc/leave-tab.md` : décrire le format `net / brut` de l'en-cours d'acquisition.
- `frontend/data/documentation-content.ts` : note expliquant que l'en-cours
affiche `net / total brut acquis` et que les congés anticipés réduisent le net.
## Comportements conservés
- Quand aucun congé anticipé n'est pris : numérateur = dénominateur
(ex. `17,50 / 17,50`). C'est voulu — la RH veut le total visible en permanence.
- Les autres compteurs du header (Année acquis, Pris, Reste à prendre, Samedis,
N-1…) sont inchangés.
- Aucun changement de calcul : `accruingDaysTotal` est une **exposition** d'une
valeur déjà calculée (`generatedDays + generatedSaturdays`), pas une nouvelle
règle métier.
## Hors périmètre
- Onglet RTT (pas d'en-cours d'acquisition).
- Écran Récap congés (pas de colonne en-cours d'acquisition).
- Header de la fiche employé (présence / jours à travailler) — inchangé.
- Affichage de la fraction pour le forfait (pas d'en-cours → non pertinent).
## Tests
- Backend : ajouter un test sur `computeYearSummary` (via le harnais de tests
existant du provider, par réflexion) vérifiant que `accruingDaysTotal` =
`generatedDays + generatedSaturdays` et `≥ accruingDays` dans un cas avec congés
anticipés. À défaut de chemin testable simple (collaborateurs `final`), couvrir
l'arithmétique exposée et vérifier manuellement l'affichage.
- Pas de harnais de test frontend ; vérification manuelle de l'affichage
`net / brut` (non-forfait) et `0` (forfait).
@@ -0,0 +1,118 @@
# Proposer toujours l'exercice suivant dans les sélecteurs Congés et RTT
**Date** : 2026-05-26
**Ticket** : SIRH-32
**Statut** : design validé
## Contexte
Les onglets **Congés** et **RTT** de la fiche employé proposent un sélecteur
d'exercice (`availableLeaveYears` / `availableRttYears`) dont la borne haute est
plafonnée à l'**exercice courant**. La RH a commencé à poser des congés sur
l'**exercice suivant**, mais ne peut pas le consulter dans la fiche employé :
l'exercice suivant n'apparaît pas dans le menu déroulant.
On veut que le sélecteur propose **toujours** l'exercice suivant pour une phase
de contrat ouverte, afin que ce besoin ne ressurgisse jamais.
## Faisabilité — déjà supportée côté backend
Aucun changement backend n'est nécessaire :
- `EmployeeLeaveSummaryProvider::clampYearToPhase` et son équivalent RTT ne
plafonnent **pas** vers le haut quand la phase est ouverte (`phase.endDate`
nul → `lastYear = null`). Une requête `?year=<exercice+1>&phaseId=<phase ouverte>`
est donc déjà calculée correctement.
- La validation `year` (20002100) couvre largement l'exercice suivant.
Le seul blocage est le **frontend**, qui calcule `maxYear = exercice courant`.
## Le changement
Frontend uniquement. Dans `frontend/composables/useEmployeeLeave.ts`
(`availableLeaveYears`) et `frontend/composables/useEmployeeRtt.ts`
(`availableRttYears`), la borne haute devient :
- **Phase ouverte** (pas de `phase.endDate`) :
`maxYear = exercice courant + 1`.
- **Phase clôturée** (`phase.endDate` présent) : **inchangé**
`maxYear = exercice de fin de phase` (on ne propose pas au-delà d'une phase
terminée).
Le `+1` porte sur le **numéro d'exercice**, donc il est correct pour le forfait
(année civile) comme pour le non-forfait (Juin N-1 → Mai N), via
`computeLeaveYearForDate` / `computeRttYearForDate`.
### Pseudo-code de la borne
```
maxYear = phase.endDate
? computeYearForDate(phase.endDate) // phase clôturée : cap à la fin de phase
: currentYear + 1 // phase ouverte : on propose l'exercice suivant
```
La borne basse (`minYear = max(phaseStartYear, dataFloor)`) est **inchangée**.
## Comportements conservés
- **Sélection par défaut** : inchangée. L'onglet s'ouvre toujours sur l'exercice
**courant** ; l'exercice suivant est seulement disponible dans le menu (pas de
saut automatique sur le futur). `initSelected*Year` continue d'initialiser sur
`current*Year`, qui reste dans la plage `[minYear ; maxYear]`.
- **Verrouillage des éditions** : `isHistoricalYear` (`selectedYear !== currentYear`)
reste tel quel. Sur l'exercice suivant, les boutons **Jours fractionnés**,
**Année N-1 payés** (onglet Congés) et **+ Payer les RTT** (onglet RTT) sont
**désactivés** — souhaitable : pas d'édition de stocks ni de paiement sur un
exercice pas encore démarré.
- **Aucune mention « passé » trompeuse** : le bandeau « Vous consultez
l'historique » est piloté par la phase (`isViewingPastPhase`), pas par l'année
sélectionnée ; sélectionner un exercice futur ne l'affiche pas.
## Affichage des congés posés sur l'exercice suivant (réponse Q1)
Le header congés (grille de compteurs de l'onglet + libellé présence du header
de fiche) reflète **le récap de l'exercice sélectionné**. Chaque récap est
calculé sur sa fenêtre `[from, to]` ; les absences/jours pris ne sont comptés que
dans cette fenêtre.
- **Sur l'exercice courant** (vue par défaut) : les congés posés sur l'exercice
suivant **n'apparaissent pas** dans les compteurs — comportement correct.
- **Sur l'exercice suivant** (sélectionné) : ils s'affichent (calendrier +
compteur « Pris »).
### Caveat fonctionnel
Sur l'exercice suivant, les compteurs **report / Année N-1 / reste** sont
**provisoires** jusqu'à la clôture de l'exercice courant (ils en dépendent). En
revanche, le **« Pris » et le calendrier** des congés posés sont exacts. À
communiquer à la RH.
## Périmètre
- Onglet **Congés** et onglet **RTT** (les deux sélecteurs partagent la même
mécanique).
- Forfait (année civile) et non-forfait (Juin→Mai).
## Hors périmètre
- Aucune modification de la mécanique de saisie d'absences (la RH pose déjà des
congés sur l'exercice suivant via les écrans Calendrier / Heures, indépendamment
de ce sélecteur).
- Pas de proposition de plusieurs exercices futurs (un seul : N+1).
- Pas d'activation des éditions de stocks/paiement sur l'exercice futur.
## Documentation à mettre à jour (règle obligatoire CLAUDE.md)
- `doc/leave-tab.md` — plage du sélecteur.
- `doc/rtt-tab.md` — plage du sélecteur.
- `CLAUDE.md` — sections « Onglet Congés » et « Onglet RTT » (description de la
plage `max(...)` → borne haute `exercice courant + 1` sur phase ouverte).
- `frontend/data/documentation-content.ts` — documentation in-app.
## Tests
Pas de harnais de test frontend dans le projet (backend PHPUnit uniquement). La
modification est de la logique de calcul de plage dans deux `computed` :
vérification manuelle (dev Nuxt) que l'exercice suivant apparaît dans les deux
sélecteurs pour une phase ouverte, et n'apparaît pas pour une phase clôturée. Les
tests backend existants doivent rester verts (aucun changement backend).
+1 -1
View File
@@ -12,7 +12,7 @@
{{ formatCount(summary?.remainingDays) }} Jours
</p>
<p class="col-start-4 p-[10px] border-b border-primary-500"><strong class="uppercase font-semibold">En cours d'acquisition :</strong>
{{ formatCount(summary?.accruingDays) }} Jours
{{ isForfaitRule ? formatCount(summary?.accruingDays) : `${formatCount(summary?.accruingDays)} / ${formatCount(summary?.accruingDaysTotal)}` }}
</p>
<p v-if="!isForfaitRule" class="col-start-1 p-[10px] border-r border-primary-500"><span class="uppercase font-semibold">Samedi acquis :</span>
{{ formatCount(summary?.acquiredSaturdays) }} Jours
+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.
+4 -2
View File
@@ -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.' },
],
},
@@ -10,6 +10,7 @@ export type EmployeeLeaveSummary = {
takenSaturdays: number
fractionedDays: number
accruingDays: number
accruingDaysTotal: number
previousYearAcquiredDays: number
previousYearTakenDays: number
previousYearRemainingDays: number
+14 -11
View File
@@ -20,17 +20,20 @@ use App\State\EmployeeLeaveSummaryProvider;
)]
final class EmployeeLeaveSummary
{
public int $year = 0;
public bool $isSupported = false;
public string $ruleCode = '';
public float $acquiredDays = 0.0;
public float $remainingDays = 0.0;
public float $takenDays = 0.0;
public float $acquiredSaturdays = 0.0;
public float $remainingSaturdays = 0.0;
public float $takenSaturdays = 0.0;
public float $fractionedDays = 0.0;
public float $accruingDays = 0.0;
public int $year = 0;
public bool $isSupported = false;
public string $ruleCode = '';
public float $acquiredDays = 0.0;
public float $remainingDays = 0.0;
public float $takenDays = 0.0;
public float $acquiredSaturdays = 0.0;
public float $remainingSaturdays = 0.0;
public float $takenSaturdays = 0.0;
public float $fractionedDays = 0.0;
public float $accruingDays = 0.0;
/** Brut généré sur l'exercice à ce jour (= accruingDays + congés pris en anticipé). Dénominateur de l'affichage « net / brut ». */
public float $accruingDaysTotal = 0.0;
public float $previousYearAcquiredDays = 0.0;
public float $previousYearTakenDays = 0.0;
public float $previousYearRemainingDays = 0.0;
+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);
+9 -2
View File
@@ -123,6 +123,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$summary->acquiredSaturdays = $yearSummary['acquiredSaturdays'];
$summary->fractionedDays = $fractionedDays;
$summary->accruingDays = $yearSummary['accruingDays'];
$summary->accruingDaysTotal = $yearSummary['accruingDaysTotal'];
$summary->takenDays = $yearSummary['takenDays'];
$summary->takenSaturdays = $yearSummary['takenSaturdays'];
$summary->remainingDays = $yearSummary['remainingDays'] + $fractionedDays;
@@ -186,6 +187,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
* acquiredDays: float,
* acquiredSaturdays: float,
* accruingDays: float,
* accruingDaysTotal: float,
* takenDays: float,
* takenSaturdays: float,
* remainingDays: float,
@@ -336,8 +338,11 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$remainingSaturdaysToImpute = max(0.0, $takenSaturdays - $takenFromAcquiredSaturdays);
$remainingGeneratedSaturdays = $generatedSaturdays - $remainingSaturdaysToImpute;
$acquiredDays = $carryDays;
$accruingDays = $remainingGenerated + $remainingGeneratedSaturdays;
$acquiredDays = $carryDays;
$accruingDays = $remainingGenerated + $remainingGeneratedSaturdays;
// Brut généré à ce jour, AVANT imputation des congés pris en anticipé
// (dénominateur de l'affichage « net / brut » sur l'onglet Congés).
$accruingDaysTotal = $generatedDays + $generatedSaturdays;
$remainingDays = $remainingAcquired;
$acquiredSaturdays = $carrySaturdays;
$remainingSaturdays = max(0.0, $remainingAcquiredSaturdays);
@@ -359,6 +364,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$acquiredDays = $leavePolicy['acquiredDays'];
$accruingDays = 0.0;
$accruingDaysTotal = 0.0;
$remainingDays = max(0.0, $acquiredDays - $takenFromCurrent);
$acquiredSaturdays = 0.0;
$remainingSaturdays = 0.0;
@@ -373,6 +379,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
'acquiredDays' => $acquiredDays,
'acquiredSaturdays' => $acquiredSaturdays,
'accruingDays' => $accruingDays,
'accruingDaysTotal' => $accruingDaysTotal,
'takenDays' => $takenDays,
'takenSaturdays' => $takenSaturdays,
'remainingDays' => $remainingDays,