feat : notification de fin de contrat (veille ouvrée du dernier jour) (#35)
Auto Tag Develop / tag (push) Successful in 17s
Auto Tag Develop / tag (push) Successful in 17s
## 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>
This commit was merged in pull request #35.
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user