From 0bdc639f0194ace61e3776d0063e0b9a150cb6f4 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 24 Jun 2026 15:08:38 +0200 Subject: [PATCH] docs : design notification fin de contrat (veille du dernier jour) Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-06-24-contract-end-notification-design.md | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-24-contract-end-notification-design.md diff --git a/docs/superpowers/specs/2026-06-24-contract-end-notification-design.md b/docs/superpowers/specs/2026-06-24-contract-end-notification-design.md new file mode 100644 index 0000000..a05b368 --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-contract-end-notification-design.md @@ -0,0 +1,142 @@ +# Notification de fin de contrat (veille du dernier jour) — Design + +**Date :** 2026-06-24 +**Branche :** feature/SIRH-43-ajouter-une-notif-la-veille-d-un-contrat-qui-se-te +**Statut :** Validé (brainstorming) + +## 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 — afin qu'ils puissent anticiper +(solde de tout compte, désactivation des accès, etc.). + +La notification réutilise le **système de notification existant** (entité `Notification`, cloche +admin dans `AppTopNav.vue`). Aucune migration de base de données. + +## Décisions de cadrage + +| Sujet | Décision | +|---|---| +| **Déclencheur** | Vraie fin d'emploi uniquement : la période qui se termine est la **dernière** période de contrat du salarié (aucune période ne lui succède). Un changement de contrat enchaîné (ex. CDD 35h → CDI 39h) ne notifie pas. | +| **Timing** | Le **dernier jour ouvré strictement avant** `endDate`, en sautant **week-ends ET jours fériés**. | +| **Message** | « Fin de {nature} de {Prénom Nom} le {dd/mm/yyyy} » — nature = libellé FR (CDI / CDD / Intérim). | +| **Catégorie** | `Contrat` | +| **Cible du clic** | `/employees/{id}` (fiche employé). | +| **Destinataires** | Tous les `ROLE_ADMIN` (`UserRepository::findAllAdmins()`). | +| **Acteur** | `null` (notif générée par un job automatique, pas par un utilisateur). | +| **Déclenchement** | Commande console quotidienne via crontab prod (~6h du matin). | + +## Rappels sur l'existant + +- `Notification` (table `notifications`) : `recipient` (NOT NULL), `actor` (nullable), + `message`, `category`, `target`, `isRead`, `createdAt`. Exposé via `getActorName()`. +- `endDate` d'une `EmployeeContractPeriod` est **inclusif** : c'est le dernier jour couvert + par le contrat (`findOneCoveringDate` : `endDate >= :date`). +- Pattern de notif existant : `WorkHourSiteValidationProcessor` crée une `Notification` par + admin (`findAllAdmins`) avec `new Notification()` + persist + flush. Pas de service factory. +- Pattern cron existant : `RttRolloverCommand` / `LeaveRolloverCommand` (`#[AsCommand]`, + logger `monolog.logger.cron`, options `--force`/`--recompute`). Déclenchées par le crontab + système (pas de Symfony Scheduler dans le projet). +- `PublicHolidayService` : source des fériés (cache 30j), déjà en place. + +## Approche retenue + +**Commande cron quotidienne + service métier dédié et testable.** + +Alternatives écartées : +- **Symfony Scheduler (Messenger)** : brique non utilisée dans le projet, inutile ici. +- **Calcul à la volée dans le provider** : casse `isRead`/historique, recalcul à chaque + ouverture de la cloche, mélange notifs persistées et virtuelles. + +## Conception détaillée + +### 1. Détection (cœur métier) + +Nouveau service `App\Service\Notification\ContractEndNotificationService`. + +Algorithme (date du jour `T` injectable pour les tests) : + +1. Si `T` est un week-end ou un férié → **sortie** (aucun jour chômé ne génère de notif). +2. Calculer `N` = **prochain jour ouvré strictement après `T`** (saute week-ends + fériés via + `PublicHolidayService`). +3. Charger en **une seule requête** la dernière période de chaque employé + (`EmployeeContractPeriodRepository::findLatestPeriodsForAllEmployees()`). +4. Candidat = dernière période dont `endDate` est non nul et vérifie `T < endDate <= N`. + - Le test « dernière période » assure nativement la règle « vraie fin d'emploi » : un + changement de contrat enchaîné a une période suivante, donc n'est jamais la dernière. + - `endDate = null` (CDI ouvert) → jamais candidat. + +**Exemples :** +- Mardi (`T`), `N` = mercredi → notifie les contrats finissant mercredi (J-1 classique). +- Vendredi (`T`), `N` = lundi → notifie les contrats finissant samedi, dimanche **ou** lundi. +- Vendredi (`T`), lundi férié → `N` = mardi → notifie samedi…mardi. +- Week-end (`T`) → rien. + +### 2. Création des notifications & idempotence + +Pour chaque candidat : +- Message : `Fin de {nature} de {Prénom Nom} le {endDate->format('d/m/Y')}`. +- Une `Notification` par admin : `recipient=admin`, `actor=null`, `category='Contrat'`, + `target='/employees/{id}'`. Persist groupé, un seul `flush()` final. + +**Idempotence** (le job peut être relancé le même jour) : avant création, vérifier qu'il +n'existe pas déjà une notif identique pour ce destinataire via +`(recipient, category='Contrat', target='/employees/{id}', message)` **exact**. Le message +étant unique par (employé + date + nature), cela empêche tout doublon — y compris après que +l'admin a lu la notif (`isRead=true`). Nouvelle méthode +`NotificationRepository::existsForRecipientTargetMessage(...)` (ou `findOneBy`). Pas de +migration : on ne stocke pas de FK « période » sur `Notification`. + +### 3. Affichage front (cloche) + +`AppTopNav.vue` rend aujourd'hui `**{actorName}** {message}`. Pour `actorName` vide : +- N'afficher que `{message}` (pas de span gras vide ni `capitalize` orphelin). +- Le reste est inchangé : avatar/pastille, `formatTimeAgo` + catégorie « Contrat », point + non-lu, lien `target`. + +Aucune route ni service front nouveau : `category` et `target` passent par le DTO existant. +Cloche déjà admin-only → rien d'autre côté visibilité. + +### 4. Commande console + +`App\Command\ContractEndNotificationCommand` — `app:contract:end-notifications`. +- Délègue tout au service. +- Option `--date=YYYY-MM-DD` : forcer la date du jour (tests / rattrapage manuel). +- Logger `monolog.logger.cron`. Sortie `SymfonyStyle` (nb de notifs créées / employés concernés). +- **Idempotente** par construction (cf. §2) → relançable sans risque. +- Crontab prod (infra) : `0 6 * * *` (tous les jours, 6h). Pas de restriction jour de semaine + (la commande s'auto-neutralise week-ends/fériés). + +## Fichiers + +**Backend — nouveaux** +- `src/Service/Notification/ContractEndNotificationService.php` +- `src/Command/ContractEndNotificationCommand.php` +- `tests/...` (tests du service) + +**Backend — modifiés** +- `src/Repository/EmployeeContractPeriodRepository.php` — `findLatestPeriodsForAllEmployees()` +- `src/Repository/NotificationRepository.php` — existence anti-doublon +- Helper « jour ouvré » (week-end + férié) — dans le service ou petit util réutilisable + +**Frontend — modifié** +- `frontend/components/AppTopNav.vue` — gérer `actorName` vide + +**Docs** +- `doc/functional-rules.md` — compléter la section 15) Notifications +- `doc/contract-end-notifications.md` — nouveau (règle complète) +- `frontend/data/documentation-content.ts` — entrée admin +- `CLAUDE.md` — note du nouveau pattern (commande cron de notification) + +Pas de migration DB. + +## Tests (PHPUnit) + +Cœur isolé dans `ContractEndNotificationService` (date `T` fixe + `PublicHolidayService` mocké) : +- Fin mercredi, `T`=mardi → 1 notif/admin. +- Fin lundi, `T`=vendredi → notifié vendredi ; rien samedi/dimanche. +- Lundi férié + fin mardi, `T`=vendredi → notifié vendredi (`N` saute le lundi férié). +- Période suivante existante (changement enchaîné) → pas de notif. +- `endDate=null` → pas de notif. +- Idempotence : 2ᵉ exécution même jour → aucun doublon. +- `T` = week-end → aucune création.