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

| 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:
2026-05-22 06:42:33 +00:00
committed by Autin
parent b541f9ded8
commit abdaf809f8
40 changed files with 5021 additions and 153 deletions

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

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

View File

@@ -189,6 +189,8 @@ Filtres disponibles:
Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
Seuls les employés dont au moins une période de contrat intersecte la période imprimée `[from, to]` apparaissent dans le PDF (même règle que la vue Calendrier). Un salarié parti avant la période (ex. contrat terminé en avril, impression de mai) est exclu. Borne calculée sur la date seule, côté backend (`AbsencePrintProvider::hasContractInRange`).
## 9) Employés
- Création employé:

View File

@@ -47,6 +47,16 @@ Quand `selectedYear !== currentYear` (consultation d'une année antérieure) :
Justification : modifier rétroactivement les stocks de report ou les jours fractionnés d'un exercice clos décalerait silencieusement les soldes de toutes les années postérieures. La consultation reste possible, l'édition non.
## Sélecteur de phase de contrat
Quand l'employé a plusieurs phases de contrat (`Employee.contractPhases.length > 1`), le picker `Vue contrat` en haut de la fiche permet de consulter une phase passée. L'onglet Congés bascule alors sur les règles de la phase choisie :
- Période Juin→Mai pour les phases non-forfait, Jan→Déc pour FORFAIT.
- Sélecteur d'année interne borné aux exercices intersectant la phase.
- Bornes d'exercice cappées sur `phase.endDate` côté backend (l'exercice de transition affiche les soldes acquis jusqu'à la date de fin de phase, pas au-delà).
- Boutons crayon `Jours fractionnés` / `Année N-1 payés` désactivés (lecture seule sur phase passée).
Cf. `doc/contract-phase-view.md` pour les détails complets.
## Implémentation
- Composable : `frontend/composables/useEmployeeLeave.ts`

View File

@@ -38,6 +38,16 @@ Quand `selectedYear !== currentYear` (consultation d'un exercice antérieur), le
La consultation reste possible, l'édition non.
## Sélecteur de phase de contrat
L'onglet RTT est visible quand la **phase de contrat sélectionnée** n'est pas FORFAIT (et non pas le contrat courant). Concrètement, sur un employé passé en FORFAIT après une période 39h :
- En vue `FORFAIT` (défaut), l'onglet RTT est masqué.
- En vue `39h` (phase passée sélectionnée via le picker `Vue contrat`), l'onglet RTT redevient visible avec les exercices Juin→Mai bornés à la phase.
Le bouton `+ Payer les RTT` est activé uniquement sur le **dernier exercice de la phase passée** (l'exercice contenant `phase.endDate`). Les exercices antérieurs sont en lecture seule.
Cf. `doc/contract-phase-view.md` pour les détails complets.
## Implémentation
- Composable : `frontend/composables/useEmployeeRtt.ts`