# Règles Fonctionnelles SIRH

Ce document centralise les règles métier actuellement implémentées dans l'application.

Documents complementaires:
- `doc/leave-rollover.md` (rollover conges et checklist de lancement)
- `doc/rtt-rollover.md` (rollover RTT et checklist de lancement)

## 1) Utilisateurs et accès

- `ROLE_ADMIN`
  - accès complet aux écrans d'administration
  - vue semaine des heures
  - validation RH des lignes d'heures
- `ROLE_SELF`
  - accès limité à son périmètre personnel
- Accès "Sites" (via `user_site_roles` avec rôle `SITE_ACCESS`)
  - accès au périmètre des sites autorisés
  - validation site des lignes d'heures

## 2) Contrats

- Le profil de temps de travail est porté par `Contract`:
  - `trackingMode`: `TIME` ou `PRESENCE`
  - `weeklyHours` (ex: 35, 39, 4, etc.)
- La nature RH est portée par période employé:
  - `CDI`, `CDD`, `INTERIM`
- Historique des contrats employé:
  - table `employee_contract_periods`
  - un employé peut avoir plusieurs périodes

### Règles de période

- `CDI`:
  - à la création d'une période: `endDate` doit être vide
  - en clôture d'un contrat en cours: `endDate` peut être renseignée
- `CDD` / `INTERIM`:
  - `endDate` obligatoire
- `endDate` ne peut pas être antérieure à `startDate`

## 3) Heures (vue jour)

- Visibilité des employés:
  - vue jour: un employé sans contrat à la date sélectionnée est masqué
  - vue semaine: un employé sans contrat sur aucun jour de la semaine est masqué
  - même règle pour les heures classiques et les heures conducteurs
- Saisie par salarié et par date:
  - matin / après-midi / soir
  - pour `PRESENCE`: demi-journées matin/après-midi
- Sélecteur de temps:
  - créneaux de 15 minutes uniquement (00:00, 00:15, ..., 23:45)
  - saisie libre possible mais valeur vidée au blur si hors options
- Calculs affichés:
  - `Jour`, `Nuit`, `Total`
- Heures de nuit:
  - fenêtres `00:00-06:00` et `21:00-24:00`
- Date de modification (`updatedAt`):
  - mise à jour uniquement quand un employé (`ROLE_SELF`) modifie ses propres heures
  - non mise à jour lors de modifications admin ou chef de site
  - affichée sous le nom de l'employé (visible admin uniquement)
- Libellé nature de contrat (CDI/CDD/Intérim) affiché sous le nom:
  - résolu à la date filtrée (période de contrat couvrant ce jour), pas à aujourd'hui
  - masqué si aucun contrat à cette date (cas rarissime en vue jour puisque l'employé est alors déjà filtré)
- **Jours travaillés (contrats CUSTOM)** : pour un contrat CUSTOM (planning `workDaysHours` renseigné), les jours effectivement travaillés sont affichés en suffixe du libellé `Site — Nature`, au format court `LU,MA,ME,JE,VE` (ex. `BUREAU — CDI — LU,JE` pour un temps partiel travaillant lundi et jeudi). Résolu à la date filtrée. Les contrats 35h/39h/Forfait/Intérim n'ont pas de planning → aucun suffixe. Écran Heures uniquement (pas Heures Conducteurs).
- **Vue Jour (Heures) — contrat à la date affichée** : le mode de suivi (saisie d'heures vs cases de présence), le libellé de contrat et la logique de sauvegarde sont résolus selon la période de contrat valable à la date filtrée (champs `trackingMode`/`weeklyHours`/`contractType`/`contractName` portés par `WorkHourDayContext`, alimentés par `EmployeeContractResolver::resolveForEmployeeAndDate`), et non selon le contrat courant de l'employé. Un salarié passé 39h/35h → Forfait conserve donc la saisie d'heures sur ses dates antérieures à la bascule, et bascule en cases de présence à partir de la date de passage en forfait. La vue Semaine était déjà résolue par date.
- **Exports heures annuelles (par salarié et tous salariés)** : affichent **tous les jours sous contrat**, même vides ou non saisis, jusqu'à la date du jour ; seuls les jours hors contrat (avant embauche, après départ, suspension) sont omis. Les samedis et dimanches sont grisés (gris foncé), les jours fériés en bleu.
- **Récap salaire (export PDF mensuel)** : seuls les salariés ayant un contrat couvrant tout ou partie du mois imprimé apparaissent (filtre `hasContractInRange`). Un salarié dont le contrat est terminé avant le mois (ex. parti en février) n'est pas listé sur le récap des mois suivants.

## 4) Absences

- Les absences sont stockées par jour (découpage lors de l'écriture)
- Une absence peut être:
  - journée complète
  - demi-journée `AM` ou `PM`
- Colonne absence (vue jour):
  - affiche le libellé
  - fond coloré selon le type d'absence
- Calendrier congés: fond coloré selon la couleur du type d'absence (`AbsenceType.color`)
  - demi-journée: dégradé diagonal
  - journée complète: fond plein
- Suppression d'une plage depuis le Calendrier:
  - clic sur un jour d'une plage → le drawer s'ouvre sur ce jour (début = fin = jour cliqué)
  - on étend la **date de fin** (ou de début) pour couvrir la plage à effacer, puis bouton **Supprimer**
  - **toutes** les absences de l'employé dont le jour tombe dans la plage sélectionnée sont supprimées (1 ligne/jour en BDD)
  - les jours de la plage sans absence sont ignorés (aucune erreur) ; un jour validé (`isValid`/site) bloque sa propre suppression
  - confirmation unique avant suppression ; au-delà de 1 jour le message rappelle le nombre de jours et l'intervalle
- Modification d'une plage depuis le Calendrier (bouton **Modifier**):
  - une absence n'a **aucun lien** entre ses jours en BDD (1 ligne/jour). Modifier réalise donc un **remplacement de bloc** : on supprime l'ancien **bloc contigu** (jours adjacents de **même type**, en partant du jour cliqué **vers l'avant**) puis on **recrée** la nouvelle plage
  - corrige le bug historique du PATCH : raccourcir une plage ne laisse plus de **jours fantômes** au-delà de la nouvelle fin, et ré-étendre ne crée plus de **doublons**
  - les jours **antérieurs** au jour cliqué ne sont jamais touchés ; toute absence d'un autre type recouverte par la nouvelle plage déclenche une confirmation « chevauche une autre »
  - implémenté côté frontend (`calendar.vue::handleSubmit`) car le backend ne peut pas reconstituer le bloc sans identifiant de groupe ; sans danger sur les écrans Heures/Heures Conducteurs où les dates du drawer sont verrouillées (`lock-dates`), donc le PATCH y reste mono-jour
- Visibilité des employés dans le Calendrier:
  - un employé est affiché si au moins une de ses périodes de contrat intersecte le mois affiché
  - un employé dont toutes les périodes se terminent avant le 1er du mois (ou commencent après la fin du mois) est masqué
  - même logique que l'écran Heures : « pas de contrat sur la période → masqué »

### Effet absence sur les heures

- Absence `AM`:
  - efface les heures du matin
- Absence `PM`:
  - efface les heures d'après-midi et du soir
- Absence journée:
  - efface toutes les plages horaires

### Absences "comptées comme travaillées"

- Si `countAsWorkedHours = true`:
  - `TIME`: crédit de minutes selon contrat actif du jour
  - `PRESENCE` (forfait): aucun crédit de présence (seules les checkboxes cochées comptent)

## 5) Validations des lignes d'heures

- Validation RH (`isValid`)
  - action admin
- Validation site (`isSiteValid`)
  - action chef de site

### Verrouillage

- Ligne validée RH:
  - verrouillée pour modifications heures/absences
- Ligne validée site:
  - verrouillée pour non-admin
  - admin peut corriger
- Toute vraie modification d'une ligne:
  - remet `isSiteValid = false`
  - remet `isValid = false`
- Si aucun changement réel à l'enregistrement:
  - les validations existantes ne sont pas altérées

## 6) Heures supplémentaires (vue semaine)

- Base de calcul:
  - dépend du contrat actif par jour
- Tranche 25%:
  - contrats <= 35h: de 35h à 43h
  - contrats >= 39h: de 39h à 43h
- Tranche 50%:
  - au-delà de 43h
- Embauche/fin de contrat en milieu de semaine (calcul RTT — `RttRecoveryComputationService`):
  - les seuils sont proratisés aux jours réellement contractés de la semaine (les jours hors contrat ne comptent pas)
  - le seuil de départ du 25% **et** le plafond 25%/50% sont décalés ensemble ; la bande 25% garde sa largeur réglementaire (4h pour un 39h, 8h pour un 35h)
  - une semaine d'embauche peut ainsi ouvrir à la fois du 25% et du 50% (ex. CDD 39h embauché le jeudi, 22h travaillées → 4h à 25% + 3h à 50%)
  - note: la synthèse de l'écran Heures (vue semaine) n'applique pas cette proratisation (calcul distinct dans `WorkHourWeeklySummaryProvider`)
- Date de début RTT (`RTT_START_DATE` dans `.env`):
  - les semaines dont la fin est antérieure à cette date sont ignorées dans le calcul de récupération
  - permet d'éviter les déficits fictifs avant la mise en service du logiciel
- Semaine en déficit (heures travaillées < heures contrat):
  - le déficit est déduit du cumul RTT : d'abord des heures à 50%, puis des heures à 25%
  - si aucun solde 50% ni 25%, les heures à 25% deviennent négatives
- Contrats CUSTOM (heures hebdo ≠ 35h et ≠ 39h, hors INTERIM/FORFAIT):
  - référence heures sup = heures contractuelles réelles (ex: 4h → référence 4h)
  - pas de bonus 25% ni 50% : 1 heure sup = 1 heure de récupération
  - le déficit (travail < contrat) réduit le cumul RTT 1:1 (peut devenir négatif, reporté N+1)

### Jour de solidarité (contrats CUSTOM < 35h)

Le Lundi de Pentecôte (jour de solidarité) impose une contribution proratisée aux temps
partiels < 35h. La RH pose un RTT sur ce jour pour tous les salariés ; pour les contrats
standard (35h/39h) cela draine ~7h du cumul RTT (comportement inchangé). Pour les CUSTOM
< 35h, poser un RTT entier n'a pas de sens : le logiciel **neutralise** le jour (quel que
soit ce qui y est saisi) et applique un déficit forfaitaire `7/35 × heuresHebdo`
(= 12 min par heure hebdo : 4h → 48 min, 28h → 5h36). Ce déficit réduit le cumul RTT
(peut le rendre négatif, reporté à l'exercice suivant) et se cumule avec les autres
déficits/surplus de la semaine. Date calculée par computus (Pâques + 50 jours),
indépendante de la liste `EXCLUDED_PUBLIC_HOLIDAYS`.

Le déficit ne s'applique **que si le salarié travaille le lundi** (jour de solidarité
planifié au contrat, `workDaysHours[lundi] > 0`). Un temps partiel ne travaillant jamais
le lundi (ex. Mar+Ven) n'est pas concerné : aucun déficit n'est imputé.

- Nature `INTERIM`:
  - pas de bonus 25%
  - pas de bonus 50%
  - pas de total récup
  - agence d'intérim optionnelle (table `interim_agencies`): affichée sur la fiche employé et le détail contrat sous la forme "Intérim (NomAgence)"

## 6bis) Heures Conducteurs

- Écran dédié `/driver-hours` pour les employés dont le contrat est marqué `isDriver = true`
- Les conducteurs sont exclus de l'écran `/hours` classique
- Colonnes spécifiques (vue jour):
  - Heure de jour (durée HH:MM via TimeSelect)
  - Heure de nuit (durée HH:MM via TimeSelect)
  - Heure atelier (durée HH:MM via TimeSelect)
  - Total (somme jour + nuit + atelier, calculé)
  - Petit déjeuner (checkbox)
  - Déjeuner (checkbox)
  - Dîner (checkbox)
  - Nuitée (checkbox)
- Stockage backend:
  - `dayHoursMinutes`, `nightHoursMinutes` et `workshopHoursMinutes` (entiers, minutes) sur `WorkHour`
  - `hasBreakfast`, `hasLunch`, `hasDinner`, `hasOvernight` (booleans) sur `WorkHour`
  - les champs time classiques (morning/afternoon/evening) sont mis à null pour les chauffeurs
- Absences `countAsWorkedHours=true`: les minutes créditées sont ajoutées aux heures de jour (vue jour et vue semaine), même logique que les employés classiques
- Validation: même logique que les heures classiques (`isValid`, `isSiteValid`, bulk)
- Vue semaine:
  - jour/nuit/atelier par jour + indicateurs repas/dîner/nuitée
  - panier de nuit (PN): **ne s'applique pas aux conducteurs** (ils disposent de leurs propres primes repas/nuitée). Aucun PN n'est crédité sur un jour conducteur, ni sur la vue semaine conducteurs ni sur le récap salaire. La règle PN (nuit > jour OU nuit ≥ 4h) ne concerne que les non-conducteurs.
  - totaux hebdo: jour, nuit, atelier, total, compteurs petit déj/déjeuner/dîner/nuitée
  - les conducteurs utilisent `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` pour le calcul RTT (au lieu des créneaux morning/afternoon/evening)
- Le flag `isDriver` est sur `EmployeeContractPeriod` (un employé peut changer de statut chauffeur selon la période)
- Exposé en API via un getter virtuel sur `Employee` (`employee:read`) qui résout depuis la période active

## 7) Fériés

- Les jours fériés sont identifiés et affichés
- Source: API `calendrier.api.gouv.fr/jours-feries/` via `PublicHolidayService` (cache 30j)
- Exclusions configurables: variable d'env `EXCLUDED_PUBLIC_HOLIDAYS` (liste de libellés séparés par virgules). Par défaut `"Lundi de Pentecôte"` — journée de solidarité généralement travaillée. Le filtre s'applique à tous les consommateurs (frontend + calculs backend) en amont du retour du service.
- Onglet congés: jours fériés affichés sur le calendrier avec fond `rgb(179, 229, 252)` et nom au survol
- Écran Heures et Heures Conducteurs (vue jour): le nom du férié est affiché dans la colonne Absence sous forme de pill (fond `#b3e5fc`, icône `mdi:calendar-star`), distinct du pill absence
- Écran Heures et Heures Conducteurs (vue semaine): la cellule du jour férié prend le fond `#b3e5fc` quand l'employé n'a pas d'absence ce jour-là, avec le nom du férié au survol (`title`). Si une absence est posée, la couleur de l'absence prime ; le `title` cumule les deux libellés (`Absence — Férié : Nom`).
- Règle courante:
  - absences autorisées sur jour férié (création/édition depuis l'écran Heures et le Calendrier). Quand une absence est posée, le crédit virtuel férié est désactivé — c'est le `countAsWorkedHours` du type d'absence qui pilote
  - saisie d'heures ou de jours de présence autorisée — les heures saisies comptent normalement dans le total hebdo et le calcul RTT
  - la référence hebdomadaire n'est pas réduite par un férié: un salarié qui ne saisit rien sur un férié est en déficit de la journée correspondante

## 8) Impression absences (PDF)

Filtres disponibles:
- période `from` / `to`
- sites
- nature de contrat (`CDI`, `CDD`, `INTERIM`)
- temps de travail (contrats de type Forfait, 35h, 39h, etc.)

Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.

Seuls les employés dont au moins une période de contrat intersecte la période imprimée `[from, to]` apparaissent dans le PDF (même règle que la vue Calendrier). Un salarié parti avant la période (ex. contrat terminé en avril, impression de mai) est exclu. Borne calculée sur la date seule, côté backend (`AbsencePrintProvider::hasContractInRange`).

## 9) Employés

- Création employé:
  - prénom, nom, site
  - type de contrat (nature RH)
  - temps de travail
  - dates début/fin (selon règles nature)
- Modification employé:
  - uniquement prénom, nom, site
  - pas de modification de contrat depuis ce drawer
- Liste employés — filtre par statut de contrat:
  - 3 options: "Avec contrat" (défaut), "Sans contrat", "Tous"
  - "Avec contrat": employés ayant une période de contrat active à la date du jour
  - "Sans contrat": employés sans période de contrat active
  - "Tous": aucun filtrage sur le contrat
- Détail employé:
  - onglet `Suivi contrat` avec affichage de l'historique des périodes de contrat
  - chaque ligne expose: nature (`CDI`/`CDD`/`INTERIM`), contrat/temps de travail, date de début, date de fin (ou "En cours")
  - action `Modifier` (clôture/solde de tout compte):
    - bouton actif s'il existe un contrat en cours non clôturé, ou si le dernier contrat est terminé (sans contrat actif après)
    - ouvre un drawer en lecture seule (type/temps de travail/date de début)
    - champs saisissables:
      - `contractEndDate` (prérempli à aujourd'hui si contrat en cours, à la date de fin existante si contrat terminé)
      - `contractPaidLeaveSettled` (checkbox "Soldé dans le solde de tout compte")
    - backend: en mode clôture, le flag `contractPaidLeaveSettled` est persisté sur la période clôturée
    - cas du contrat déjà terminé: permet de modifier `paidLeaveSettled` et le commentaire sur le dernier contrat terminé (ex: solde de tout compte CDD)
  - action `Ajouter`:
    - conserve le flux d'ajout d'un nouveau contrat via drawer dédié
    - disponible uniquement s'il n'y a pas de contrat en cours, ou si le contrat en cours a déjà une date de fin
  - onglet `Congé`:
    - endpoint de synthèse: `GET /api/employees/{id}/leave-summary?year=YYYY`
    - phase 1 métier (`CDI`/`CDD` non forfait + `FORFAIT`):
      - exercice CP:
        - `CDI`/`CDD` non forfait: du `1er juin (YYYY-1)` au `31 mai (YYYY)` (paramètre `year` = année de fin d'exercice)
        - `FORFAIT`: du `1er janvier (YYYY)` au `31 décembre (YYYY)` (paramètre `year` = année civile)
      - contrats `39h` / `35h` / `25h` (et plus largement CDI/CDD non forfait hors `4h`):
        - acquis annuel CP: `25`
        - acquis annuel samedi: `5`
        - en cours d'acquisition jours: `25/12 = 2,08` jours/mois
        - en cours d'acquisition samedis: `5/12 = 0,42` samedi/mois (non detaille en UI)
        - en cas de début/fin en cours de mois, l'acquisition est proratisée au nombre de jours calendaires couverts dans le mois
        - en cas de suspension en cours de mois, l'acquisition est proratisée en jours ouvrés (lun-ven hors fériés) travaillés / 22 (standard mensuel)
        - arrêt maladie long (absences continues de type `M` > 1 mois):
          - premier mois de maladie (date début + 1 mois calendaire): acquisition normale (`2,50`/mois)
          - après le premier mois: acquisition réduite à `2,00`/mois (facteur `0,80` appliqué aux deux taux jours et samedis)
          - en cas de mois partiellement couvert par la période réduite, le prorata est calculé en jours calendaires (jours normaux × taux normal + jours réduits × taux réduit)
          - la détection est automatique à partir des absences MALADIE consécutives en base (tolérance de gap ≤ 3 jours)
        - samedis acquis affiches: uniquement `opening_saturdays` (report N-1)
      - contrat `4h`:
        - acquis annuel CP: `10`
        - acquis annuel samedi: `0`
        - en cours d'acquisition: `0.83` jour/mois
        - en cas de début/fin en cours de mois, l'acquisition est proratisée au nombre de jours calendaires couverts dans le mois
        - en cas de suspension en cours de mois, l'acquisition est proratisée en jours ouvrés (lun-ven hors fériés) travaillés / 22
      - contrat `FORFAIT`:
        - base annuelle: `jours ouvrés de l'exercice (lundi-vendredi, hors jours fériés métropole) - 218`
        - bonus weekend/férié: chaque jour travaillé un weekend ou jour férié donne 1 jour de congé supplémentaire (journée ≥ 5h = 1.0 jour, demi-journée > 0h et < 5h = 0.5 jour), sans plafond
        - prorata: en cas de démarrage/fin de contrat en cours d'année civile, le calcul ne couvre que l'intervalle actif du contrat dans l'année
        - reste à prendre: `acquis - absences` (toutes absences, demi-journées incluses)
        - pas de samedi (`0`)
        - pas de jours en cours d'acquisition (`0`)
      - fractionné: saisie manuelle par la RH via `PATCH /employees/{id}/fractioned-days`, stocké dans `employee_leave_balances.fractioned_days`. Les jours fractionnés sont ajoutés aux acquis et au reste à prendre.
      - **dimanche jamais décompté** : un congé `C` posé un dimanche n'est **jamais** compté comme congé pris, où que ce soit (récap congés, rollover, jours de présence, et **récap salaire**). Permet de poser une période à cheval sur un week-end (ex. jeu→mar) sans « perdre » le dimanche. Ne concerne que le code `C` (maladie/AT inchangés) ; le samedi conserve son budget dédié. **Le calendrier et son impression PDF continuent d'afficher** la ligne du dimanche (la ligne d'absence existe en base, choix RH).
      - pour `CDI`/`CDD` non forfait:
        - pris CP: basé sur absences de type code `C` (CONGÉ), en tenant compte des demi-journées (dimanche exclu, samedi compté à part)
        - samedi pris: absences `C` posées le samedi (demi-journée incluse)
        - restants = acquis - pris (borné à 0)
      - pour `FORFAIT`:
        - pris: basé sur toutes les absences (demi-journées incluses)
        - restants = acquis - pris (borné à 0)
        - paiement congés N-1: saisie RH via `PATCH /employees/{id}/paid-leave-days` (body: `paidLeaveDays`, `year`). Stocké dans `employee_leave_balances.paid_leave_days`. Les jours payés réduisent le stock N-1 **avant** l'attribution des jours pris : `disponible_N-1 = max(0, acquis_N-1 - payés)`, puis `pris_N-1 = min(disponible_N-1, total_pris)`, surplus pris basculé sur N. Reste à prendre N-1 = `max(0, disponible_N-1 - pris_N-1)`. Uniquement pour les contrats forfait.
        - jours de présence et récap salaire: pour un forfait, les jours de congé imputés sur le stock N-1 (`previousYearTakenDays`) **ne réduisent pas** les jours de présence et **ne s'affichent pas** comme congés. Sur l'export Récap salaire (mensuel), le budget N-1 est consommé chronologiquement depuis le 1er janvier ; les jours couverts deviennent des jours de présence, les jours au-delà restent affichés en congés. Le budget est le même que la fiche employé (jours payés déduits du stock N-1 d'abord).
      - report annuel:
        - le reliquat (`restants`) de l'exercice précédent est reporté dans les acquis de l'exercice courant
        - pour `CDI`/`CDD` non forfait: report séparé jours + samedis
        - pour `FORFAIT`: report uniquement sur les jours
        - si un solde d'ouverture existe en base (`employee_leave_balances`) pour l'exercice courant, ce solde devient la source prioritaire du report
        - si une clôture de contrat est marquée `contractPaidLeaveSettled=true` sur l'exercice précédent, le report vers l'exercice suivant est remis à `0`
        - si une clôture `contractPaidLeaveSettled=true` existe dans l'exercice courant, le calcul est réinitialisé à partir du lendemain de cette clôture (pas de continuité intra-exercice)
      - lecture des compteurs:
        - `acquis` = droits reportés de l'exercice N-1 (après application des règles de soldé)
        - `en cours d'acquisition` = total droits générés sur l'exercice N (jours + samedis en cours), sans detail séparé en UI
        - `en cours d'acquisition` est arrêté au dernier jour du mois précédent
      - règle de consommation:
        - les absences s'imputent d'abord sur `acquis`, puis sur `en cours d'acquisition`
        - la prise sur `en cours d'acquisition` est autorisée (usage anticipé)
        - `en cours d'acquisition` peut devenir négatif si la prise dépasse le généré (ex: `2.08 - 3 = -0.92`), puis se reconstitue avec les acquisitions suivantes
      - date d'arret de calcul:
        - `reste à prendre` est calculé en prévisionnel jusqu'à la fin de l'exercice
        - les absences futures déjà posées sur l'exercice sont déduites du `reste à prendre`
        - `en cours d'acquisition` reste calculé jusqu'au dernier jour du mois précédent
        - exemple: au `11/03/2026`, l'exercice `2026` déduit les absences posées jusqu'au `31/05/2026`, mais l'acquisition reste arrêtée au `28/02/2026`
    - hors périmètre phase 1: `INTERIM` (retour non supporté)
  - onglet `RTT`:
    - endpoint de synthèse: `GET /api/employees/{id}/rtt-summary?year=YYYY`
    - exercice RTT: du `1er juin (YYYY-1)` au `31 mai (YYYY)` (paramètre `year` = année de fin d'exercice)
    - affichage:
      - détail hebdomadaire (semaine ISO) regroupé par mois
      - total mensuel des minutes de récupération
      - compteur global exercice = `report N-1 + acquis N`
    - attribution mensuelle des semaines:
      - une semaine ISO qui chevauche deux mois est affichée dans **les deux mois**, avec les valeurs réparties proportionnellement aux minutes travaillées de chaque portion
      - le calcul des heures supplémentaires reste hebdomadaire (seuils 35h/39h/43h appliqués sur la semaine entière), seul l'affichage est scindé
      - exemple: S14 lundi-mardi en mars, mercredi-dimanche en avril → la S14 apparaît en mars (avec la part des heures de lun-mar) et en avril (avec la part mer-dim)
    - logique de calcul:
      - base identique aux calculs d'heures supplémentaires de la vue semaine Heures
      - minutes de récupération hebdomadaires = `HS totales + bonus 25% + bonus 50%`
      - contrats `INTERIM` et suivi `PRESENCE`: récupération à `0`
      - date limite de calcul: uniquement les semaines terminées (jusqu'au dernier dimanche), **ou** la semaine en cours si tous les jours existants sont validés RH (`isValid = true`). En cas de fin de contrat en milieu de semaine, seuls les jours jusqu'à la date de fin sont vérifiés.
    - compteur global:
      - affiché en **jours** (1 jour = 7h = 420 minutes)
    - report:
      - le report N-1 correspond à la somme des minutes de récupération calculées sur l'exercice précédent
      - si une ligne existe dans `employee_rtt_balances` pour `(employee, year)`, le champ `opening_minutes` est utilisé en priorité
      - sinon, le calcul dynamique sur l'exercice N-1 est effectué
    - rollover automatique:
      - commande: `php bin/console app:rtt:rollover`
      - s'exécute le `1er juin` (même cron que le rollover congés)
      - calcule le total récup N-1 et le persiste en `opening_minutes` du nouvel exercice
      - idempotent (ne recrée pas si la ligne existe)
    - paiement RTT:
      - saisie RH via `PATCH /employees/{id}/rtt-payments` (body: `month`, `minutes`, `rate`)
      - stocké dans `employee_rtt_payments` (employee, year, month, minutes, rate)
      - `rate`: taux de majoration, valeurs `25` ou `50`
      - les heures payées sont soustraites du disponible RTT (`availableMinutes -= totalPaidMinutes`)
      - affichage: 2 lignes par mois dans le tableau (25% et 50%)
    - colonnes Total 25% et Total 50%: somme base + bonus de chaque tranche
    - colonne Cumul (dernière colonne): solde RTT à la fin de chaque semaine = `report N-1 + somme totalMinutes des semaines jusqu'à celle-ci − paiements RTT des mois antérieurs au mois de la semaine`. Le paiement d'un mois M n'est déduit qu'à partir des semaines du mois M+1 (cohérent avec la logique de la ligne "Report mois précédent"). Permet la comparaison ligne à ligne avec un suivi RH externe (Excel)
    - ligne Report N-1 (carry rollover): affichée en juin uniquement si carry > 0
    - ligne Report mois précédent: solde cumulé (carry N-1 + semaines antérieures − paiements antérieurs), affichée à partir de juillet (masquée si nul)
    - Reste = Report cumulé + Total du mois − Payé du mois (balance courante en fin de mois)
    - affichage:
      - le compteur global RTT est affiché en **heures** (format `Xh00`)

## 10) Export récap. congés & RTT (PDF)

- Accessible depuis la page Employés via le bouton "Export récap. congés" (réservé `ROLE_ADMIN`)
- Clic direct (pas de drawer), génère un PDF A4 portrait à la date du jour
- Endpoint: `GET /api/leave-recap/print`
- Seuls les employés avec contrat actif sont inclus
- Données groupées par site

### Colonnes du tableau

| Colonne | Logique |
|---------|---------|
| Nom | lastName + firstName |
| Contrat | Contract.name |
| CP N-1 restant | CDI/CDD: acquis N-1 − pris sur N-1. Forfait: report N-1 restant |
| Samedi restant | CDI/CDD: samedis acquis N-1 − pris. Forfait: `-` |
| CP N | Forfait: restant sur quota année civile (acquis − pris depuis N, sans toucher au stock N-1). Non-forfait: en cours d'acquisition |
| RTT | Minutes disponibles (report N-1 + acquis N - payés). Format `X h Y m`. Forfait et INTERIM: `-` |

## 10bis) Écran Récap. congés (tableau)

- Complément de l'export PDF : même logique de calcul, mais accessible aux employés et chefs de site
- Endpoint: `GET /api/leave-recap`
- Accès conditionné au flag `User.hasLeaveRecapAccess` (défaut `false`, activé au create/edit user)
- Le flag s'applique à tous les profils, y compris admin (pas de bypass)
- Scoping :
  - `ROLE_ADMIN` : tous les employés
  - `ROLE_USER` (chef de site) : employés des sites autorisés (`UserSiteRole`)
  - `ROLE_SELF` : uniquement son employé lié
- **Cutoff temporel** : le récap est figé à la fin de la semaine S-1 (dimanche 23:59:59)
  - Formule : `cutoffDate = dimanche(lundi_semaine_courante − 7 jours)`
  - Exemple : mardi 14/04/2026 (S16) → dimanche 12/04/2026 (fin S15)
  - `isValid` n'entre PAS en compte : cutoff purement temporel
  - Les heures et absences postérieures au cutoff sont ignorées dans les calculs
- Colonnes identiques au PDF (voir §10)
- Détails techniques : voir `doc/leave-recap-screen.md`

## Export Contingent heures de nuit

- Accès : drawer « Export » de la liste employés, type « Contingent H.nuit ».
  Endpoint `GET /night-hours-contingent/print?year=YYYY`, `ROLE_USER`.
- Périmètre : `EmployeeRepository::findScoped($user)` (admin → tous, chef de
  site → ses sites). Employés ayant ≥ 1 contrat sur l'année civile uniquement.
- PDF A4 **paysage** : lignes = employés (groupés par site, triés displayOrder
  puis nom/prénom), colonnes = 12 mois (Janv→Déc), chaque mois avec 2 sous-
  colonnes « H.nuit » et « N.jours ».
- Heures de nuit : minutes travaillées dans la fenêtre **21h→6h**
  (`NightHoursCalculator`, identique au reste de l'app). Conducteurs inclus :
  champ manuel `WorkHour.nightHoursMinutes`.
- « N.jours » : un jour compte 1 dès que ses minutes de nuit ≥ 240 (4h).
- Aucun crédit absence/férié : seules les heures réellement travaillées comptent.
- Services : `App\State\NightHoursContingentPrintProvider` +
  `App\Service\WorkHours\NightContingentExportBuilder`.

## 11) Récapitulatif Salaire (PDF mensuel)

- Accessible depuis la page Employés via le bouton "Récap. Salaire" (réservé `ROLE_ADMIN`)
- Sélecteur de mois (défaut = mois courant), génère un PDF A3 paysage
- Endpoint: `GET /api/salary-recap/print?month=YYYY-MM`
- Données groupées par site, un en-tête par site

### Colonnes du tableau

| Colonne | Source | Logique |
|---------|--------|---------|
| Nom | Employee | firstName + lastName |
| Base | Contract.name | Via EmployeeContractResolver pour le mois |
| Jour de présence Cadre | WorkHour | Uniquement FORFAIT (PRESENCE). Somme isPresentMorning (0.5) + isPresentAfternoon (0.5) |
| Heures de nuit | WorkHour | Non-chauffeurs: calcul intervalles nuit (00:00-06:00, 21:00-24:00). Chauffeurs: somme nightHoursMinutes |
| Panier de nuit | WorkHour | Nombre de jours où (nightMinutes > dayMinutes) OU (nightMinutes >= 240, soit 4h entre 21h-6h) |
| Heures payés | EmployeeRttPayment | Somme base25Minutes + base50Minutes du mois, convertie en heures |
| Congés - Nombre | Absence code 'C' | Jours (demi-journées = 0.5) |
| Congés - Date | Absence code 'C' | Dates formatées dd/mm |
| Maladie - Nombre | Absence code 'M' ou 'AT' | Jours (demi-journées = 0.5) |
| Maladie - Date | Absence code 'M' ou 'AT' | Dates formatées dd/mm |
| CHAUFFEUR - PDJ | WorkHour.hasBreakfast | Comptage mois (chauffeurs uniquement) |
| CHAUFFEUR - REPAS | WorkHour.hasLunch + hasDinner | Somme sur le mois : +1 par déjeuner coché et +1 par dîner coché (un jour avec les deux compte 2 repas, chauffeurs uniquement) |
| CHAUFFEUR - NUITEE | WorkHour.hasOvernight | Comptage mois (chauffeurs uniquement) |
| CHAUFFEUR - samedi | WorkHour (samedi) | Samedis travaillés (chauffeurs uniquement) |
| Observations | — | Colonne vide pour saisie manuelle |

## 12) Frais

- Onglet "Frais" sur la fiche employé (icône `mdi:account-cash-outline`)
- Entité `MileageAllowance` (table `mileage_allowances`)
- Champs:
  - `month` (mois, obligatoire)
  - `kilometers` (nombre de km, optionnel)
  - `amount` (montant en €, optionnel)
  - `comment` (commentaire, optionnel)
  - `receiptPath` / `receiptName` (justificatif Km, PDF)
  - `amountReceiptPath` / `amountReceiptName` (justificatif Montant, PDF)
- Règle de validation:
  - le mois est obligatoire
  - au moins un des deux champs `kilometers` ou `amount` doit être > 0
  - les deux peuvent être remplis simultanément
- Tableau: colonnes Mois, Nombre de Km, Montant €, Commentaire, Justif. Km, Justif. Montant
- Deux justificatifs distincts (upload PDF uniquement):
  - Justificatif Km : upload via `/mileage_allowances/{id}/receipt`, téléchargement via GET même URL
  - Justificatif Montant : upload via `/mileage_allowances/{id}/amount-receipt`, téléchargement via GET même URL
- La suppression d'un frais supprime les deux fichiers justificatifs du disque

## 13) Observations

- Onglet "Observation" sur la fiche employé (icône `mdi:note-text-outline`)
- Entité `Observation` (table `observations`)
- Champs:
  - `month` (mois, obligatoire)
  - `content` (texte d'observation, obligatoire)
- Contrainte: une seule observation par mois par employé (unique sur `employee_id + month`)
- Tableau: colonnes Mois | Observation
- Drawer avec champs mois (`type="month"`) et textarea "Observation"
- CRUD standard: création, modification, suppression avec confirmation

## 14) Verrouillage utilisateur

- Champ `isLocked` (boolean, default false) sur l'entité `User`
- Un admin peut verrouiller/déverrouiller un utilisateur depuis la page Utilisateurs (checkbox dans le drawer)
- Un utilisateur verrouillé ne peut plus se connecter (vérification via `UserChecker` sur les firewalls `login` et `api`)
- Colonne "Statut" dans le tableau utilisateurs avec label "Actif" (vert) ou "Verrouillé" (rouge)

## 15) Notifications

- Icône cloche en topbar:
  - badge = nombre de notifications non lues
  - ouverture panneau = liste des non lues
  - fermeture panneau = marquage "lu" en masse

### Règle métier de déclenchement

- Les notifications de validation site ne sont pas envoyées ligne par ligne.
- Une notification est créée uniquement quand un chef de site termine la validation complète:
  - condition: plus aucune ligne `work_hours` du site à la date concernée avec `isSiteValid = false`
  - destinataires: utilisateurs `ROLE_ADMIN`

- **Fin de contrat (J-1 ouvré)** : une commande quotidienne (`app:contract:end-notifications`)
  notifie tous les admins, sur le dernier jour ouvré précédant la fin d'un contrat, qu'un
  salarié arrive au terme de son emploi. Cible = **dernière** période de l'employé (un
  changement de contrat enchaîné ne notifie pas). Week-ends et fériés sautés. Message
  « Fin de {nature} de {Nom} le {date} », catégorie `Contrat`, lien vers la fiche employé,
  sans acteur. Idempotente. Détail : `doc/contract-end-notifications.md`.

## 16) Export PDF des heures annuelles

- Accessible depuis la fiche employé (bouton imprimante à droite du nom)
- Ouvre un drawer pour choisir l'année (civile, Jan-Déc)
- Génère un PDF avec le détail jour par jour des heures de l'employé
- Seuls les jours avec heures saisies, absence, week-end ou jour férié sont affichés
- Les jours fériés apparaissent toujours sur une ligne dédiée (fond bleu clair) avec la mention "Férié : {nom}" dans la colonne Absence (même si aucune saisie)

### Colonnes selon le mode de suivi

- **TIME (non-chauffeur)**: Date | Absence | Début matin | Fin matin | Début après-midi | Fin après-midi | Début soir | Fin soir | Total
- **PRESENCE (forfait)**: Date | Absence | Présence matin | Présence après-midi | Total
- **Chauffeur**: Date | Absence | Heures jour | Heures nuit | Heures atelier | Total

### Changement de contrat en cours d'année

- Si l'employé change de mode de suivi (TIME/PRESENCE) ou de statut chauffeur en cours d'année, le PDF affiche des sections séparées avec les colonnes adaptées à chaque période
- Le nom du contrat est affiché en sous-titre de chaque section

### Calcul du total

- TIME non-chauffeur: somme des créneaux matin + après-midi + soir, plus minutes créditées des absences `countAsWorkedHours`
- Chauffeur: `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` + minutes créditées
- PRESENCE: 0.5 par demi-journée présente (matin/après-midi), max 1.0
- Jour férié Lun-Ven (hors Forfait, sans absence) : `total = max(saisie + crédit absence, référence contractuelle)` — même règle que l'écran Heures (cf. `HolidayVirtualHoursResolver`). Pour Forfait : pas de crédit virtuel, la ligne férié affiche juste l'éventuelle présence saisie.

### Nom du fichier

- Format: `{nom}_{prenom}_{annee}.pdf`
