## 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>
7.1 KiB
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(tablenotifications) :recipient(NOT NULL),actor(nullable),message,category,target,isRead,createdAt. Exposé viagetActorName().endDated'uneEmployeeContractPeriodest inclusif : c'est le dernier jour couvert par le contrat (findOneCoveringDate:endDate >= :date).- Pattern de notif existant :
WorkHourSiteValidationProcessorcrée uneNotificationpar admin (findAllAdmins) avecnew Notification()+ persist + flush. Pas de service factory. - Pattern cron existant :
RttRolloverCommand/LeaveRolloverCommand(#[AsCommand], loggermonolog.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) :
- Si
Test un week-end ou un férié → sortie (aucun jour chômé ne génère de notif). - Calculer
N= prochain jour ouvré strictement aprèsT(saute week-ends + fériés viaPublicHolidayService). - Charger en une seule requête la dernière période de chaque employé
(
EmployeeContractPeriodRepository::findLatestPeriodsForAllEmployees()). - Candidat = dernière période dont
endDateest non nul et vérifieT < 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
Notificationpar admin :recipient=admin,actor=null,category='Contrat',target='/employees/{id}'. Persist groupé, un seulflush()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 nicapitalizeorphelin). - Le reste est inchangé : avatar/pastille,
formatTimeAgo+ catégorie « Contrat », point non-lu, lientarget.
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. SortieSymfonyStyle(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.phpsrc/Command/ContractEndNotificationCommand.phptests/...(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éreractorNamevide
Docs
doc/functional-rules.md— compléter la section 15) Notificationsdoc/contract-end-notifications.md— nouveau (règle complète)frontend/data/documentation-content.ts— entrée adminCLAUDE.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 (Nsaute 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.