Compare commits
31 Commits
v0.1.100
...
feat/contr
| Author | SHA1 | Date | |
|---|---|---|---|
| 750f2bffa8 | |||
| 653845655f | |||
| 54d9a30f71 | |||
| 2acee03e00 | |||
| bbde6ddcf3 | |||
| f48f1d2f3a | |||
| 3da1cab2c8 | |||
| fd71e2ab65 | |||
| f0dec14b29 | |||
| 9c82e4a0f2 | |||
| 835e758ed1 | |||
| a5c75a9129 | |||
| 9d4a12f6cb | |||
| fd9e551542 | |||
| 3209be8c33 | |||
| 2730e34f31 | |||
| ac53bbd5a1 | |||
| 03acaae135 | |||
| 5fc9dd1ec9 | |||
| 8f355e05ad | |||
| 613ac02e1d | |||
| 8684d240bc | |||
| 5a2a43bf51 | |||
| 9efe0e81a0 | |||
| e7035a7c30 | |||
| a56f797ed7 | |||
| 7ee2e91e71 | |||
| a2874b545a | |||
|
|
b541f9ded8 | ||
| 47f9bea57d | |||
| 7cadcfa362 |
28
CLAUDE.md
28
CLAUDE.md
@@ -69,6 +69,34 @@
|
|||||||
- FORFAIT weekend/holiday bonus: each weekend or public holiday day worked gives bonus leave (full day if morning+afternoon, 0.5 if only one). Added to acquired days, no cap. PRESENCE mode only.
|
- FORFAIT weekend/holiday bonus: each weekend or public holiday day worked gives bonus leave (full day if morning+afternoon, 0.5 if only one). Added to acquired days, no cap. PRESENCE mode only.
|
||||||
- **FORFAIT — jours de présence et N-1** : les congés posés et imputés sur le stock N-1 ne décrémentent **pas** les jours de présence affichés (`presenceDaysByMonth` et `presenceDaysToToday`). Implémenté dans `EmployeeLeaveSummaryProvider::computePresenceDaysByMonth` via un budget N-1 (= `previousYearTakenDays`) consommé chronologiquement avant comptage des absences. Pour les non-forfait, ce budget vaut toujours 0 → comportement inchangé.
|
- **FORFAIT — jours de présence et N-1** : les congés posés et imputés sur le stock N-1 ne décrémentent **pas** les jours de présence affichés (`presenceDaysByMonth` et `presenceDaysToToday`). Implémenté dans `EmployeeLeaveSummaryProvider::computePresenceDaysByMonth` via un budget N-1 (= `previousYearTakenDays`) consommé chronologiquement avant comptage des absences. Pour les non-forfait, ce budget vaut toujours 0 → comportement inchangé.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- 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.
|
||||||
|
- 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`.
|
||||||
|
- 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`.
|
||||||
|
|
||||||
|
## Vue contrat (sélecteur de phase)
|
||||||
|
- Picker `Vue contrat` en haut de la fiche employé (`pages/employees/[id].vue`). Caché si l'employé n'a qu'une phase.
|
||||||
|
- Phase = groupe d'`EmployeeContractPeriod` consécutifs partageant la signature `(contract.type, weeklyHours, isDriver)`. Résolu par `App\Service\Contracts\EmployeeContractPhaseResolver`.
|
||||||
|
- **Filtre `RTT_START_DATE`** : les phases dont `endDate < RTT_START_DATE` sont masquées (aucune donnée logiciel avant la mise en service). Le resolver reçoit la date via DI (`services.yaml`) ; `Employee::getContractPhases()` lit `$_SERVER['RTT_START_DATE']` pour instancier le resolver côté entité.
|
||||||
|
- Exposé via `Employee.contractPhases` (`employee:read`). Endpoints `GET /employees/{id}/leave-summary` et `GET /employees/{id}/rtt-summary` acceptent `?phaseId=N` ; défaut = phase courante.
|
||||||
|
- Sélectionner une phase passée :
|
||||||
|
- Onglet **Congés** : période et règles de la phase (Juin→Mai non-forfait, Jan→Déc FORFAIT). Exercice de transition capé sur `phase.endDate`. **Cap `from` au `phase.startDate` uniquement pour FORFAIT** (sémantique année civile). Pour le non-forfait, l'exercice CP reste annuel et continu à travers les changements d'heures (35h→39h, etc.) — seul `resolveEffectivePeriodStart` clampe sur la date d'entrée en contrat des nouveaux embauchés.
|
||||||
|
- Onglet **RTT** : visible ssi `phase.contractType !== FORFAIT`. Tableau hebdo affiché sur l'exercice complet (Juin→Mai) ; `periodFrom` non capé sur `phase.startDate` (les semaines avant embauche ou hors phase apparaissent à 0). `periodTo`/`limitDate` capés sur `phase.endDate` pour les phases clôturées. `+ Payer les RTT` actif uniquement sur l'exercice contenant `phase.endDate`.
|
||||||
|
- Bandeau jaune affiché en mode phase passée. Édition d'absences et des stocks de report (jours fractionnés, Année N-1 payés) désactivée.
|
||||||
|
- Sélection non persistée — chaque ouverture de fiche démarre sur la phase courante.
|
||||||
|
- CP : solder via `EmployeeContractPeriod.paidLeaveSettledClosureDate` (mécanisme existant). RTT : créer un `EmployeeRttPayment` sur le dernier exercice de la phase.
|
||||||
|
- "Exercise year for date" mutualisé dans `App\Service\Exercise\ExerciseYearResolver` (forfait = année civile, non-forfait = Juin N-1 → Mai N).
|
||||||
|
- Doc complète : `doc/contract-phase-view.md`.
|
||||||
|
|
||||||
## Récap. congés (écran)
|
## Récap. congés (écran)
|
||||||
- Accès via sidebar `Récap. congés`, conditionné au flag `User.hasLeaveRecapAccess` (défaut `false`) — activé au create/edit user. Le flag s'applique à tous les profils, y compris admin.
|
- Accès via sidebar `Récap. congés`, conditionné au flag `User.hasLeaveRecapAccess` (défaut `false`) — activé au create/edit user. Le flag s'applique à tous les profils, y compris admin.
|
||||||
- Scope : `ROLE_ADMIN` → tous les employés, `ROLE_USER` (chef de site) → employés de ses sites, `ROLE_SELF` → sa ligne
|
- Scope : `ROLE_ADMIN` → tous les employés, `ROLE_USER` (chef de site) → employés de ses sites, `ROLE_SELF` → sa ligne
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -23,5 +23,17 @@ docker compose exec -T db psql -U root -d sirh < sirh.sql
|
|||||||
```sql
|
```sql
|
||||||
UPDATE users SET roles = '["ROLE_ADMIN","ROLE_SUPER_ADMIN"]' WHERE username = 'emilie';
|
UPDATE users SET roles = '["ROLE_ADMIN","ROLE_SUPER_ADMIN"]' WHERE username = 'emilie';
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Récupérer la bdd de prod en local
|
||||||
|
|
||||||
|
Sur le serveur de prod, créer le dump :
|
||||||
|
```shell
|
||||||
sudo -u postgres pg_dump --no-owner --no-privileges --clean --if-exists sirh_prod > /tmp/sirh_prod_$(date +%F).sql
|
sudo -u postgres pg_dump --no-owner --no-privileges --clean --if-exists sirh_prod > /tmp/sirh_prod_$(date +%F).sql
|
||||||
scp user@<serveur>:/tmp/sirh_prod_2026-04-14.dump ~/workspace/
|
```
|
||||||
|
|
||||||
|
En local, récupérer le fichier et l'importer (remplace `YYYY-MM-DD` par la date du dump) :
|
||||||
|
```shell
|
||||||
|
scp user@<serveur>:/tmp/sirh_prod_YYYY-MM-DD.sql ~/workspace/SIRH/sirh.sql
|
||||||
|
docker compose exec -T db psql -U root -d sirh -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
|
||||||
|
docker compose exec -T db psql -U root -d sirh < ~/workspace/SIRH/sirh.sql
|
||||||
|
```
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ services:
|
|||||||
arguments:
|
arguments:
|
||||||
$rttStartDate: '%env(RTT_START_DATE)%'
|
$rttStartDate: '%env(RTT_START_DATE)%'
|
||||||
|
|
||||||
|
App\State\EmployeeLeaveSummaryProvider:
|
||||||
|
arguments:
|
||||||
|
$dataStartDate: '%env(RTT_START_DATE)%'
|
||||||
|
|
||||||
|
App\Service\Contracts\EmployeeContractPhaseResolver:
|
||||||
|
arguments:
|
||||||
|
$dataStartDate: '%env(RTT_START_DATE)%'
|
||||||
|
|
||||||
App\Repository\Contract\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository'
|
App\Repository\Contract\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository'
|
||||||
App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface: '@App\Repository\EmployeeContractPeriodRepository'
|
App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface: '@App\Repository\EmployeeContractPeriodRepository'
|
||||||
App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository'
|
App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository'
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.100'
|
app.version: '0.1.101'
|
||||||
|
|||||||
90
doc/contract-phase-view.md
Normal file
90
doc/contract-phase-view.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# 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].
|
||||||
|
|
||||||
|
### 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)
|
||||||
70
doc/leave-tab.md
Normal file
70
doc/leave-tab.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Onglet "Congés" — fiche employé
|
||||||
|
|
||||||
|
## Vue d'ensemble
|
||||||
|
|
||||||
|
L'onglet **Congés** de la fiche employé (`frontend/components/employees/LeaveTab.vue`) affiche :
|
||||||
|
- un bandeau de compteurs (acquis, pris, reste, en cours d'acquisition, N-1 ou samedis selon le contrat) ;
|
||||||
|
- un calendrier annuel coloré des congés posés (12 mois en grille 4×3) ;
|
||||||
|
- pour chaque mois, le nombre de jours de présence (`presenceDaysByMonth`) ;
|
||||||
|
- un sélecteur d'année en pied de calendrier.
|
||||||
|
|
||||||
|
## Période affichée
|
||||||
|
|
||||||
|
La période dépend du **type de contrat actuel** de l'employé :
|
||||||
|
|
||||||
|
| Type de contrat | Période affichée |
|
||||||
|
|-------------------|--------------------------------|
|
||||||
|
| FORFAIT | Janvier → Décembre (année civile) |
|
||||||
|
| Autres | Juin (Y-1) → Mai (Y) (exercice CP) |
|
||||||
|
|
||||||
|
Cette règle suit `EmployeeLeaveSummaryProvider::resolveYear()` côté backend : la sélection FORFAIT vs non-FORFAIT se fait toujours sur le contrat **courant**, pas sur celui qui était en vigueur à l'année consultée.
|
||||||
|
|
||||||
|
## Sélecteur d'année
|
||||||
|
|
||||||
|
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 ;
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
Format des libellés :
|
||||||
|
- FORFAIT : `2026`, `2025`, `2024`…
|
||||||
|
- Autres : `Juin 2025 → Mai 2026`, `Juin 2024 → Mai 2025`…
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Quand `selectedYear !== currentYear` (consultation d'une année antérieure) :
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
- État : `selectedLeaveYear`, computed `currentLeaveYear`, `availableLeaveYears`
|
||||||
|
- API : `setSelectedLeaveYear(year)`, `loadLeaveData()`, `resetLoaded()`
|
||||||
|
- `resetLoaded()` (appelé au changement d'employé) remet `selectedLeaveYear = null` pour que la valeur par défaut soit recalculée à partir du nouveau contrat.
|
||||||
|
- Composant : `frontend/components/employees/LeaveTab.vue`
|
||||||
|
- Props : `selectedYear`, `availableYears`, `currentYear`
|
||||||
|
- Event : `update-selected-year`
|
||||||
|
- Page : `frontend/pages/employees/[id].vue` (câble le composable au composant)
|
||||||
|
- Backend : `EmployeeLeaveSummaryProvider` reçoit `RTT_START_DATE` via `services.yaml` (argument `$dataStartDate`) et l'expose dans la réponse `EmployeeLeaveSummary.dataStartDate`. Le filtrage `?year=YYYY` était déjà accepté (validation 2000–2100).
|
||||||
62
doc/rtt-tab.md
Normal file
62
doc/rtt-tab.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Onglet "RTT" — fiche employé
|
||||||
|
|
||||||
|
## Vue d'ensemble
|
||||||
|
|
||||||
|
L'onglet **RTT** de la fiche employé (`frontend/components/employees/RttTab.vue`) affiche un tableau hebdomadaire détaillé des heures supplémentaires accumulées et payées sur un exercice :
|
||||||
|
- bandeau de navigation par mois (chevrons gauche/droite) ;
|
||||||
|
- table semaine par semaine : Heure / Base 25% / 25% / Total 25% / Base 50% / 50% / Total 50% / Total / Cumul ;
|
||||||
|
- ligne Report (carry N-1 ou cumul mois précédents) ;
|
||||||
|
- ligne Total mois, ligne Payé, ligne Reste ;
|
||||||
|
- bouton « + Payer les RTT » dans le bandeau ;
|
||||||
|
- sélecteur d'exercice en pied de tableau.
|
||||||
|
|
||||||
|
L'onglet est **masqué pour les contrats FORFAIT** (filtre `showRttTab` dans `useEmployeeDetailPage`). Les FORFAIT n'accumulent pas de RTT.
|
||||||
|
|
||||||
|
## Période affichée
|
||||||
|
|
||||||
|
Toujours **Juin (Y-1) → Mai (Y)**. Le champ `EmployeeRttSummary.year` correspond à `Y` (année de fin d'exercice) ; ex. `year=2026` = `01/06/2025 → 31/05/2026`.
|
||||||
|
|
||||||
|
## Sélecteur d'année
|
||||||
|
|
||||||
|
Position : sous la table, à l'intérieur de la zone scrollable, à gauche.
|
||||||
|
|
||||||
|
Plage proposée :
|
||||||
|
- du plus récent (= exercice courant) au plus ancien ;
|
||||||
|
- **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)
|
||||||
|
- la valeur est exposée par l'API `GET /employees/{id}/rtt-summary` via le champ `rttStartDate` (déjà existant — mais peuplé uniquement quand la date tombe dans l'exercice retourné, donc le composable utilise la première réponse pour borner la plage).
|
||||||
|
- format unique : `Juin 2025 → Mai 2026`, `Juin 2024 → Mai 2025`…
|
||||||
|
|
||||||
|
Comportement :
|
||||||
|
- changer d'exercice recharge `getEmployeeRttSummary?year=YYYY` (le backend valide 2000–2100) ;
|
||||||
|
- la table redéploie les semaines de l'exercice sélectionné, navigation par mois conservée.
|
||||||
|
|
||||||
|
## Verrouillage des édition sur exercices passés
|
||||||
|
|
||||||
|
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é.
|
||||||
|
|
||||||
|
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`
|
||||||
|
- État : `selectedRttYear`, computed `currentRttYear`, `availableRttYears`
|
||||||
|
- API : `setSelectedRttYear(year)`, `loadRttData()`, `resetLoaded()`
|
||||||
|
- `resetLoaded()` (appelé au changement d'employé) remet `selectedRttYear = null`.
|
||||||
|
- Composant : `frontend/components/employees/RttTab.vue`
|
||||||
|
- Props : `selectedYear`, `availableYears`, `currentYear`
|
||||||
|
- Event : `update-selected-year`
|
||||||
|
- Renommage `currentYear` (computed local de l'année du mois affiché) → `displayedMonthYear` pour éviter la collision avec la nouvelle prop.
|
||||||
|
- Page : `frontend/pages/employees/[id].vue`
|
||||||
|
- Backend : aucun changement — `EmployeeRttSummaryProvider` accepte déjà `?year=YYYY` (validation 2000–2100) et expose `rttStartDate`.
|
||||||
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
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é.
|
||||||
@@ -39,6 +39,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="flex items-center"
|
class="flex items-center"
|
||||||
|
:class="isHistoricalYear ? 'opacity-40 cursor-not-allowed' : ''"
|
||||||
|
:disabled="isHistoricalYear"
|
||||||
@click="openPaidLeaveDrawer"
|
@click="openPaidLeaveDrawer"
|
||||||
>
|
>
|
||||||
<Icon name="mdi:edit-box" size="24"/>
|
<Icon name="mdi:edit-box" size="24"/>
|
||||||
@@ -51,6 +53,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="flex items-center"
|
class="flex items-center"
|
||||||
|
:class="isHistoricalYear ? 'opacity-40 cursor-not-allowed' : ''"
|
||||||
|
:disabled="isHistoricalYear"
|
||||||
@click="openFractionedDrawer"
|
@click="openFractionedDrawer"
|
||||||
>
|
>
|
||||||
<Icon name="mdi:edit-box" size="24"/>
|
<Icon name="mdi:edit-box" size="24"/>
|
||||||
@@ -90,6 +94,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-6 flex items-center gap-3">
|
||||||
|
<label for="leave-year-select" class="text-md font-semibold text-primary-500 uppercase">
|
||||||
|
{{ isForfaitRule ? 'Année :' : 'Exercice :' }}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="leave-year-select"
|
||||||
|
:value="selectedYear ?? ''"
|
||||||
|
:disabled="!availableYears.length"
|
||||||
|
class="border border-primary-500 rounded-md px-3 py-1 text-md font-semibold text-primary-500 bg-white focus:outline-none focus:ring-2 focus:ring-secondary-500/20 disabled:opacity-50"
|
||||||
|
@change="handleYearChange"
|
||||||
|
>
|
||||||
|
<option v-for="option in availableYears" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AppDrawer v-model="isFractionedDrawerOpen" title="Jours fractionnés">
|
<AppDrawer v-model="isFractionedDrawerOpen" title="Jours fractionnés">
|
||||||
<form class="space-y-4" @submit.prevent="handleSubmitFractioned">
|
<form class="space-y-4" @submit.prevent="handleSubmitFractioned">
|
||||||
@@ -173,17 +193,39 @@ type DayLeaveState = {
|
|||||||
colors: string[]
|
colors: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LeaveYearOption = {
|
||||||
|
value: number
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
absences: Absence[]
|
absences: Absence[]
|
||||||
summary: EmployeeLeaveSummary | null
|
summary: EmployeeLeaveSummary | null
|
||||||
publicHolidays: Record<string, string>
|
publicHolidays: Record<string, string>
|
||||||
|
selectedYear: number | null
|
||||||
|
availableYears: LeaveYearOption[]
|
||||||
|
currentYear: number | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: 'update-fractioned-days', days: number): void
|
(event: 'update-fractioned-days', days: number): void
|
||||||
(event: 'update-paid-leave-days', days: number): void
|
(event: 'update-paid-leave-days', days: number): void
|
||||||
|
(event: 'update-selected-year', year: number): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const isHistoricalYear = computed(() =>
|
||||||
|
props.selectedYear !== null
|
||||||
|
&& props.currentYear !== null
|
||||||
|
&& props.selectedYear !== props.currentYear
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleYearChange = (event: Event) => {
|
||||||
|
const target = event.target as HTMLSelectElement
|
||||||
|
const value = Number(target.value)
|
||||||
|
if (Number.isNaN(value)) return
|
||||||
|
emit('update-selected-year', value)
|
||||||
|
}
|
||||||
|
|
||||||
const isFractionedDrawerOpen = ref(false)
|
const isFractionedDrawerOpen = ref(false)
|
||||||
const fractionedForm = reactive({days: 0})
|
const fractionedForm = reactive({days: 0})
|
||||||
|
|
||||||
@@ -239,6 +281,7 @@ const currentYearTakenDays = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const displayedYear = computed(() => {
|
const displayedYear = computed(() => {
|
||||||
|
if (props.selectedYear) return props.selectedYear
|
||||||
if (props.summary?.year) return props.summary.year
|
if (props.summary?.year) return props.summary.year
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
const year = today.getFullYear()
|
const year = today.getFullYear()
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<Icon name="mdi:chevron-left" size="24"/>
|
<Icon name="mdi:chevron-left" size="24"/>
|
||||||
</button>
|
</button>
|
||||||
<span class="text-lg font-bold tracking-wide min-w-[170px] text-center">
|
<span class="text-lg font-bold tracking-wide min-w-[170px] text-center">
|
||||||
{{ currentMonthLabel }} {{ currentYear }}
|
{{ currentMonthLabel }} {{ displayedMonthYear }}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
class="rounded px-2 py-1 font-bold hover:bg-primary-600 disabled:opacity-40 disabled:cursor-not-allowed flex items-center"
|
class="rounded px-2 py-1 font-bold hover:bg-primary-600 disabled:opacity-40 disabled:cursor-not-allowed flex items-center"
|
||||||
@@ -27,7 +27,8 @@
|
|||||||
</p>
|
</p>
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<button
|
<button
|
||||||
class="rounded-md bg-primary-500 px-8 py-2 font-bold text-white hover:bg-primary-600"
|
class="rounded-md bg-primary-500 px-8 py-2 font-bold text-white hover:bg-primary-600 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
:disabled="isPayDisabled"
|
||||||
@click="openPaymentDrawer"
|
@click="openPaymentDrawer"
|
||||||
>
|
>
|
||||||
+ Payer les RTT
|
+ Payer les RTT
|
||||||
@@ -183,6 +184,22 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<div class="mt-6 flex items-center gap-3">
|
||||||
|
<label for="rtt-year-select" class="text-md font-semibold text-primary-500 uppercase">
|
||||||
|
Exercice :
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="rtt-year-select"
|
||||||
|
:value="selectedYear ?? ''"
|
||||||
|
:disabled="!availableYears.length"
|
||||||
|
class="border border-primary-500 rounded-md px-3 py-1 text-md font-semibold text-primary-500 bg-white focus:outline-none focus:ring-2 focus:ring-secondary-500/20 disabled:opacity-50"
|
||||||
|
@change="handleYearChange"
|
||||||
|
>
|
||||||
|
<option v-for="option in availableYears" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Payment Drawer -->
|
<!-- Payment Drawer -->
|
||||||
@@ -259,16 +276,54 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { EmployeeRttSummary, EmployeeRttWeekSummary } from '~/services/dto/employee-rtt-summary'
|
import type { EmployeeRttSummary, EmployeeRttWeekSummary } from '~/services/dto/employee-rtt-summary'
|
||||||
|
import type { ContractPhase } from '~/services/dto/contract-phase'
|
||||||
import AppDrawer from '~/components/AppDrawer.vue'
|
import AppDrawer from '~/components/AppDrawer.vue'
|
||||||
|
|
||||||
|
type RttYearOption = {
|
||||||
|
value: number
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
summary: EmployeeRttSummary | null
|
summary: EmployeeRttSummary | null
|
||||||
|
selectedYear: number | null
|
||||||
|
availableYears: RttYearOption[]
|
||||||
|
currentYear: number | null
|
||||||
|
selectedPhase: ContractPhase | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: 'submit-rtt-payment', month: number, base25Minutes: number, bonus25Minutes: number, base50Minutes: number, bonus50Minutes: number): void
|
(event: 'submit-rtt-payment', month: number, base25Minutes: number, bonus25Minutes: number, base50Minutes: number, bonus50Minutes: number): void
|
||||||
|
(event: 'update-selected-year', year: number): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const isHistoricalYear = computed(() =>
|
||||||
|
props.selectedYear !== null
|
||||||
|
&& props.currentYear !== null
|
||||||
|
&& props.selectedYear !== props.currentYear
|
||||||
|
)
|
||||||
|
|
||||||
|
const isLastExerciseOfPhase = computed(() => {
|
||||||
|
if (!props.selectedPhase || props.selectedPhase.isCurrent) return false
|
||||||
|
if (!props.selectedPhase.endDate) return false
|
||||||
|
const endDate = new Date(`${props.selectedPhase.endDate}T00:00:00`)
|
||||||
|
const endYear = endDate.getMonth() >= 5
|
||||||
|
? endDate.getFullYear() + 1
|
||||||
|
: endDate.getFullYear()
|
||||||
|
return props.selectedYear === endYear
|
||||||
|
})
|
||||||
|
|
||||||
|
const isPayDisabled = computed(() =>
|
||||||
|
isHistoricalYear.value && !isLastExerciseOfPhase.value
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleYearChange = (event: Event) => {
|
||||||
|
const target = event.target as HTMLSelectElement
|
||||||
|
const value = Number(target.value)
|
||||||
|
if (Number.isNaN(value)) return
|
||||||
|
emit('update-selected-year', value)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Last complete week number ---
|
// --- Last complete week number ---
|
||||||
|
|
||||||
const lastCompleteWeek = computed(() => {
|
const lastCompleteWeek = computed(() => {
|
||||||
@@ -324,7 +379,7 @@ const currentMonth = computed(() => orderedMonths[currentMonthIndex.value])
|
|||||||
|
|
||||||
const currentMonthLabel = computed(() => monthLabels[currentMonth.value])
|
const currentMonthLabel = computed(() => monthLabels[currentMonth.value])
|
||||||
|
|
||||||
const currentYear = computed(() => {
|
const displayedMonthYear = computed(() => {
|
||||||
if (!props.summary) return ''
|
if (!props.summary) return ''
|
||||||
return currentMonth.value >= 6 ? props.summary.year - 1 : props.summary.year
|
return currentMonth.value >= 6 ? props.summary.year - 1 : props.summary.year
|
||||||
})
|
})
|
||||||
|
|||||||
83
frontend/composables/useEmployeeContractPhase.ts
Normal file
83
frontend/composables/useEmployeeContractPhase.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import type { Ref } from 'vue'
|
||||||
|
import type { Employee } from '~/services/dto/employee'
|
||||||
|
import type { ContractPhase } from '~/services/dto/contract-phase'
|
||||||
|
import { CONTRACT_TYPES } from '~/services/dto/contract'
|
||||||
|
|
||||||
|
const formatDateFr = (iso: string | null): string => {
|
||||||
|
if (!iso) return ''
|
||||||
|
const [y, m, d] = iso.split('-')
|
||||||
|
return `${d}/${m}/${y}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatContractTypeLabel = (phase: ContractPhase): string => {
|
||||||
|
switch (phase.contractType) {
|
||||||
|
case CONTRACT_TYPES.FORFAIT:
|
||||||
|
return 'FORFAIT'
|
||||||
|
case CONTRACT_TYPES.H35:
|
||||||
|
return '35h'
|
||||||
|
case CONTRACT_TYPES.H39:
|
||||||
|
return '39h'
|
||||||
|
case CONTRACT_TYPES.INTERIM:
|
||||||
|
return 'Intérim'
|
||||||
|
case CONTRACT_TYPES.CUSTOM:
|
||||||
|
return `CUSTOM (${phase.weeklyHours ?? '?'}h)`
|
||||||
|
default:
|
||||||
|
return String(phase.contractType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatPhaseLabel = (phase: ContractPhase): string => {
|
||||||
|
const base = formatContractTypeLabel(phase)
|
||||||
|
const driver = phase.isDriver ? ' (driver)' : ''
|
||||||
|
const dates = phase.endDate
|
||||||
|
? `${formatDateFr(phase.startDate)} → ${formatDateFr(phase.endDate)}`
|
||||||
|
: `depuis ${formatDateFr(phase.startDate)}`
|
||||||
|
const suffix = phase.isCurrent ? ' (actuel)' : ''
|
||||||
|
return `${base}${driver} — ${dates}${suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEmployeeContractPhase = (employee: Ref<Employee | null>) => {
|
||||||
|
const selectedPhaseId = ref<number | null>(null)
|
||||||
|
|
||||||
|
const availablePhases = computed<ContractPhase[]>(() => employee.value?.contractPhases ?? [])
|
||||||
|
|
||||||
|
const currentPhase = computed<ContractPhase | null>(() => {
|
||||||
|
return availablePhases.value.find((p) => p.isCurrent) ?? availablePhases.value[0] ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedPhase = computed<ContractPhase | null>(() => {
|
||||||
|
if (selectedPhaseId.value === null) return currentPhase.value
|
||||||
|
return availablePhases.value.find((p) => p.id === selectedPhaseId.value) ?? currentPhase.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const isViewingPastPhase = computed<boolean>(() => {
|
||||||
|
if (!selectedPhase.value || !currentPhase.value) return false
|
||||||
|
return selectedPhase.value.id !== currentPhase.value.id
|
||||||
|
})
|
||||||
|
|
||||||
|
const phaseOptions = computed(() =>
|
||||||
|
availablePhases.value.map((p) => ({ value: p.id, label: formatPhaseLabel(p) }))
|
||||||
|
)
|
||||||
|
|
||||||
|
const showPicker = computed(() => availablePhases.value.length > 1)
|
||||||
|
|
||||||
|
const setSelectedPhase = (phaseId: number) => {
|
||||||
|
selectedPhaseId.value = phaseId
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetToCurrent = () => {
|
||||||
|
selectedPhaseId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedPhaseId,
|
||||||
|
selectedPhase,
|
||||||
|
currentPhase,
|
||||||
|
availablePhases,
|
||||||
|
phaseOptions,
|
||||||
|
showPicker,
|
||||||
|
isViewingPastPhase,
|
||||||
|
setSelectedPhase,
|
||||||
|
resetToCurrent,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Employee } from '~/services/dto/employee'
|
import type { Employee } from '~/services/dto/employee'
|
||||||
import { CONTRACT_TYPES } from '~/services/dto/contract'
|
import { CONTRACT_TYPES } from '~/services/dto/contract'
|
||||||
import { getEmployee } from '~/services/employees'
|
import { getEmployee } from '~/services/employees'
|
||||||
|
import { useEmployeeContractPhase } from '~/composables/useEmployeeContractPhase'
|
||||||
|
|
||||||
export const useEmployeeDetailPage = () => {
|
export const useEmployeeDetailPage = () => {
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -8,9 +9,11 @@ export const useEmployeeDetailPage = () => {
|
|||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const activeTab = ref<'contract' | 'leave' | 'rtt' | 'mileage' | 'formation' | 'bonus' | 'observation'>('contract')
|
const activeTab = ref<'contract' | 'leave' | 'rtt' | 'mileage' | 'formation' | 'bonus' | 'observation'>('contract')
|
||||||
|
|
||||||
|
const phase = useEmployeeContractPhase(employee)
|
||||||
|
|
||||||
const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM')
|
const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM')
|
||||||
const showRttTab = computed(() => employee.value?.contract?.type !== CONTRACT_TYPES.FORFAIT)
|
const showRttTab = computed(() => phase.selectedPhase.value?.contractType !== CONTRACT_TYPES.FORFAIT)
|
||||||
const isForfait = computed(() => employee.value?.contract?.type === CONTRACT_TYPES.FORFAIT)
|
const isForfait = computed(() => phase.selectedPhase.value?.contractType === CONTRACT_TYPES.FORFAIT)
|
||||||
const employeeContractWorkLabel = computed(() => {
|
const employeeContractWorkLabel = computed(() => {
|
||||||
const contract = employee.value?.contract
|
const contract = employee.value?.contract
|
||||||
if (!contract) return '-'
|
if (!contract) return '-'
|
||||||
@@ -29,6 +32,7 @@ export const useEmployeeDetailPage = () => {
|
|||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
employee.value = await getEmployee(employeeId)
|
employee.value = await getEmployee(employeeId)
|
||||||
|
phase.resetToCurrent()
|
||||||
|
|
||||||
if (!showLeaveTab.value && activeTab.value === 'leave') {
|
if (!showLeaveTab.value && activeTab.value === 'leave') {
|
||||||
activeTab.value = 'contract'
|
activeTab.value = 'contract'
|
||||||
@@ -66,7 +70,7 @@ export const useEmployeeDetailPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const contract = useEmployeeContract(employee, loadEmployee)
|
const contract = useEmployeeContract(employee, loadEmployee)
|
||||||
const leave = useEmployeeLeave(employee, loadEmployee)
|
const leave = useEmployeeLeave(employee, loadEmployee, phase.selectedPhase)
|
||||||
const forfaitRemainingDaysLabel = computed(() => {
|
const forfaitRemainingDaysLabel = computed(() => {
|
||||||
if (!isForfait.value) return ''
|
if (!isForfait.value) return ''
|
||||||
const presence = leave.leaveSummary.value?.presenceDaysToToday
|
const presence = leave.leaveSummary.value?.presenceDaysToToday
|
||||||
@@ -74,12 +78,26 @@ export const useEmployeeDetailPage = () => {
|
|||||||
const remaining = 218 - presence
|
const remaining = 218 - presence
|
||||||
return ` (${remaining} restants)`
|
return ` (${remaining} restants)`
|
||||||
})
|
})
|
||||||
const rtt = useEmployeeRtt(employee, loadEmployee)
|
const rtt = useEmployeeRtt(employee, loadEmployee, phase.selectedPhase)
|
||||||
const mileage = useEmployeeMileage(employee, loadEmployee)
|
const mileage = useEmployeeMileage(employee, loadEmployee)
|
||||||
const formation = useEmployeeFormation(employee, loadEmployee)
|
const formation = useEmployeeFormation(employee, loadEmployee)
|
||||||
const bonus = useEmployeeBonus(employee, loadEmployee)
|
const bonus = useEmployeeBonus(employee, loadEmployee)
|
||||||
const observation = useEmployeeObservation(employee, loadEmployee)
|
const observation = useEmployeeObservation(employee, loadEmployee)
|
||||||
|
|
||||||
|
watch(() => phase.selectedPhase.value?.id, (newId, oldId) => {
|
||||||
|
if (newId === oldId || oldId === undefined) return
|
||||||
|
// Bascule onglet si on entre dans une phase qui ne supporte plus le tab actuel
|
||||||
|
if (!showRttTab.value && activeTab.value === 'rtt') {
|
||||||
|
activeTab.value = 'leave'
|
||||||
|
}
|
||||||
|
// Recharger l'onglet courant
|
||||||
|
if (activeTab.value === 'leave' && showLeaveTab.value) {
|
||||||
|
leave.loadLeaveData()
|
||||||
|
} else if (activeTab.value === 'rtt' && showRttTab.value) {
|
||||||
|
rtt.loadRttData()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
watch(activeTab, (tab) => {
|
watch(activeTab, (tab) => {
|
||||||
if (tab === 'leave' && !leave.leaveDataLoaded.value && showLeaveTab.value) {
|
if (tab === 'leave' && !leave.leaveDataLoaded.value && showLeaveTab.value) {
|
||||||
leave.loadLeaveData()
|
leave.loadLeaveData()
|
||||||
@@ -109,6 +127,7 @@ export const useEmployeeDetailPage = () => {
|
|||||||
showRttTab,
|
showRttTab,
|
||||||
employeeContractWorkLabel,
|
employeeContractWorkLabel,
|
||||||
forfaitRemainingDaysLabel,
|
forfaitRemainingDaysLabel,
|
||||||
|
...phase,
|
||||||
...contract,
|
...contract,
|
||||||
...leave,
|
...leave,
|
||||||
...rtt,
|
...rtt,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
import type { Absence } from '~/services/dto/absence'
|
import type { Absence } from '~/services/dto/absence'
|
||||||
|
import type { ContractPhase } from '~/services/dto/contract-phase'
|
||||||
import type { EmployeeLeaveSummary } from '~/services/dto/employee-leave-summary'
|
import type { EmployeeLeaveSummary } from '~/services/dto/employee-leave-summary'
|
||||||
import type { Employee } from '~/services/dto/employee'
|
import type { Employee } from '~/services/dto/employee'
|
||||||
import { CONTRACT_TYPES } from '~/services/dto/contract'
|
import { CONTRACT_TYPES } from '~/services/dto/contract'
|
||||||
@@ -7,34 +8,99 @@ import { listAbsences } from '~/services/absences'
|
|||||||
import { getEmployeeLeaveSummary, updateFractionedDays, updatePaidLeaveDays } from '~/services/employee-leave-summary'
|
import { getEmployeeLeaveSummary, updateFractionedDays, updatePaidLeaveDays } from '~/services/employee-leave-summary'
|
||||||
import { listPublicHolidays } from '~/services/public-holidays'
|
import { listPublicHolidays } from '~/services/public-holidays'
|
||||||
|
|
||||||
export const useEmployeeLeave = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
|
export type LeaveYearOption = {
|
||||||
|
value: number
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEmployeeLeave = (
|
||||||
|
employee: Ref<Employee | null>,
|
||||||
|
reloadEmployee: () => Promise<void>,
|
||||||
|
selectedPhase: Ref<ContractPhase | null>,
|
||||||
|
) => {
|
||||||
const employeeAbsences = ref<Absence[]>([])
|
const employeeAbsences = ref<Absence[]>([])
|
||||||
const leaveSummary = ref<EmployeeLeaveSummary | null>(null)
|
const leaveSummary = ref<EmployeeLeaveSummary | null>(null)
|
||||||
const publicHolidays = ref<Record<string, string>>({})
|
const publicHolidays = ref<Record<string, string>>({})
|
||||||
const isLeaveLoading = ref(false)
|
const isLeaveLoading = ref(false)
|
||||||
const leaveDataLoaded = ref(false)
|
const leaveDataLoaded = ref(false)
|
||||||
|
const selectedLeaveYear = ref<number | null>(null)
|
||||||
|
|
||||||
const getLeaveYear = () => {
|
const isForfaitOnPhase = computed(() =>
|
||||||
const now = new Date()
|
selectedPhase.value?.contractType === CONTRACT_TYPES.FORFAIT
|
||||||
const isForfait = employee.value?.contract?.type === CONTRACT_TYPES.FORFAIT
|
)
|
||||||
return isForfait
|
|
||||||
? now.getFullYear()
|
const computeLeaveYearForDate = (date: Date): number => {
|
||||||
: (now.getMonth() >= 5 ? now.getFullYear() + 1 : now.getFullYear())
|
if (isForfaitOnPhase.value) return date.getFullYear()
|
||||||
|
return date.getMonth() >= 5 ? date.getFullYear() + 1 : date.getFullYear()
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentLeaveYear = computed<number | null>(() => {
|
||||||
|
if (!employee.value) return null
|
||||||
|
return computeLeaveYearForDate(new Date())
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatLeaveYearLabel = (year: number, isForfait: boolean): string => {
|
||||||
|
if (isForfait) return String(year)
|
||||||
|
return `Juin ${year - 1} → Mai ${year}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableLeaveYears = computed<LeaveYearOption[]>(() => {
|
||||||
|
if (!employee.value || !selectedPhase.value || currentLeaveYear.value === null) return []
|
||||||
|
const isForfait = isForfaitOnPhase.value
|
||||||
|
const phase = selectedPhase.value
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
let dataFloor: number | null = null
|
||||||
|
const dataStart = leaveSummary.value?.dataStartDate
|
||||||
|
if (dataStart) {
|
||||||
|
const dataStartDate = new Date(`${dataStart.substring(0, 10)}T00:00:00`)
|
||||||
|
if (!Number.isNaN(dataStartDate.getTime())) {
|
||||||
|
dataFloor = computeLeaveYearForDate(dataStartDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const minYear = dataFloor !== null ? Math.max(phaseStartYear, dataFloor) : phaseStartYear
|
||||||
|
const maxYear = phaseEndYear
|
||||||
|
|
||||||
|
const years: LeaveYearOption[] = []
|
||||||
|
for (let y = maxYear; y >= minYear; y -= 1) {
|
||||||
|
years.push({ value: y, label: formatLeaveYearLabel(y, isForfait) })
|
||||||
|
}
|
||||||
|
return years
|
||||||
|
})
|
||||||
|
|
||||||
|
const initSelectedLeaveYear = () => {
|
||||||
|
if (selectedLeaveYear.value !== null) return
|
||||||
|
if (currentLeaveYear.value !== null) {
|
||||||
|
selectedLeaveYear.value = currentLeaveYear.value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadLeaveData = async () => {
|
const loadLeaveData = async () => {
|
||||||
if (!employee.value || isLeaveLoading.value) return
|
if (!employee.value || isLeaveLoading.value) return
|
||||||
|
initSelectedLeaveYear()
|
||||||
|
if (selectedLeaveYear.value === null) return
|
||||||
isLeaveLoading.value = true
|
isLeaveLoading.value = true
|
||||||
try {
|
try {
|
||||||
const isForfait = employee.value.contract?.type === CONTRACT_TYPES.FORFAIT
|
const isForfait = isForfaitOnPhase.value
|
||||||
const leaveYear = getLeaveYear()
|
const leaveYear = selectedLeaveYear.value
|
||||||
const from = isForfait ? `${leaveYear}-01-01` : `${leaveYear - 1}-06-01`
|
let from = isForfait ? `${leaveYear}-01-01` : `${leaveYear - 1}-06-01`
|
||||||
const to = isForfait ? `${leaveYear}-12-31` : `${leaveYear}-05-31`
|
let to = isForfait ? `${leaveYear}-12-31` : `${leaveYear}-05-31`
|
||||||
|
const phase = selectedPhase.value
|
||||||
|
if (phase?.startDate && phase.startDate > from) from = phase.startDate
|
||||||
|
if (phase?.endDate && phase.endDate < to) to = phase.endDate
|
||||||
const holidayYears = isForfait ? [leaveYear] : [leaveYear - 1, leaveYear]
|
const holidayYears = isForfait ? [leaveYear] : [leaveYear - 1, leaveYear]
|
||||||
|
|
||||||
const [absences, summary, ...holidayResults] = await Promise.all([
|
const [absences, summary, ...holidayResults] = await Promise.all([
|
||||||
listAbsences({ from, to, employeeId: employee.value.id }),
|
listAbsences({ from, to, employeeId: employee.value.id }),
|
||||||
getEmployeeLeaveSummary(employee.value.id, leaveYear),
|
getEmployeeLeaveSummary(employee.value.id, leaveYear, selectedPhase.value?.id),
|
||||||
...holidayYears.map((y) => listPublicHolidays('metropole', y))
|
...holidayYears.map((y) => listPublicHolidays('metropole', y))
|
||||||
])
|
])
|
||||||
employeeAbsences.value = absences
|
employeeAbsences.value = absences
|
||||||
@@ -46,10 +112,25 @@ export const useEmployeeLeave = (employee: Ref<Employee | null>, reloadEmployee:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setSelectedLeaveYear = async (year: number) => {
|
||||||
|
if (selectedLeaveYear.value === year) return
|
||||||
|
selectedLeaveYear.value = year
|
||||||
|
leaveDataLoaded.value = false
|
||||||
|
await loadLeaveData()
|
||||||
|
}
|
||||||
|
|
||||||
const resetLoaded = () => {
|
const resetLoaded = () => {
|
||||||
leaveDataLoaded.value = false
|
leaveDataLoaded.value = false
|
||||||
|
selectedLeaveYear.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(() => selectedPhase.value?.id, () => {
|
||||||
|
// Reset l'année car la plage a peut-être changé.
|
||||||
|
selectedLeaveYear.value = null
|
||||||
|
leaveDataLoaded.value = false
|
||||||
|
// Le rechargement effectif est piloté par useEmployeeDetailPage.
|
||||||
|
})
|
||||||
|
|
||||||
const submitFractionedDays = async (days: number) => {
|
const submitFractionedDays = async (days: number) => {
|
||||||
if (!employee.value) return
|
if (!employee.value) return
|
||||||
const year = leaveSummary.value?.year ?? undefined
|
const year = leaveSummary.value?.year ?? undefined
|
||||||
@@ -70,6 +151,10 @@ export const useEmployeeLeave = (employee: Ref<Employee | null>, reloadEmployee:
|
|||||||
publicHolidays,
|
publicHolidays,
|
||||||
isLeaveLoading,
|
isLeaveLoading,
|
||||||
leaveDataLoaded,
|
leaveDataLoaded,
|
||||||
|
selectedLeaveYear,
|
||||||
|
currentLeaveYear,
|
||||||
|
availableLeaveYears,
|
||||||
|
setSelectedLeaveYear,
|
||||||
loadLeaveData,
|
loadLeaveData,
|
||||||
resetLoaded,
|
resetLoaded,
|
||||||
submitFractionedDays,
|
submitFractionedDays,
|
||||||
|
|||||||
@@ -1,29 +1,107 @@
|
|||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
|
import type { ContractPhase } from '~/services/dto/contract-phase'
|
||||||
import type { EmployeeRttSummary } from '~/services/dto/employee-rtt-summary'
|
import type { EmployeeRttSummary } from '~/services/dto/employee-rtt-summary'
|
||||||
import type { Employee } from '~/services/dto/employee'
|
import type { Employee } from '~/services/dto/employee'
|
||||||
import { getEmployeeRttSummary, createRttPayment } from '~/services/employee-rtt-summary'
|
import { getEmployeeRttSummary, createRttPayment } from '~/services/employee-rtt-summary'
|
||||||
|
|
||||||
export const useEmployeeRtt = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
|
export type RttYearOption = {
|
||||||
|
value: number
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEmployeeRtt = (
|
||||||
|
employee: Ref<Employee | null>,
|
||||||
|
reloadEmployee: () => Promise<void>,
|
||||||
|
selectedPhase: Ref<ContractPhase | null>,
|
||||||
|
) => {
|
||||||
const rttSummary = ref<EmployeeRttSummary | null>(null)
|
const rttSummary = ref<EmployeeRttSummary | null>(null)
|
||||||
const isRttLoading = ref(false)
|
const isRttLoading = ref(false)
|
||||||
const rttDataLoaded = ref(false)
|
const rttDataLoaded = ref(false)
|
||||||
|
const selectedRttYear = ref<number | null>(null)
|
||||||
|
|
||||||
|
// Exercice RTT : Juin (Y-1) → Mai (Y). Toujours, peu importe le type de contrat
|
||||||
|
// (l'onglet RTT est masqué pour les FORFAIT côté page).
|
||||||
|
const computeRttYearForDate = (date: Date): number =>
|
||||||
|
date.getMonth() >= 5 ? date.getFullYear() + 1 : date.getFullYear()
|
||||||
|
|
||||||
|
const currentRttYear = computed<number | null>(() => {
|
||||||
|
if (!employee.value) return null
|
||||||
|
return computeRttYearForDate(new Date())
|
||||||
|
})
|
||||||
|
|
||||||
|
const availableRttYears = computed<RttYearOption[]>(() => {
|
||||||
|
if (!employee.value || !selectedPhase.value || currentRttYear.value === null) return []
|
||||||
|
const phase = selectedPhase.value
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// Hard floor : rttStartDate (env RTT_START_DATE) — pas d'historique avant.
|
||||||
|
let dataFloor: number | null = null
|
||||||
|
const dataStart = rttSummary.value?.rttStartDate
|
||||||
|
if (dataStart) {
|
||||||
|
const dataStartDate = new Date(`${dataStart.substring(0, 10)}T00:00:00`)
|
||||||
|
if (!Number.isNaN(dataStartDate.getTime())) {
|
||||||
|
dataFloor = computeRttYearForDate(dataStartDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const minYear = dataFloor !== null ? Math.max(phaseStartYear, dataFloor) : phaseStartYear
|
||||||
|
const maxYear = phaseEndYear
|
||||||
|
|
||||||
|
const years: RttYearOption[] = []
|
||||||
|
for (let y = maxYear; y >= minYear; y -= 1) {
|
||||||
|
years.push({ value: y, label: `Juin ${y - 1} → Mai ${y}` })
|
||||||
|
}
|
||||||
|
return years
|
||||||
|
})
|
||||||
|
|
||||||
|
const initSelectedRttYear = () => {
|
||||||
|
if (selectedRttYear.value !== null) return
|
||||||
|
if (currentRttYear.value !== null) {
|
||||||
|
selectedRttYear.value = currentRttYear.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const loadRttData = async () => {
|
const loadRttData = async () => {
|
||||||
if (!employee.value || isRttLoading.value) return
|
if (!employee.value || isRttLoading.value) return
|
||||||
|
initSelectedRttYear()
|
||||||
|
if (selectedRttYear.value === null) return
|
||||||
isRttLoading.value = true
|
isRttLoading.value = true
|
||||||
try {
|
try {
|
||||||
const rttYear = new Date().getMonth() >= 5 ? new Date().getFullYear() + 1 : new Date().getFullYear()
|
rttSummary.value = await getEmployeeRttSummary(
|
||||||
rttSummary.value = await getEmployeeRttSummary(employee.value.id, rttYear)
|
employee.value.id,
|
||||||
|
selectedRttYear.value,
|
||||||
|
selectedPhase.value?.id,
|
||||||
|
)
|
||||||
rttDataLoaded.value = true
|
rttDataLoaded.value = true
|
||||||
} finally {
|
} finally {
|
||||||
isRttLoading.value = false
|
isRttLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setSelectedRttYear = async (year: number) => {
|
||||||
|
if (selectedRttYear.value === year) return
|
||||||
|
selectedRttYear.value = year
|
||||||
|
rttDataLoaded.value = false
|
||||||
|
await loadRttData()
|
||||||
|
}
|
||||||
|
|
||||||
const resetLoaded = () => {
|
const resetLoaded = () => {
|
||||||
rttDataLoaded.value = false
|
rttDataLoaded.value = false
|
||||||
|
selectedRttYear.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(() => selectedPhase.value?.id, () => {
|
||||||
|
// Reset l'année car la plage a peut-être changé.
|
||||||
|
selectedRttYear.value = null
|
||||||
|
rttDataLoaded.value = false
|
||||||
|
// Le rechargement effectif est piloté par useEmployeeDetailPage.
|
||||||
|
})
|
||||||
|
|
||||||
const submitRttPayment = async (month: number, base25Minutes: number, bonus25Minutes: number, base50Minutes: number, bonus50Minutes: number) => {
|
const submitRttPayment = async (month: number, base25Minutes: number, bonus25Minutes: number, base50Minutes: number, bonus50Minutes: number) => {
|
||||||
if (!employee.value) return
|
if (!employee.value) return
|
||||||
const year = rttSummary.value?.year ?? undefined
|
const year = rttSummary.value?.year ?? undefined
|
||||||
@@ -35,6 +113,10 @@ export const useEmployeeRtt = (employee: Ref<Employee | null>, reloadEmployee: (
|
|||||||
rttSummary,
|
rttSummary,
|
||||||
isRttLoading,
|
isRttLoading,
|
||||||
rttDataLoaded,
|
rttDataLoaded,
|
||||||
|
selectedRttYear,
|
||||||
|
currentRttYear,
|
||||||
|
availableRttYears,
|
||||||
|
setSelectedRttYear,
|
||||||
loadRttData,
|
loadRttData,
|
||||||
resetLoaded,
|
resetLoaded,
|
||||||
submitRttPayment
|
submitRttPayment
|
||||||
|
|||||||
@@ -301,6 +301,18 @@ export const documentationSections: DocSection[] = [
|
|||||||
{ type: 'paragraph', content: 'Un employé conducteur apparaît uniquement sur l\'écran "Heures Conducteurs" et non sur l\'écran "Heures" classique.' },
|
{ type: 'paragraph', content: 'Un employé conducteur apparaît uniquement sur l\'écran "Heures Conducteurs" et non sur l\'écran "Heures" classique.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'contract-phase-view',
|
||||||
|
title: 'Vue contrat — sélecteur de phase',
|
||||||
|
requiredLevel: 'admin',
|
||||||
|
blocks: [
|
||||||
|
{ type: 'paragraph', content: 'Quand un employé change de type de contrat (ex. 39h → FORFAIT) ou enchaîne plusieurs CDD avec solde de tout compte, ses anciennes phases de contrat restent consultables via le sélecteur "Vue contrat" en haut de la fiche.' },
|
||||||
|
{ type: 'paragraph', content: 'Choisir une phase passée fait basculer les onglets Congés et RTT sur les règles de cette phase. L\'onglet RTT réapparaît si la phase n\'est pas un FORFAIT. Un bandeau jaune indique que vous êtes en mode historique.' },
|
||||||
|
{ type: 'paragraph', content: 'Sur une phase passée, vous pouvez :' },
|
||||||
|
{ type: 'list', content: 'Solder les RTT restants — bouton "+ Payer les RTT" actif uniquement sur le dernier exercice de la phase (celui contenant la date de fin)\nSolder les CP restants via le champ "Solde de tout compte" sur la période de contrat correspondante (onglet Contrat)' },
|
||||||
|
{ type: 'note', content: 'L\'édition d\'absences et des stocks de report (jours fractionnés, Année N-1) est désactivée en mode phase passée.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -457,6 +469,17 @@ export const documentationSections: DocSection[] = [
|
|||||||
{ type: 'paragraph', content: 'Compteurs visibles sur l\'onglet Congé de la fiche employé : acquis, en cours d\'acquisition, pris, restant.' },
|
{ type: 'paragraph', content: 'Compteurs visibles sur l\'onglet Congé de la fiche employé : acquis, en cours d\'acquisition, pris, restant.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'onglet-conges-fiche-employe',
|
||||||
|
title: 'Onglet Congés (fiche employé)',
|
||||||
|
requiredLevel: 'admin',
|
||||||
|
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: '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.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'ecran-recap-conges',
|
id: 'ecran-recap-conges',
|
||||||
title: 'Écran Récap. congés',
|
title: 'Écran Récap. congés',
|
||||||
@@ -504,6 +527,15 @@ export const documentationSections: DocSection[] = [
|
|||||||
{ type: 'list', content: 'Saisie : mois, nombre de minutes, taux (25% ou 50%)\nPlusieurs paiements possibles par mois\nLes heures payées sont soustraites du solde disponible' },
|
{ type: 'list', content: 'Saisie : mois, nombre de minutes, taux (25% ou 50%)\nPlusieurs paiements possibles par mois\nLes heures payées sont soustraites du solde disponible' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'rtt-selecteur-exercice',
|
||||||
|
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: '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.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'rtt-semaines-mois',
|
id: 'rtt-semaines-mois',
|
||||||
title: 'Attribution des semaines aux mois',
|
title: 'Attribution des semaines aux mois',
|
||||||
|
|||||||
@@ -30,6 +30,24 @@
|
|||||||
<p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p>
|
<p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="showPicker" class="mt-3 flex items-center gap-3">
|
||||||
|
<MalioSelect
|
||||||
|
label="Contrat"
|
||||||
|
:model-value="selectedPhase?.id ?? null"
|
||||||
|
:options="phaseOptions"
|
||||||
|
group-class="w-[420px]"
|
||||||
|
min-width=""
|
||||||
|
@update:model-value="(v) => { if (v !== null) setSelectedPhase(Number(v)) }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="isViewingPastPhase && selectedPhase"
|
||||||
|
class="mt-3 rounded-md border border-amber-300 bg-amber-100 px-4 py-2 text-sm text-amber-900"
|
||||||
|
>
|
||||||
|
Vous consultez l'historique
|
||||||
|
<strong>{{ formatPhaseLabel(selectedPhase) }}</strong>.
|
||||||
|
Les paiements de solde sont possibles ; l'édition d'absences et des stocks de report est désactivée.
|
||||||
|
</div>
|
||||||
<div class="mt-[44px] border-b border-primary-500">
|
<div class="mt-[44px] border-b border-primary-500">
|
||||||
<div class="flex justify-center gap-16 text-2xl font-bold">
|
<div class="flex justify-center gap-16 text-2xl font-bold">
|
||||||
<button
|
<button
|
||||||
@@ -160,15 +178,29 @@
|
|||||||
:absences="employeeAbsences"
|
:absences="employeeAbsences"
|
||||||
:summary="leaveSummary"
|
:summary="leaveSummary"
|
||||||
:public-holidays="publicHolidays"
|
:public-holidays="publicHolidays"
|
||||||
|
:selected-year="selectedLeaveYear"
|
||||||
|
:available-years="availableLeaveYears"
|
||||||
|
:current-year="currentLeaveYear"
|
||||||
@update-fractioned-days="submitFractionedDays"
|
@update-fractioned-days="submitFractionedDays"
|
||||||
@update-paid-leave-days="submitPaidLeaveDays"
|
@update-paid-leave-days="submitPaidLeaveDays"
|
||||||
|
@update-selected-year="setSelectedLeaveYear"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="showRttTab && activeTab === 'rtt'" class="h-full">
|
<div v-else-if="showRttTab && activeTab === 'rtt'" class="h-full">
|
||||||
<div v-if="isRttLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
<div v-if="isRttLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||||
Chargement...
|
Chargement...
|
||||||
</div>
|
</div>
|
||||||
<EmployeesRttTab v-else class="h-full" :summary="rttSummary" @submit-rtt-payment="submitRttPayment" />
|
<EmployeesRttTab
|
||||||
|
v-else
|
||||||
|
class="h-full"
|
||||||
|
:summary="rttSummary"
|
||||||
|
:selected-year="selectedRttYear"
|
||||||
|
:available-years="availableRttYears"
|
||||||
|
:current-year="currentRttYear"
|
||||||
|
:selected-phase="selectedPhase"
|
||||||
|
@submit-rtt-payment="submitRttPayment"
|
||||||
|
@update-selected-year="setSelectedRttYear"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="activeTab === 'mileage'" class="h-full">
|
<div v-else-if="activeTab === 'mileage'" class="h-full">
|
||||||
<div v-if="isMileageLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
<div v-if="isMileageLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||||
@@ -240,6 +272,7 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import EmployeeYearlyHoursDrawer from '~/components/EmployeeYearlyHoursDrawer.vue'
|
import EmployeeYearlyHoursDrawer from '~/components/EmployeeYearlyHoursDrawer.vue'
|
||||||
import { usePdfPrinter } from '~/composables/usePdfPrinter'
|
import { usePdfPrinter } from '~/composables/usePdfPrinter'
|
||||||
|
import { formatPhaseLabel } from '~/composables/useEmployeeContractPhase'
|
||||||
|
|
||||||
const { printPdf } = usePdfPrinter()
|
const { printPdf } = usePdfPrinter()
|
||||||
const isYearlyHoursDrawerOpen = ref(false)
|
const isYearlyHoursDrawerOpen = ref(false)
|
||||||
@@ -253,6 +286,14 @@ const {
|
|||||||
leaveSummary,
|
leaveSummary,
|
||||||
rttSummary,
|
rttSummary,
|
||||||
publicHolidays,
|
publicHolidays,
|
||||||
|
selectedLeaveYear,
|
||||||
|
currentLeaveYear,
|
||||||
|
availableLeaveYears,
|
||||||
|
setSelectedLeaveYear,
|
||||||
|
selectedRttYear,
|
||||||
|
currentRttYear,
|
||||||
|
availableRttYears,
|
||||||
|
setSelectedRttYear,
|
||||||
showLeaveTab,
|
showLeaveTab,
|
||||||
showRttTab,
|
showRttTab,
|
||||||
contractHistory,
|
contractHistory,
|
||||||
@@ -321,7 +362,12 @@ const {
|
|||||||
isObservationLoading,
|
isObservationLoading,
|
||||||
submitCreateObservation,
|
submitCreateObservation,
|
||||||
submitUpdateObservation,
|
submitUpdateObservation,
|
||||||
submitDeleteObservation
|
submitDeleteObservation,
|
||||||
|
selectedPhase,
|
||||||
|
showPicker,
|
||||||
|
phaseOptions,
|
||||||
|
setSelectedPhase,
|
||||||
|
isViewingPastPhase,
|
||||||
} = useEmployeeDetailPage()
|
} = useEmployeeDetailPage()
|
||||||
|
|
||||||
const handleYearlyHoursPrint = async (payload: { year: number; month: number | null }) => {
|
const handleYearlyHoursPrint = async (payload: { year: number; month: number | null }) => {
|
||||||
|
|||||||
13
frontend/services/dto/contract-phase.ts
Normal file
13
frontend/services/dto/contract-phase.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { ContractType } from './contract'
|
||||||
|
|
||||||
|
export type ContractPhase = {
|
||||||
|
id: number
|
||||||
|
contractType: ContractType
|
||||||
|
weeklyHours: number | null
|
||||||
|
isDriver: boolean
|
||||||
|
startDate: string
|
||||||
|
endDate: string | null
|
||||||
|
periodIds: number[]
|
||||||
|
isCurrent: boolean
|
||||||
|
contractNature: 'CDI' | 'CDD' | 'INTERIM'
|
||||||
|
}
|
||||||
@@ -16,5 +16,6 @@ export type EmployeeLeaveSummary = {
|
|||||||
previousYearPaidDays: number
|
previousYearPaidDays: number
|
||||||
presenceDaysByMonth: Record<string, number>
|
presenceDaysByMonth: Record<string, number>
|
||||||
presenceDaysToToday: number
|
presenceDaysToToday: number
|
||||||
|
dataStartDate: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Site } from './site'
|
import type { Site } from './site'
|
||||||
import type { Contract } from './contract'
|
import type { Contract } from './contract'
|
||||||
|
import type { ContractPhase } from './contract-phase'
|
||||||
|
|
||||||
export type ContractSuspension = {
|
export type ContractSuspension = {
|
||||||
id: number
|
id: number
|
||||||
@@ -41,4 +42,5 @@ export type Employee = {
|
|||||||
currentSuspensions?: ContractSuspension[]
|
currentSuspensions?: ContractSuspension[]
|
||||||
currentInterimAgencyId?: number | null
|
currentInterimAgencyId?: number | null
|
||||||
currentInterimAgencyName?: string | null
|
currentInterimAgencyName?: string | null
|
||||||
|
contractPhases?: ContractPhase[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { EmployeeLeaveSummary } from './dto/employee-leave-summary'
|
import type { EmployeeLeaveSummary } from './dto/employee-leave-summary'
|
||||||
|
|
||||||
export const getEmployeeLeaveSummary = async (employeeId: number, year?: number) => {
|
export const getEmployeeLeaveSummary = async (employeeId: number, year?: number, phaseId?: number) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const query: Record<string, string> = {}
|
const query: Record<string, string> = {}
|
||||||
if (year) query.year = String(year)
|
if (year) query.year = String(year)
|
||||||
|
if (phaseId !== undefined) query.phaseId = String(phaseId)
|
||||||
|
|
||||||
return api.get<EmployeeLeaveSummary>(`/employees/${employeeId}/leave-summary`, query, { toast: false })
|
return api.get<EmployeeLeaveSummary>(`/employees/${employeeId}/leave-summary`, query, { toast: false })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { EmployeeRttSummary } from './dto/employee-rtt-summary'
|
import type { EmployeeRttSummary } from './dto/employee-rtt-summary'
|
||||||
|
|
||||||
export const getEmployeeRttSummary = async (employeeId: number, year?: number) => {
|
export const getEmployeeRttSummary = async (employeeId: number, year?: number, phaseId?: number) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const query = year ? { year } : {}
|
const query: Record<string, number> = {}
|
||||||
|
if (year) query.year = year
|
||||||
|
if (phaseId !== undefined) query.phaseId = phaseId
|
||||||
return api.get<EmployeeRttSummary>(`/employees/${employeeId}/rtt-summary`, query, { toast: false })
|
return api.get<EmployeeRttSummary>(`/employees/${employeeId}/rtt-summary`, query, { toast: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,4 +41,7 @@ final class EmployeeLeaveSummary
|
|||||||
|
|
||||||
/** Cumul des jours de présence depuis le début de l'année de congé jusqu'à aujourd'hui (forfait). */
|
/** Cumul des jours de présence depuis le début de l'année de congé jusqu'à aujourd'hui (forfait). */
|
||||||
public float $presenceDaysToToday = 0.0;
|
public float $presenceDaysToToday = 0.0;
|
||||||
|
|
||||||
|
/** Date de mise en service du logiciel (env RTT_START_DATE) — borne minimale pour les sélecteurs d'historique. */
|
||||||
|
public ?string $dataStartDate = null;
|
||||||
}
|
}
|
||||||
|
|||||||
27
src/Dto/Contracts/ContractPhase.php
Normal file
27
src/Dto/Contracts/ContractPhase.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Dto\Contracts;
|
||||||
|
|
||||||
|
use App\Enum\ContractNature;
|
||||||
|
use App\Enum\ContractType;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
final readonly class ContractPhase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<int> $periodIds
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public int $id,
|
||||||
|
public ContractType $contractType,
|
||||||
|
public ?int $weeklyHours,
|
||||||
|
public bool $isDriver,
|
||||||
|
public DateTimeImmutable $startDate,
|
||||||
|
public ?DateTimeImmutable $endDate,
|
||||||
|
public array $periodIds,
|
||||||
|
public bool $isCurrent,
|
||||||
|
public ContractNature $contractNature,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -6,9 +6,11 @@ namespace App\Entity;
|
|||||||
|
|
||||||
use ApiPlatform\Metadata\ApiProperty;
|
use ApiPlatform\Metadata\ApiProperty;
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use App\Dto\Contracts\ContractPhase;
|
||||||
use App\Dto\Employees\ContractHistoryItem;
|
use App\Dto\Employees\ContractHistoryItem;
|
||||||
use App\Enum\ContractNature;
|
use App\Enum\ContractNature;
|
||||||
use App\Repository\EmployeeRepository;
|
use App\Repository\EmployeeRepository;
|
||||||
|
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
||||||
use App\State\EmployeeWriteProcessor;
|
use App\State\EmployeeWriteProcessor;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
@@ -428,6 +430,43 @@ class Employee
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{
|
||||||
|
* id: int,
|
||||||
|
* contractType: string,
|
||||||
|
* weeklyHours: ?int,
|
||||||
|
* isDriver: bool,
|
||||||
|
* startDate: string,
|
||||||
|
* endDate: ?string,
|
||||||
|
* periodIds: list<int>,
|
||||||
|
* isCurrent: bool,
|
||||||
|
* contractNature: string
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
public function getContractPhases(): array
|
||||||
|
{
|
||||||
|
// Read RTT_START_DATE directly here: the entity has no DI but must filter
|
||||||
|
// out contract phases that ended before the application's data start.
|
||||||
|
$rawDate = $_SERVER['RTT_START_DATE'] ?? $_ENV['RTT_START_DATE'] ?? '';
|
||||||
|
$resolver = new EmployeeContractPhaseResolver(is_string($rawDate) ? $rawDate : '');
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
static fn (ContractPhase $phase): array => [
|
||||||
|
'id' => $phase->id,
|
||||||
|
'contractType' => $phase->contractType->value,
|
||||||
|
'weeklyHours' => $phase->weeklyHours,
|
||||||
|
'isDriver' => $phase->isDriver,
|
||||||
|
'startDate' => $phase->startDate->format('Y-m-d'),
|
||||||
|
'endDate' => $phase->endDate?->format('Y-m-d'),
|
||||||
|
'periodIds' => $phase->periodIds,
|
||||||
|
'isCurrent' => $phase->isCurrent,
|
||||||
|
'contractNature' => $phase->contractNature->value,
|
||||||
|
],
|
||||||
|
$resolver->resolvePhases($this),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveCurrentContractPeriod(): ?EmployeeContractPeriod
|
private function resolveCurrentContractPeriod(): ?EmployeeContractPeriod
|
||||||
{
|
{
|
||||||
$today = new DateTimeImmutable('today');
|
$today = new DateTimeImmutable('today');
|
||||||
|
|||||||
110
src/Service/Contracts/EmployeeContractPhaseResolver.php
Normal file
110
src/Service/Contracts/EmployeeContractPhaseResolver.php
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Contracts;
|
||||||
|
|
||||||
|
use App\Dto\Contracts\ContractPhase;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeContractPeriod;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use LogicException;
|
||||||
|
|
||||||
|
final readonly class EmployeeContractPhaseResolver
|
||||||
|
{
|
||||||
|
private ?DateTimeImmutable $dataStartDate;
|
||||||
|
|
||||||
|
public function __construct(string $dataStartDate = '')
|
||||||
|
{
|
||||||
|
$trimmed = trim($dataStartDate);
|
||||||
|
if ('' === $trimmed) {
|
||||||
|
$this->dataStartDate = null;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parsed = DateTimeImmutable::createFromFormat('!Y-m-d', $trimmed);
|
||||||
|
$this->dataStartDate = $parsed instanceof DateTimeImmutable ? $parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<ContractPhase>
|
||||||
|
*/
|
||||||
|
public function resolvePhases(Employee $employee): array
|
||||||
|
{
|
||||||
|
$periods = $employee->getContractPeriods()->toArray();
|
||||||
|
usort(
|
||||||
|
$periods,
|
||||||
|
static fn (EmployeeContractPeriod $a, EmployeeContractPeriod $b): int => $a->getStartDate() <=> $b->getStartDate()
|
||||||
|
);
|
||||||
|
|
||||||
|
$today = new DateTimeImmutable('today');
|
||||||
|
$phases = [];
|
||||||
|
$group = [];
|
||||||
|
$signature = null;
|
||||||
|
|
||||||
|
foreach ($periods as $period) {
|
||||||
|
$currentSignature = $this->signature($period);
|
||||||
|
if (null !== $signature && $currentSignature !== $signature) {
|
||||||
|
$phases[] = $this->buildPhase($group, $today);
|
||||||
|
$group = [];
|
||||||
|
}
|
||||||
|
$group[] = $period;
|
||||||
|
$signature = $currentSignature;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([] !== $group) {
|
||||||
|
$phases[] = $this->buildPhase($group, $today);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide phases entirely before the application's data start date: no usable
|
||||||
|
// work-hour or absence data exists before that date, so exposing them would
|
||||||
|
// confuse HR (e.g. legacy contract periods predating the software launch).
|
||||||
|
if (null !== $this->dataStartDate) {
|
||||||
|
$dataStart = $this->dataStartDate;
|
||||||
|
$phases = array_values(array_filter(
|
||||||
|
$phases,
|
||||||
|
static fn (ContractPhase $phase): bool => null === $phase->endDate || $phase->endDate >= $dataStart,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Most recent first.
|
||||||
|
return array_reverse($phases);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function signature(EmployeeContractPeriod $period): string
|
||||||
|
{
|
||||||
|
$contract = $period->getContract();
|
||||||
|
$type = $contract?->getType()->value ?? '';
|
||||||
|
$hours = $contract?->getWeeklyHours() ?? -1;
|
||||||
|
$driver = $period->getIsDriver() ? '1' : '0';
|
||||||
|
|
||||||
|
return sprintf('%s|%d|%s', $type, $hours, $driver);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param non-empty-list<EmployeeContractPeriod> $group
|
||||||
|
*/
|
||||||
|
private function buildPhase(array $group, DateTimeImmutable $today): ContractPhase
|
||||||
|
{
|
||||||
|
$first = $group[0];
|
||||||
|
$last = end($group);
|
||||||
|
|
||||||
|
$endDate = $last->getEndDate();
|
||||||
|
$isCurrent = null === $endDate || $endDate >= $today;
|
||||||
|
|
||||||
|
$contract = $first->getContract();
|
||||||
|
|
||||||
|
return new ContractPhase(
|
||||||
|
id: (int) $first->getId(),
|
||||||
|
contractType: $contract?->getType() ?? throw new LogicException('Phase requires a contract type'),
|
||||||
|
weeklyHours: $contract?->getWeeklyHours(),
|
||||||
|
isDriver: $first->getIsDriver(),
|
||||||
|
startDate: $first->getStartDate(),
|
||||||
|
endDate: $endDate,
|
||||||
|
periodIds: array_map(static fn (EmployeeContractPeriod $p): int => (int) $p->getId(), $group),
|
||||||
|
isCurrent: $isCurrent,
|
||||||
|
contractNature: $first->getContractNatureEnum(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/Service/Exercise/ExerciseYearResolver.php
Normal file
27
src/Service/Exercise/ExerciseYearResolver.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Exercise;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
final readonly class ExerciseYearResolver
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Convert a date to its leave/RTT exercise year.
|
||||||
|
*
|
||||||
|
* - Forfait: calendar year (Jan→Dec) — returns $date.Y.
|
||||||
|
* - Non-forfait: leave year (Juin N-1 → Mai N) — returns $date.Y+1 if month >= 6, else $date.Y.
|
||||||
|
*/
|
||||||
|
public function forDate(DateTimeImmutable $date, bool $isForfait = false): int
|
||||||
|
{
|
||||||
|
if ($isForfait) {
|
||||||
|
return (int) $date->format('Y');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $date->format('n') >= 6
|
||||||
|
? (int) $date->format('Y') + 1
|
||||||
|
: (int) $date->format('Y');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -413,10 +413,17 @@ final readonly class RttRecoveryComputationService
|
|||||||
{
|
{
|
||||||
$total = 0;
|
$total = 0;
|
||||||
foreach ($days as $date) {
|
foreach ($days as $date) {
|
||||||
$isoDay = (int) new DateTimeImmutable($date)->format('N');
|
$isoDay = (int) new DateTimeImmutable($date)->format('N');
|
||||||
$contract = $contractsByDate[$date] ?? null;
|
$contract = $contractsByDate[$date] ?? null;
|
||||||
$hours = $contract?->getWeeklyHours();
|
$hours = $contract?->getWeeklyHours();
|
||||||
$startHours = (null !== $hours && $hours >= 39) ? 39 : 35;
|
// Days without an active contract (pre-hire, post-termination, contract
|
||||||
|
// gaps) must NOT contribute to the weekly 25% overtime threshold —
|
||||||
|
// otherwise hiring mid-week artificially inflates the threshold and
|
||||||
|
// erases legitimate overtime.
|
||||||
|
if (null === $hours || $hours <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$startHours = $hours >= 39 ? 39 : 35;
|
||||||
$total += $this->resolveDailyReferenceMinutes($startHours, $isoDay);
|
$total += $this->resolveDailyReferenceMinutes($startHours, $isoDay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\State;
|
|||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
use ApiPlatform\State\ProviderInterface;
|
use ApiPlatform\State\ProviderInterface;
|
||||||
use App\ApiResource\EmployeeLeaveSummary;
|
use App\ApiResource\EmployeeLeaveSummary;
|
||||||
|
use App\Dto\Contracts\ContractPhase;
|
||||||
use App\Entity\Absence;
|
use App\Entity\Absence;
|
||||||
use App\Entity\ContractSuspension;
|
use App\Entity\ContractSuspension;
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
@@ -20,6 +21,8 @@ use App\Repository\EmployeeLeaveBalanceRepository;
|
|||||||
use App\Repository\EmployeeRepository;
|
use App\Repository\EmployeeRepository;
|
||||||
use App\Repository\WorkHourRepository;
|
use App\Repository\WorkHourRepository;
|
||||||
use App\Security\EmployeeScopeService;
|
use App\Security\EmployeeScopeService;
|
||||||
|
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
||||||
|
use App\Service\Exercise\ExerciseYearResolver;
|
||||||
use App\Service\Leave\LeaveBalanceComputationService;
|
use App\Service\Leave\LeaveBalanceComputationService;
|
||||||
use App\Service\Leave\LongMaladieService;
|
use App\Service\Leave\LongMaladieService;
|
||||||
use App\Service\Leave\SuspensionDaysCalculator;
|
use App\Service\Leave\SuspensionDaysCalculator;
|
||||||
@@ -45,6 +48,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
private const float CDI_NON_FORFAIT_4H_SATURDAY_ACCRUAL_PER_MONTH = 0.0;
|
private const float CDI_NON_FORFAIT_4H_SATURDAY_ACCRUAL_PER_MONTH = 0.0;
|
||||||
private const float LONG_MALADIE_MONTHLY_ACCRUAL = 2.0;
|
private const float LONG_MALADIE_MONTHLY_ACCRUAL = 2.0;
|
||||||
|
|
||||||
|
private ?string $dataStartDate;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private Security $security,
|
private Security $security,
|
||||||
private RequestStack $requestStack,
|
private RequestStack $requestStack,
|
||||||
@@ -58,7 +63,12 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
private PublicHolidayServiceInterface $publicHolidayService,
|
private PublicHolidayServiceInterface $publicHolidayService,
|
||||||
private SuspensionDaysCalculator $suspensionDaysCalculator,
|
private SuspensionDaysCalculator $suspensionDaysCalculator,
|
||||||
private WorkHourRepository $workHourRepository,
|
private WorkHourRepository $workHourRepository,
|
||||||
) {}
|
private EmployeeContractPhaseResolver $phaseResolver,
|
||||||
|
private ExerciseYearResolver $exerciseYearResolver,
|
||||||
|
string $dataStartDate = '',
|
||||||
|
) {
|
||||||
|
$this->dataStartDate = '' !== $dataStartDate ? $dataStartDate : null;
|
||||||
|
}
|
||||||
|
|
||||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmployeeLeaveSummary
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmployeeLeaveSummary
|
||||||
{
|
{
|
||||||
@@ -81,13 +91,15 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
throw new AccessDeniedHttpException('Employee outside your scope.');
|
throw new AccessDeniedHttpException('Employee outside your scope.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$year = $this->resolveYear($employee);
|
$phase = $this->resolveTargetPhase($employee);
|
||||||
|
$year = $this->resolveYear($employee, $phase);
|
||||||
|
|
||||||
$summary = new EmployeeLeaveSummary();
|
$summary = new EmployeeLeaveSummary();
|
||||||
$summary->year = $year;
|
$summary->year = $year;
|
||||||
$summary->ruleCode = LeaveRuleCode::UNSUPPORTED->value;
|
$summary->ruleCode = LeaveRuleCode::UNSUPPORTED->value;
|
||||||
|
$summary->dataStartDate = $this->dataStartDate;
|
||||||
|
|
||||||
$yearSummary = $this->computeYearSummary($employee, $year);
|
$yearSummary = $this->computeYearSummary($employee, $year, 0.0, null, $phase);
|
||||||
if (null === $yearSummary) {
|
if (null === $yearSummary) {
|
||||||
return $summary;
|
return $summary;
|
||||||
}
|
}
|
||||||
@@ -98,7 +110,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
// For forfait contracts, paid days reduce N-1 stock before taken-day attribution.
|
// For forfait contracts, paid days reduce N-1 stock before taken-day attribution.
|
||||||
// Recompute with paidLeaveDays so taken days shift from N-1 to N when N-1 is consumed by payment.
|
// Recompute with paidLeaveDays so taken days shift from N-1 to N when N-1 is consumed by payment.
|
||||||
if ($paidLeaveDays > 0.0) {
|
if ($paidLeaveDays > 0.0) {
|
||||||
$yearSummary = $this->computeYearSummary($employee, $year, $paidLeaveDays);
|
$yearSummary = $this->computeYearSummary($employee, $year, $paidLeaveDays, null, $phase);
|
||||||
if (null === $yearSummary) {
|
if (null === $yearSummary) {
|
||||||
return $summary;
|
return $summary;
|
||||||
}
|
}
|
||||||
@@ -119,7 +131,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
$summary->previousYearRemainingDays = $yearSummary['previousYearRemainingDays'];
|
$summary->previousYearRemainingDays = $yearSummary['previousYearRemainingDays'];
|
||||||
$summary->previousYearPaidDays = $paidLeaveDays;
|
$summary->previousYearPaidDays = $paidLeaveDays;
|
||||||
|
|
||||||
[$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year);
|
[$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year, $phase);
|
||||||
// Forfait-only: leaves taken from N-1 stock do NOT decrement presence days.
|
// Forfait-only: leaves taken from N-1 stock do NOT decrement presence days.
|
||||||
// For non-forfait, previousYearTakenDays is always 0, so the budget has no effect.
|
// For non-forfait, previousYearTakenDays is always 0, so the budget has no effect.
|
||||||
$n1AbsencesBudget = $yearSummary['previousYearTakenDays'];
|
$n1AbsencesBudget = $yearSummary['previousYearTakenDays'];
|
||||||
@@ -161,9 +173,20 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
* previousYearRemainingDays: float
|
* previousYearRemainingDays: float
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function computeYearSummary(Employee $employee, int $targetYear, float $paidLeaveDays = 0.0, ?DateTimeImmutable $asOfDate = null): ?array
|
public function computeYearSummary(Employee $employee, int $targetYear, float $paidLeaveDays = 0.0, ?DateTimeImmutable $asOfDate = null, ?ContractPhase $phase = null): ?array
|
||||||
{
|
{
|
||||||
$firstYear = max($this->resolveFirstComputationYear($employee), $targetYear - 1);
|
// Track whether a phase was provided explicitly. When the caller supplies $phase,
|
||||||
|
// we apply the phase-end cap on period bounds. When we fall back to resolveCurrentPhase
|
||||||
|
// (legacy callers without phase awareness, e.g. LeaveRecapRowBuilder), we preserve
|
||||||
|
// the pre-phase-cap behavior to avoid changing observable results for terminated
|
||||||
|
// employees (the resolved fallback phase would otherwise unduly cap `to`).
|
||||||
|
$applyPhaseEndCap = null !== $phase;
|
||||||
|
$phase ??= $this->resolveCurrentPhase($employee);
|
||||||
|
if (null === $phase) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$firstYear = max($this->resolveFirstComputationYear($employee, $phase), $targetYear - 1);
|
||||||
if ($targetYear < $firstYear) {
|
if ($targetYear < $firstYear) {
|
||||||
$targetYear = $firstYear;
|
$targetYear = $firstYear;
|
||||||
}
|
}
|
||||||
@@ -173,8 +196,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
$targetSummary = null;
|
$targetSummary = null;
|
||||||
|
|
||||||
for ($year = $firstYear; $year <= $targetYear; ++$year) {
|
for ($year = $firstYear; $year <= $targetYear; ++$year) {
|
||||||
[$from, $to] = $this->resolvePeriodBounds($employee, $year);
|
[$from, $to] = $this->resolvePeriodBounds($employee, $year, $phase, $applyPhaseEndCap);
|
||||||
$leavePolicy = $this->resolveLeavePolicy($employee, $from, $to);
|
$leavePolicy = $this->resolveLeavePolicy($employee, $phase, $from, $to);
|
||||||
if (null === $leavePolicy) {
|
if (null === $leavePolicy) {
|
||||||
if ($year === $targetYear) {
|
if ($year === $targetYear) {
|
||||||
return null;
|
return null;
|
||||||
@@ -218,8 +241,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
$effectiveAsOfDate = ($year === $targetYear) ? $asOfDate : null;
|
$effectiveAsOfDate = ($year === $targetYear) ? $asOfDate : null;
|
||||||
$accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee, $effectiveAsOfDate);
|
$accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee, $phase, $effectiveAsOfDate, $applyPhaseEndCap);
|
||||||
$takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee, $effectiveAsOfDate);
|
$takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee, $phase, $effectiveAsOfDate, $applyPhaseEndCap);
|
||||||
$suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace(
|
$suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace(
|
||||||
$this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to)
|
$this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to)
|
||||||
);
|
);
|
||||||
@@ -227,7 +250,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
$longMaladiePeriods = [];
|
$longMaladiePeriods = [];
|
||||||
$longMaladieReductionFactor = 1.0;
|
$longMaladieReductionFactor = 1.0;
|
||||||
if (LeaveRuleCode::CDI_CDD_NON_FORFAIT->value === $leavePolicy['ruleCode']
|
if (LeaveRuleCode::CDI_CDD_NON_FORFAIT->value === $leavePolicy['ruleCode']
|
||||||
&& 4 !== $employee->getContract()?->getWeeklyHours()
|
&& 4 !== $phase->weeklyHours
|
||||||
&& null !== $accrualCalculationEnd
|
&& null !== $accrualCalculationEnd
|
||||||
) {
|
) {
|
||||||
$longMaladiePeriods = $this->longMaladieService->findReducedRatePeriods($employee, $effectiveFrom, $accrualCalculationEnd);
|
$longMaladiePeriods = $this->longMaladieService->findReducedRatePeriods($employee, $effectiveFrom, $accrualCalculationEnd);
|
||||||
@@ -414,16 +437,29 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
return $earliest;
|
return $earliest;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveYear(Employee $employee): int
|
private function resolveYear(Employee $employee, ContractPhase $phase): int
|
||||||
{
|
{
|
||||||
$raw = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? '');
|
$isForfait = ContractType::FORFAIT === $phase->contractType;
|
||||||
|
$raw = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? '');
|
||||||
|
$phaseIdRaw = $this->requestStack->getCurrentRequest()?->query->get('phaseId');
|
||||||
|
$phaseIdProvided = null !== $phaseIdRaw && '' !== (string) $phaseIdRaw;
|
||||||
|
|
||||||
if ('' === $raw) {
|
if ('' === $raw) {
|
||||||
$today = new DateTimeImmutable('today');
|
// When a phaseId is explicitly provided, default to the year derived from
|
||||||
if (ContractType::FORFAIT === $employee->getContract()?->getType()) {
|
// the phase's end date (or today if the phase is still current).
|
||||||
return (int) $today->format('Y');
|
if ($phaseIdProvided) {
|
||||||
|
$reference = $phase->endDate ?? new DateTimeImmutable('today');
|
||||||
|
|
||||||
|
return $isForfait
|
||||||
|
? (int) $reference->format('Y')
|
||||||
|
: $this->resolveCurrentLeaveYear($reference);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->resolveCurrentLeaveYear($today);
|
$today = new DateTimeImmutable('today');
|
||||||
|
|
||||||
|
return $isForfait
|
||||||
|
? (int) $today->format('Y')
|
||||||
|
: $this->resolveCurrentLeaveYear($today);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!preg_match('/^\d{4}$/', $raw)) {
|
if (!preg_match('/^\d{4}$/', $raw)) {
|
||||||
@@ -435,9 +471,79 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.');
|
throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When a phaseId is explicit, silently clamp the requested year to the
|
||||||
|
// first/last exercise covered by the phase.
|
||||||
|
if ($phaseIdProvided) {
|
||||||
|
$year = $this->clampYearToPhase($year, $phase, $isForfait);
|
||||||
|
}
|
||||||
|
|
||||||
return $year;
|
return $year;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function clampYearToPhase(int $year, ContractPhase $phase, bool $isForfait): int
|
||||||
|
{
|
||||||
|
$firstYear = $this->exerciseYearResolver->forDate($phase->startDate, $isForfait);
|
||||||
|
$lastYear = $phase->endDate instanceof DateTimeImmutable
|
||||||
|
? $this->exerciseYearResolver->forDate($phase->endDate, $isForfait)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if ($year < $firstYear) {
|
||||||
|
return $firstYear;
|
||||||
|
}
|
||||||
|
if (null !== $lastYear && $year > $lastYear) {
|
||||||
|
return $lastYear;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $year;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveTargetPhase(Employee $employee): ContractPhase
|
||||||
|
{
|
||||||
|
$raw = $this->requestStack->getCurrentRequest()?->query->get('phaseId');
|
||||||
|
$phases = $this->phaseResolver->resolvePhases($employee);
|
||||||
|
if ([] === $phases) {
|
||||||
|
throw new UnprocessableEntityHttpException('Employee has no contract phase.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $raw || '' === (string) $raw) {
|
||||||
|
// Phase courante par défaut = celle marquée isCurrent ou, à défaut, la plus récente.
|
||||||
|
foreach ($phases as $phase) {
|
||||||
|
if ($phase->isCurrent) {
|
||||||
|
return $phase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $phases[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preg_match('/^\d+$/', (string) $raw)) {
|
||||||
|
throw new UnprocessableEntityHttpException('phaseId must be a positive integer.');
|
||||||
|
}
|
||||||
|
$phaseId = (int) $raw;
|
||||||
|
foreach ($phases as $phase) {
|
||||||
|
if ($phase->id === $phaseId) {
|
||||||
|
return $phase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new UnprocessableEntityHttpException('phaseId does not match any phase of this employee.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveCurrentPhase(Employee $employee): ?ContractPhase
|
||||||
|
{
|
||||||
|
$phases = $this->phaseResolver->resolvePhases($employee);
|
||||||
|
if ([] === $phases) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
foreach ($phases as $phase) {
|
||||||
|
if ($phase->isCurrent) {
|
||||||
|
return $phase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $phases[0];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<ContractSuspension> $suspensions
|
* @param list<ContractSuspension> $suspensions
|
||||||
* @param list<array{start: DateTimeImmutable, end: DateTimeImmutable}> $longMaladiePeriods
|
* @param list<array{start: DateTimeImmutable, end: DateTimeImmutable}> $longMaladiePeriods
|
||||||
@@ -512,14 +618,21 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
int $year,
|
int $year,
|
||||||
DateTimeImmutable $periodEnd,
|
DateTimeImmutable $periodEnd,
|
||||||
Employee $employee,
|
Employee $employee,
|
||||||
?DateTimeImmutable $asOfDate = null
|
ContractPhase $phase,
|
||||||
|
?DateTimeImmutable $asOfDate = null,
|
||||||
|
bool $applyPhaseEndCap = true
|
||||||
): ?DateTimeImmutable {
|
): ?DateTimeImmutable {
|
||||||
$reference = $asOfDate ?? new DateTimeImmutable('today');
|
$reference = $asOfDate ?? new DateTimeImmutable('today');
|
||||||
$currentYear = LeaveRuleCode::FORFAIT_218->value === $ruleCode
|
$currentYear = LeaveRuleCode::FORFAIT_218->value === $ruleCode
|
||||||
? (int) $reference->format('Y')
|
? (int) $reference->format('Y')
|
||||||
: $this->resolveCurrentLeaveYear($reference);
|
: $this->resolveCurrentLeaveYear($reference);
|
||||||
|
|
||||||
if ($year < $currentYear) {
|
// When viewing a closed phase explicitly, treat its end date as the reference cutoff:
|
||||||
|
// accrual is bounded to the phase end, never running to "today".
|
||||||
|
// Legacy callers (no explicit phase) skip this cap to preserve pre-phase behavior.
|
||||||
|
if ($applyPhaseEndCap && !$phase->isCurrent && null !== $phase->endDate) {
|
||||||
|
$end = $phase->endDate < $periodEnd ? $phase->endDate : $periodEnd;
|
||||||
|
} elseif ($year < $currentYear) {
|
||||||
$end = $periodEnd;
|
$end = $periodEnd;
|
||||||
} elseif ($year > $currentYear) {
|
} elseif ($year > $currentYear) {
|
||||||
$end = null;
|
$end = null;
|
||||||
@@ -532,12 +645,17 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
$end = $lastDayPreviousMonth < $periodEnd ? $lastDayPreviousMonth : $periodEnd;
|
$end = $lastDayPreviousMonth < $periodEnd ? $lastDayPreviousMonth : $periodEnd;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cap at contract end date if the employee has left.
|
// Cap at contract end date if the employee has left (only meaningful when
|
||||||
$contractEndRaw = $employee->getCurrentContractEndDate();
|
// viewing the current phase; closed phases are already capped above).
|
||||||
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
// Legacy callers (no explicit phase) always evaluate this branch to mimic
|
||||||
$contractEnd = $this->parseYmdDate($contractEndRaw);
|
// the pre-phase behavior, which relied on getCurrentContractEndDate().
|
||||||
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
|
if (!$applyPhaseEndCap || $phase->isCurrent) {
|
||||||
$end = $contractEnd;
|
$contractEndRaw = $employee->getCurrentContractEndDate();
|
||||||
|
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
||||||
|
$contractEnd = $this->parseYmdDate($contractEndRaw);
|
||||||
|
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
|
||||||
|
$end = $contractEnd;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,7 +665,9 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
private function resolveTakenCalculationEndDate(
|
private function resolveTakenCalculationEndDate(
|
||||||
DateTimeImmutable $periodEnd,
|
DateTimeImmutable $periodEnd,
|
||||||
Employee $employee,
|
Employee $employee,
|
||||||
?DateTimeImmutable $asOfDate = null
|
ContractPhase $phase,
|
||||||
|
?DateTimeImmutable $asOfDate = null,
|
||||||
|
bool $applyPhaseEndCap = true
|
||||||
): ?DateTimeImmutable {
|
): ?DateTimeImmutable {
|
||||||
$end = $periodEnd;
|
$end = $periodEnd;
|
||||||
|
|
||||||
@@ -555,12 +675,21 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
$end = $asOfDate;
|
$end = $asOfDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cap at contract end date if the employee has left.
|
// Closed phase: cap taken-absence accounting at the phase end.
|
||||||
$contractEndRaw = $employee->getCurrentContractEndDate();
|
// Skip for legacy callers (no explicit phase) to preserve pre-phase behavior.
|
||||||
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
if ($applyPhaseEndCap && !$phase->isCurrent && null !== $phase->endDate && $phase->endDate < $end) {
|
||||||
$contractEnd = $this->parseYmdDate($contractEndRaw);
|
$end = $phase->endDate;
|
||||||
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
|
}
|
||||||
$end = $contractEnd;
|
|
||||||
|
// Legacy callers (no explicit phase) always use the live contract end date,
|
||||||
|
// mirroring the pre-phase implementation.
|
||||||
|
if (!$applyPhaseEndCap || $phase->isCurrent) {
|
||||||
|
$contractEndRaw = $employee->getCurrentContractEndDate();
|
||||||
|
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
||||||
|
$contractEnd = $this->parseYmdDate($contractEndRaw);
|
||||||
|
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
|
||||||
|
$end = $contractEnd;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,9 +707,9 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
* splitSaturdays: bool
|
* splitSaturdays: bool
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
private function resolveLeavePolicy(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): ?array
|
private function resolveLeavePolicy(Employee $employee, ContractPhase $phase, DateTimeImmutable $from, DateTimeImmutable $to): ?array
|
||||||
{
|
{
|
||||||
$type = $employee->getContract()?->getType();
|
$type = $phase->contractType;
|
||||||
if (ContractType::FORFAIT === $type) {
|
if (ContractType::FORFAIT === $type) {
|
||||||
// Business days for forfait must use the RAW holiday list (excluded holidays like
|
// 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
|
// "Lundi de Pentecôte" / journée de solidarité still count as non-working days for
|
||||||
@@ -609,12 +738,13 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$nature = ContractNature::tryFrom($employee->getCurrentContractNature());
|
// Resolve nature directly from the phase DTO (populated by EmployeeContractPhaseResolver).
|
||||||
|
$nature = $phase->contractNature;
|
||||||
if (ContractNature::CDI !== $nature && ContractNature::CDD !== $nature) {
|
if (ContractNature::CDI !== $nature && ContractNature::CDD !== $nature) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$weeklyHours = $employee->getContract()?->getWeeklyHours();
|
$weeklyHours = $phase->weeklyHours;
|
||||||
if (4 === $weeklyHours) {
|
if (4 === $weeklyHours) {
|
||||||
return [
|
return [
|
||||||
'ruleCode' => LeaveRuleCode::CDI_CDD_NON_FORFAIT->value,
|
'ruleCode' => LeaveRuleCode::CDI_CDD_NON_FORFAIT->value,
|
||||||
@@ -809,13 +939,33 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
/**
|
/**
|
||||||
* @return array{DateTimeImmutable, DateTimeImmutable}
|
* @return array{DateTimeImmutable, DateTimeImmutable}
|
||||||
*/
|
*/
|
||||||
private function resolvePeriodBounds(Employee $employee, int $year): array
|
private function resolvePeriodBounds(Employee $employee, int $year, ContractPhase $phase, bool $applyPhaseEndCap = true): array
|
||||||
{
|
{
|
||||||
if (ContractType::FORFAIT === $employee->getContract()?->getType()) {
|
if (ContractType::FORFAIT === $phase->contractType) {
|
||||||
return $this->resolveForfaitYearBounds($employee, $year);
|
[$from, $to] = $this->resolveForfaitYearBounds($employee, $year, $phase);
|
||||||
|
|
||||||
|
// For FORFAIT, cap from at phase.startDate: the 218-day FORFAIT accrual
|
||||||
|
// is calendar-year scoped and only counts the FORFAIT portion of the year.
|
||||||
|
if ($phase->startDate > $from) {
|
||||||
|
$from = $phase->startDate;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
[$from, $to] = $this->resolveLeavePeriodBounds($year);
|
||||||
|
|
||||||
|
// For non-forfait, do NOT cap from at phase.startDate: CP accrual is
|
||||||
|
// annual (Juin→Mai) and continuous across signature changes within the
|
||||||
|
// same leave rule (e.g. 35h → 39h, driver flag flip, weeklyHours bump).
|
||||||
|
// The contract-entry-date cap is handled by resolveEffectivePeriodStart().
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->resolveLeavePeriodBounds($year);
|
// End cap applies to both modes. Skipped when the phase was not explicitly
|
||||||
|
// provided (legacy callers) to preserve pre-phase-cap behavior for
|
||||||
|
// terminated employees.
|
||||||
|
if ($applyPhaseEndCap && null !== $phase->endDate && $phase->endDate < $to) {
|
||||||
|
$to = $phase->endDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$from, $to];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -833,24 +983,29 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
/**
|
/**
|
||||||
* @return array{DateTimeImmutable, DateTimeImmutable}
|
* @return array{DateTimeImmutable, DateTimeImmutable}
|
||||||
*/
|
*/
|
||||||
private function resolveForfaitYearBounds(Employee $employee, int $year): array
|
private function resolveForfaitYearBounds(Employee $employee, int $year, ContractPhase $phase): array
|
||||||
{
|
{
|
||||||
$from = new DateTimeImmutable(sprintf('%d-01-01 00:00:00', $year));
|
$from = new DateTimeImmutable(sprintf('%d-01-01 00:00:00', $year));
|
||||||
$to = new DateTimeImmutable(sprintf('%d-12-31 00:00:00', $year));
|
$to = new DateTimeImmutable(sprintf('%d-12-31 00:00:00', $year));
|
||||||
|
|
||||||
$contractStartRaw = $employee->getCurrentContractStartDate();
|
// When viewing the current phase, prefer the live "current contract" dates
|
||||||
if (null !== $contractStartRaw && '' !== trim($contractStartRaw)) {
|
// for backward compat with existing tests/usage. Closed phases rely on the
|
||||||
$contractStart = $this->parseYmdDate($contractStartRaw);
|
// generic cap applied in resolvePeriodBounds().
|
||||||
if ($contractStart instanceof DateTimeImmutable && $contractStart > $from) {
|
if ($phase->isCurrent) {
|
||||||
$from = $contractStart;
|
$contractStartRaw = $employee->getCurrentContractStartDate();
|
||||||
|
if (null !== $contractStartRaw && '' !== trim($contractStartRaw)) {
|
||||||
|
$contractStart = $this->parseYmdDate($contractStartRaw);
|
||||||
|
if ($contractStart instanceof DateTimeImmutable && $contractStart > $from) {
|
||||||
|
$from = $contractStart;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
$contractEndRaw = $employee->getCurrentContractEndDate();
|
$contractEndRaw = $employee->getCurrentContractEndDate();
|
||||||
if (null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
if (null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
||||||
$contractEnd = $this->parseYmdDate($contractEndRaw);
|
$contractEnd = $this->parseYmdDate($contractEndRaw);
|
||||||
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $to) {
|
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $to) {
|
||||||
$to = $contractEnd;
|
$to = $contractEnd;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -872,16 +1027,19 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
return $month >= 6 ? $year + 1 : $year;
|
return $month >= 6 ? $year + 1 : $year;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveFirstComputationYear(Employee $employee): int
|
private function resolveFirstComputationYear(Employee $employee, ContractPhase $phase): int
|
||||||
{
|
{
|
||||||
$isForfait = ContractType::FORFAIT === $employee->getContract()?->getType();
|
$isForfait = ContractType::FORFAIT === $phase->contractType;
|
||||||
$fallbackYear = $isForfait
|
$fallbackYear = $isForfait
|
||||||
? (int) new DateTimeImmutable('today')->format('Y')
|
? (int) new DateTimeImmutable('today')->format('Y')
|
||||||
: $this->resolveCurrentLeaveYear(new DateTimeImmutable('today'));
|
: $this->resolveCurrentLeaveYear(new DateTimeImmutable('today'));
|
||||||
|
|
||||||
|
// Do not go before the exercice containing $phase->startDate.
|
||||||
|
$phaseFirstYear = $this->exerciseYearResolver->forDate($phase->startDate, $isForfait);
|
||||||
|
|
||||||
$history = $employee->getContractHistory();
|
$history = $employee->getContractHistory();
|
||||||
if ([] === $history) {
|
if ([] === $history) {
|
||||||
return $fallbackYear;
|
return max($phaseFirstYear, $fallbackYear);
|
||||||
}
|
}
|
||||||
|
|
||||||
$oldestStartDate = null;
|
$oldestStartDate = null;
|
||||||
@@ -897,22 +1055,19 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
|
|
||||||
if (null === $oldestStartDate) {
|
if (null === $oldestStartDate) {
|
||||||
$oldestBalanceYear = $this->leaveBalanceRepository->findEarliestYearForEmployee($employee);
|
$oldestBalanceYear = $this->leaveBalanceRepository->findEarliestYearForEmployee($employee);
|
||||||
|
$candidate = null === $oldestBalanceYear ? $fallbackYear : min($fallbackYear, $oldestBalanceYear);
|
||||||
|
|
||||||
return null === $oldestBalanceYear ? $fallbackYear : min($fallbackYear, $oldestBalanceYear);
|
return max($phaseFirstYear, $candidate);
|
||||||
}
|
}
|
||||||
|
|
||||||
$firstYear = $isForfait
|
$firstYear = $this->exerciseYearResolver->forDate($oldestStartDate, $isForfait);
|
||||||
? (int) $oldestStartDate->format('Y')
|
|
||||||
: ((int) $oldestStartDate->format('n') >= 6
|
|
||||||
? (int) $oldestStartDate->format('Y') + 1
|
|
||||||
: (int) $oldestStartDate->format('Y'));
|
|
||||||
|
|
||||||
$oldestBalanceYear = $this->leaveBalanceRepository->findEarliestYearForEmployee($employee);
|
$oldestBalanceYear = $this->leaveBalanceRepository->findEarliestYearForEmployee($employee);
|
||||||
if (null !== $oldestBalanceYear && $oldestBalanceYear < $firstYear) {
|
if (null !== $oldestBalanceYear && $oldestBalanceYear < $firstYear) {
|
||||||
return $oldestBalanceYear;
|
$firstYear = $oldestBalanceYear;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $firstYear;
|
return max($phaseFirstYear, $firstYear);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function parseYmdDate(string $value): ?DateTimeImmutable
|
private function parseYmdDate(string $value): ?DateTimeImmutable
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ use App\Entity\EmployeeRttPayment;
|
|||||||
use App\Repository\EmployeeRepository;
|
use App\Repository\EmployeeRepository;
|
||||||
use App\Repository\EmployeeRttPaymentRepository;
|
use App\Repository\EmployeeRttPaymentRepository;
|
||||||
use App\Service\AuditLogger;
|
use App\Service\AuditLogger;
|
||||||
use DateTimeImmutable;
|
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
||||||
|
use App\Service\Exercise\ExerciseYearResolver;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Psr\Clock\ClockInterface;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||||
|
|
||||||
@@ -24,6 +26,9 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
|
|||||||
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
||||||
private EntityManagerInterface $entityManager,
|
private EntityManagerInterface $entityManager,
|
||||||
private AuditLogger $auditLogger,
|
private AuditLogger $auditLogger,
|
||||||
|
private EmployeeContractPhaseResolver $phaseResolver,
|
||||||
|
private ClockInterface $clock,
|
||||||
|
private ExerciseYearResolver $exerciseYearResolver,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EmployeeRttPaymentInput
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EmployeeRttPaymentInput
|
||||||
@@ -48,6 +53,8 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
|
|||||||
|
|
||||||
$year = $data->year ?? $this->resolveCurrentExerciseYear();
|
$year = $data->year ?? $this->resolveCurrentExerciseYear();
|
||||||
|
|
||||||
|
$this->assertYearAllowedForPayment($employee, $year);
|
||||||
|
|
||||||
$payment = $this->rttPaymentRepository->findOneByEmployeeYearMonth($employee, $year, $data->month);
|
$payment = $this->rttPaymentRepository->findOneByEmployeeYearMonth($employee, $year, $data->month);
|
||||||
|
|
||||||
if (null === $payment) {
|
if (null === $payment) {
|
||||||
@@ -83,10 +90,33 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
|
|||||||
|
|
||||||
private function resolveCurrentExerciseYear(): int
|
private function resolveCurrentExerciseYear(): int
|
||||||
{
|
{
|
||||||
$today = new DateTimeImmutable('today');
|
return $this->exerciseYearResolver->forDate($this->clock->now());
|
||||||
$year = (int) $today->format('Y');
|
}
|
||||||
$month = (int) $today->format('n');
|
|
||||||
|
|
||||||
return $month >= 6 ? $year + 1 : $year;
|
/**
|
||||||
|
* Allow payment when the requested exercise is either the current one
|
||||||
|
* or the last exercise of a closed contract phase (the one containing
|
||||||
|
* the phase end date). Reject any other exercise (past or future).
|
||||||
|
*/
|
||||||
|
private function assertYearAllowedForPayment(Employee $employee, int $year): void
|
||||||
|
{
|
||||||
|
$currentExerciseYear = $this->resolveCurrentExerciseYear();
|
||||||
|
if ($year === $currentExerciseYear) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$phases = $this->phaseResolver->resolvePhases($employee);
|
||||||
|
foreach ($phases as $phase) {
|
||||||
|
if ($phase->isCurrent || null === $phase->endDate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($year === $this->exerciseYearResolver->forDate($phase->endDate)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new UnprocessableEntityHttpException(
|
||||||
|
'RTT payment is only allowed on the current exercise or the last exercise of a closed contract phase.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\State;
|
|||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
use ApiPlatform\State\ProviderInterface;
|
use ApiPlatform\State\ProviderInterface;
|
||||||
use App\ApiResource\EmployeeRttSummary;
|
use App\ApiResource\EmployeeRttSummary;
|
||||||
|
use App\Dto\Contracts\ContractPhase;
|
||||||
use App\Dto\Rtt\EmployeeRttWeekSummary;
|
use App\Dto\Rtt\EmployeeRttWeekSummary;
|
||||||
use App\Dto\Rtt\RttMonthPayment;
|
use App\Dto\Rtt\RttMonthPayment;
|
||||||
use App\Dto\Rtt\WeekRecoveryDetail;
|
use App\Dto\Rtt\WeekRecoveryDetail;
|
||||||
@@ -17,6 +18,8 @@ use App\Repository\EmployeeRttBalanceRepository;
|
|||||||
use App\Repository\EmployeeRttPaymentRepository;
|
use App\Repository\EmployeeRttPaymentRepository;
|
||||||
use App\Repository\WorkHourRepository;
|
use App\Repository\WorkHourRepository;
|
||||||
use App\Security\EmployeeScopeService;
|
use App\Security\EmployeeScopeService;
|
||||||
|
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
||||||
|
use App\Service\Exercise\ExerciseYearResolver;
|
||||||
use App\Service\Rtt\RttRecoveryComputationService;
|
use App\Service\Rtt\RttRecoveryComputationService;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
@@ -38,6 +41,8 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
|||||||
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
||||||
private RttRecoveryComputationService $rttRecoveryService,
|
private RttRecoveryComputationService $rttRecoveryService,
|
||||||
private WorkHourRepository $workHourRepository,
|
private WorkHourRepository $workHourRepository,
|
||||||
|
private EmployeeContractPhaseResolver $phaseResolver,
|
||||||
|
private ExerciseYearResolver $exerciseYearResolver,
|
||||||
string $rttStartDate = '',
|
string $rttStartDate = '',
|
||||||
) {
|
) {
|
||||||
$this->rttStartDate = '' !== $rttStartDate ? $rttStartDate : null;
|
$this->rttStartDate = '' !== $rttStartDate ? $rttStartDate : null;
|
||||||
@@ -64,12 +69,25 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
|||||||
throw new AccessDeniedHttpException('Employee outside your scope.');
|
throw new AccessDeniedHttpException('Employee outside your scope.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$year = $this->resolveYear();
|
$phase = $this->resolveTargetPhase($employee);
|
||||||
|
$year = $this->resolveYear($phase);
|
||||||
$today = new DateTimeImmutable('today');
|
$today = new DateTimeImmutable('today');
|
||||||
$currentExerciseYear = $this->resolveCurrentExerciseYear($today);
|
$currentExerciseYear = $this->resolveCurrentExerciseYear($today);
|
||||||
[$periodFrom, $periodTo] = $this->rttRecoveryService->resolveExerciseBounds($year);
|
[$periodFrom, $periodTo] = $this->rttRecoveryService->resolveExerciseBounds($year);
|
||||||
$weeks = $this->rttRecoveryService->buildWeeksForExercise($periodFrom, $periodTo);
|
|
||||||
$weekRanges = array_map(
|
// Cap periodTo at the phase endDate for closed phases so the RTT table does
|
||||||
|
// not extend past the date the phase ended.
|
||||||
|
// Do NOT cap periodFrom at phase.startDate: keep the full exercise
|
||||||
|
// displayed so weeks before the employee's hire (or before a past phase
|
||||||
|
// started) appear at 0, matching the previous behavior. Weeks outside the
|
||||||
|
// contract range contribute 0 minutes to the cumul naturally (no contract
|
||||||
|
// ⇒ no reference, no worked hours).
|
||||||
|
if (!$phase->isCurrent && null !== $phase->endDate && $phase->endDate < $periodTo) {
|
||||||
|
$periodTo = $phase->endDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
$weeks = $this->rttRecoveryService->buildWeeksForExercise($periodFrom, $periodTo);
|
||||||
|
$weekRanges = array_map(
|
||||||
static fn (array $week): array => [
|
static fn (array $week): array => [
|
||||||
'weekNumber' => (int) $week['weekNumber'],
|
'weekNumber' => (int) $week['weekNumber'],
|
||||||
'start' => $week['start'],
|
'start' => $week['start'],
|
||||||
@@ -96,6 +114,12 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For a closed phase: cap the week-computation limit at the phase end date,
|
||||||
|
// so weeks beyond the phase are not counted.
|
||||||
|
if (!$phase->isCurrent && null !== $phase->endDate && $phase->endDate < $limitDate) {
|
||||||
|
$limitDate = $phase->endDate;
|
||||||
|
}
|
||||||
|
|
||||||
$currentByWeekStart = $this->rttRecoveryService->computeRecoveryByWeek($employee, $weekRanges, $periodFrom, $periodTo, $limitDate);
|
$currentByWeekStart = $this->rttRecoveryService->computeRecoveryByWeek($employee, $weekRanges, $periodFrom, $periodTo, $limitDate);
|
||||||
[$carry, $carryMonth] = $this->resolveCarry($employee, $year);
|
[$carry, $carryMonth] = $this->resolveCarry($employee, $year);
|
||||||
|
|
||||||
@@ -110,14 +134,11 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
|||||||
$summary->currentYearRecoveryMinutes = array_sum(array_map(static fn ($d) => $d->totalMinutes, $currentByWeekStart));
|
$summary->currentYearRecoveryMinutes = array_sum(array_map(static fn ($d) => $d->totalMinutes, $currentByWeekStart));
|
||||||
$summary->availableMinutes = $summary->carryFromPreviousYearMinutes + $summary->currentYearRecoveryMinutes;
|
$summary->availableMinutes = $summary->carryFromPreviousYearMinutes + $summary->currentYearRecoveryMinutes;
|
||||||
|
|
||||||
// Pass rttStartDate only if it falls within this exercise
|
// Always expose rttStartDate so the frontend can use it as a hard floor
|
||||||
if (null !== $this->rttStartDate) {
|
// for the year selector. Frontend already uses month-level comparison
|
||||||
$startDate = new DateTimeImmutable($this->rttStartDate);
|
// to hide carry/report rows when the date is outside the exercise.
|
||||||
if ($startDate >= $periodFrom && $startDate <= $periodTo) {
|
$summary->rttStartDate = $this->rttStartDate;
|
||||||
$summary->rttStartDate = $this->rttStartDate;
|
$summary->weeks = $this->buildWeekSummaries($weekRanges, $currentByWeekStart, $periodFrom, $periodTo);
|
||||||
}
|
|
||||||
}
|
|
||||||
$summary->weeks = $this->buildWeekSummaries($weekRanges, $currentByWeekStart, $periodFrom, $periodTo);
|
|
||||||
|
|
||||||
// Post-process: distribute deficit weeks across cumulative balance (50% first, then 25%)
|
// Post-process: distribute deficit weeks across cumulative balance (50% first, then 25%)
|
||||||
$cumulative50 = $carry->base50Minutes + $carry->bonus50Minutes;
|
$cumulative50 = $carry->base50Minutes + $carry->bonus50Minutes;
|
||||||
@@ -216,10 +237,21 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveYear(): int
|
private function resolveYear(ContractPhase $phase): int
|
||||||
{
|
{
|
||||||
$raw = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? '');
|
$raw = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? '');
|
||||||
|
$phaseIdRaw = $this->requestStack->getCurrentRequest()?->query->get('phaseId');
|
||||||
|
$phaseIdProvided = null !== $phaseIdRaw && '' !== (string) $phaseIdRaw;
|
||||||
|
|
||||||
if ('' === $raw) {
|
if ('' === $raw) {
|
||||||
|
// When a phaseId is explicitly provided, default to the exercise year derived from
|
||||||
|
// the phase's end date (or today if the phase is still current).
|
||||||
|
if ($phaseIdProvided) {
|
||||||
|
$reference = $phase->endDate ?? new DateTimeImmutable('today');
|
||||||
|
|
||||||
|
return $this->resolveCurrentExerciseYear($reference);
|
||||||
|
}
|
||||||
|
|
||||||
return $this->resolveCurrentExerciseYear(new DateTimeImmutable('today'));
|
return $this->resolveCurrentExerciseYear(new DateTimeImmutable('today'));
|
||||||
}
|
}
|
||||||
if (!preg_match('/^\d{4}$/', $raw)) {
|
if (!preg_match('/^\d{4}$/', $raw)) {
|
||||||
@@ -231,9 +263,64 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
|||||||
throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.');
|
throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When a phaseId is explicit, silently clamp the requested year to the
|
||||||
|
// first/last exercise covered by the phase.
|
||||||
|
if ($phaseIdProvided) {
|
||||||
|
$year = $this->clampYearToPhase($year, $phase);
|
||||||
|
}
|
||||||
|
|
||||||
return $year;
|
return $year;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function clampYearToPhase(int $year, ContractPhase $phase): int
|
||||||
|
{
|
||||||
|
$firstYear = $this->exerciseYearResolver->forDate($phase->startDate);
|
||||||
|
$lastYear = $phase->endDate instanceof DateTimeImmutable
|
||||||
|
? $this->exerciseYearResolver->forDate($phase->endDate)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if ($year < $firstYear) {
|
||||||
|
return $firstYear;
|
||||||
|
}
|
||||||
|
if (null !== $lastYear && $year > $lastYear) {
|
||||||
|
return $lastYear;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $year;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveTargetPhase(Employee $employee): ContractPhase
|
||||||
|
{
|
||||||
|
$raw = $this->requestStack->getCurrentRequest()?->query->get('phaseId');
|
||||||
|
$phases = $this->phaseResolver->resolvePhases($employee);
|
||||||
|
if ([] === $phases) {
|
||||||
|
throw new UnprocessableEntityHttpException('Employee has no contract phase.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $raw || '' === (string) $raw) {
|
||||||
|
// Phase courante par défaut = celle marquée isCurrent ou, à défaut, la plus récente.
|
||||||
|
foreach ($phases as $phase) {
|
||||||
|
if ($phase->isCurrent) {
|
||||||
|
return $phase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $phases[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preg_match('/^\d+$/', (string) $raw)) {
|
||||||
|
throw new UnprocessableEntityHttpException('phaseId must be a positive integer.');
|
||||||
|
}
|
||||||
|
$phaseId = (int) $raw;
|
||||||
|
foreach ($phases as $phase) {
|
||||||
|
if ($phase->id === $phaseId) {
|
||||||
|
return $phase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new UnprocessableEntityHttpException('phaseId does not match any phase of this employee.');
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveCurrentExerciseYear(DateTimeImmutable $today): int
|
private function resolveCurrentExerciseYear(DateTimeImmutable $today): int
|
||||||
{
|
{
|
||||||
$year = (int) $today->format('Y');
|
$year = (int) $today->format('Y');
|
||||||
|
|||||||
196
tests/Service/Contracts/EmployeeContractPhaseResolverTest.php
Normal file
196
tests/Service/Contracts/EmployeeContractPhaseResolverTest.php
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service\Contracts;
|
||||||
|
|
||||||
|
use App\Entity\Contract;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeContractPeriod;
|
||||||
|
use App\Enum\ContractNature;
|
||||||
|
use App\Enum\ContractType;
|
||||||
|
use App\Enum\TrackingMode;
|
||||||
|
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use ReflectionProperty;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class EmployeeContractPhaseResolverTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testSinglePeriodYieldsSinglePhaseMarkedCurrent(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployee([
|
||||||
|
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2020-06-01', 'end' => null],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
|
||||||
|
self::assertCount(1, $phases);
|
||||||
|
self::assertSame(ContractType::H39, $phases[0]->contractType);
|
||||||
|
self::assertTrue($phases[0]->isCurrent);
|
||||||
|
self::assertNull($phases[0]->endDate);
|
||||||
|
self::assertSame(ContractNature::CDI, $phases[0]->contractNature);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testThreeConsecutivePeriodsSameSignatureCollapseIntoSinglePhase(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployee([
|
||||||
|
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2020-06-01', 'end' => '2021-05-31'],
|
||||||
|
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2021-06-01', 'end' => '2022-05-31'],
|
||||||
|
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2022-06-01', 'end' => null],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
|
||||||
|
self::assertCount(1, $phases);
|
||||||
|
self::assertCount(3, $phases[0]->periodIds);
|
||||||
|
self::assertSame('2020-06-01', $phases[0]->startDate->format('Y-m-d'));
|
||||||
|
self::assertNull($phases[0]->endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSwitchFromH39ToForfaitProducesTwoPhasesMostRecentFirst(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployee([
|
||||||
|
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2020-06-01', 'end' => '2026-04-30'],
|
||||||
|
['type' => ContractType::FORFAIT, 'hours' => 39, 'driver' => false, 'start' => '2026-05-01', 'end' => null],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
|
||||||
|
self::assertCount(2, $phases);
|
||||||
|
self::assertSame(ContractType::FORFAIT, $phases[0]->contractType);
|
||||||
|
self::assertTrue($phases[0]->isCurrent);
|
||||||
|
self::assertSame(ContractType::H39, $phases[1]->contractType);
|
||||||
|
self::assertFalse($phases[1]->isCurrent);
|
||||||
|
self::assertSame('2026-04-30', $phases[1]->endDate?->format('Y-m-d'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInterimBetweenTwoH39PeriodsBreaksThePhases(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployee([
|
||||||
|
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2020-06-01', 'end' => '2023-12-31'],
|
||||||
|
['type' => ContractType::INTERIM, 'hours' => null, 'driver' => false, 'start' => '2024-01-01', 'end' => '2024-04-30'],
|
||||||
|
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2024-05-01', 'end' => null],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
|
||||||
|
self::assertCount(3, $phases);
|
||||||
|
self::assertSame(ContractType::H39, $phases[0]->contractType);
|
||||||
|
self::assertSame(ContractType::INTERIM, $phases[1]->contractType);
|
||||||
|
self::assertSame(ContractType::H39, $phases[2]->contractType);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCustomPhasesSplitOnWeeklyHoursChange(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployee([
|
||||||
|
['type' => ContractType::CUSTOM, 'hours' => 28, 'driver' => false, 'start' => '2024-01-01', 'end' => '2024-12-31'],
|
||||||
|
['type' => ContractType::CUSTOM, 'hours' => 30, 'driver' => false, 'start' => '2025-01-01', 'end' => null],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
|
||||||
|
self::assertCount(2, $phases);
|
||||||
|
self::assertSame(30, $phases[0]->weeklyHours);
|
||||||
|
self::assertSame(28, $phases[1]->weeklyHours);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPhasesSplitOnIsDriverChange(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployee([
|
||||||
|
['type' => ContractType::H35, 'hours' => 35, 'driver' => false, 'start' => '2023-01-01', 'end' => '2024-12-31'],
|
||||||
|
['type' => ContractType::H35, 'hours' => 35, 'driver' => true, 'start' => '2025-01-01', 'end' => null],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
|
||||||
|
self::assertCount(2, $phases);
|
||||||
|
self::assertTrue($phases[0]->isDriver);
|
||||||
|
self::assertFalse($phases[1]->isDriver);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPhasesEntirelyBeforeDataStartDateAreFilteredOut(): void
|
||||||
|
{
|
||||||
|
// H35 phase ends before 2026-02-23 → must be hidden; H39 phase spans the date → kept.
|
||||||
|
$employee = $this->buildEmployee([
|
||||||
|
['type' => ContractType::H35, 'hours' => 35, 'driver' => false, 'start' => '2014-07-01', 'end' => '2025-10-31'],
|
||||||
|
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2025-11-01', 'end' => null],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$phases = new EmployeeContractPhaseResolver('2026-02-23')->resolvePhases($employee);
|
||||||
|
|
||||||
|
self::assertCount(1, $phases);
|
||||||
|
self::assertSame(ContractType::H39, $phases[0]->contractType);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPhaseEndingExactlyOnDataStartDateIsKept(): void
|
||||||
|
{
|
||||||
|
// Edge case: a phase whose endDate equals the data start date is kept
|
||||||
|
// (the inequality is `>= $dataStart`, not strict).
|
||||||
|
$employee = $this->buildEmployee([
|
||||||
|
['type' => ContractType::H35, 'hours' => 35, 'driver' => false, 'start' => '2020-01-01', 'end' => '2026-02-23'],
|
||||||
|
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2026-02-24', 'end' => null],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$phases = new EmployeeContractPhaseResolver('2026-02-23')->resolvePhases($employee);
|
||||||
|
|
||||||
|
self::assertCount(2, $phases);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNoFilteringWhenDataStartDateIsEmpty(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployee([
|
||||||
|
['type' => ContractType::H35, 'hours' => 35, 'driver' => false, 'start' => '2014-07-01', 'end' => '2020-12-31'],
|
||||||
|
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2021-01-01', 'end' => null],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
|
||||||
|
self::assertCount(2, $phases);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInvalidDataStartDateStringIsTreatedAsNull(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployee([
|
||||||
|
['type' => ContractType::H35, 'hours' => 35, 'driver' => false, 'start' => '2014-07-01', 'end' => '2020-12-31'],
|
||||||
|
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2021-01-01', 'end' => null],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$phases = new EmployeeContractPhaseResolver('not-a-date')->resolvePhases($employee);
|
||||||
|
|
||||||
|
self::assertCount(2, $phases);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array{type: ContractType, hours: ?int, driver: bool, start: string, end: ?string}> $periodsSpec
|
||||||
|
*/
|
||||||
|
private function buildEmployee(array $periodsSpec): Employee
|
||||||
|
{
|
||||||
|
$employee = new Employee();
|
||||||
|
$id = 0;
|
||||||
|
foreach ($periodsSpec as $spec) {
|
||||||
|
$contract = new Contract();
|
||||||
|
$contract->setName($spec['type']->value);
|
||||||
|
$contract->setTrackingMode(
|
||||||
|
ContractType::FORFAIT === $spec['type'] ? TrackingMode::PRESENCE->value : TrackingMode::TIME->value
|
||||||
|
);
|
||||||
|
$contract->setWeeklyHours($spec['hours']);
|
||||||
|
|
||||||
|
$period = new EmployeeContractPeriod();
|
||||||
|
$reflection = new ReflectionProperty(EmployeeContractPeriod::class, 'id');
|
||||||
|
$reflection->setValue($period, ++$id);
|
||||||
|
$period->setEmployee($employee);
|
||||||
|
$period->setContract($contract);
|
||||||
|
$period->setStartDate(new DateTimeImmutable($spec['start']));
|
||||||
|
$period->setEndDate(null !== $spec['end'] ? new DateTimeImmutable($spec['end']) : null);
|
||||||
|
$period->setContractNature(ContractNature::CDI);
|
||||||
|
$period->setIsDriver($spec['driver']);
|
||||||
|
$employee->getContractPeriods()->add($period);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $employee;
|
||||||
|
}
|
||||||
|
}
|
||||||
62
tests/Service/Exercise/ExerciseYearResolverTest.php
Normal file
62
tests/Service/Exercise/ExerciseYearResolverTest.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service\Exercise;
|
||||||
|
|
||||||
|
use App\Service\Exercise\ExerciseYearResolver;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class ExerciseYearResolverTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testNonForfaitJuneMapsToNextYear(): void
|
||||||
|
{
|
||||||
|
$resolver = new ExerciseYearResolver();
|
||||||
|
|
||||||
|
self::assertSame(2026, $resolver->forDate(new DateTimeImmutable('2025-06-01')));
|
||||||
|
self::assertSame(2026, $resolver->forDate(new DateTimeImmutable('2025-06-30')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNonForfaitMayMapsToSameYear(): void
|
||||||
|
{
|
||||||
|
$resolver = new ExerciseYearResolver();
|
||||||
|
|
||||||
|
self::assertSame(2025, $resolver->forDate(new DateTimeImmutable('2025-05-01')));
|
||||||
|
self::assertSame(2025, $resolver->forDate(new DateTimeImmutable('2025-05-31')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNonForfaitDecemberMapsToNextYear(): void
|
||||||
|
{
|
||||||
|
$resolver = new ExerciseYearResolver();
|
||||||
|
|
||||||
|
self::assertSame(2026, $resolver->forDate(new DateTimeImmutable('2025-12-31')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNonForfaitJanuaryMapsToSameYear(): void
|
||||||
|
{
|
||||||
|
$resolver = new ExerciseYearResolver();
|
||||||
|
|
||||||
|
self::assertSame(2025, $resolver->forDate(new DateTimeImmutable('2025-01-15')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testForfaitReturnsCalendarYearRegardlessOfMonth(): void
|
||||||
|
{
|
||||||
|
$resolver = new ExerciseYearResolver();
|
||||||
|
|
||||||
|
self::assertSame(2025, $resolver->forDate(new DateTimeImmutable('2025-01-15'), true));
|
||||||
|
self::assertSame(2025, $resolver->forDate(new DateTimeImmutable('2025-06-01'), true));
|
||||||
|
self::assertSame(2025, $resolver->forDate(new DateTimeImmutable('2025-12-31'), true));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testForfaitFlagDefaultsToFalse(): void
|
||||||
|
{
|
||||||
|
$resolver = new ExerciseYearResolver();
|
||||||
|
|
||||||
|
// June without explicit flag must follow non-forfait rule (year + 1).
|
||||||
|
self::assertSame(2026, $resolver->forDate(new DateTimeImmutable('2025-06-01')));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,16 +4,33 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Tests\State;
|
namespace App\Tests\State;
|
||||||
|
|
||||||
|
use App\Dto\Contracts\ContractPhase;
|
||||||
|
use App\Entity\Contract;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeContractPeriod;
|
||||||
|
use App\Enum\ContractNature;
|
||||||
|
use App\Enum\ContractType;
|
||||||
|
use App\Enum\TrackingMode;
|
||||||
|
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
||||||
|
use App\Service\Exercise\ExerciseYearResolver;
|
||||||
use App\State\EmployeeLeaveSummaryProvider;
|
use App\State\EmployeeLeaveSummaryProvider;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use ReflectionClass;
|
use ReflectionClass;
|
||||||
|
use ReflectionProperty;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final class EmployeeLeaveSummaryProviderTest extends TestCase
|
final class EmployeeLeaveSummaryProviderTest extends TestCase
|
||||||
{
|
{
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Existing tests (unchanged) — verify accrual prorating arithmetic.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
public function testComputeAccruedDaysFromStartProratesPartialFirstMonth(): void
|
public function testComputeAccruedDaysFromStartProratesPartialFirstMonth(): void
|
||||||
{
|
{
|
||||||
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
|
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
|
||||||
@@ -68,4 +85,393 @@ final class EmployeeLeaveSummaryProviderTest extends TestCase
|
|||||||
|
|
||||||
self::assertEqualsWithDelta(25.0 / 12.0, $result, 0.0001);
|
self::assertEqualsWithDelta(25.0 / 12.0, $result, 0.0001);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Phase resolution tests (Task 3 — phaseId support).
|
||||||
|
// The repository / service dependencies are typed against final classes
|
||||||
|
// which PHPUnit cannot double, so phase resolution is exercised via
|
||||||
|
// reflection on private methods to avoid instantiating the full DI graph.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function testResolveTargetPhasePicksH39PhaseFromPhaseId(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
$h39Phase = $phases[1]; // oldest = 39h
|
||||||
|
|
||||||
|
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id]);
|
||||||
|
$resolved = $this->invokePrivate($provider, 'resolveTargetPhase', $employee);
|
||||||
|
|
||||||
|
self::assertInstanceOf(ContractPhase::class, $resolved);
|
||||||
|
self::assertSame($h39Phase->id, $resolved->id);
|
||||||
|
self::assertSame(ContractType::H39, $resolved->contractType);
|
||||||
|
self::assertFalse($resolved->isCurrent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResolveTargetPhaseDefaultsToCurrentPhaseWhenPhaseIdAbsent(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
$currentPhase = $phases[0]; // most recent = FORFAIT
|
||||||
|
|
||||||
|
$provider = $this->buildProvider([]);
|
||||||
|
$resolved = $this->invokePrivate($provider, 'resolveTargetPhase', $employee);
|
||||||
|
|
||||||
|
self::assertSame($currentPhase->id, $resolved->id);
|
||||||
|
self::assertSame(ContractType::FORFAIT, $resolved->contractType);
|
||||||
|
self::assertTrue($resolved->isCurrent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPastH39PhaseAppliesNonForfaitRuleCodeEvenWhenCurrentIsForfait(): void
|
||||||
|
{
|
||||||
|
// Verifies resolveLeavePolicy uses the phase's contractType (not the current contract).
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
$h39Phase = $phases[1];
|
||||||
|
|
||||||
|
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id]);
|
||||||
|
$from = new DateTimeImmutable('2025-06-01');
|
||||||
|
$to = new DateTimeImmutable('2026-04-30');
|
||||||
|
$leavePolicy = $this->invokePrivate($provider, 'resolveLeavePolicy', $employee, $h39Phase, $from, $to);
|
||||||
|
|
||||||
|
self::assertNotNull($leavePolicy);
|
||||||
|
self::assertSame('CDI_CDD_NON_FORFAIT', $leavePolicy['ruleCode']);
|
||||||
|
self::assertSame(25.0, $leavePolicy['acquiredDays']);
|
||||||
|
self::assertEqualsWithDelta(25.0 / 12.0, $leavePolicy['accrualPerMonth'], 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResolvePeriodBoundsCapsAtPhaseEndDate(): void
|
||||||
|
{
|
||||||
|
// 39h phase (June 2020 → April 30 2026). Exercise 2026 spans June 2025 → May 31 2026.
|
||||||
|
// The phase cap should clip the upper bound to April 30 2026.
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
$h39Phase = $phases[1];
|
||||||
|
|
||||||
|
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2026']);
|
||||||
|
[$from, $to] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2026, $h39Phase);
|
||||||
|
|
||||||
|
self::assertSame('2025-06-01', $from->format('Y-m-d'));
|
||||||
|
self::assertSame('2026-04-30', $to->format('Y-m-d'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTransitionExerciseOnH39PhaseAccruesAround22Point9Days(): void
|
||||||
|
{
|
||||||
|
// 11 full months of accrual at 25/12 ≈ 22.917 days.
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
$h39Phase = $phases[1];
|
||||||
|
|
||||||
|
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2026']);
|
||||||
|
$method = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->getMethod('computeAccruedDaysFromStart');
|
||||||
|
|
||||||
|
// Period bounds for exercise 2026 on H39 phase = June 1 2025 → April 30 2026.
|
||||||
|
[$from, $to] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2026, $h39Phase);
|
||||||
|
$acquired = $method->invoke($provider, 25.0, 25.0 / 12.0, $from, $to);
|
||||||
|
|
||||||
|
self::assertEqualsWithDelta(22.92, $acquired, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNonForfaitPhaseStartingMidExerciseUsesFullExerciseFromAsStart(): void
|
||||||
|
{
|
||||||
|
// Scenario: 35h CDI from 2014-07-01 to 2025-10-31, then 39h CDI from 2025-11-01.
|
||||||
|
// Both phases are non-forfait (same leave rule CDI_CDD_NON_FORFAIT).
|
||||||
|
// Viewing exercise 2026 on the current 39h phase, accrual must run from the
|
||||||
|
// exercise start (June 1, 2025), NOT from the phase start (November 1, 2025).
|
||||||
|
// Otherwise the 5 months of June-October under 35h would be lost from the
|
||||||
|
// annual CP accrual, which is wrong (CP exercise is annual, not per-phase).
|
||||||
|
$employee = $this->buildH35ToH39Transition('2014-07-01', '2025-10-31', '2025-11-01');
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
$h39Phase = $phases[0]; // current
|
||||||
|
|
||||||
|
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2026']);
|
||||||
|
|
||||||
|
[$from, $to] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2026, $h39Phase);
|
||||||
|
|
||||||
|
self::assertSame('2025-06-01', $from->format('Y-m-d'));
|
||||||
|
self::assertSame('2026-05-31', $to->format('Y-m-d'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testForfaitPhaseStartingMidYearCapsFromAtPhaseStart(): void
|
||||||
|
{
|
||||||
|
// Scenario: 39h CDI ends 2026-04-30, FORFAIT from 2026-05-01.
|
||||||
|
// Viewing year 2026 on the FORFAIT phase, the period must be capped at
|
||||||
|
// phase start (May 1) so that only the FORFAIT portion of the calendar
|
||||||
|
// year is counted.
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
$forfaitPhase = $phases[0]; // current FORFAIT
|
||||||
|
|
||||||
|
$provider = $this->buildProvider(['phaseId' => (string) $forfaitPhase->id, 'year' => '2026']);
|
||||||
|
|
||||||
|
[$from, $to] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2026, $forfaitPhase);
|
||||||
|
|
||||||
|
self::assertSame('2026-05-01', $from->format('Y-m-d'));
|
||||||
|
self::assertSame('2026-12-31', $to->format('Y-m-d'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testYearOutsidePhaseRangeIsSilentlyClampedToPhaseLastExercise(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
$h39Phase = $phases[1];
|
||||||
|
|
||||||
|
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2030']);
|
||||||
|
$year = $this->invokePrivate($provider, 'resolveYear', $employee, $h39Phase);
|
||||||
|
|
||||||
|
self::assertSame(2026, $year);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testYearBeforePhaseIsClampedToPhaseFirstExercise(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
$h39Phase = $phases[1];
|
||||||
|
|
||||||
|
// Phase starts 2020-06-01 → first exercise (non-forfait) = 2021 (since month >=6 = year+1).
|
||||||
|
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2010']);
|
||||||
|
$year = $this->invokePrivate($provider, 'resolveYear', $employee, $h39Phase);
|
||||||
|
|
||||||
|
self::assertSame(2021, $year);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInvalidPhaseIdReturns422(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$provider = $this->buildProvider(['phaseId' => '99999']);
|
||||||
|
|
||||||
|
$this->expectException(UnprocessableEntityHttpException::class);
|
||||||
|
$this->invokePrivate($provider, 'resolveTargetPhase', $employee);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNonNumericPhaseIdReturns422(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$provider = $this->buildProvider(['phaseId' => 'abc']);
|
||||||
|
|
||||||
|
$this->expectException(UnprocessableEntityHttpException::class);
|
||||||
|
$this->invokePrivate($provider, 'resolveTargetPhase', $employee);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDefaultYearForPhaseIdOnClosedPhaseUsesPhaseEndDate(): void
|
||||||
|
{
|
||||||
|
// No `year` param + explicit phaseId → default year is derived from $phase->endDate.
|
||||||
|
// H39 phase ends 2026-04-30 → non-forfait exercise containing that date = 2026.
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
$h39Phase = $phases[1];
|
||||||
|
|
||||||
|
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id]);
|
||||||
|
$year = $this->invokePrivate($provider, 'resolveYear', $employee, $h39Phase);
|
||||||
|
|
||||||
|
self::assertSame(2026, $year);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNoQueryParamsKeepsLegacyYearDefaulting(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
$currentPhase = $phases[0];
|
||||||
|
|
||||||
|
$provider = $this->buildProvider([]);
|
||||||
|
$year = $this->invokePrivate($provider, 'resolveYear', $employee, $currentPhase);
|
||||||
|
|
||||||
|
// Today is 2026-05-19, FORFAIT phase → year is the current calendar year (2026).
|
||||||
|
self::assertSame(2026, $year);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Regression: terminated-employee path through `computeYearSummary` without
|
||||||
|
// an explicit phase (legacy callers: LeaveRecapRowBuilder,
|
||||||
|
// DumpVerificationSnapshotCommand). Before the phase-aware refactor, the
|
||||||
|
// period bounds were NOT capped at the contract end for terminated
|
||||||
|
// employees (because Employee::getCurrentContractEndDate() returns null
|
||||||
|
// when no period covers "today"). The new code resolves a fallback phase
|
||||||
|
// whose `isCurrent` is false, which would otherwise cap `to` at the phase
|
||||||
|
// end — a behavior change for legacy callers. The flag `applyPhaseEndCap`
|
||||||
|
// toggles this cap so legacy callers get the pre-refactor behavior.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function testTerminatedEmployeeWithoutExplicitPhaseSkipsPhaseEndCap(): void
|
||||||
|
{
|
||||||
|
// Terminated employee: H39 phase ending 2024-12-31 (well in the past).
|
||||||
|
$employee = $this->buildTerminatedEmployee('2020-06-01', '2024-12-31');
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
self::assertCount(1, $phases);
|
||||||
|
$phase = $phases[0];
|
||||||
|
self::assertFalse($phase->isCurrent, 'Sanity: terminated phase must not be flagged as current.');
|
||||||
|
|
||||||
|
$provider = $this->buildProvider([]);
|
||||||
|
|
||||||
|
// applyPhaseEndCap=false → mimics legacy callers (no explicit phase):
|
||||||
|
// the upper bound MUST stay at the natural leave-year end (May 31).
|
||||||
|
[$fromLegacy, $toLegacy] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2025, $phase, false);
|
||||||
|
self::assertSame('2024-06-01', $fromLegacy->format('Y-m-d'));
|
||||||
|
self::assertSame('2025-05-31', $toLegacy->format('Y-m-d'));
|
||||||
|
|
||||||
|
// applyPhaseEndCap=true → explicit-phase callers get the cap at phase end.
|
||||||
|
[$fromCap, $toCap] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2025, $phase, true);
|
||||||
|
self::assertSame('2024-06-01', $fromCap->format('Y-m-d'));
|
||||||
|
self::assertSame('2024-12-31', $toCap->format('Y-m-d'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Test harness helpers.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a terminated-employee fixture: a single H39 period ending before today.
|
||||||
|
*/
|
||||||
|
private function buildTerminatedEmployee(string $start, string $end): Employee
|
||||||
|
{
|
||||||
|
$employee = new Employee();
|
||||||
|
$this->setEntityId($employee, 2);
|
||||||
|
|
||||||
|
$contract = new Contract();
|
||||||
|
$contract->setName('39H');
|
||||||
|
$contract->setTrackingMode(TrackingMode::TIME->value);
|
||||||
|
$contract->setWeeklyHours(39);
|
||||||
|
|
||||||
|
$period = new EmployeeContractPeriod();
|
||||||
|
$this->setEntityId($period, 10);
|
||||||
|
$period->setEmployee($employee);
|
||||||
|
$period->setContract($contract);
|
||||||
|
$period->setStartDate(new DateTimeImmutable($start));
|
||||||
|
$period->setEndDate(new DateTimeImmutable($end));
|
||||||
|
$period->setContractNature(ContractNature::CDI);
|
||||||
|
$period->setIsDriver(false);
|
||||||
|
|
||||||
|
$employee->getContractPeriods()->add($period);
|
||||||
|
|
||||||
|
return $employee;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a two-period employee transitioning from H39 to FORFAIT.
|
||||||
|
*/
|
||||||
|
private function buildH35ToH39Transition(string $h35Start, string $h35End, string $h39Start): Employee
|
||||||
|
{
|
||||||
|
$employee = new Employee();
|
||||||
|
$this->setEntityId($employee, 1);
|
||||||
|
|
||||||
|
$h35Contract = new Contract();
|
||||||
|
$h35Contract->setName('35H');
|
||||||
|
$h35Contract->setTrackingMode(TrackingMode::TIME->value);
|
||||||
|
$h35Contract->setWeeklyHours(35);
|
||||||
|
|
||||||
|
$h39Contract = new Contract();
|
||||||
|
$h39Contract->setName('39H');
|
||||||
|
$h39Contract->setTrackingMode(TrackingMode::TIME->value);
|
||||||
|
$h39Contract->setWeeklyHours(39);
|
||||||
|
|
||||||
|
$h35Period = new EmployeeContractPeriod();
|
||||||
|
$this->setEntityId($h35Period, 1);
|
||||||
|
$h35Period->setEmployee($employee);
|
||||||
|
$h35Period->setContract($h35Contract);
|
||||||
|
$h35Period->setStartDate(new DateTimeImmutable($h35Start));
|
||||||
|
$h35Period->setEndDate(new DateTimeImmutable($h35End));
|
||||||
|
$h35Period->setContractNature(ContractNature::CDI);
|
||||||
|
$h35Period->setIsDriver(false);
|
||||||
|
|
||||||
|
$h39Period = new EmployeeContractPeriod();
|
||||||
|
$this->setEntityId($h39Period, 2);
|
||||||
|
$h39Period->setEmployee($employee);
|
||||||
|
$h39Period->setContract($h39Contract);
|
||||||
|
$h39Period->setStartDate(new DateTimeImmutable($h39Start));
|
||||||
|
$h39Period->setEndDate(null);
|
||||||
|
$h39Period->setContractNature(ContractNature::CDI);
|
||||||
|
$h39Period->setIsDriver(false);
|
||||||
|
|
||||||
|
$employee->getContractPeriods()->add($h35Period);
|
||||||
|
$employee->getContractPeriods()->add($h39Period);
|
||||||
|
|
||||||
|
return $employee;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildEmployeeWithTransition(string $h39Start, string $h39End, string $forfaitStart): Employee
|
||||||
|
{
|
||||||
|
$employee = new Employee();
|
||||||
|
$this->setEntityId($employee, 1);
|
||||||
|
|
||||||
|
$h39Contract = new Contract();
|
||||||
|
$h39Contract->setName('39H');
|
||||||
|
$h39Contract->setTrackingMode(TrackingMode::TIME->value);
|
||||||
|
$h39Contract->setWeeklyHours(39);
|
||||||
|
|
||||||
|
$forfaitContract = new Contract();
|
||||||
|
$forfaitContract->setName('Forfait');
|
||||||
|
$forfaitContract->setTrackingMode(TrackingMode::PRESENCE->value);
|
||||||
|
$forfaitContract->setWeeklyHours(null);
|
||||||
|
|
||||||
|
$h39Period = new EmployeeContractPeriod();
|
||||||
|
$this->setEntityId($h39Period, 1);
|
||||||
|
$h39Period->setEmployee($employee);
|
||||||
|
$h39Period->setContract($h39Contract);
|
||||||
|
$h39Period->setStartDate(new DateTimeImmutable($h39Start));
|
||||||
|
$h39Period->setEndDate(new DateTimeImmutable($h39End));
|
||||||
|
$h39Period->setContractNature(ContractNature::CDI);
|
||||||
|
$h39Period->setIsDriver(false);
|
||||||
|
|
||||||
|
$forfaitPeriod = new EmployeeContractPeriod();
|
||||||
|
$this->setEntityId($forfaitPeriod, 2);
|
||||||
|
$forfaitPeriod->setEmployee($employee);
|
||||||
|
$forfaitPeriod->setContract($forfaitContract);
|
||||||
|
$forfaitPeriod->setStartDate(new DateTimeImmutable($forfaitStart));
|
||||||
|
$forfaitPeriod->setEndDate(null);
|
||||||
|
$forfaitPeriod->setContractNature(ContractNature::CDI);
|
||||||
|
$forfaitPeriod->setIsDriver(false);
|
||||||
|
|
||||||
|
$employee->getContractPeriods()->add($h39Period);
|
||||||
|
$employee->getContractPeriods()->add($forfaitPeriod);
|
||||||
|
|
||||||
|
return $employee;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an uninitialized provider with a RequestStack pre-loaded with the given query.
|
||||||
|
*
|
||||||
|
* The provider's repository/service dependencies are typed against final classes
|
||||||
|
* (EmployeeRepository, LeaveBalanceComputationService, etc.) which PHPUnit cannot
|
||||||
|
* double. We bypass full instantiation by using newInstanceWithoutConstructor and
|
||||||
|
* only setting the properties that the tested private methods actually read:
|
||||||
|
* `requestStack` and `phaseResolver`. Tests targeting heavier code paths exercise
|
||||||
|
* private methods directly (resolveTargetPhase, resolvePeriodBounds, etc.).
|
||||||
|
*
|
||||||
|
* @param array<string, string> $request query parameters (year, phaseId, ...)
|
||||||
|
*/
|
||||||
|
private function buildProvider(array $request = []): EmployeeLeaveSummaryProvider
|
||||||
|
{
|
||||||
|
$requestStack = new RequestStack();
|
||||||
|
$requestStack->push(new Request(query: $request));
|
||||||
|
|
||||||
|
$reflection = new ReflectionClass(EmployeeLeaveSummaryProvider::class);
|
||||||
|
$provider = $reflection->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
$this->setReadonlyProperty($provider, 'requestStack', $requestStack);
|
||||||
|
$this->setReadonlyProperty($provider, 'phaseResolver', new EmployeeContractPhaseResolver());
|
||||||
|
$this->setReadonlyProperty($provider, 'exerciseYearResolver', new ExerciseYearResolver());
|
||||||
|
$this->setReadonlyProperty($provider, 'dataStartDate', null);
|
||||||
|
|
||||||
|
return $provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function invokePrivate(object $obj, string $method, mixed ...$args): mixed
|
||||||
|
{
|
||||||
|
$reflection = new ReflectionClass($obj::class);
|
||||||
|
$m = $reflection->getMethod($method);
|
||||||
|
|
||||||
|
return $m->invoke($obj, ...$args);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setReadonlyProperty(object $obj, string $property, mixed $value): void
|
||||||
|
{
|
||||||
|
$reflection = new ReflectionProperty($obj::class, $property);
|
||||||
|
$reflection->setValue($obj, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setEntityId(object $entity, int $id): void
|
||||||
|
{
|
||||||
|
$reflection = new ReflectionProperty($entity::class, 'id');
|
||||||
|
$reflection->setValue($entity, $id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
169
tests/State/EmployeeRttPaymentProcessorTest.php
Normal file
169
tests/State/EmployeeRttPaymentProcessorTest.php
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\State;
|
||||||
|
|
||||||
|
use App\Entity\Contract;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeContractPeriod;
|
||||||
|
use App\Enum\ContractNature;
|
||||||
|
use App\Enum\TrackingMode;
|
||||||
|
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
||||||
|
use App\Service\Exercise\ExerciseYearResolver;
|
||||||
|
use App\State\EmployeeRttPaymentProcessor;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Clock\ClockInterface;
|
||||||
|
use ReflectionClass;
|
||||||
|
use ReflectionProperty;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*
|
||||||
|
* Exercises the year-acceptance guard of EmployeeRttPaymentProcessor.
|
||||||
|
*
|
||||||
|
* The processor depends on final repositories (EmployeeRepository,
|
||||||
|
* EmployeeRttPaymentRepository) which PHPUnit cannot double. The guard logic
|
||||||
|
* lives in a private helper (assertYearAllowedForPayment) tested directly via
|
||||||
|
* reflection — same pattern used in EmployeeRttSummaryProviderTest.
|
||||||
|
*/
|
||||||
|
final class EmployeeRttPaymentProcessorTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testPaymentAllowedOnCurrentExercise(): void
|
||||||
|
{
|
||||||
|
// Today = 2026-05-19 (env clock) → current exercise = 2026.
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$processor = $this->buildProcessorWithClock(new DateTimeImmutable('2026-05-19'));
|
||||||
|
|
||||||
|
$this->invokePrivate($processor, 'assertYearAllowedForPayment', $employee, 2026);
|
||||||
|
|
||||||
|
// No exception → guard accepts current exercise.
|
||||||
|
self::assertTrue(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPaymentAllowedOnLastExerciseOfClosedPhase(): void
|
||||||
|
{
|
||||||
|
// Phase 39h closed 2026-04-30, FORFAIT from 2026-05-01.
|
||||||
|
// Exercise 2026 (Juin 2025 → Mai 2026) contains the H39 phase end date.
|
||||||
|
// Payment must be allowed on exercise 2026 even when current exercise is 2027.
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$processor = $this->buildProcessorWithClock(new DateTimeImmutable('2027-01-15'));
|
||||||
|
|
||||||
|
$this->invokePrivate($processor, 'assertYearAllowedForPayment', $employee, 2026);
|
||||||
|
|
||||||
|
self::assertTrue(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPaymentRejectedOnEarlierExerciseOfClosedPhase(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$processor = $this->buildProcessorWithClock(new DateTimeImmutable('2027-01-15'));
|
||||||
|
|
||||||
|
$this->expectException(UnprocessableEntityHttpException::class);
|
||||||
|
$this->invokePrivate($processor, 'assertYearAllowedForPayment', $employee, 2024);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPaymentRejectedOnFutureExercise(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$processor = $this->buildProcessorWithClock(new DateTimeImmutable('2026-05-19'));
|
||||||
|
|
||||||
|
$this->expectException(UnprocessableEntityHttpException::class);
|
||||||
|
$this->invokePrivate($processor, 'assertYearAllowedForPayment', $employee, 2030);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Test harness helpers.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a two-period employee transitioning from H39 to FORFAIT.
|
||||||
|
*/
|
||||||
|
private function buildEmployeeWithTransition(string $h39Start, string $h39End, string $forfaitStart): Employee
|
||||||
|
{
|
||||||
|
$employee = new Employee();
|
||||||
|
$this->setEntityId($employee, 1);
|
||||||
|
|
||||||
|
$h39Contract = new Contract();
|
||||||
|
$h39Contract->setName('39H');
|
||||||
|
$h39Contract->setTrackingMode(TrackingMode::TIME->value);
|
||||||
|
$h39Contract->setWeeklyHours(39);
|
||||||
|
|
||||||
|
$forfaitContract = new Contract();
|
||||||
|
$forfaitContract->setName('Forfait');
|
||||||
|
$forfaitContract->setTrackingMode(TrackingMode::PRESENCE->value);
|
||||||
|
$forfaitContract->setWeeklyHours(null);
|
||||||
|
|
||||||
|
$h39Period = new EmployeeContractPeriod();
|
||||||
|
$this->setEntityId($h39Period, 1);
|
||||||
|
$h39Period->setEmployee($employee);
|
||||||
|
$h39Period->setContract($h39Contract);
|
||||||
|
$h39Period->setStartDate(new DateTimeImmutable($h39Start));
|
||||||
|
$h39Period->setEndDate(new DateTimeImmutable($h39End));
|
||||||
|
$h39Period->setContractNature(ContractNature::CDI);
|
||||||
|
$h39Period->setIsDriver(false);
|
||||||
|
|
||||||
|
$forfaitPeriod = new EmployeeContractPeriod();
|
||||||
|
$this->setEntityId($forfaitPeriod, 2);
|
||||||
|
$forfaitPeriod->setEmployee($employee);
|
||||||
|
$forfaitPeriod->setContract($forfaitContract);
|
||||||
|
$forfaitPeriod->setStartDate(new DateTimeImmutable($forfaitStart));
|
||||||
|
$forfaitPeriod->setEndDate(null);
|
||||||
|
$forfaitPeriod->setContractNature(ContractNature::CDI);
|
||||||
|
$forfaitPeriod->setIsDriver(false);
|
||||||
|
|
||||||
|
$employee->getContractPeriods()->add($h39Period);
|
||||||
|
$employee->getContractPeriods()->add($forfaitPeriod);
|
||||||
|
|
||||||
|
return $employee;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an uninitialized processor with a fixed clock. The repositories are
|
||||||
|
* declared on final classes that PHPUnit cannot double, so we bypass full
|
||||||
|
* instantiation via newInstanceWithoutConstructor and only seed the
|
||||||
|
* properties the tested private guard reads: phaseResolver + clock.
|
||||||
|
*/
|
||||||
|
private function buildProcessorWithClock(DateTimeImmutable $today): EmployeeRttPaymentProcessor
|
||||||
|
{
|
||||||
|
$reflection = new ReflectionClass(EmployeeRttPaymentProcessor::class);
|
||||||
|
$processor = $reflection->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
$clock = new readonly class($today) implements ClockInterface {
|
||||||
|
public function __construct(private DateTimeImmutable $now) {}
|
||||||
|
|
||||||
|
public function now(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->now;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->setReadonlyProperty($processor, 'phaseResolver', new EmployeeContractPhaseResolver());
|
||||||
|
$this->setReadonlyProperty($processor, 'clock', $clock);
|
||||||
|
$this->setReadonlyProperty($processor, 'exerciseYearResolver', new ExerciseYearResolver());
|
||||||
|
|
||||||
|
return $processor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function invokePrivate(object $obj, string $method, mixed ...$args): mixed
|
||||||
|
{
|
||||||
|
$reflection = new ReflectionClass($obj::class);
|
||||||
|
$m = $reflection->getMethod($method);
|
||||||
|
|
||||||
|
return $m->invoke($obj, ...$args);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setReadonlyProperty(object $obj, string $property, mixed $value): void
|
||||||
|
{
|
||||||
|
$reflection = new ReflectionProperty($obj::class, $property);
|
||||||
|
$reflection->setValue($obj, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setEntityId(object $entity, int $id): void
|
||||||
|
{
|
||||||
|
$reflection = new ReflectionProperty($entity::class, 'id');
|
||||||
|
$reflection->setValue($entity, $id);
|
||||||
|
}
|
||||||
|
}
|
||||||
293
tests/State/EmployeeRttSummaryProviderTest.php
Normal file
293
tests/State/EmployeeRttSummaryProviderTest.php
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\State;
|
||||||
|
|
||||||
|
use App\Dto\Contracts\ContractPhase;
|
||||||
|
use App\Entity\Contract;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeContractPeriod;
|
||||||
|
use App\Enum\ContractNature;
|
||||||
|
use App\Enum\ContractType;
|
||||||
|
use App\Enum\TrackingMode;
|
||||||
|
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
||||||
|
use App\Service\Exercise\ExerciseYearResolver;
|
||||||
|
use App\State\EmployeeRttSummaryProvider;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use ReflectionClass;
|
||||||
|
use ReflectionProperty;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class EmployeeRttSummaryProviderTest extends TestCase
|
||||||
|
{
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Phase resolution tests (Task 4 — phaseId support).
|
||||||
|
// The repository / service dependencies are typed against final classes
|
||||||
|
// which PHPUnit cannot double, so phase resolution is exercised via
|
||||||
|
// reflection on private methods to avoid instantiating the full DI graph.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function testResolveTargetPhasePicksH39PhaseFromPhaseId(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
$h39Phase = $phases[1]; // oldest = 39h
|
||||||
|
|
||||||
|
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id]);
|
||||||
|
$resolved = $this->invokePrivate($provider, 'resolveTargetPhase', $employee);
|
||||||
|
|
||||||
|
self::assertInstanceOf(ContractPhase::class, $resolved);
|
||||||
|
self::assertSame($h39Phase->id, $resolved->id);
|
||||||
|
self::assertSame(ContractType::H39, $resolved->contractType);
|
||||||
|
self::assertFalse($resolved->isCurrent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResolveTargetPhaseDefaultsToCurrentPhaseWhenPhaseIdAbsent(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
$currentPhase = $phases[0]; // most recent = FORFAIT
|
||||||
|
|
||||||
|
$provider = $this->buildProvider([]);
|
||||||
|
$resolved = $this->invokePrivate($provider, 'resolveTargetPhase', $employee);
|
||||||
|
|
||||||
|
self::assertSame($currentPhase->id, $resolved->id);
|
||||||
|
self::assertTrue($resolved->isCurrent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPastH39PhaseRttSummaryIsCappedAtPhaseEndDate(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
$h39Phase = $phases[1];
|
||||||
|
|
||||||
|
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2026']);
|
||||||
|
$year = $this->invokePrivate($provider, 'resolveYear', $h39Phase);
|
||||||
|
|
||||||
|
self::assertSame(2026, $year);
|
||||||
|
|
||||||
|
// The phase-cap branch in provide() narrows periodTo to the phase end date.
|
||||||
|
// Reproduce that logic to validate the resulting window.
|
||||||
|
$periodFrom = new DateTimeImmutable('2025-06-01');
|
||||||
|
$periodTo = new DateTimeImmutable('2026-05-31');
|
||||||
|
|
||||||
|
if (!$h39Phase->isCurrent && null !== $h39Phase->endDate && $h39Phase->endDate < $periodTo) {
|
||||||
|
$periodTo = $h39Phase->endDate;
|
||||||
|
}
|
||||||
|
if ($h39Phase->startDate > $periodFrom) {
|
||||||
|
$periodFrom = $h39Phase->startDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertSame('2025-06-01', $periodFrom->format('Y-m-d'));
|
||||||
|
self::assertSame('2026-04-30', $periodTo->format('Y-m-d'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testYearOutsidePhaseRangeIsSilentlyClampedToPhaseLastExercise(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
$h39Phase = $phases[1];
|
||||||
|
|
||||||
|
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2030']);
|
||||||
|
$year = $this->invokePrivate($provider, 'resolveYear', $h39Phase);
|
||||||
|
|
||||||
|
// Phase ends 2026-04-30 → exercice (Juin-Mai) containing it = 2026.
|
||||||
|
self::assertSame(2026, $year);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testYearBeforePhaseIsClampedToPhaseFirstExercise(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
$h39Phase = $phases[1];
|
||||||
|
|
||||||
|
// Phase starts 2020-06-01 → first exercise (Juin-Mai) = 2021.
|
||||||
|
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2010']);
|
||||||
|
$year = $this->invokePrivate($provider, 'resolveYear', $h39Phase);
|
||||||
|
|
||||||
|
self::assertSame(2021, $year);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInvalidPhaseIdReturns422(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$provider = $this->buildProvider(['phaseId' => '99999']);
|
||||||
|
|
||||||
|
$this->expectException(UnprocessableEntityHttpException::class);
|
||||||
|
$this->invokePrivate($provider, 'resolveTargetPhase', $employee);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNonNumericPhaseIdReturns422(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$provider = $this->buildProvider(['phaseId' => 'abc']);
|
||||||
|
|
||||||
|
$this->expectException(UnprocessableEntityHttpException::class);
|
||||||
|
$this->invokePrivate($provider, 'resolveTargetPhase', $employee);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDefaultYearForPhaseIdOnClosedPhaseUsesPhaseEndDate(): void
|
||||||
|
{
|
||||||
|
// No `year` param + explicit phaseId → default year is derived from $phase->endDate.
|
||||||
|
// H39 phase ends 2026-04-30 → RTT exercise containing that date = 2026.
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
$h39Phase = $phases[1];
|
||||||
|
|
||||||
|
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id]);
|
||||||
|
$year = $this->invokePrivate($provider, 'resolveYear', $h39Phase);
|
||||||
|
|
||||||
|
self::assertSame(2026, $year);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNoQueryParamsKeepsLegacyYearDefaulting(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
$currentPhase = $phases[0];
|
||||||
|
|
||||||
|
$provider = $this->buildProvider([]);
|
||||||
|
$year = $this->invokePrivate($provider, 'resolveYear', $currentPhase);
|
||||||
|
|
||||||
|
// Today is 2026-05-19 → current RTT exercise (Juin N-1 → Mai N) = 2026.
|
||||||
|
self::assertSame(2026, $year);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInvalidYearFormatReturns422(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
$currentPhase = $phases[0];
|
||||||
|
|
||||||
|
$provider = $this->buildProvider(['year' => '20XX']);
|
||||||
|
|
||||||
|
$this->expectException(UnprocessableEntityHttpException::class);
|
||||||
|
$this->invokePrivate($provider, 'resolveYear', $currentPhase);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testYearOutsideBoundsReturns422(): void
|
||||||
|
{
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
$currentPhase = $phases[0];
|
||||||
|
|
||||||
|
$provider = $this->buildProvider(['year' => '1900']);
|
||||||
|
|
||||||
|
$this->expectException(UnprocessableEntityHttpException::class);
|
||||||
|
$this->invokePrivate($provider, 'resolveYear', $currentPhase);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testYearWithoutPhaseIdIsNotClamped(): void
|
||||||
|
{
|
||||||
|
// No `phaseId` → legacy callers must keep their requested year as-is,
|
||||||
|
// even if it falls outside the current phase range.
|
||||||
|
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||||
|
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||||
|
$currentPhase = $phases[0];
|
||||||
|
|
||||||
|
$provider = $this->buildProvider(['year' => '2030']);
|
||||||
|
$year = $this->invokePrivate($provider, 'resolveYear', $currentPhase);
|
||||||
|
|
||||||
|
self::assertSame(2030, $year);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Test harness helpers.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a two-period employee transitioning from H39 to FORFAIT.
|
||||||
|
*/
|
||||||
|
private function buildEmployeeWithTransition(string $h39Start, string $h39End, string $forfaitStart): Employee
|
||||||
|
{
|
||||||
|
$employee = new Employee();
|
||||||
|
$this->setEntityId($employee, 1);
|
||||||
|
|
||||||
|
$h39Contract = new Contract();
|
||||||
|
$h39Contract->setName('39H');
|
||||||
|
$h39Contract->setTrackingMode(TrackingMode::TIME->value);
|
||||||
|
$h39Contract->setWeeklyHours(39);
|
||||||
|
|
||||||
|
$forfaitContract = new Contract();
|
||||||
|
$forfaitContract->setName('Forfait');
|
||||||
|
$forfaitContract->setTrackingMode(TrackingMode::PRESENCE->value);
|
||||||
|
$forfaitContract->setWeeklyHours(null);
|
||||||
|
|
||||||
|
$h39Period = new EmployeeContractPeriod();
|
||||||
|
$this->setEntityId($h39Period, 1);
|
||||||
|
$h39Period->setEmployee($employee);
|
||||||
|
$h39Period->setContract($h39Contract);
|
||||||
|
$h39Period->setStartDate(new DateTimeImmutable($h39Start));
|
||||||
|
$h39Period->setEndDate(new DateTimeImmutable($h39End));
|
||||||
|
$h39Period->setContractNature(ContractNature::CDI);
|
||||||
|
$h39Period->setIsDriver(false);
|
||||||
|
|
||||||
|
$forfaitPeriod = new EmployeeContractPeriod();
|
||||||
|
$this->setEntityId($forfaitPeriod, 2);
|
||||||
|
$forfaitPeriod->setEmployee($employee);
|
||||||
|
$forfaitPeriod->setContract($forfaitContract);
|
||||||
|
$forfaitPeriod->setStartDate(new DateTimeImmutable($forfaitStart));
|
||||||
|
$forfaitPeriod->setEndDate(null);
|
||||||
|
$forfaitPeriod->setContractNature(ContractNature::CDI);
|
||||||
|
$forfaitPeriod->setIsDriver(false);
|
||||||
|
|
||||||
|
$employee->getContractPeriods()->add($h39Period);
|
||||||
|
$employee->getContractPeriods()->add($forfaitPeriod);
|
||||||
|
|
||||||
|
return $employee;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an uninitialized provider with a RequestStack pre-loaded with the given query.
|
||||||
|
*
|
||||||
|
* The provider's repository/service dependencies are typed against final classes
|
||||||
|
* (EmployeeRepository, RttRecoveryComputationService, etc.) which PHPUnit cannot
|
||||||
|
* double. We bypass full instantiation by using newInstanceWithoutConstructor and
|
||||||
|
* only setting the properties that the tested private methods actually read:
|
||||||
|
* `requestStack` and `phaseResolver`.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $request query parameters (year, phaseId, ...)
|
||||||
|
*/
|
||||||
|
private function buildProvider(array $request = []): EmployeeRttSummaryProvider
|
||||||
|
{
|
||||||
|
$requestStack = new RequestStack();
|
||||||
|
$requestStack->push(new Request(query: $request));
|
||||||
|
|
||||||
|
$reflection = new ReflectionClass(EmployeeRttSummaryProvider::class);
|
||||||
|
$provider = $reflection->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
$this->setReadonlyProperty($provider, 'requestStack', $requestStack);
|
||||||
|
$this->setReadonlyProperty($provider, 'phaseResolver', new EmployeeContractPhaseResolver());
|
||||||
|
$this->setReadonlyProperty($provider, 'exerciseYearResolver', new ExerciseYearResolver());
|
||||||
|
$this->setReadonlyProperty($provider, 'rttStartDate', null);
|
||||||
|
|
||||||
|
return $provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function invokePrivate(object $obj, string $method, mixed ...$args): mixed
|
||||||
|
{
|
||||||
|
$reflection = new ReflectionClass($obj::class);
|
||||||
|
$m = $reflection->getMethod($method);
|
||||||
|
|
||||||
|
return $m->invoke($obj, ...$args);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setReadonlyProperty(object $obj, string $property, mixed $value): void
|
||||||
|
{
|
||||||
|
$reflection = new ReflectionProperty($obj::class, $property);
|
||||||
|
$reflection->setValue($obj, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setEntityId(object $entity, int $id): void
|
||||||
|
{
|
||||||
|
$reflection = new ReflectionProperty($entity::class, 'id');
|
||||||
|
$reflection->setValue($entity, $id);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user