188 lines
8.3 KiB
Markdown
188 lines
8.3 KiB
Markdown
# Suspension de contrat — Design Spec
|
||
|
||
## Objectif
|
||
|
||
Permettre de suspendre un contrat employé. Une suspension empêche l'acquisition de congés durant la période concernée (prorata). S'applique aux CDI/CDD non forfait et aux forfaits 218.
|
||
|
||
## Contraintes
|
||
|
||
- Plusieurs suspensions possibles par période de contrat
|
||
- Pas de suppression de suspension (hors scope)
|
||
- Règle de calcul : on exclut les jours de suspension jusqu'au dernier mois complet terminé (cohérent avec la règle existante)
|
||
- Suspension sans date de fin = suspension en cours indéfiniment (exclut les mois jusqu'au dernier mois terminé)
|
||
- Les suspensions ne doivent pas se chevaucher sur une même période de contrat
|
||
|
||
## Modèle de données
|
||
|
||
### Nouvelle entité `ContractSuspension`
|
||
|
||
Nouvelle table `contract_suspensions` :
|
||
|
||
| Colonne | Type | Nullable | Description |
|
||
|---------|------|----------|-------------|
|
||
| `id` | SERIAL | non | PK |
|
||
| `contract_period_id` | INT | non | FK vers `employee_contract_periods`, CASCADE delete |
|
||
| `start_date` | DATE | non | Début de suspension |
|
||
| `end_date` | DATE | oui | Fin de suspension (null = en cours) |
|
||
| `comment` | TEXT | oui | Commentaire libre |
|
||
| `created_at` | TIMESTAMP | non | Date de création technique |
|
||
|
||
Index : `(contract_period_id, start_date)`.
|
||
|
||
Relation : `EmployeeContractPeriod` ← OneToMany → `ContractSuspension`.
|
||
|
||
## Backend — API
|
||
|
||
### Endpoint dédié
|
||
|
||
Les suspensions sont gérées via un endpoint dédié plutôt que via les champs transients Employee. Cela évite de complexifier le `EmployeeWriteProcessor` et permet de gérer N suspensions proprement.
|
||
|
||
**Nouvel ApiResource `ContractSuspension` :**
|
||
- `POST /api/contract_suspensions` — créer une suspension (body : `contractPeriod` IRI, `startDate`, `endDate`, `comment`)
|
||
- `PATCH /api/contract_suspensions/{id}` — modifier une suspension existante
|
||
- Security : `ROLE_ADMIN`
|
||
- Pagination désactivée
|
||
|
||
**Processor custom `ContractSuspensionWriteProcessor` :**
|
||
- Résout la période de contrat depuis l'IRI
|
||
- Validation :
|
||
- `startDate` requis
|
||
- `endDate >= startDate` si renseigné
|
||
- `startDate >= period.startDate`
|
||
- Pour les CDD/contrats avec date de fin : `startDate` et `endDate` dans les bornes de la période
|
||
- Pas de chevauchement avec les autres suspensions de la même période
|
||
- Rejet si la période de contrat est déjà clôturée (date de fin dans le passé)
|
||
|
||
### Lecture
|
||
|
||
Exposer les suspensions dans la sérialisation de l'Employee :
|
||
|
||
- `Employee::getCurrentSuspensions(): array` — retourne les suspensions de la période de contrat courante, groupe `employee:read`
|
||
- Ajouter les suspensions au `ContractHistoryItem` via `EmployeeContractPeriod::getSuspensions()`
|
||
|
||
## Backend — Calcul des congés
|
||
|
||
### Deux points d'impact
|
||
|
||
Le calcul d'acquisition existe à **deux endroits** qui doivent tous les deux prendre en compte les suspensions :
|
||
|
||
1. **`EmployeeLeaveSummaryProvider::computeAccruedDaysFromStart()`** — affichage live des congés en cours d'acquisition
|
||
2. **`LeaveBalanceComputationService::computeAccruedDays()`** — utilisé par le rollover (`LeaveRolloverCommand`) pour calculer le solde de report
|
||
|
||
Les deux méthodes ont la même structure (boucle mois par mois) et doivent être modifiées de la même manière.
|
||
|
||
### Modification des méthodes de calcul
|
||
|
||
Pour les deux méthodes, ajouter un paramètre optionnel : `array $suspensions = []` (tableau de `{start: DateTimeImmutable, end: ?DateTimeImmutable}`).
|
||
|
||
Dans la boucle mois par mois, pour chaque mois :
|
||
1. Calculer les jours couverts par la période de contrat (existant)
|
||
2. Pour chaque suspension, calculer le nombre de jours suspendus qui tombent dans ce mois
|
||
3. Soustraire le total des jours suspendus
|
||
4. Le ratio du mois = max(0, jours couverts - jours suspendus) / jours dans le mois
|
||
|
||
Cela gère automatiquement les suspensions qui commencent/finissent en milieu de mois (prorata).
|
||
|
||
Une suspension sans date de fin utilise la date de fin de calcul comme borne (dernier jour du mois précédent, cohérent avec la règle existante).
|
||
|
||
**Note :** chaque méthode est appelée deux fois — une pour les jours, une pour les samedis. La soustraction de suspension s'applique aux deux appels.
|
||
|
||
### Impact sur les forfaits 218
|
||
|
||
Pour les forfaits, les jours acquis en début d'exercice (ex: 34 jours pour 2026) sont réduits au prorata des jours de suspension.
|
||
|
||
Calcul : `jours acquis = base × (jours ouvrés effectifs / jours ouvrés totaux de l'exercice)`
|
||
|
||
Où `jours ouvrés effectifs = jours ouvrés totaux - jours ouvrés suspendus`.
|
||
|
||
Cela impacte `EmployeeLeaveSummaryProvider` dans la branche forfait et `LeaveBalanceComputationService` dans le calcul forfait de `computeDynamicClosingForYear()`.
|
||
|
||
### Passage des données de suspension aux méthodes
|
||
|
||
- **`EmployeeLeaveSummaryProvider`** : le provider a accès aux périodes de contrat via l'Employee. Il doit résoudre les suspensions de la période couvrant l'exercice et les passer aux méthodes de calcul.
|
||
- **`LeaveBalanceComputationService`** : le service utilise `$employee->getContractHistory()`. Il doit trouver les suspensions de la période couvrant l'exercice. L'accès au repository `EmployeeContractPeriodRepository` est déjà injecté — ajouter l'accès au repository `ContractSuspensionRepository` ou passer par la relation Doctrine.
|
||
|
||
### Impact sur la bascule d'exercice (rollover au 01/06)
|
||
|
||
Le rollover (`LeaveRolloverCommand`) appelle `LeaveBalanceComputationService::computeDynamicClosingForYear()` qui appelle `computeAccruedDays()`. En modifiant `computeAccruedDays()` pour accepter et traiter les suspensions, le rollover prendra automatiquement en compte les suspensions. Les jours acquis au rollover reflèteront la déduction.
|
||
|
||
**Exemple CDI :** exercice 2027 (juin 2026 - mai 2027), 2 suspensions totalisant 3 mois → au lieu de 25j acquis, l'employé bascule avec ~18.75j (9 mois effectifs × 2.083j/mois).
|
||
|
||
### Règles non impactées
|
||
|
||
- INTERIM : pas de congés gérés
|
||
|
||
## Frontend — UI
|
||
|
||
### Bouton et drawer
|
||
|
||
Le bouton "Clôturer" devient **"Modifier"**. Il ouvre le drawer existant avec le titre **"Modifier le contrat"**. Le bouton **"+ Ajouter"** (création de nouveau contrat) reste inchangé.
|
||
|
||
Le drawer contient 2 onglets :
|
||
|
||
**Onglet "Clôturer"** — contenu identique à l'actuel (type contrat, temps de travail, début contrat en readonly, date fin, commentaire, checkbox solde de tout compte).
|
||
|
||
**Onglet "Suspendre"** — formulaires empilés :
|
||
- Pour chaque suspension existante : un formulaire pré-rempli avec les 3 champs (date début, date fin, commentaire) et un bouton **"Modifier"**
|
||
- En bas : un bouton **"+ Ajouter"** qui ajoute un nouveau formulaire vide avec les 3 champs et un bouton **"Ajouter"**
|
||
- Chaque formulaire est indépendant (soumission individuelle)
|
||
|
||
### Champs par formulaire de suspension
|
||
|
||
- Date de début (required, input date)
|
||
- Date de fin (optionnel, input date)
|
||
- Commentaire (optionnel, textarea)
|
||
|
||
### Données nécessaires côté frontend
|
||
|
||
Nouveau type `ContractSuspension` (DTO) :
|
||
```typescript
|
||
type ContractSuspension = {
|
||
id: number
|
||
startDate: string
|
||
endDate?: string | null
|
||
comment?: string | null
|
||
}
|
||
```
|
||
|
||
Ajouter au type `Employee` (DTO) :
|
||
- `currentSuspensions?: ContractSuspension[]`
|
||
|
||
Ajouter au type `ContractHistoryItem` :
|
||
- `suspensions?: ContractSuspension[]`
|
||
|
||
Nouveau service `frontend/services/contractSuspensions.ts` :
|
||
- `createSuspension(payload)` — POST
|
||
- `updateSuspension(id, payload)` — PATCH
|
||
|
||
## Exemples de calcul
|
||
|
||
### CDI/CDD non forfait
|
||
|
||
Contrat CDI démarré le 01/06/2026, exercice 2027 (juin 2026 - mai 2027).
|
||
Accrual : 25j / 12 mois = 2.083j/mois.
|
||
|
||
Sans suspension au 12/03/2027 (9 mois complets : juin-février) :
|
||
- En cours d'acquisition = 9 × 2.083 = 18.75j
|
||
|
||
Avec 2 suspensions (01/01 au 31/01 + 01/03 au 31/03 = 2 mois) au 12/04/2027 (10 mois complets - 2 suspendus = 8 mois effectifs) :
|
||
- En cours d'acquisition = 8 × 2.083 = 16.67j
|
||
|
||
Samedis (5/12 par mois) :
|
||
- Sans suspension : 9 × 0.417 = 3.75j
|
||
- Avec 2 suspensions : 8 × 0.417 = 3.33j
|
||
|
||
### Forfait 218
|
||
|
||
Exercice 2026 (année civile), 34 jours acquis, 252 jours ouvrés dans l'année.
|
||
Suspension de 2 mois (44 jours ouvrés).
|
||
|
||
- Jours ouvrés effectifs = 252 - 44 = 208
|
||
- Jours acquis = 34 × (208 / 252) = 28.06j
|
||
|
||
## Hors scope
|
||
|
||
- Suppression d'une suspension
|
||
- Affichage de la suspension dans l'historique des contrats (les données sont sérialisées mais pas de rendu spécifique dans le tableau historique)
|
||
- Auto-fermeture des suspensions lors de la clôture du contrat
|