Files
SIRH/docs/superpowers/specs/2026-05-19-contract-phase-view-design.md
tristan a2874b545a docs(spec) : contract phase view selector design
Add design spec for the contract-phase picker on the employee detail page.
Lets HR navigate past contract phases (e.g. 39h before a switch to FORFAIT,
or a closed CDD) so they can view and settle leftover CP/RTT balances
without changing the default behavior for the current contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:16:47 +02:00

13 KiB

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.

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) :

"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 :

  • FORFAITFORFAIT
  • THIRTY_FIVE_HOURS35h
  • THIRTY_NINE_HOURS39h
  • INTERIMIntérim
  • CUSTOMCUSTOM ({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é.