## Objectif
Prévenir automatiquement les administrateurs, sur le **dernier jour ouvré précédant la fin d'un contrat**, qu'un salarié arrive au terme de son emploi.
## Fonctionnement
- Commande quotidienne `app:contract:end-notifications` (à brancher sur le crontab prod, ~6h ; option `--date=YYYY-MM-DD` pour test/rattrapage).
- Cible **la dernière période de contrat** d'un employé (un changement de contrat enchaîné, ex. CDD→CDI, ne notifie pas).
- Notifie sur le **dernier jour ouvré strictement avant** `endDate` (inclusif). Week-ends **et fériés** sautés → une fin de contrat le lundi est signalée dès le vendredi. Le Lundi de Pentecôte reste un jour ouvré (cohérent avec le reste de l'app).
- Une notification par admin : message « Fin de {nature} de {Nom} le {date} », catégorie `Contrat`, lien `/employees/{id}`, sans acteur.
- **Idempotent** : déduplication par `(recipient, category, target, message)`.
- Front : la cloche (déjà admin-only) affiche proprement les notifs sans acteur.
- **Aucune migration** (réutilise la table `notifications`).
## Architecture
Logique pure isolée et testée : `WorkingDayCalculator` (week-end + férié) + `ContractEndNotificationPlanner` (fenêtre + message). Persistance dans `ContractEndNotificationService`, exposée par `ContractEndNotificationCommand`. Méthodes repo `findLatestPeriodsForAllEmployees` + `existsForRecipientCategoryTargetMessage`.
## Tests & vérification
- 11 tests unitaires ajoutés ; suite complète verte (264 tests, 564 assertions).
- Vérif e2e manuelle : run du vendredi → 6 notifs/1 contrat finissant le lundi (saut de week-end OK), relance idempotente (0), contenu BDD correct.
## Documentation
`doc/contract-end-notifications.md`, `doc/functional-rules.md` (§15), doc in-app (`documentation-content.ts`), `CLAUDE.md`.
## ⚠️ Tâche infra
Ajouter la ligne crontab prod : `0 6 * * * … bin/console app:contract:end-notifications`
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Reviewed-on: #35
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
## Contexte
Sur le calendrier, une absence est stockée **une ligne par jour** sans lien entre les jours (cf. `AbsenceWriteProcessor::expandAbsenceRange`). Conséquences pour la RH :
- **Impossible de retirer une plage** : la suppression n'effaçait que le jour cliqué → il fallait supprimer chaque jour un par un.
- **Modifier une plage laissait des incohérences** : le `PATCH` réutilisait une ligne et en recréait d'autres sans nettoyer l'ancien bloc → jours « fantômes » au raccourcissement et doublons à l'allongement.
## Changements (`frontend/pages/calendar.vue`)
- **Supprimer** (`handleDelete`) : efface **toutes** les absences de l'employé comprises dans la plage `[début ; fin]` du drawer. Flux RH : clic sur un jour → étendre la date de fin → Supprimer. Jours sans absence ignorés (aucune erreur) ; jour validé (`isValid`/site) protégé côté backend. Confirmation avec nombre de jours + intervalle.
- **Modifier** (`handleSubmit`) : **remplacement de bloc** — supprime l'ancien bloc contigu de même type (vers l'avant depuis le jour cliqué) + les absences recouvertes par la nouvelle plage, puis recrée la plage via `createAbsence`. Corrige le bug du `PATCH`. Les jours antérieurs au jour cliqué ne sont jamais touchés ; confirmation « chevauche une autre » seulement pour un autre type. `updateAbsence` n'est plus appelé depuis le calendrier.
## Pourquoi côté frontend
Le backend ne peut pas reconstituer « la plage » (aucun identifiant de groupe en BDD) ; le frontend a la plage visible. Vérifié : les écrans **Heures** et **Heures Conducteurs** verrouillent les dates du drawer (`lock-dates`), donc le `PATCH` y reste mono-jour — le calendrier est le seul écran à reshaper une plage. `AbsenceWriteProcessor` non modifié.
## Documentation
- `doc/functional-rules.md`, `frontend/data/documentation-content.ts` (in-app), `CLAUDE.md`.
## Tests
- 253 tests PHPUnit verts (hook pre-commit). Pas de framework de test frontend dans le projet.
- À valider en réel : raccourcir / allonger une plage (pas de jour fantôme ni doublon), supprimer une plage d'un coup.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Reviewed-on: #34
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
## Contexte
Certains comptes sont **partagés** par plusieurs personnes (ex. compte « Usine »), y compris depuis des smartphones. Le journal d'activité ne stockait que le `username` → impossible de distinguer les intervenants. Cette PR ajoute un **contexte forensique automatique** à chaque entrée du journal.
## Ce qui est ajouté (capté automatiquement, sans friction utilisateur)
- **Adresse IP** de la requête
- **User-Agent brut** (borné à 1024 caractères)
- **Libellé appareil lisible** dérivé du User-Agent : `Type · OS · Navigateur` (ex. `Mobile · Android · Chrome`)
- **Identifiant d'appareil persistant** envoyé par le front (header `X-Device-Id`, stocké en `localStorage`, borné à 64 car.) — distingue les **appareils** derrière un compte partagé
## Implémentation
- `UserAgentParser` (service maison, sans dépendance) — détection ordonnée OS/navigateur, testée
- 4 colonnes **nullable** sur `audit_logs` + migration réversible (pas de backfill, rétro-compatible)
- Capture **centralisée** dans `AuditLogger::log()` via `RequestStack` — aucun processor modifié
- Champs exposés dans l'API lecture (`AuditLogProvider` + DTO TS aligné) via `AuditLogReadRepositoryInterface` (suit le pattern existant des autres read-repos)
- Front : `useDeviceId` + injection du header `X-Device-Id` dans `useApi` (sur toutes les requêtes, SSR-safe)
- `framework.trusted_proxies` documenté (commenté) pour une IP correcte derrière un reverse proxy
- Docs : `doc/audit-logging.md` + `CLAUDE.md`
## Hors périmètre (étapes suivantes)
- **Écran du journal (`audit-logs.vue`) non modifié** — l'affichage des nouvelles colonnes fera l'objet d'une refonte séparée. Les données sont prêtes côté API.
- La doc in-app (`documentation-content.ts`) n'est pas touchée : le journal est un outil caché `ROLE_SUPER_ADMIN` sans article existant ni niveau de doc super-admin.
## À noter pour le déploiement
- L'IP n'est fiable derrière un reverse proxy qu'une fois `framework.trusted_proxies` activé (livré commenté).
## Tests
`OK (249 tests, 533 assertions)` — sortie PHPUnit propre (aucune notice).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Reviewed-on: #33
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
## Problème
Sur l'écran **Heures** / **Heures Conducteurs**, l'enregistrement envoyait au bulk-upsert une entrée pour **tous** les employés visibles non verrouillés, à partir de l'état en mémoire de la grille. Le backend (`WorkHourBulkUpsertProcessor`) traitant une **entrée vide comme une suppression**, un admin avec une grille **périmée** pouvait supprimer une ligne saisie entre-temps par un autre utilisateur.
### Scénario reproduit
1. Un admin ouvre l'écran ; la ligne d'un salarié `ROLE_SELF` est vide.
2. Ce salarié saisit ses heures dans sa propre session → ligne créée, **non validée** (donc non verrouillée).
3. L'admin, sur sa grille périmée, enregistre d'autres employés.
4. Le payload contient une entrée **vide** pour le salarié → le backend supprime sa ligne. **Perte de données.**
## Correctif (suivi des lignes modifiées)
`hydrateRows` capture un instantané `loadedRows` de l'état chargé depuis le serveur. `handleSave` ne transmet plus que les lignes **dont l'état courant diffère de l'instantané**.
- Ligne **intouchée** → jamais envoyée → jamais supprimée ✅
- Ligne **vidée volontairement** → envoyée vide → supprimée (métier conservé)
- Ligne **remplie/modifiée** → envoyée → créée/mise à jour
Symétrique dans `useHoursPage.ts` et `useDriverHoursPage.ts`.
## Limite connue
Pas de verrou optimiste backend : l'édition **explicite** d'une ligne sur données périmées peut toujours écraser une saisie concurrente sur cette même ligne (hors périmètre).
## Doc
- `doc/hours-save-dirty-tracking.md` (nouveau)
- Note `CLAUDE.md` (section *Validation Rules*)
## Vérification
- Pre-commit hook : **236 tests PHPUnit OK**.
- Pas de harnais de tests frontend (revue de code uniquement).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Reviewed-on: #31
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
## Fonctionnel
- Calendrier MalioDate en vue Jour (écrans Heures ET Heures Conducteurs) : les jours entièrement validés par un admin sont peints en vert.
- Endpoint `GET /work-hours/validation-status?from=&to=[&driver=1]` (scope conducteur inversé via `driver=1`), périmètre complet (ignore le filtre sites).
- Chargement à la volée par mois (event `@month-change`), refresh après validation / saisie / absence.
## Harmonisation @malio/layer-ui 1.7.11
- `reserveMessageSpace=false` sur tous les champs (alignement).
- Tous les drawers migrés sur `MalioDrawer` (titre via slot `#header`, `AppDrawer` custom supprimé).
- Boutons d'action en `MalioButton` ; deux boutons côte à côte partagent l'espace.
- Inputs date en `MalioDate`, sélecteur semaine en `MalioDateWeek`.
- Boutons d'ajout uniformisés sur « Ajouter » + icône.
## Divers
- `.env` : `EXCLUDED_PUBLIC_HOLIDAYS="null"`.
- Doc : `doc/hours-validated-days.md`, `documentation-content.ts`, `CLAUDE.md`.
- Tests : provider `WorkHourValidationStatus` (suite complète 236/236 OK via pre-commit hook).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Reviewed-on: #30
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
Le récap salaire comptait les congés (C) tombant un dimanche via
countAbsencesByCode, alors que l'onglet Congés, le rollover et les jours de
présence l'ignoraient déjà. Garde ajoutée (C + dimanche → ignoré) pour aligner :
poser une période à cheval sur un week-end (ex. jeu→mar) ne fait plus perdre le
dimanche. Correctif au comptage uniquement : les lignes d'absence du dimanche
restent créées et affichées sur le calendrier (volonté RH), l'existant cesse de
compter sans migration. Périmètre strict : code C (maladie/AT inchangés), samedi
inchangé (budget dédié).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Le libellé sous le nom de l'employé affiche en suffixe les jours du planning
workDaysHours au format court (ex. BUREAU — CDI — LU,JE) pour les contrats CUSTOM.
Résolu à la date filtrée et exposé via WorkHourDayContext.workDaysHours ; formaté
front par formatWorkedDaysShort. Limité aux CUSTOM (35h/39h/forfait/intérim sans
planning → rien affiché) et à l'écran Heures (pas Heures Conducteurs).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Un contrat CUSTOM < 35h qui ne travaille pas le lundi (jour de solidarité,
workDaysHours[lundi] absent → attendu = 0) ne portait à tort un déficit
forfaitaire ((0 − 0) − prorata = −prorata). Garde ajoutée : aucun déficit
quand expectedMinutes === 0. Ewa (Lun+Jeu) reste à −0h48 ; Nadia (Mar+Ven)
passe de −0h48 à 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Les heures contractuelles au-delà de 35h (ex. 39h → 17,33h décimales = 17h20/mois)
sont payées chaque mois sans transiter par les paiements RTT (référence 39h). Elles
manquaient au contingent. Ajout via StructuralOvertimeContingentCalculator :
(weeklyHours-35)×260 min/mois, généralisé aux contrats non-forfait/non-intérim >35h,
proratisé aux jours sous contrat. Branché sur l'encart fiche et l'export PDF.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Résumé
Nouvel export PDF **Contingent heures de nuit** dans le drawer Export de la liste employés.
- PDF **A4 paysage** : lignes = employés (groupés par site, triés displayOrder/nom/prénom), colonnes = 12 mois civils, chaque mois avec 2 sous-colonnes **H.nuit** et **N.jours**.
- Heures de nuit = minutes dans la fenêtre **21h→6h** via un service partagé `NightHoursCalculator` (mutualisé avec `WorkHourWeeklySummaryProvider` et `YearlyHoursExportBuilder` — duplication supprimée, sans changement de comportement).
- **Conducteurs inclus** via `WorkHour.nightHoursMinutes`. Statut conducteur résolu par date.
- **N.jours** = nb de jours où les minutes de nuit ≥ 240 (4h). Aucun crédit absence/férié.
- Périmètre via `EmployeeRepository::findScoped` (admin → tous, chef de site → ses sites), endpoint `GET /night-hours-contingent/print?year=YYYY` (`ROLE_USER`).
- Sélecteur d'année (année civile). Colonne Nom calibrée, séparateurs de mois épais.
## Composants
- Service `NightHoursCalculator`, builder `NightContingentExportBuilder`, DTO `NightContingentRow`
- Provider `NightHoursContingentPrintProvider` + opération API `NightHoursContingentPrint`
- Gabarit `templates/night-hours-contingent/print.html.twig`
- Option frontend dans `frontend/pages/employees/index.vue`
- Docs : `doc/functional-rules.md`, `CLAUDE.md`, `frontend/data/documentation-content.ts`
## Tests
- Nouveaux tests unitaires : `NightHoursCalculatorTest` (fenêtre 21h→6h, passage minuit, bornes), `NightContingentExportBuilderTest` (agrégation mensuelle, règle ≥4h=1j, conducteur, cas sans heures)
- Suite complète : **208 tests OK**
- Rendu PDF validé visuellement (Twig→Dompdf)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Reviewed-on: #28
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
Le récap des congés se fige désormais à la fin de la semaine précédente
(S-1) au lieu de l'avant-dernière (S-2), incluant ainsi une semaine de
plus. Demande métier.
- LeaveRecapCutoff : -14j -> -7j
- Test unitaire figeant la règle S-1
- Doc fonctionnelle, doc in-app et CLAUDE.md mis à jour
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
L'export des heures de la vue Jour était réservé aux admins. Il est désormais
ouvert aux chefs de site, restreint à leurs sites :
- sécurité endpoint ROLE_ADMIN -> ROLE_USER
- périmètre résolu côté backend via EmployeeRepository::findScoped() (un siteIds
hors périmètre est ignoré, aucune fuite inter-sites)
- bouton Exporter visible pour admin + chef de site (masqué pour ROLE_SELF)
- doc, doc in-app et CLAUDE.md mis à jour
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Le tri intra-site de l'export PDF des heures (vue Jour) reprend désormais celui du calendrier : **`displayOrder` (ordre manuel) → nom → prénom**, au lieu du nom seul.
`doc/` (CLAUDE.md) mis à jour. Tests backend verts (173/361).
Reviewed-on: #26
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
Affinements de l'export PDF des heures (vue Jour) :
- **Colonne Statut** : affiche le **code** du type d'absence (ex. `AT`) au lieu du libellé, sur sa couleur de fond. Férié sans absence inchangé (nom du férié sur fond bleu clair).
- **Colonne Total** en gras.
- **Légende** sous le tableau : carré coloré contenant le code + libellé à droite, 6 éléments par ligne, triée et dédupliquée (hors férié).
- **Bouton Exporter masqué en vue Semaine** (visible uniquement en vue Jour).
Docs mises à jour : `doc/hours-day-export.md`, `frontend/data/documentation-content.ts`, `CLAUDE.md`. Tests backend verts (173/361).
Reviewed-on: #25
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
## Résumé
Ajoute un bouton **Exporter** (admin uniquement) à droite du titre « Heures » qui génère un **PDF d'une journée**, regroupé par site, reprenant les colonnes de la vue Jour **sans la colonne « Valider »**.
- Drawer : champ date (préremplit la date affichée) + cases à cocher des sites (préselectionnées sur le filtre courant).
- Portée identique à l'écran : non-conducteurs, sous contrat à la date, sites cochés (lignes vides incluses).
- Jour/Nuit/Total incluent le crédit d'absence et le crédit virtuel férié.
## Implémentation
- Back : `WorkHourDayExport` (ApiResource) + `WorkHourDayExportProvider`, endpoint `GET /work-hours/day-export?workDate=&siteIds=` (ROLE_ADMIN).
- Calcul des cellules mutualisé via `YearlyHoursExportBuilder::buildDayRowsForEmployees` (source unique de vérité).
- Gabarit `templates/work-hour-day-export/print.html.twig` (A4 portrait compact).
- Front : `HoursDayExportDrawer.vue` + câblage dans `pages/hours.vue`.
- Docs : `doc/hours-day-export.md`, `documentation-content.ts`, `CLAUDE.md`.
## Tests
- Test unitaire `YearlyHoursDayRowsTest` ajouté.
- Suite complète verte : 173 tests, 359 assertions.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Reviewed-on: #24
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
## Besoin RH
Pouvoir saisir un paiement RTT sur l'exercice précédent (ex. RTT de mai réglés après la bascule du 1er juin).
## Implémentation (Option B)
- Paiement autorisé sur l'exercice courant + l'exercice immédiatement précédent (N-1).
- Après saisie sur N-1, le report d'ouverture de l'exercice courant est recalculé automatiquement (computeClosingBalance) dans une transaction → aucun double comptage.
- Refus si ce report est verrouillé (is_locked) : la RH le déverrouille d'abord.
- Fallback EmployeeRttSummaryProvider::resolveCarry aligné sur computeClosingBalance : disponible correct même sans ligne stockée.
- Front : bouton « + Payer les RTT » actif sur l'exercice précédent.
- Docs : CLAUDE.md, doc/rtt-tab.md, documentation-content.ts.
## Vérification
- ✅ 172 tests OK, cs-fixer OK, conteneur compile.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Reviewed-on: #23
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
La bascule app:rtt:rollover ne reprenait que les RTT acquis de l'exercice qui
se terminait : le report d'ouverture déjà présent était perdu et les paiements
n'étaient pas déduits. Le nouveau report reprend le solde de clôture =
report d'ouverture(N-1) + acquis(N-1) − RTT payés(N-1), soit le "Disponible"
affiché par EmployeeRttSummaryProvider.
- nouveau RttClosingBalanceService (fold pur testé : invariant somme tranches =
disponible, cascade déficit 50% avant 25%, récup CUSTOM non perdue)
- RttRolloverCommand branché dessus + option --recompute (écrase les lignes
existantes non verrouillées, pour reprise d'une bascule erronée)
- test date-sensible EmployeeRttSummaryProviderTest rendu robuste
- docs: doc/rtt-rollover.md, CLAUDE.md, documentation-content.ts
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
| | |
## Description de la PR
## Modification du .env
## Check list
- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié
Reviewed-on: #22
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
## Correctifs RH (branche fix/retour-rh)
### Vue Jour (Heures)
- Mode saisie/présence, libellé de contrat et sauvegarde résolus **à la date affichée** (et non au contrat courant). Corrige les salariés passés 39h/35h → Forfait.
### RTT — heures supplémentaires
- Proratisation du **plafond 25%/50%** pour les embauches en milieu de semaine (la bande +25% se décale au lieu de rester bloquée à 43h). Témoin Dylan : 4h à 25% + 3h à 50%.
### Récap salaire (PDF mensuel)
- Forfait : congés imputés **N-1** non affichés et comptés en présence.
- Colonne « Heures payés » **scindée 25% / 50%** (en-tête fusionné).
- **Exclusion des salariés sans contrat** sur le mois (ex. Marine, contrat terminé).
### Exports heures annuelles (par salarié + tous)
- **Tous les jours sous contrat** affichés, même vides/non saisis (corrige les lignes manquantes).
- Samedis/dimanches en **gris plus foncé**.
### Panier de nuit
- **Ne s'applique pas aux conducteurs** (vue semaine + récap salaire).
## Tests
- 11 tests ajoutés. Suite verte hors un test legacy pré-existant dépendant de la date (`EmployeeRttSummaryProviderTest::testNoQueryParamsKeepsLegacyYearDefaulting`, non modifié par cette branche).
## À noter (hors scope)
- L'export heures annuelles *tous salariés* peut dépasser `memory_limit=256M` (Dompdf) — limitation **pré-existante**, non corrigée ici.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Reviewed-on: #21
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>