Gestion du changement de type de contrat + correction du calcule des RTT sur un contrat qui commence en milieu de semaine (#19)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket | |------------------|-----------------| | | | ## Description de la PR ## Modification du .env ## Check list - [x] Pas de régression - [x] TU/TI/TF rédigée - [x] TU/TI/TF OK - [x] CHANGELOG modifié Reviewed-on: #19 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #19.
This commit is contained in:
1607
docs/superpowers/plans/2026-05-19-contract-phase-view.md
Normal file
1607
docs/superpowers/plans/2026-05-19-contract-phase-view.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,503 @@
|
||||
# FORFAIT mid-year entry — congés à poser Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Quand un employé entre en FORFAIT en cours d'année civile, calculer les congés à poser comme `jours_de_repos_proratisés + CP_acquis_reportés_de_la_phase_précédente`, au lieu du `max(0, businessDays − 218)` actuel qui retourne 0.
|
||||
|
||||
**Architecture:** Modification ciblée de `EmployeeLeaveSummaryProvider`. Uniquement la **branche FORFAIT de `resolveLeavePolicy`**, et **uniquement l'année civile d'entrée** (période partielle). Les forfaits sur année pleine gardent le calcul 218 existant (régression nulle). Les jours de repos proratisés sont une arithmétique pure (testable isolément). Les CP reportés sont sourcés en ré-exécutant `computeYearSummary` sur la phase non-forfait précédente (récursion à un seul niveau, terminée car la phase précédente n'est pas FORFAIT).
|
||||
|
||||
**Tech Stack:** Symfony, API Platform State Provider, PHPUnit 12 (tests par réflexion sur `newInstanceWithoutConstructor`, cf. classes finales non-mockables).
|
||||
|
||||
**Règle métier de référence :** mémoire `forfait-218-prorata-pending`, validée par la comptable 2026-05-20. Cas témoin : Grégory BARRIBAULT (id 41), FORFAIT depuis 2026-05-01, précédé d'une phase 39h → **13 jours à poser** (`6 repos + ~7 CP nets`).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Modify** `src/State/EmployeeLeaveSummaryProvider.php`
|
||||
- Nouvelle constante `FORFAIT_STANDARD_CP_DAYS = 25`
|
||||
- Nouvelle méthode pure `computeProratedForfaitRepoDays(int $businessDaysYear, int $businessDaysPeriod): float`
|
||||
- Nouvelle méthode `isForfaitEntryYear(ContractPhase $phase, int $year): bool`
|
||||
- Nouvelle méthode `resolvePhaseImmediatelyBefore(Employee $employee, ContractPhase $phase): ?ContractPhase`
|
||||
- Nouvelle méthode `resolveCarriedCpFromPriorPhase(Employee $employee, ContractPhase $forfaitPhase): float`
|
||||
- Modification de la branche FORFAIT de `resolveLeavePolicy` (ligne ~713)
|
||||
- **Modify** `tests/State/EmployeeLeaveSummaryProviderTest.php` (nouveaux tests, helpers existants réutilisés)
|
||||
- **Modify** `CLAUDE.md` (section « Vue contrat » + « Onglet Congés »)
|
||||
- **Modify** `doc/contract-phase-view.md` (section transition d'exercice FORFAIT)
|
||||
- **Modify** `frontend/data/documentation-content.ts` (doc in-app onglet Congés FORFAIT)
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Repos proratisés — helper arithmétique pur
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/State/EmployeeLeaveSummaryProvider.php`
|
||||
- Test: `tests/State/EmployeeLeaveSummaryProviderTest.php`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Ajouter dans `tests/State/EmployeeLeaveSummaryProviderTest.php` :
|
||||
|
||||
```php
|
||||
public function testComputeProratedForfaitRepoDaysGregoryCase(): void
|
||||
{
|
||||
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
|
||||
|
||||
// 2026 : 252 jours ouvrés/an, 168 sur la période 01/05→31/12.
|
||||
// repos année = 252 - 218 - 25 = 9 ; proratisé = 9 × 168/252 = 6.0
|
||||
$result = $this->invokePrivate($provider, 'computeProratedForfaitRepoDays', 252, 168);
|
||||
|
||||
self::assertEqualsWithDelta(6.0, $result, 0.001);
|
||||
}
|
||||
|
||||
public function testComputeProratedForfaitRepoDaysFullYearEquals9(): void
|
||||
{
|
||||
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
|
||||
|
||||
// Année pleine : 9 × 252/252 = 9.0
|
||||
$result = $this->invokePrivate($provider, 'computeProratedForfaitRepoDays', 252, 252);
|
||||
|
||||
self::assertEqualsWithDelta(9.0, $result, 0.001);
|
||||
}
|
||||
|
||||
public function testComputeProratedForfaitRepoDaysClampsNegativeToZero(): void
|
||||
{
|
||||
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
|
||||
|
||||
// Année avec trop peu de jours ouvrés (240 - 218 - 25 < 0) → 0
|
||||
$result = $this->invokePrivate($provider, 'computeProratedForfaitRepoDays', 240, 160);
|
||||
|
||||
self::assertSame(0.0, $result);
|
||||
}
|
||||
|
||||
public function testComputeProratedForfaitRepoDaysZeroYearGuard(): void
|
||||
{
|
||||
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
|
||||
|
||||
self::assertSame(0.0, $this->invokePrivate($provider, 'computeProratedForfaitRepoDays', 0, 0));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `make test` (ou `docker compose exec -T php vendor/bin/phpunit tests/State/EmployeeLeaveSummaryProviderTest.php --filter computeProratedForfaitRepoDays`)
|
||||
Expected: FAIL — `Method computeProratedForfaitRepoDays does not exist`.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Dans `src/State/EmployeeLeaveSummaryProvider.php`, ajouter la constante près de `FORFAIT_TARGET_WORKED_DAYS` (ligne ~40) :
|
||||
|
||||
```php
|
||||
private const int FORFAIT_TARGET_WORKED_DAYS = 218;
|
||||
private const int FORFAIT_STANDARD_CP_DAYS = 25;
|
||||
```
|
||||
|
||||
Et la méthode (à placer près de `resolveLeavePolicy`) :
|
||||
|
||||
```php
|
||||
/**
|
||||
* Jours de repos forfait proratisés sur la fraction de jours ouvrés couverte.
|
||||
*
|
||||
* Repos année pleine = jours_ouvrés_année − 218 (cible travaillée) − 25 (CP standard).
|
||||
* Pour 2026 : 252 − 218 − 25 = 9, proratisés au ratio jours_ouvrés_période / jours_ouvrés_année.
|
||||
*/
|
||||
private function computeProratedForfaitRepoDays(int $businessDaysYear, int $businessDaysPeriod): float
|
||||
{
|
||||
if ($businessDaysYear <= 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$repoDaysYear = max(0, $businessDaysYear - self::FORFAIT_TARGET_WORKED_DAYS - self::FORFAIT_STANDARD_CP_DAYS);
|
||||
|
||||
return $repoDaysYear * $businessDaysPeriod / $businessDaysYear;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `docker compose exec -T php vendor/bin/phpunit tests/State/EmployeeLeaveSummaryProviderTest.php --filter computeProratedForfaitRepoDays`
|
||||
Expected: PASS (4 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/State/EmployeeLeaveSummaryProvider.php tests/State/EmployeeLeaveSummaryProviderTest.php
|
||||
git commit -m "feat(leave) : add prorated forfait repo days helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Détection de l'année d'entrée en forfait
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/State/EmployeeLeaveSummaryProvider.php`
|
||||
- Test: `tests/State/EmployeeLeaveSummaryProviderTest.php`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```php
|
||||
public function testIsForfaitEntryYearTrueOnStartYear(): void
|
||||
{
|
||||
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||
$forfaitPhase = new EmployeeContractPhaseResolver()->resolvePhases($employee)[0];
|
||||
$provider = $this->buildProvider();
|
||||
|
||||
self::assertTrue($this->invokePrivate($provider, 'isForfaitEntryYear', $forfaitPhase, 2026));
|
||||
}
|
||||
|
||||
public function testIsForfaitEntryYearFalseOnSubsequentFullYear(): void
|
||||
{
|
||||
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||
$forfaitPhase = new EmployeeContractPhaseResolver()->resolvePhases($employee)[0];
|
||||
$provider = $this->buildProvider();
|
||||
|
||||
self::assertFalse($this->invokePrivate($provider, 'isForfaitEntryYear', $forfaitPhase, 2027));
|
||||
}
|
||||
|
||||
public function testIsForfaitEntryYearFalseWhenForfaitStartsJan1(): void
|
||||
{
|
||||
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2025-12-31', '2026-01-01');
|
||||
$forfaitPhase = new EmployeeContractPhaseResolver()->resolvePhases($employee)[0];
|
||||
$provider = $this->buildProvider();
|
||||
|
||||
// Forfait démarrant un 1er janvier = année pleine, pas une entrée en cours d'année.
|
||||
self::assertFalse($this->invokePrivate($provider, 'isForfaitEntryYear', $forfaitPhase, 2026));
|
||||
}
|
||||
|
||||
public function testIsForfaitEntryYearFalseForNonForfaitPhase(): void
|
||||
{
|
||||
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||
$h39Phase = new EmployeeContractPhaseResolver()->resolvePhases($employee)[1];
|
||||
$provider = $this->buildProvider();
|
||||
|
||||
self::assertFalse($this->invokePrivate($provider, 'isForfaitEntryYear', $h39Phase, 2026));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `docker compose exec -T php vendor/bin/phpunit tests/State/EmployeeLeaveSummaryProviderTest.php --filter isForfaitEntryYear`
|
||||
Expected: FAIL — `Method isForfaitEntryYear does not exist`.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```php
|
||||
/**
|
||||
* Vrai si la phase FORFAIT démarre en cours de l'année civile consultée
|
||||
* (donc avec une période partielle), faux pour une année pleine ou un démarrage le 1er janvier.
|
||||
*/
|
||||
private function isForfaitEntryYear(ContractPhase $phase, int $year): bool
|
||||
{
|
||||
if (ContractType::FORFAIT !== $phase->contractType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) $phase->startDate->format('Y') === $year
|
||||
&& '01-01' !== $phase->startDate->format('m-d');
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `docker compose exec -T php vendor/bin/phpunit tests/State/EmployeeLeaveSummaryProviderTest.php --filter isForfaitEntryYear`
|
||||
Expected: PASS (4 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/State/EmployeeLeaveSummaryProvider.php tests/State/EmployeeLeaveSummaryProviderTest.php
|
||||
git commit -m "feat(leave) : detect forfait mid-year entry exercise"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Résolution de la phase immédiatement précédente
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/State/EmployeeLeaveSummaryProvider.php`
|
||||
- Test: `tests/State/EmployeeLeaveSummaryProviderTest.php`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```php
|
||||
public function testResolvePhaseImmediatelyBeforeReturnsPriorH39Phase(): void
|
||||
{
|
||||
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||
$forfaitPhase = $phases[0]; // current FORFAIT
|
||||
$h39Phase = $phases[1];
|
||||
$provider = $this->buildProvider();
|
||||
|
||||
$prior = $this->invokePrivate($provider, 'resolvePhaseImmediatelyBefore', $employee, $forfaitPhase);
|
||||
|
||||
self::assertNotNull($prior);
|
||||
self::assertSame($h39Phase->id, $prior->id);
|
||||
self::assertSame(ContractType::THIRTY_NINE_HOURS, $prior->contractType);
|
||||
}
|
||||
|
||||
public function testResolvePhaseImmediatelyBeforeReturnsNullForFirstPhase(): void
|
||||
{
|
||||
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||
$firstPhase = $phases[1]; // the H39 (earliest)
|
||||
$provider = $this->buildProvider();
|
||||
|
||||
self::assertNull($this->invokePrivate($provider, 'resolvePhaseImmediatelyBefore', $employee, $firstPhase));
|
||||
}
|
||||
```
|
||||
|
||||
> Note : si la signature exacte de `ContractType` du 39h n'est pas `THIRTY_NINE_HOURS`, ajuster l'assertion en lisant `src/Enum/ContractType.php`. Le test reste valide via `$prior->id`.
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `docker compose exec -T php vendor/bin/phpunit tests/State/EmployeeLeaveSummaryProviderTest.php --filter resolvePhaseImmediatelyBefore`
|
||||
Expected: FAIL — `Method resolvePhaseImmediatelyBefore does not exist`.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```php
|
||||
/**
|
||||
* Phase dont la date de début est la plus proche en deçà de celle de $phase
|
||||
* (la phase qui précède immédiatement). Null si $phase est la première.
|
||||
*/
|
||||
private function resolvePhaseImmediatelyBefore(Employee $employee, ContractPhase $phase): ?ContractPhase
|
||||
{
|
||||
$prior = null;
|
||||
foreach ($this->phaseResolver->resolvePhases($employee) as $candidate) {
|
||||
if ($candidate->startDate >= $phase->startDate) {
|
||||
continue;
|
||||
}
|
||||
if (null === $prior || $candidate->startDate > $prior->startDate) {
|
||||
$prior = $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return $prior;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `docker compose exec -T php vendor/bin/phpunit tests/State/EmployeeLeaveSummaryProviderTest.php --filter resolvePhaseImmediatelyBefore`
|
||||
Expected: PASS (2 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/State/EmployeeLeaveSummaryProvider.php tests/State/EmployeeLeaveSummaryProviderTest.php
|
||||
git commit -m "feat(leave) : resolve phase immediately preceding a given phase"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Source des CP reportés depuis la phase précédente
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/State/EmployeeLeaveSummaryProvider.php`
|
||||
|
||||
**Pas de test unitaire isolé** : la méthode ré-exécute `computeYearSummary` (dépendances repository non-mockables, cf. note `buildProvider`). La validation se fait en intégration (Task 6) contre le cas Grégory.
|
||||
|
||||
- [ ] **Step 1: Write implementation**
|
||||
|
||||
```php
|
||||
/**
|
||||
* CP nets encore disponibles (jours + samedis) hérités de la phase non-forfait
|
||||
* précédant immédiatement une entrée en FORFAIT. 0 si aucune phase précédente
|
||||
* ou si la précédente est elle-même un FORFAIT (nouvel embauché → cas 2).
|
||||
*
|
||||
* Le total disponible = remainingDays (acquis restant) + accruingDays (généré
|
||||
* restant, samedis générés inclus) + remainingSaturdays (samedis acquis restant).
|
||||
* Les congés déjà posés sous la phase précédente sont déjà déduits par
|
||||
* computeYearSummary, donc on récupère bien le NET (ex. Grégory : 12 acquis − 5 pris ≈ 7).
|
||||
*/
|
||||
private function resolveCarriedCpFromPriorPhase(Employee $employee, ContractPhase $forfaitPhase): float
|
||||
{
|
||||
$prior = $this->resolvePhaseImmediatelyBefore($employee, $forfaitPhase);
|
||||
if (null === $prior || ContractType::FORFAIT === $prior->contractType) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$reference = $prior->endDate ?? new DateTimeImmutable('today');
|
||||
$priorYear = $this->exerciseYearResolver->forDate($reference, false);
|
||||
|
||||
$summary = $this->computeYearSummary($employee, $priorYear, 0.0, null, $prior);
|
||||
if (null === $summary) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return $summary['remainingDays'] + $summary['accruingDays'] + $summary['remainingSaturdays'];
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify it compiles / no regression**
|
||||
|
||||
Run: `docker compose exec -T php vendor/bin/phpunit tests/State/EmployeeLeaveSummaryProviderTest.php`
|
||||
Expected: PASS (suite inchangée — la méthode n'est pas encore appelée).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/State/EmployeeLeaveSummaryProvider.php
|
||||
git commit -m "feat(leave) : source carried CP from prior non-forfait phase"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Brancher dans la branche FORFAIT de `resolveLeavePolicy`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/State/EmployeeLeaveSummaryProvider.php:713` (branche `if (ContractType::FORFAIT === $type)`)
|
||||
|
||||
- [ ] **Step 1: Write implementation**
|
||||
|
||||
Au tout début de la branche FORFAIT de `resolveLeavePolicy`, avant le calcul `$businessDaysInPeriod` existant, insérer la dérivation « année d'entrée » :
|
||||
|
||||
```php
|
||||
$type = $phase->contractType;
|
||||
if (ContractType::FORFAIT === $type) {
|
||||
$year = (int) $from->format('Y'); // période forfait = année civile
|
||||
|
||||
// Entrée en FORFAIT en cours d'année : repos proratisés + CP reportés de
|
||||
// la phase précédente (au lieu de max(0, businessDays − 218) qui donne 0).
|
||||
if ($this->isForfaitEntryYear($phase, $year)) {
|
||||
$yearStart = new DateTimeImmutable(sprintf('%d-01-01 00:00:00', $year));
|
||||
$yearEnd = new DateTimeImmutable(sprintf('%d-12-31 00:00:00', $year));
|
||||
$rawYearHolidays = $this->buildRawPublicHolidayMap($yearStart, $yearEnd);
|
||||
|
||||
$businessDaysYear = $this->countBusinessDays($yearStart, $yearEnd, $rawYearHolidays);
|
||||
$businessDaysPeriod = $this->countBusinessDays($from, $to, $rawYearHolidays);
|
||||
|
||||
$repoDays = $this->computeProratedForfaitRepoDays($businessDaysYear, $businessDaysPeriod);
|
||||
$carriedCp = $this->resolveCarriedCpFromPriorPhase($employee, $phase);
|
||||
|
||||
return [
|
||||
'ruleCode' => LeaveRuleCode::FORFAIT_218->value,
|
||||
'acquiredDays' => $repoDays + $carriedCp,
|
||||
'acquiredSaturdays' => 0.0,
|
||||
'accrualPerMonth' => 0.0,
|
||||
'saturdayAccrualPerMonth' => 0.0,
|
||||
'countOnlyCp' => false,
|
||||
'splitSaturdays' => false,
|
||||
];
|
||||
}
|
||||
|
||||
// Année pleine : calcul 218 existant (INCHANGÉ).
|
||||
$businessDaysInPeriod = $this->countBusinessDays($from, $to, $this->buildRawPublicHolidayMap($from, $to));
|
||||
// ... reste de la branche FORFAIT existante (bonusDays + return) ...
|
||||
```
|
||||
|
||||
> Conserver tel quel tout le bloc existant (`$publicHolidays`, `$weekdayHolidays`, `$bonusDays`, `return [...]`) pour le chemin année pleine. N'ajouter QUE le bloc `if ($this->isForfaitEntryYear(...))` au-dessus.
|
||||
|
||||
- [ ] **Step 2: Run the full provider test suite**
|
||||
|
||||
Run: `docker compose exec -T php vendor/bin/phpunit tests/State/EmployeeLeaveSummaryProviderTest.php`
|
||||
Expected: PASS — les tests existants forfait année pleine (ex. `testForfaitPhaseStartingMidYearCapsFromAtPhaseStart`) restent verts (ils testent les bornes, pas l'acquired ; vérifier qu'aucun n'assert l'ancien acquired=0 sur l'entrée — si oui, mettre à jour cette assertion vers la nouvelle valeur).
|
||||
|
||||
- [ ] **Step 3: Run the whole backend suite**
|
||||
|
||||
Run: `make test`
|
||||
Expected: PASS (suite complète).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/State/EmployeeLeaveSummaryProvider.php
|
||||
git commit -m "feat(leave) : forfait mid-year entry credits prorated repos + carried CP"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Validation d'intégration contre Grégory (id 41) — ✅ FAIT
|
||||
|
||||
**Résultat (probe sur BDD prod locale, supprimée après usage) :**
|
||||
| Cas | Employé | Obtenu | Attendu |
|
||||
|---|---|---|---|
|
||||
| 1 — entrée après non-forfait | Grégory 2026 | 12.94 ≈ 13 | 13 ✓ |
|
||||
| Non-régression année pleine | Geoffrey 2026 | 34.0 | 34 ✓ |
|
||||
| 2 — nouvel embauché | Olivier 2025 | 0.54 (repos seuls) | repos seuls ✓ |
|
||||
|
||||
**Décision métier (Tristan) :** un **samedi de congé déjà posé** sous la phase précédente ne réduit PAS le report (seuls les jours ouvrés posés le réduisent). Implémenté en ré-ajoutant `takenSaturdays` dans `resolveCarriedCpFromPriorPhase` (commit `52d1111`). C'est ce qui fait passer Grégory de 11.94 à 12.94 ≈ 13.
|
||||
|
||||
**Files:** aucun (validation sur la BDD prod locale).
|
||||
|
||||
`resolveCarriedCpFromPriorPhase` réutilise **le calcul que l'UI affiche déjà** pour la phase 39h (1 samedi + 5 pris + 6 restant = 12 acquis, soit **7 nets** non pris). Aucune calibration de date n'est nécessaire : on consomme le même `computeYearSummary` que l'écran 39h, donc le 13 tombe mécaniquement (6 repos + 7 nets).
|
||||
|
||||
- [ ] **Step 1: Confirmer Grégory = 13**
|
||||
|
||||
Onglet Congés, phase FORFAIT courante, année 2026. Relever `acquiredDays`, `takenDays`, `remainingDays`.
|
||||
Attendu : `remainingDays ≈ 13` (acquis ≈ 13 = 6 repos + 7 CP nets ; pris = 0 dans la fenêtre mai→déc, les 5 d'avril restent rattachés à la phase 39h).
|
||||
|
||||
Croiser avec l'écran de la phase 39h : il doit toujours afficher 1 samedi + 5 pris + 6 restant (inchangé).
|
||||
|
||||
⚠️ Vérifier que Grégory n'a **pas de jours fractionnés** sur sa phase 39h (`fractionedDays` exclus volontairement de `resolveCarriedCpFromPriorPhase`). Si la cible 13 n'est pas atteinte ET qu'il a des fractionnés, c'est l'explication — décider alors avec Tristan s'il faut les inclure.
|
||||
|
||||
- [ ] **Step 2: Vérifier le cas 2 (nouvel embauché forfait)**
|
||||
|
||||
Pour un employé FORFAIT sans phase précédente (ou dont la précédente est FORFAIT), `remainingDays` doit valoir uniquement les repos proratisés (ex. 6 si entrée 01/05/2026). Vérifier sur un employé réel ou un cas synthétique.
|
||||
|
||||
- [ ] **Step 3: Vérifier la non-régression année pleine**
|
||||
|
||||
Pour un FORFAIT présent toute l'année 2026 : `acquiredDays` doit rester `34` (= 252 − 218). Vérifier sur un employé forfait existant.
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Documentation (OBLIGATOIRE — règle CLAUDE.md)
|
||||
|
||||
**Files:**
|
||||
- Modify: `doc/contract-phase-view.md`
|
||||
- Modify: `CLAUDE.md`
|
||||
- Modify: `frontend/data/documentation-content.ts`
|
||||
|
||||
- [ ] **Step 1: `doc/contract-phase-view.md`**
|
||||
|
||||
Dans la section « ### Phase FORFAIT (passée ou courante) », ajouter un paragraphe :
|
||||
|
||||
```markdown
|
||||
**Entrée en FORFAIT en cours d'année civile** : l'année d'entrée (période partielle,
|
||||
ex. 01/05 → 31/12) ne calcule pas `max(0, businessDays − 218)` (qui donnerait 0) mais :
|
||||
|
||||
jours_repos_année = jours_ouvrés_année − 218 − 25
|
||||
jours_repos_proratisés = jours_repos_année × (jours_ouvrés_période / jours_ouvrés_année)
|
||||
congés_à_poser = jours_repos_proratisés + CP_nets_reportés_phase_précédente
|
||||
|
||||
Les CP reportés proviennent de la phase non-forfait immédiatement précédente (net des
|
||||
congés déjà pris). Un nouvel embauché forfait (pas de phase précédente) n'a que les
|
||||
repos proratisés. Les années pleines suivantes du forfait gardent le calcul 218.
|
||||
Exemple Grégory BARRIBAULT (forfait 01/05/2026 après 39h) : 6 repos + ~7 CP = 13 jours.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `CLAUDE.md`**
|
||||
|
||||
Dans la section « ## Vue contrat (sélecteur de phase) », sous-puce « Onglet Congés », ajouter :
|
||||
|
||||
```markdown
|
||||
- **Entrée FORFAIT en cours d'année** : l'exercice d'entrée crédite `repos_proratisés + CP_nets_reportés` (et non `max(0, businessDays−218)`=0). Repos année = `jours_ouvrés_année − 218 − 25`, proratisés par jours ouvrés. CP reportés = CP nets de la phase non-forfait précédente. Nouvel embauché = repos seuls. Années pleines suivantes = calcul 218 inchangé. Service : `EmployeeLeaveSummaryProvider::resolveLeavePolicy` (branche FORFAIT) + `computeProratedForfaitRepoDays`/`resolveCarriedCpFromPriorPhase`.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `frontend/data/documentation-content.ts`**
|
||||
|
||||
Localiser la section congés FORFAIT (rechercher « forfait » / « 218 ») et ajouter une explication utilisateur : un forfait qui démarre en cours d'année voit ses congés à poser = jours de repos proratisés + reliquat de CP de son contrat précédent.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add doc/contract-phase-view.md CLAUDE.md frontend/data/documentation-content.ts
|
||||
git commit -m "docs(leave) : document forfait mid-year entry leave calculation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage :**
|
||||
- Repos proratisés (jours ouvrés) → Task 1 ✓
|
||||
- Périmètre « année d'entrée seulement » → Task 2 + garde Task 5 ✓
|
||||
- CP reportés depuis phase précédente (cas 1) → Task 3 + 4 ✓
|
||||
- Cas 2 (nouvel embauché, pas de report) → garde `null/FORFAIT` dans Task 4, validé Task 6 Step 3 ✓
|
||||
- Congés anticipés déduits → via le NET de `computeYearSummary` de la phase précédente (Task 4 doc) ✓
|
||||
- Non-régression année pleine (= 34) → garde Task 5 + validation Task 6 Step 4 ✓
|
||||
- Docs obligatoires → Task 7 ✓
|
||||
|
||||
**Pas de risque de calibration :** la source CP réutilise le calcul déjà affiché par l'écran de la phase 39h (12 acquis / 7 nets), donc le 13 est mécanique.
|
||||
|
||||
**Type consistency :** `computeProratedForfaitRepoDays(int,int): float`, `isForfaitEntryYear(ContractPhase,int): bool`, `resolvePhaseImmediatelyBefore(Employee,ContractPhase): ?ContractPhase`, `resolveCarriedCpFromPriorPhase(Employee,ContractPhase): float` — signatures cohérentes entre tâches et appels.
|
||||
230
docs/superpowers/specs/2026-05-19-contract-phase-view-design.md
Normal file
230
docs/superpowers/specs/2026-05-19-contract-phase-view-design.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# Vue contrat (sélecteur de phase) — Design Spec
|
||||
|
||||
## Objectif
|
||||
|
||||
Permettre à la RH de consulter les onglets Congés et RTT d'un employé **selon le contrat actuel ET selon ses contrats passés**, sans changement de comportement par défaut.
|
||||
|
||||
Cas qui motive : un employé passe de 39h à FORFAIT. Tant que le contrat courant est FORFAIT, les soldes CP/RTT accumulés sous l'ancien contrat 39h sont invisibles ou faussés (l'onglet RTT est masqué, la période Congés passe de Juin→Mai à Jan→Déc, les règles d'acquisition appliquent du FORFAIT_218 à toute année consultée). Conséquence : la RH ne peut plus payer les soldes restants.
|
||||
|
||||
Le même verrou existe pour toute fin de contrat avec **solde de tout compte** (notamment fin de CDD) suivie d'un nouveau contrat de type différent.
|
||||
|
||||
## Principe directeur
|
||||
|
||||
> Une fois passé en contrat X, on utilise toutes les règles X par défaut. Le sélecteur permet de revenir sur les phases passées pour les consulter et solder leurs reliquats.
|
||||
|
||||
## Concept de "phase de contrat"
|
||||
|
||||
Une **phase** est un groupe d'`EmployeeContractPeriod` consécutifs (triés par `startDate`) partageant la même **signature contractuelle** = `(contract.type, weeklyHours, isDriver)`. La fusion s'arrête dès qu'une période diffère sur l'un de ces trois axes, même si une période identique apparaît plus tard.
|
||||
|
||||
La signature inclut `weeklyHours` et `isDriver` parce que :
|
||||
- `weeklyHours` détermine les tranches d'heures supp (25%/50%), le rythme d'acquisition CP (cas 4h), la base contractuelle quotidienne.
|
||||
- `isDriver` change l'écran (`/driver-hours` vs `/hours`) et les colonnes de WorkHour utilisées pour le calcul RTT.
|
||||
|
||||
Exemples :
|
||||
- CDD 39h → CDI 39h → FORFAIT : 2 phases (`39h`, `FORFAIT`).
|
||||
- CDD 35h → CDI 39h : 2 phases (`35h`, `39h`).
|
||||
- 3 CDD 39h consécutifs sans interruption : 1 phase (`39h`).
|
||||
- 39h → INTERIM 4 mois → 39h : 3 phases (les `39h` ne fusionnent pas à travers l'`INTERIM`).
|
||||
- CUSTOM 28h → CUSTOM 30h : 2 phases (`weeklyHours` diffère).
|
||||
- 35h non-driver → 35h driver : 2 phases (`isDriver` diffère).
|
||||
|
||||
La règle de groupement vit dans un service backend, pas dans le frontend.
|
||||
|
||||
## Backend
|
||||
|
||||
### Nouveau service `EmployeeContractPhaseResolver`
|
||||
|
||||
Localisation : `src/Service/Contracts/EmployeeContractPhaseResolver.php`.
|
||||
|
||||
```php
|
||||
public function resolvePhases(Employee $employee): array;
|
||||
```
|
||||
|
||||
Retour : liste ordonnée (plus récente d'abord) de `ContractPhase` :
|
||||
|
||||
| Champ | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `int` | `EmployeeContractPeriod.id` de la première période (par date) du groupe — sert d'identifiant stable. |
|
||||
| `contractType` | `ContractType` | Type partagé par les périodes du groupe. |
|
||||
| `weeklyHours` | `int` | Heures hebdomadaires (partagées par construction). |
|
||||
| `isDriver` | `bool` | Driver flag (partagé par construction). |
|
||||
| `startDate` | `DateTimeImmutable` | `startDate` de la première période. |
|
||||
| `endDate` | `?DateTimeImmutable` | `endDate` de la dernière période, ou `null` si en cours. |
|
||||
| `periodIds` | `list<int>` | IDs des périodes composant la phase, par ordre chronologique. |
|
||||
| `isCurrent` | `bool` | `true` si la phase couvre la date du jour (= `endDate === null` ou `endDate >= today`). |
|
||||
|
||||
### Exposition API
|
||||
|
||||
Nouveau computed field sur `Employee` (lecture seule, groupe `employee:read`) :
|
||||
|
||||
```json
|
||||
"contractPhases": [
|
||||
{ "id": 42, "contractType": "FORFAIT", "weeklyHours": 39, "isDriver": false, "startDate": "2026-05-01", "endDate": null, "isCurrent": true },
|
||||
{ "id": 17, "contractType": "THIRTY_NINE_HOURS", "weeklyHours": 39, "isDriver": false, "startDate": "2020-06-01", "endDate": "2026-04-30", "isCurrent": false }
|
||||
]
|
||||
```
|
||||
|
||||
Le calcul se fait à la sérialisation via un getter virtuel `Employee::getContractPhases(): array` qui délègue au resolver.
|
||||
|
||||
### Endpoints impactés
|
||||
|
||||
Les endpoints `GET /employees/{id}/leave-summary` et `GET /employees/{id}/rtt-summary` acceptent un nouveau paramètre optionnel :
|
||||
|
||||
- `?phaseId=N` : id de la phase à consulter.
|
||||
- Si absent → phase courante (= comportement actuel inchangé).
|
||||
- Si invalide (phase n'appartient pas à l'employé) → 422.
|
||||
- `?year=YYYY` reste accepté en parallèle et continue de cibler un exercice précis.
|
||||
|
||||
### Modifications des providers
|
||||
|
||||
**`EmployeeLeaveSummaryProvider`** :
|
||||
- Nouvelle méthode `resolveTargetPhase(Employee $e, ?int $phaseId): ContractPhase` qui retourne la phase demandée ou la phase courante.
|
||||
- `resolveLeavePolicy(...)` reçoit la phase au lieu de lire `$employee->getContract()`. Le `contract.type` et le `weeklyHours` viennent de la signature de la phase (homogène par construction).
|
||||
- `resolvePeriodBounds(...)` : les bornes de l'exercice sont en plus contraintes à `[max(periodStart, phase.startDate), min(periodEnd, phase.endDate ?? periodEnd)]`.
|
||||
- `resolveYear(...)` : si `phaseId` fourni et pas de `year` explicite, default = dernier exercice intersectant la phase (= année de `phase.endDate` ou année courante si phase en cours).
|
||||
- Si `?year` est fourni hors de la plage des exercices intersectant la phase → **clamp silencieux** à l'exercice valide le plus proche, pas d'erreur 422 (cohérence avec l'expérience picker frontend).
|
||||
- `resolveAccrualCalculationEndDate(...)` et `resolveTakenCalculationEndDate(...)` : caps additionnels sur `phase.endDate` quand la phase n'est pas la phase courante.
|
||||
- `resolveFirstComputationYear(...)` : restreint aux exercices intersectant la phase.
|
||||
|
||||
**`EmployeeRttSummaryProvider`** :
|
||||
- Mêmes principes : `?phaseId` côté API, `resolveTargetPhase`, bornes d'exercice cappées à la phase, `rttStartDate` exposé pour borner le sélecteur d'année frontend.
|
||||
- Pour une phase FORFAIT, le tab RTT est masqué frontend, donc l'endpoint n'est pas appelé en pratique. Pas de garde spécifique côté backend, le comportement existant (retour d'un summary potentiellement vide/inutile) suffit.
|
||||
|
||||
### Paiements de solde
|
||||
|
||||
**RTT — `EmployeeRttPaymentProcessor`** :
|
||||
- Garde actuelle "exercice courant uniquement" devient : "exercice courant OU dernier exercice d'une phase clôturée".
|
||||
- Concrètement, autoriser la création d'un `EmployeeRttPayment` sur l'exercice contenant `phase.endDate` d'une phase non courante.
|
||||
- Les exercices antérieurs au dernier de la phase restent verrouillés (lecture seule).
|
||||
|
||||
**CP — settlement period-level** :
|
||||
- Le mécanisme existant `EmployeeContractPeriod.paidLeaveSettledClosureDate` reste le canal pour solder. Aucun changement de modèle.
|
||||
- Le bouton "Année N-1 payés" (FORFAIT) et "Jours fractionnés" (non-FORFAIT) restent désactivés sur une phase passée — ce ne sont pas des paiements de solde mais des éditions de stock.
|
||||
|
||||
### Audit
|
||||
|
||||
- La création d'`EmployeeRttPayment` est déjà auditée (existant).
|
||||
- La modification de `paidLeaveSettledClosureDate` est déjà auditée via `EmployeeContractPeriodManager` (existant).
|
||||
- Aucun audit nouveau requis.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Picker
|
||||
|
||||
- Composant `MalioSelect` placé dans `pages/employees/[id].vue`, dans le header de la fiche, sous le nom de l'employé et au-dessus de la barre d'onglets.
|
||||
- Libellé : `Vue contrat`.
|
||||
- Options formatées :
|
||||
- `FORFAIT — depuis 01/05/2026 (actuel)`
|
||||
- `39h CDI — 01/06/2020 → 30/04/2026`
|
||||
- **Caché** si `contractPhases.length <= 1` (employé mono-phase, ~majorité des cas).
|
||||
- Sélection en mémoire (état du composable), **non persistée** entre navigations ou rechargements. Chaque ouverture de fiche démarre sur la phase courante.
|
||||
|
||||
### Bandeau d'information
|
||||
|
||||
Affiché quand `selectedPhase.id !== currentPhase.id` :
|
||||
|
||||
> Vous consultez l'historique **{contractType} — jusqu'au {endDate}**.
|
||||
> Les paiements de solde sont possibles ; l'édition d'absences et des stocks de report est désactivée.
|
||||
|
||||
Style : bandeau jaune doux (`bg-warning-100 border-warning-300`), sous le picker, au-dessus des onglets.
|
||||
|
||||
### Composables
|
||||
|
||||
**Nouveau `useEmployeeContractPhase()`** :
|
||||
- État : `selectedPhase`, `currentPhase` (computed depuis `employee.contractPhases`).
|
||||
- Computed : `availablePhases`, `isViewingPastPhase`.
|
||||
- API : `setSelectedPhase(phaseId)`, `resetToCurrent()`.
|
||||
- `resetToCurrent()` appelé au changement d'employé.
|
||||
|
||||
**`useEmployeeLeave`** :
|
||||
- Reçoit `phaseId` en paramètre lors des appels à `getEmployeeLeaveSummary` / `listAbsences`.
|
||||
- `availableLeaveYears` borné aux exercices intersectant la phase sélectionnée.
|
||||
- `setSelectedPhase` côté parent → reset de `selectedLeaveYear` et reload.
|
||||
|
||||
**`useEmployeeRtt`** :
|
||||
- Idem pour `getEmployeeRttSummary`, `availableRttYears`.
|
||||
|
||||
**`useEmployeeDetailPage`** :
|
||||
- `showRttTab` devient : `selectedPhase.contractType !== FORFAIT`.
|
||||
- La logique de fallback ("si sur l'onglet RTT et FORFAIT, basculer ailleurs") s'applique aussi quand on bascule de phase 39h vers phase FORFAIT.
|
||||
|
||||
### Onglets
|
||||
|
||||
- **Congés** : reçoit la phase via le composable. Bouton "Année N-1 payés" / "Jours fractionnés" reste désactivé sur phase passée (idem que sur exercice passé).
|
||||
- **RTT** : visibilité driver par la phase. Bouton "+ Payer les RTT" activé **uniquement sur le dernier exercice de la phase passée**, désactivé sur les exercices antérieurs de la phase.
|
||||
- **Heures, Frais, Formation, Contrat, Calendrier** : non impactés.
|
||||
|
||||
### Format des libellés du picker
|
||||
|
||||
Format de la phase : `{labelContractType} — {startDateFR} → {endDateFR}`, suffixé `(actuel)` si phase courante.
|
||||
|
||||
`labelContractType` mapping :
|
||||
- `FORFAIT` → `FORFAIT`
|
||||
- `THIRTY_FIVE_HOURS` → `35h`
|
||||
- `THIRTY_NINE_HOURS` → `39h`
|
||||
- `INTERIM` → `Intérim`
|
||||
- `CUSTOM` → `CUSTOM ({weeklyHours}h)` (les heures hebdo sont homogènes par construction dans une phase)
|
||||
|
||||
Suffixe `(driver)` ajouté quand `isDriver=true`, ex. `35h CDI (driver) — ...`.
|
||||
|
||||
## Migration et impact sur l'existant
|
||||
|
||||
- Aucune migration de données. Le concept de phase est calculé à la volée depuis l'historique existant.
|
||||
- Comportement inchangé pour tout employé avec une seule phase (cas standard).
|
||||
- Comportement inchangé quand `phaseId` n'est pas fourni → phase courante.
|
||||
- Pas de breaking change API : `contractPhases` est un champ additionnel ; `?phaseId` est un paramètre optionnel.
|
||||
|
||||
## Tests
|
||||
|
||||
### Unit
|
||||
|
||||
- `EmployeeContractPhaseResolverTest` :
|
||||
- Employé mono-période → 1 phase, `isCurrent=true`.
|
||||
- Trois périodes même signature consécutives → 1 phase.
|
||||
- Switch 39h → FORFAIT → 2 phases avec `startDate`/`endDate` correctes.
|
||||
- 39h → INTERIM 4 mois → 39h → 3 phases (pas de fusion).
|
||||
- 35h → 39h → 2 phases (type différent).
|
||||
- CUSTOM 28h → CUSTOM 30h → 2 phases (`weeklyHours` diffère).
|
||||
- 35h non-driver → 35h driver → 2 phases (`isDriver` diffère).
|
||||
|
||||
### Functional
|
||||
|
||||
- `EmployeeLeaveSummaryProvider` avec `phaseId` :
|
||||
- Phase 39h passée → `ruleCode = CDI_CDD_NON_FORFAIT`, période Juin→Mai, exercice de transition capé à `phase.endDate`.
|
||||
- Phase FORFAIT passée → `ruleCode = FORFAIT_218`, période Jan→Déc.
|
||||
- `phaseId` invalide pour l'employé → 422.
|
||||
- `?year` hors de la plage de la phase → clamp silencieux à l'exercice intersectant le plus proche.
|
||||
|
||||
- `EmployeeRttSummaryProvider` avec `phaseId` :
|
||||
- Phase 39h passée → données RTT renvoyées, bornes cappées sur `phase.endDate`.
|
||||
- `?year` hors de la plage de la phase → clamp silencieux.
|
||||
|
||||
- `EmployeeRttPaymentProcessor` :
|
||||
- Création autorisée sur exercice de fin d'une phase passée.
|
||||
- Création refusée sur un exercice antérieur d'une phase passée.
|
||||
|
||||
### Documentation à mettre à jour
|
||||
|
||||
Obligatoire par CLAUDE.md :
|
||||
|
||||
- `doc/contract-phase-view.md` — nouveau fichier détaillant la fonctionnalité.
|
||||
- `doc/leave-tab.md` — section "Sélecteur de phase" + interaction avec le sélecteur d'année.
|
||||
- `doc/rtt-tab.md` — section "Sélecteur de phase" + règle de visibilité.
|
||||
- `frontend/data/documentation-content.ts` — article niveau `admin`.
|
||||
- `CLAUDE.md` — bloc "Vue contrat (sélecteur de phase)" sous Onglet Congés / Onglet RTT.
|
||||
|
||||
## Hors scope
|
||||
|
||||
- Surface d'alerte automatique sur les fiches employés ayant des soldes non payés sur des phases passées (potentiel follow-up).
|
||||
- Persistance de la sélection du picker entre navigations.
|
||||
- Picker exposé sur le calendrier global ou tout autre écran que la fiche employé.
|
||||
- Modification de `WorkHourDayContext` (déjà date-driven, pas concerné).
|
||||
- Évolution du mécanisme `paidLeaveSettledClosureDate` (canal existant suffisant).
|
||||
- Cas exotiques : phases overlap (interdit par la modélisation actuelle), périodes avec dates incohérentes.
|
||||
|
||||
## Décisions confirmées avec l'utilisateur
|
||||
|
||||
- Picker global en haut de la fiche, **pas** par onglet.
|
||||
- Phases groupées par `contract.type` consécutif.
|
||||
- Sur une phase passée : exercices antérieurs visibles **en lecture seule**, seul le dernier exercice de la phase ouvre les actions de solde (RTT pay, CP settlement period-level).
|
||||
- Comportement par défaut (phase courante) strictement inchangé.
|
||||
Reference in New Issue
Block a user