Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d723c7631a | |||
| 029a09dc09 | |||
| 45c32989e5 | |||
| 77ae6820d7 |
@@ -39,6 +39,10 @@
|
|||||||
- **Calendrier des jours validés (vue Jour)** (`WorkHourValidationStatusProvider`, ressource `WorkHourValidationStatus`, endpoint `GET /work-hours/validation-status?from=&to=[&driver=1]`, `ROLE_USER`) : en **vue Jour**, sur les **deux écrans** (Heures **et** Heures Conducteurs), le sélecteur de date est un `MalioDate` (layer `@malio/layer-ui >= 1.7.x` : prop `markedDates` + event `@month-change`) qui peint **en vert** (`markedDates` → `'success'`) les jours **entièrement validés**. **Définition** : un jour est vert ssi il porte ≥1 ligne `WorkHour` du scope ce jour-là **et** aucune n'est `isValid=false` — on se base sur la **seule** colonne `is_valid` (validation admin ; `isSiteValid` ignoré). Jour **sans aucune ligne** → neutre (jamais vert). **Périmètre complet** via `EmployeeRepository::findScoped` (admin = tous sites, chef de site = ses sites), **indépendant du filtre sites** de l'écran. **Scope conducteur inversé** par `?driver=1` : écran Heures → non-conducteurs (défaut), écran Heures Conducteurs → conducteurs (résolu par date via `EmployeeContractResolver::resolveIsDriverForEmployeeAndDate`, mémoïsé ; garde `if ($isDriver !== $driverOnly) continue`). Provider : une requête `WorkHourReadRepositoryInterface::findByDateRangeAndEmployees`, agrégation par jour (`total`/`pending`), plage bornée à 366 j. **Chargement à la volée par mois** (jamais préchargé) : `@month-change {month,year}` (à l'ouverture + nav) → fetch de la **grille visible** (lundi avant le 1er → dimanche après le dernier) → cache `validatedDaysByMonth` (`useHoursPage` / `useDriverHoursPage`, ce dernier passe `{ driver: true }` au service) → `markedDates` réactif. **Rafraîchissement** du mois en cache (`reloadValidationMonth`) après `toggleValidation`/`toggleValidationBulk`/`handleSave`/`refreshAfterAbsenceChange` (pas la validation site). La **vue Semaine** utilise un `MalioDateWeek` (sélecteur de semaine, v-model ISO week `YYYY-Www`, sans coloration). Le **stepper ‹ › du mode jour est remplacé** par le `MalioDate` (raccourcis Hier/Aujourd'hui/Demain conservés) ; `PeriodStepperPicker` reste un fallback de la vue Jour quand `showValidationCalendar` est absent (aucun appelant actuel). Activation par écran via la prop `showValidationCalendar` de `HoursToolbar` (les deux pages la passent à `true` + `markedDates` + `@month-change`). Alignement vertical de la ligne via `lg:items-center` (les champs Malio font `h-12` vs `h-10` des boutons). Doc complète : `doc/hours-validated-days.md`.
|
- **Calendrier des jours validés (vue Jour)** (`WorkHourValidationStatusProvider`, ressource `WorkHourValidationStatus`, endpoint `GET /work-hours/validation-status?from=&to=[&driver=1]`, `ROLE_USER`) : en **vue Jour**, sur les **deux écrans** (Heures **et** Heures Conducteurs), le sélecteur de date est un `MalioDate` (layer `@malio/layer-ui >= 1.7.x` : prop `markedDates` + event `@month-change`) qui peint **en vert** (`markedDates` → `'success'`) les jours **entièrement validés**. **Définition** : un jour est vert ssi il porte ≥1 ligne `WorkHour` du scope ce jour-là **et** aucune n'est `isValid=false` — on se base sur la **seule** colonne `is_valid` (validation admin ; `isSiteValid` ignoré). Jour **sans aucune ligne** → neutre (jamais vert). **Périmètre complet** via `EmployeeRepository::findScoped` (admin = tous sites, chef de site = ses sites), **indépendant du filtre sites** de l'écran. **Scope conducteur inversé** par `?driver=1` : écran Heures → non-conducteurs (défaut), écran Heures Conducteurs → conducteurs (résolu par date via `EmployeeContractResolver::resolveIsDriverForEmployeeAndDate`, mémoïsé ; garde `if ($isDriver !== $driverOnly) continue`). Provider : une requête `WorkHourReadRepositoryInterface::findByDateRangeAndEmployees`, agrégation par jour (`total`/`pending`), plage bornée à 366 j. **Chargement à la volée par mois** (jamais préchargé) : `@month-change {month,year}` (à l'ouverture + nav) → fetch de la **grille visible** (lundi avant le 1er → dimanche après le dernier) → cache `validatedDaysByMonth` (`useHoursPage` / `useDriverHoursPage`, ce dernier passe `{ driver: true }` au service) → `markedDates` réactif. **Rafraîchissement** du mois en cache (`reloadValidationMonth`) après `toggleValidation`/`toggleValidationBulk`/`handleSave`/`refreshAfterAbsenceChange` (pas la validation site). La **vue Semaine** utilise un `MalioDateWeek` (sélecteur de semaine, v-model ISO week `YYYY-Www`, sans coloration). Le **stepper ‹ › du mode jour est remplacé** par le `MalioDate` (raccourcis Hier/Aujourd'hui/Demain conservés) ; `PeriodStepperPicker` reste un fallback de la vue Jour quand `showValidationCalendar` est absent (aucun appelant actuel). Activation par écran via la prop `showValidationCalendar` de `HoursToolbar` (les deux pages la passent à `true` + `markedDates` + `@month-change`). Alignement vertical de la ligne via `lg:items-center` (les champs Malio font `h-12` vs `h-10` des boutons). Doc complète : `doc/hours-validated-days.md`.
|
||||||
- **Export Contingent heures de nuit** (`NightHoursContingentPrintProvider`, endpoint `GET /night-hours-contingent/print?year=YYYY`, `ROLE_USER`) : option « Contingent H.nuit » du drawer Export de la liste employés. PDF **A4 paysage**, lignes = employés **groupés par site** et triés `displayOrder`/nom/prénom (comme le day-export), colonnes = 12 mois civils, chacun avec 2 sous-colonnes **H.nuit** et **N.jours**. Heures de nuit = minutes dans la fenêtre **21h→6h** via le service partagé `App\Service\WorkHours\NightHoursCalculator` (source unique mutualisée avec `WorkHourWeeklySummaryProvider`, `YearlyHoursExportBuilder`, `RttRecoveryComputationService` et `SalaryRecapPrintProvider`). Conducteurs inclus via `WorkHour.nightHoursMinutes`. **N.jours** = nb de jours où minutes de nuit ≥ 240 (4h). **Aucun crédit** absence/férié. Agrégation : `App\Service\WorkHours\NightContingentExportBuilder`. Gabarit `templates/night-hours-contingent/print.html.twig`.
|
- **Export Contingent heures de nuit** (`NightHoursContingentPrintProvider`, endpoint `GET /night-hours-contingent/print?year=YYYY`, `ROLE_USER`) : option « Contingent H.nuit » du drawer Export de la liste employés. PDF **A4 paysage**, lignes = employés **groupés par site** et triés `displayOrder`/nom/prénom (comme le day-export), colonnes = 12 mois civils, chacun avec 2 sous-colonnes **H.nuit** et **N.jours**. Heures de nuit = minutes dans la fenêtre **21h→6h** via le service partagé `App\Service\WorkHours\NightHoursCalculator` (source unique mutualisée avec `WorkHourWeeklySummaryProvider`, `YearlyHoursExportBuilder`, `RttRecoveryComputationService` et `SalaryRecapPrintProvider`). Conducteurs inclus via `WorkHour.nightHoursMinutes`. **N.jours** = nb de jours où minutes de nuit ≥ 240 (4h). **Aucun crédit** absence/férié. Agrégation : `App\Service\WorkHours\NightContingentExportBuilder`. Gabarit `templates/night-hours-contingent/print.html.twig`.
|
||||||
- **Écran Calendrier** : un employé est affiché uniquement si au moins une de ses périodes de contrat (`employee.contractHistory`) intersecte le mois affiché (`[1er ; dernier jour]`). Filtre côté frontend dans `visibleEmployees` (`pages/calendar.vue`). **L'impression PDF des absences applique le même filtre** côté backend (`AbsencePrintProvider::hasContractInRange` sur la période `[from, to]`) : un salarié parti en avril n'apparaît pas sur une impression de mai. **Le récap salaire applique le même filtre** (`SalaryRecapPrintProvider::hasContractInRange` sur le mois imprimé) : un salarié sans contrat sur le mois (ex. parti en février) n'apparaît pas sur le récap de juin.
|
- **Écran Calendrier** : un employé est affiché uniquement si au moins une de ses périodes de contrat (`employee.contractHistory`) intersecte le mois affiché (`[1er ; dernier jour]`). Filtre côté frontend dans `visibleEmployees` (`pages/calendar.vue`). **L'impression PDF des absences applique le même filtre** côté backend (`AbsencePrintProvider::hasContractInRange` sur la période `[from, to]`) : un salarié parti en avril n'apparaît pas sur une impression de mai. **Le récap salaire applique le même filtre** (`SalaryRecapPrintProvider::hasContractInRange` sur le mois imprimé) : un salarié sans contrat sur le mois (ex. parti en février) n'apparaît pas sur le récap de juin.
|
||||||
|
- **Suppression / modification d'une plage de congés** (`calendar.vue`) : une absence = **une ligne par jour** en BDD (aucun lien entre les jours, cf. `expandAbsenceRange`), donc tout se gère côté frontend qui a la plage visible.
|
||||||
|
- **Supprimer** (`handleDelete`) : efface **toutes les absences de l'employé dont le jour tombe dans `[form.startDate ; form.endDate]`** (filtrage sur `absences.value`, boucle `deleteAbsence`), pas seulement le jour cliqué. Flux RH : clic sur un jour → drawer (début = fin) → étendre la date de fin → Supprimer. Jours sans absence ignorés ; jour validé (`isValid`/site) bloque sa propre ligne (backend). Confirmation avec nombre de jours + intervalle (`formatYmdToFr`) dès > 1 jour.
|
||||||
|
- **Modifier** (`handleSubmit`, branche `editingAbsence`) : **remplacement de bloc** — supprime l'**ancien bloc contigu** (jours adjacents de **même type**, en partant du jour cliqué **vers l'avant** via `shiftYmd`) **+** toute absence recouverte par la nouvelle plage, puis **`createAbsence`** sur la nouvelle plage (plus de `PATCH`). Corrige le bug historique : raccourcir ne laisse plus de **jours fantômes**, ré-étendre ne crée plus de **doublons**. Jamais de modification des jours **antérieurs** au jour cliqué ; confirmation « chevauche une autre » seulement si on écrase un **autre type**. La branche **Création** garde sa détection de chevauchement demi-journée (`getSegmentsForDate`).
|
||||||
|
- Backend `AbsenceWriteProcessor` (PATCH) **non touché** : il reste mono-jour en pratique car les écrans Heures/Heures Conducteurs verrouillent les dates du drawer (`lock-dates`), seul le calendrier reshape une plage. `updateAbsence` n'est plus appelé depuis `calendar.vue` (import retiré).
|
||||||
- **Planning jours travaillés** (`EmployeeContractPeriod.workDaysHours` : JSON `{iso_day: minutes}`) : obligatoire pour tout contrat TIME **hors 35h/39h/INTERIM** (ex. 4h, 25h, 28h). Somme = `weeklyHours × 60`. Utilisé par `HolidayVirtualHoursResolver` (crédit férié) et `WorkedHoursCreditPolicy` (crédit absence) pour ne créditer que les jours effectivement travaillés. Validation : `EmployeeContractPeriodValidator::assertWorkDaysHours`.
|
- **Planning jours travaillés** (`EmployeeContractPeriod.workDaysHours` : JSON `{iso_day: minutes}`) : obligatoire pour tout contrat TIME **hors 35h/39h/INTERIM** (ex. 4h, 25h, 28h). Somme = `weeklyHours × 60`. Utilisé par `HolidayVirtualHoursResolver` (crédit férié) et `WorkedHoursCreditPolicy` (crédit absence) pour ne créditer que les jours effectivement travaillés. Validation : `EmployeeContractPeriodValidator::assertWorkDaysHours`.
|
||||||
- Absences: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots
|
- Absences: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots
|
||||||
- Absences with `countAsWorkedHours=true`: credit minutes (TIME) or nothing (PRESENCE)
|
- Absences with `countAsWorkedHours=true`: credit minutes (TIME) or nothing (PRESENCE)
|
||||||
@@ -212,6 +216,10 @@
|
|||||||
- **Écran Journal refondu** (`frontend/pages/audit-logs.vue` + `useAuditLogsList`) : tableau en `MalioDataTable` (1er usage SIRH), **drawer de filtre** façon STARSEED (`MalioDrawer` + `MalioAccordion`, état brouillon/appliqué, badge compteur, Réinitialiser/Appliquer), **drawer de détail** au clic ligne. Filtres backend : `employee` (LIKE nom/prénom de l'employé affecté, via join), `username`/`ip`/`device` (LIKE insensible casse), `entityType[]`/`action[]` (IN), `perPage` (10/25/50/100, défaut 10). Filtres du drawer = champs texte (recherche libre), période en `MalioDateRange`, type/action en cases à cocher. Logique dans `useAuditLogsList` ; libellés FR en dur ; filtres hors URL. Provider/`AuditLogReadRepositoryInterface`/repository portent les nouveaux critères.
|
- **Écran Journal refondu** (`frontend/pages/audit-logs.vue` + `useAuditLogsList`) : tableau en `MalioDataTable` (1er usage SIRH), **drawer de filtre** façon STARSEED (`MalioDrawer` + `MalioAccordion`, état brouillon/appliqué, badge compteur, Réinitialiser/Appliquer), **drawer de détail** au clic ligne. Filtres backend : `employee` (LIKE nom/prénom de l'employé affecté, via join), `username`/`ip`/`device` (LIKE insensible casse), `entityType[]`/`action[]` (IN), `perPage` (10/25/50/100, défaut 10). Filtres du drawer = champs texte (recherche libre), période en `MalioDateRange`, type/action en cases à cocher. Logique dans `useAuditLogsList` ; libellés FR en dur ; filtres hors URL. Provider/`AuditLogReadRepositoryInterface`/repository portent les nouveaux critères.
|
||||||
- Documentation: `doc/audit-logging.md`
|
- Documentation: `doc/audit-logging.md`
|
||||||
|
|
||||||
|
## Notifications
|
||||||
|
- Système : entité `Notification` (table `notifications`, `recipient`/`actor`/`message`/`category`/`target`/`isRead`), cloche **admin-only** dans `AppTopNav.vue`, providers `/notifications/{unread,today,history}` + `POST /notifications/mark-all-read`. Création historique : `WorkHourSiteValidationProcessor` (1 notif/admin via `UserRepository::findAllAdmins`).
|
||||||
|
- **Fin de contrat (J-1 ouvré)** : commande cron quotidienne `app:contract:end-notifications` (crontab prod, ~6h ; option `--date`). Notifie les admins sur le **dernier jour ouvré avant** `endDate` (inclusif) de la **dernière** période d'un employé (changement de contrat enchaîné exclu). Week-ends + fériés sautés (`WorkingDayCalculator`, via `getHolidaysDayByYears` → applique `EXCLUDED_PUBLIC_HOLIDAYS`, donc **Lundi de Pentecôte traité comme jour ouvré**, cohérent avec le reste de l'app). Fenêtre couverte un jour J = `]J ; prochain_jour_ouvré(J)]`. Message « Fin de {nature} de {Nom} le {date} », catégorie `Contrat`, target `/employees/{id}`, acteur null. Idempotent (`NotificationRepository::existsForRecipientCategoryTargetMessage`). Logique pure testée : `ContractEndNotificationPlanner` + `WorkingDayCalculator`. Front : `AppTopNav.vue` masque le span acteur si `actorName` vide. Doc : `doc/contract-end-notifications.md`.
|
||||||
|
|
||||||
## Backend Conventions
|
## Backend Conventions
|
||||||
- Prefer explicit DTOs over associative arrays
|
- Prefer explicit DTOs over associative arrays
|
||||||
- Business rules in backend (providers/processors/services), frontend is display/interaction only
|
- Business rules in backend (providers/processors/services), frontend is display/interaction only
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.124'
|
app.version: '0.1.126'
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Notification de fin de contrat (veille du dernier jour)
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
Prévenir les administrateurs, sur le dernier jour ouvré précédant la fin d'un contrat, qu'un
|
||||||
|
salarié arrive au terme de son emploi.
|
||||||
|
|
||||||
|
## Déclenchement
|
||||||
|
Commande `app:contract:end-notifications`, lancée chaque jour par le crontab de production
|
||||||
|
(ex. `0 6 * * *`). Option `--date=YYYY-MM-DD` pour test/rattrapage. Logger `cron`.
|
||||||
|
|
||||||
|
## Règle métier
|
||||||
|
- **Cible** : la **dernière** période de contrat d'un employé (aucune période ne lui succède).
|
||||||
|
Un changement de contrat enchaîné (ex. CDD → CDI) ne notifie pas.
|
||||||
|
- **Quand** : sur le **dernier jour ouvré strictement avant** `endDate` (`endDate` est inclusif
|
||||||
|
= dernier jour travaillé). Les week-ends ET jours fériés (`PublicHolidayService`, zone
|
||||||
|
`metropole`) sont sautés. Concrètement, le jour J ouvré couvre les fins de contrat dans
|
||||||
|
l'intervalle `]J ; prochain_jour_ouvré(J)]` — un vendredi notifie ainsi les fins du
|
||||||
|
samedi, dimanche et lundi (mardi si lundi férié).
|
||||||
|
- **Jour de solidarité (Lundi de Pentecôte)** : traité comme un **jour ouvré** (choix
|
||||||
|
délibéré). Le calcul s'appuie sur `getHolidaysDayByYears`, qui applique
|
||||||
|
`EXCLUDED_PUBLIC_HOLIDAYS` (défaut = `"Lundi de Pentecôte"`) — la même liste de fériés que
|
||||||
|
le reste de l'app (heures, congés, RTT). On évite ainsi une définition de « férié »
|
||||||
|
divergente pour ce seul calcul ; et le jour de solidarité est, par nature, un jour travaillé
|
||||||
|
(admins présents → la cloche est vue). Une fin de contrat le mardi après Pentecôte est donc
|
||||||
|
notifiée le Lundi de Pentecôte, pas le vendredi précédent.
|
||||||
|
- **Destinataires** : tous les `ROLE_ADMIN`.
|
||||||
|
- **Message** : `Fin de {CDI|CDD|Intérim} de {Prénom Nom} le {dd/mm/yyyy}`, catégorie
|
||||||
|
`Contrat`, cible `/employees/{id}`, sans acteur.
|
||||||
|
|
||||||
|
## Idempotence
|
||||||
|
Avant création, on vérifie l'absence d'une notif identique
|
||||||
|
`(recipient, category='Contrat', target, message)`. Le message étant unique par
|
||||||
|
(employé + date + nature), relancer la commande le même jour ne crée aucun doublon.
|
||||||
|
|
||||||
|
## Implémentation
|
||||||
|
- `App\Service\Notification\WorkingDayCalculator` — jour ouvré / prochain jour ouvré.
|
||||||
|
- `App\Service\Notification\ContractEndNotificationPlanner` — sélection + message (pur, testé).
|
||||||
|
- `App\Service\Notification\ContractEndNotificationService` — persistance (1 notif/admin).
|
||||||
|
- `App\Command\ContractEndNotificationCommand` — `app:contract:end-notifications`.
|
||||||
|
- `EmployeeContractPeriodRepository::findLatestPeriodsForAllEmployees`,
|
||||||
|
`NotificationRepository::existsForRecipientCategoryTargetMessage`.
|
||||||
|
- Pas de migration : réutilise la table `notifications`.
|
||||||
@@ -78,6 +78,17 @@ Documents complementaires:
|
|||||||
- Calendrier congés: fond coloré selon la couleur du type d'absence (`AbsenceType.color`)
|
- Calendrier congés: fond coloré selon la couleur du type d'absence (`AbsenceType.color`)
|
||||||
- demi-journée: dégradé diagonal
|
- demi-journée: dégradé diagonal
|
||||||
- journée complète: fond plein
|
- 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:
|
- 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é 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é
|
- un employé dont toutes les périodes se terminent avant le 1er du mois (ou commencent après la fin du mois) est masqué
|
||||||
@@ -486,6 +497,13 @@ Seuls les employés dont au moins une période de contrat intersecte la période
|
|||||||
- condition: plus aucune ligne `work_hours` du site à la date concernée avec `isSiteValid = false`
|
- condition: plus aucune ligne `work_hours` du site à la date concernée avec `isSiteValid = false`
|
||||||
- destinataires: utilisateurs `ROLE_ADMIN`
|
- 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
|
## 16) Export PDF des heures annuelles
|
||||||
|
|
||||||
- Accessible depuis la fiche employé (bouton imprimante à droite du nom)
|
- Accessible depuis la fiche employé (bouton imprimante à droite du nom)
|
||||||
|
|||||||
@@ -0,0 +1,974 @@
|
|||||||
|
# Notification de fin de contrat (veille du dernier jour) — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 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.
|
||||||
|
|
||||||
|
**Architecture:** Une commande console quotidienne (`app:contract:end-notifications`, déclenchée par le crontab prod) délègue à un service. La logique « dure » (saut des week-ends/fériés, fenêtre de détection, libellé du message) vit dans deux collaborateurs purs et testés en isolation (`WorkingDayCalculator`, `ContractEndNotificationPlanner`). Le service oriente le résultat vers la création de `Notification` (une par admin), avec déduplication par message exact. Aucune migration : on réutilise la table `notifications` existante.
|
||||||
|
|
||||||
|
**Tech Stack:** Symfony 7 + API Platform + Doctrine ORM (backend), PHPUnit (tests), Nuxt 4 / Vue 3 (front). Conteneur de test Docker `php-sirh-fpm`.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **PHP** : `declare(strict_types=1);` en tête de chaque fichier ; classes services en `final readonly` quand sans état mutable (suivre `RttRolloverCommand`, `HolidayVirtualHoursResolver`).
|
||||||
|
- **Commit message** : format `<type> : <message>` — **espace obligatoire avant les deux-points** (hook pre-commit), types autorisés : `feat, fix, docs, refactor, test, chore`, etc. Exemple : `feat : add working day calculator`.
|
||||||
|
- **Pre-commit hook** : lance php-cs-fixer + **toute** la suite PHPUnit. Tout commit échoue si un test casse → garder la suite verte à chaque commit.
|
||||||
|
- **Lancer les tests** : `make test` (suite complète) ou ciblé `make test FILES="--filter NomDuTest"` (= `docker exec -u www-data php-sirh-fpm php vendor/bin/phpunit ...`).
|
||||||
|
- **Fériés** : zone `'metropole'`, via `PublicHolidayServiceInterface::getHolidaysDayByYears('metropole', $year)` → tableau `['Y-m-d' => 'libellé']` (suivre `HolidayVirtualHoursResolver::isPublicHoliday`).
|
||||||
|
- **Catégorie** notif = `'Contrat'` ; **target** = `'/employees/{id}'` ; **acteur** = `null` ; destinataires = `UserRepository::findAllAdmins()`.
|
||||||
|
- **Règles projet (CLAUDE.md)** : toute évolution fonctionnelle MET À JOUR `doc/` ET `frontend/data/documentation-content.ts` dans la même intervention ; mettre à jour `CLAUDE.md` à la fin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Backend — nouveaux**
|
||||||
|
- `src/Service/Notification/WorkingDayCalculator.php` — jour ouvré (week-end + férié), prochain jour ouvré. Pur (dépend de `PublicHolidayServiceInterface`).
|
||||||
|
- `src/Service/Notification/ContractEndNotice.php` — DTO immuable `{ ?int employeeId, string message }`.
|
||||||
|
- `src/Service/Notification/ContractEndNotificationPlanner.php` — sélection des candidats + construction du message. Pur (dépend de `WorkingDayCalculator`).
|
||||||
|
- `src/Service/Notification/ContractEndNotificationResult.php` — DTO résultat `{ int notificationsCreated, int contractsMatched }`.
|
||||||
|
- `src/Service/Notification/ContractEndNotificationService.php` — orchestration (repos + EntityManager).
|
||||||
|
- `src/Command/ContractEndNotificationCommand.php` — commande `app:contract:end-notifications`.
|
||||||
|
|
||||||
|
**Backend — modifiés**
|
||||||
|
- `src/Repository/EmployeeContractPeriodRepository.php` — `findLatestPeriodsForAllEmployees()`.
|
||||||
|
- `src/Repository/NotificationRepository.php` — `existsForRecipientCategoryTargetMessage()`.
|
||||||
|
|
||||||
|
**Tests — nouveaux**
|
||||||
|
- `tests/Service/Notification/WorkingDayCalculatorTest.php`
|
||||||
|
- `tests/Service/Notification/ContractEndNotificationPlannerTest.php`
|
||||||
|
|
||||||
|
**Frontend — modifié**
|
||||||
|
- `frontend/components/AppTopNav.vue` — gérer `actorName` vide (ligne 65).
|
||||||
|
|
||||||
|
**Docs — modifiés/nouveaux**
|
||||||
|
- `doc/functional-rules.md` (section 15), `doc/contract-end-notifications.md` (nouveau), `frontend/data/documentation-content.ts`, `CLAUDE.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1 : WorkingDayCalculator (jour ouvré : week-end + férié)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Service/Notification/WorkingDayCalculator.php`
|
||||||
|
- Test: `tests/Service/Notification/WorkingDayCalculatorTest.php`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `App\Service\PublicHolidayServiceInterface::getHolidaysDayByYears(string $zone, string $year): array`
|
||||||
|
- Produces:
|
||||||
|
- `WorkingDayCalculator::__construct(PublicHolidayServiceInterface $holidays)`
|
||||||
|
- `WorkingDayCalculator::isWorkingDay(DateTimeImmutable $date): bool`
|
||||||
|
- `WorkingDayCalculator::nextWorkingDay(DateTimeImmutable $date): DateTimeImmutable` — premier jour ouvré **strictement après** `$date` (heure remise à 00:00:00).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`tests/Service/Notification/WorkingDayCalculatorTest.php` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service\Notification;
|
||||||
|
|
||||||
|
use App\Service\Notification\WorkingDayCalculator;
|
||||||
|
use App\Service\PublicHolidayServiceInterface;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class WorkingDayCalculatorTest extends TestCase
|
||||||
|
{
|
||||||
|
private function calculator(): WorkingDayCalculator
|
||||||
|
{
|
||||||
|
$holidays = $this->createStub(PublicHolidayServiceInterface::class);
|
||||||
|
$holidays->method('getHolidaysDayByYears')->willReturn([
|
||||||
|
// Lundi 14/07/2025 férié
|
||||||
|
'2025-07-14' => 'Fête nationale',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return new WorkingDayCalculator($holidays);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWeekdayIsWorkingDay(): void
|
||||||
|
{
|
||||||
|
// Mardi 08/07/2025
|
||||||
|
self::assertTrue($this->calculator()->isWorkingDay(new DateTimeImmutable('2025-07-08')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSaturdayAndSundayAreNotWorkingDays(): void
|
||||||
|
{
|
||||||
|
self::assertFalse($this->calculator()->isWorkingDay(new DateTimeImmutable('2025-07-12'))); // samedi
|
||||||
|
self::assertFalse($this->calculator()->isWorkingDay(new DateTimeImmutable('2025-07-13'))); // dimanche
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPublicHolidayIsNotWorkingDay(): void
|
||||||
|
{
|
||||||
|
self::assertFalse($this->calculator()->isWorkingDay(new DateTimeImmutable('2025-07-14'))); // lundi férié
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNextWorkingDayFromWeekdayIsTomorrow(): void
|
||||||
|
{
|
||||||
|
// Mardi 08/07 -> Mercredi 09/07
|
||||||
|
self::assertSame(
|
||||||
|
'2025-07-09',
|
||||||
|
$this->calculator()->nextWorkingDay(new DateTimeImmutable('2025-07-08'))->format('Y-m-d')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNextWorkingDayFromFridaySkipsWeekend(): void
|
||||||
|
{
|
||||||
|
// Vendredi 11/07 -> lundi 14/07 est férié -> mardi 15/07
|
||||||
|
self::assertSame(
|
||||||
|
'2025-07-15',
|
||||||
|
$this->calculator()->nextWorkingDay(new DateTimeImmutable('2025-07-11'))->format('Y-m-d')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `make test FILES="--filter WorkingDayCalculatorTest"`
|
||||||
|
Expected: FAIL — `Class "App\Service\Notification\WorkingDayCalculator" not found`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
`src/Service/Notification/WorkingDayCalculator.php` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Notification;
|
||||||
|
|
||||||
|
use App\Service\PublicHolidayServiceInterface;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final readonly class WorkingDayCalculator
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private PublicHolidayServiceInterface $holidays,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function isWorkingDay(DateTimeImmutable $date): bool
|
||||||
|
{
|
||||||
|
$dayOfWeek = (int) $date->format('N'); // 1 (lundi) .. 7 (dimanche)
|
||||||
|
if ($dayOfWeek >= 6) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !$this->isPublicHoliday($date);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function nextWorkingDay(DateTimeImmutable $date): DateTimeImmutable
|
||||||
|
{
|
||||||
|
$candidate = $date->modify('+1 day')->setTime(0, 0, 0);
|
||||||
|
while (!$this->isWorkingDay($candidate)) {
|
||||||
|
$candidate = $candidate->modify('+1 day');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isPublicHoliday(DateTimeImmutable $date): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$holidays = $this->holidays->getHolidaysDayByYears('metropole', $date->format('Y'));
|
||||||
|
} catch (Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isset($holidays[$date->format('Y-m-d')]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `make test FILES="--filter WorkingDayCalculatorTest"`
|
||||||
|
Expected: PASS (5 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Service/Notification/WorkingDayCalculator.php tests/Service/Notification/WorkingDayCalculatorTest.php
|
||||||
|
git commit -m "feat : add working day calculator (weekend + holiday aware)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2 : ContractEndNotice DTO
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Service/Notification/ContractEndNotice.php`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `ContractEndNotice::__construct(public ?int $employeeId, public string $message)` (lecture seule).
|
||||||
|
|
||||||
|
Pas de test dédié (DTO sans logique) — sera couvert par le test du planner (Task 3).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the DTO**
|
||||||
|
|
||||||
|
`src/Service/Notification/ContractEndNotice.php` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Notification;
|
||||||
|
|
||||||
|
final readonly class ContractEndNotice
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public ?int $employeeId,
|
||||||
|
public string $message,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Service/Notification/ContractEndNotice.php
|
||||||
|
git commit -m "feat : add contract end notice DTO"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3 : ContractEndNotificationPlanner (fenêtre + message)
|
||||||
|
|
||||||
|
Sélectionne, parmi les **dernières périodes** de chaque employé, celles dont la fin tombe dans la fenêtre `]today, nextWorkingDay(today)]`, et construit le message FR.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Service/Notification/ContractEndNotificationPlanner.php`
|
||||||
|
- Test: `tests/Service/Notification/ContractEndNotificationPlannerTest.php`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes:
|
||||||
|
- `WorkingDayCalculator::isWorkingDay(...)`, `::nextWorkingDay(...)` (Task 1)
|
||||||
|
- `App\Entity\EmployeeContractPeriod::getEndDate(): ?DateTimeImmutable`, `::getEmployee(): ?Employee`, `::getContractNatureEnum(): App\Enum\ContractNature`
|
||||||
|
- `App\Entity\Employee::getId(): ?int`, `::getFirstName(): string`, `::getLastName(): string`
|
||||||
|
- `App\Enum\ContractNature` (cases `CDI`, `CDD`, `INTERIM`)
|
||||||
|
- Produces:
|
||||||
|
- `ContractEndNotificationPlanner::__construct(WorkingDayCalculator $calculator)`
|
||||||
|
- `ContractEndNotificationPlanner::plan(array $latestPeriods, DateTimeImmutable $today): array` — `@param EmployeeContractPeriod[] $latestPeriods` → `@return ContractEndNotice[]`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`tests/Service/Notification/ContractEndNotificationPlannerTest.php` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service\Notification;
|
||||||
|
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeContractPeriod;
|
||||||
|
use App\Enum\ContractNature;
|
||||||
|
use App\Service\Notification\ContractEndNotificationPlanner;
|
||||||
|
use App\Service\Notification\WorkingDayCalculator;
|
||||||
|
use App\Service\PublicHolidayServiceInterface;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class ContractEndNotificationPlannerTest extends TestCase
|
||||||
|
{
|
||||||
|
private function planner(): ContractEndNotificationPlanner
|
||||||
|
{
|
||||||
|
$holidays = $this->createStub(PublicHolidayServiceInterface::class);
|
||||||
|
$holidays->method('getHolidaysDayByYears')->willReturn([
|
||||||
|
'2025-07-14' => 'Fête nationale', // lundi 14/07 férié
|
||||||
|
]);
|
||||||
|
|
||||||
|
return new ContractEndNotificationPlanner(new WorkingDayCalculator($holidays));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function period(
|
||||||
|
string $firstName,
|
||||||
|
string $lastName,
|
||||||
|
?string $endDate,
|
||||||
|
ContractNature $nature = ContractNature::CDD,
|
||||||
|
): EmployeeContractPeriod {
|
||||||
|
$employee = new Employee();
|
||||||
|
$employee->setFirstName($firstName)->setLastName($lastName);
|
||||||
|
|
||||||
|
$period = new EmployeeContractPeriod();
|
||||||
|
$period->setEmployee($employee)
|
||||||
|
->setContractNature($nature)
|
||||||
|
->setEndDate($endDate === null ? null : new DateTimeImmutable($endDate))
|
||||||
|
;
|
||||||
|
|
||||||
|
return $period;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNotifiesContractEndingTomorrowOnAWeekday(): void
|
||||||
|
{
|
||||||
|
// Mardi 08/07 -> fin mercredi 09/07
|
||||||
|
$notices = $this->planner()->plan(
|
||||||
|
[$this->period('Jean', 'Dupont', '2025-07-09')],
|
||||||
|
new DateTimeImmutable('2025-07-08'),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertCount(1, $notices);
|
||||||
|
self::assertSame('Fin de CDD de Jean Dupont le 09/07/2025', $notices[0]->message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFridayNotifiesContractsEndingOverTheWeekendAndMonday(): void
|
||||||
|
{
|
||||||
|
// Vendredi 11/07 ; lundi 14/07 férié -> prochain ouvré = mardi 15/07.
|
||||||
|
// Fenêtre ]11/07 ; 15/07] -> samedi 12, dimanche 13, lundi 14, mardi 15.
|
||||||
|
$notices = $this->planner()->plan(
|
||||||
|
[
|
||||||
|
$this->period('A', 'Sat', '2025-07-12'), // samedi -> inclus
|
||||||
|
$this->period('B', 'Mon', '2025-07-14'), // lundi férié -> inclus
|
||||||
|
$this->period('C', 'Tue', '2025-07-15'), // mardi (= borne haute) -> inclus
|
||||||
|
$this->period('D', 'Wed', '2025-07-16'), // mercredi -> hors fenêtre
|
||||||
|
],
|
||||||
|
new DateTimeImmutable('2025-07-11'),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertCount(3, $notices);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIgnoresOpenEndedContract(): void
|
||||||
|
{
|
||||||
|
$notices = $this->planner()->plan(
|
||||||
|
[$this->period('Jean', 'Dupont', null, ContractNature::CDI)],
|
||||||
|
new DateTimeImmutable('2025-07-08'),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame([], $notices);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIgnoresContractEndingToday(): void
|
||||||
|
{
|
||||||
|
// fin = today -> trop tard, pas de notif (on notifie la veille)
|
||||||
|
$notices = $this->planner()->plan(
|
||||||
|
[$this->period('Jean', 'Dupont', '2025-07-08')],
|
||||||
|
new DateTimeImmutable('2025-07-08'),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame([], $notices);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReturnsNothingWhenTodayIsNotAWorkingDay(): void
|
||||||
|
{
|
||||||
|
// Samedi 12/07 -> aucun jour chômé ne génère de notif
|
||||||
|
$notices = $this->planner()->plan(
|
||||||
|
[$this->period('Jean', 'Dupont', '2025-07-14')],
|
||||||
|
new DateTimeImmutable('2025-07-12'),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame([], $notices);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInterimNatureLabel(): void
|
||||||
|
{
|
||||||
|
$notices = $this->planner()->plan(
|
||||||
|
[$this->period('Marie', 'Martin', '2025-07-09', ContractNature::INTERIM)],
|
||||||
|
new DateTimeImmutable('2025-07-08'),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame('Fin de Intérim de Marie Martin le 09/07/2025', $notices[0]->message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `make test FILES="--filter ContractEndNotificationPlannerTest"`
|
||||||
|
Expected: FAIL — `Class "App\Service\Notification\ContractEndNotificationPlanner" not found`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
`src/Service/Notification/ContractEndNotificationPlanner.php` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Notification;
|
||||||
|
|
||||||
|
use App\Entity\EmployeeContractPeriod;
|
||||||
|
use App\Enum\ContractNature;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
final readonly class ContractEndNotificationPlanner
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private WorkingDayCalculator $calculator,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param EmployeeContractPeriod[] $latestPeriods
|
||||||
|
*
|
||||||
|
* @return ContractEndNotice[]
|
||||||
|
*/
|
||||||
|
public function plan(array $latestPeriods, DateTimeImmutable $today): array
|
||||||
|
{
|
||||||
|
$today = $today->setTime(0, 0, 0);
|
||||||
|
if (!$this->calculator->isWorkingDay($today)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$upperBound = $this->calculator->nextWorkingDay($today);
|
||||||
|
|
||||||
|
$notices = [];
|
||||||
|
foreach ($latestPeriods as $period) {
|
||||||
|
$endDate = $period->getEndDate();
|
||||||
|
if (null === $endDate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$endDate = $endDate->setTime(0, 0, 0);
|
||||||
|
if ($endDate <= $today || $endDate > $upperBound) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$employee = $period->getEmployee();
|
||||||
|
if (null === $employee) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = sprintf(
|
||||||
|
'Fin de %s de %s %s le %s',
|
||||||
|
$this->natureLabel($period->getContractNatureEnum()),
|
||||||
|
$employee->getFirstName(),
|
||||||
|
$employee->getLastName(),
|
||||||
|
$endDate->format('d/m/Y'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$notices[] = new ContractEndNotice($employee->getId(), $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $notices;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function natureLabel(ContractNature $nature): string
|
||||||
|
{
|
||||||
|
return match ($nature) {
|
||||||
|
ContractNature::CDI => 'CDI',
|
||||||
|
ContractNature::CDD => 'CDD',
|
||||||
|
ContractNature::INTERIM => 'Intérim',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `make test FILES="--filter ContractEndNotificationPlannerTest"`
|
||||||
|
Expected: PASS (6 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Service/Notification/ContractEndNotificationPlanner.php tests/Service/Notification/ContractEndNotificationPlannerTest.php
|
||||||
|
git commit -m "feat : add contract end notification planner"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4 : Méthodes de repository
|
||||||
|
|
||||||
|
Deux requêtes : la dernière période par employé, et le test d'existence anti-doublon.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Repository/EmployeeContractPeriodRepository.php`
|
||||||
|
- Modify: `src/Repository/NotificationRepository.php`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces:
|
||||||
|
- `EmployeeContractPeriodRepository::findLatestPeriodsForAllEmployees(): array` (`@return EmployeeContractPeriod[]` — une période par employé, celle de `startDate` max).
|
||||||
|
- `NotificationRepository::existsForRecipientCategoryTargetMessage(User $recipient, string $category, string $target, string $message): bool`.
|
||||||
|
|
||||||
|
> Pas de test unitaire (accès Doctrine, pas de tests d'intégration DB dans ce projet) — vérifié manuellement en Task 6.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `findLatestPeriodsForAllEmployees` to EmployeeContractPeriodRepository**
|
||||||
|
|
||||||
|
Ajouter cette méthode dans `src/Repository/EmployeeContractPeriodRepository.php` (après `findLatestPeriod`) :
|
||||||
|
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* Latest contract period (max startDate) for every employee that has at least one.
|
||||||
|
*
|
||||||
|
* @return EmployeeContractPeriod[]
|
||||||
|
*/
|
||||||
|
public function findLatestPeriodsForAllEmployees(): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('p')
|
||||||
|
->andWhere('p.startDate = (
|
||||||
|
SELECT MAX(p2.startDate)
|
||||||
|
FROM App\Entity\EmployeeContractPeriod p2
|
||||||
|
WHERE p2.employee = p.employee
|
||||||
|
)')
|
||||||
|
->getQuery()
|
||||||
|
->getResult()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `existsForRecipientCategoryTargetMessage` to NotificationRepository**
|
||||||
|
|
||||||
|
Ajouter dans `src/Repository/NotificationRepository.php` (après `markAllReadByRecipient`) :
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function existsForRecipientCategoryTargetMessage(
|
||||||
|
User $recipient,
|
||||||
|
string $category,
|
||||||
|
string $target,
|
||||||
|
string $message,
|
||||||
|
): bool {
|
||||||
|
$id = $this->createQueryBuilder('n')
|
||||||
|
->select('n.id')
|
||||||
|
->andWhere('n.recipient = :recipient')
|
||||||
|
->andWhere('n.category = :category')
|
||||||
|
->andWhere('n.target = :target')
|
||||||
|
->andWhere('n.message = :message')
|
||||||
|
->setParameter('recipient', $recipient)
|
||||||
|
->setParameter('category', $category)
|
||||||
|
->setParameter('target', $target)
|
||||||
|
->setParameter('message', $message)
|
||||||
|
->setMaxResults(1)
|
||||||
|
->getQuery()
|
||||||
|
->getOneOrNullResult()
|
||||||
|
;
|
||||||
|
|
||||||
|
return null !== $id;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> `User` est déjà importé dans `NotificationRepository` (`use App\Entity\User;`). Si l'import manquait, l'ajouter.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the suite still passes**
|
||||||
|
|
||||||
|
Run: `make test`
|
||||||
|
Expected: PASS (suite complète, aucun test cassé).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Repository/EmployeeContractPeriodRepository.php src/Repository/NotificationRepository.php
|
||||||
|
git commit -m "feat : add repository queries for contract end notifications"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5 : Service + Result DTO + Command
|
||||||
|
|
||||||
|
Assemble la détection (planner) et la persistance (Notification par admin, dédupliquée), exposée par une commande console.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Service/Notification/ContractEndNotificationResult.php`
|
||||||
|
- Create: `src/Service/Notification/ContractEndNotificationService.php`
|
||||||
|
- Create: `src/Command/ContractEndNotificationCommand.php`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes:
|
||||||
|
- `EmployeeContractPeriodRepository::findLatestPeriodsForAllEmployees()` (Task 4)
|
||||||
|
- `NotificationRepository::existsForRecipientCategoryTargetMessage(...)` (Task 4)
|
||||||
|
- `App\Repository\UserRepository::findAllAdmins(): array` (existant)
|
||||||
|
- `ContractEndNotificationPlanner::plan(...)` (Task 3) renvoyant `ContractEndNotice[]`
|
||||||
|
- `App\Entity\Notification` setters `setRecipient/setMessage/setCategory/setTarget`
|
||||||
|
- `Doctrine\ORM\EntityManagerInterface`
|
||||||
|
- Produces:
|
||||||
|
- `ContractEndNotificationResult::__construct(public int $notificationsCreated, public int $contractsMatched)`
|
||||||
|
- `ContractEndNotificationService::run(DateTimeImmutable $today): ContractEndNotificationResult`
|
||||||
|
- Commande `app:contract:end-notifications` avec option `--date=YYYY-MM-DD`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the Result DTO**
|
||||||
|
|
||||||
|
`src/Service/Notification/ContractEndNotificationResult.php` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Notification;
|
||||||
|
|
||||||
|
final readonly class ContractEndNotificationResult
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $notificationsCreated,
|
||||||
|
public int $contractsMatched,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create the service**
|
||||||
|
|
||||||
|
`src/Service/Notification/ContractEndNotificationService.php` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Notification;
|
||||||
|
|
||||||
|
use App\Entity\Notification;
|
||||||
|
use App\Repository\EmployeeContractPeriodRepository;
|
||||||
|
use App\Repository\NotificationRepository;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
|
final readonly class ContractEndNotificationService
|
||||||
|
{
|
||||||
|
private const CATEGORY = 'Contrat';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private EmployeeContractPeriodRepository $periodRepository,
|
||||||
|
private NotificationRepository $notificationRepository,
|
||||||
|
private UserRepository $userRepository,
|
||||||
|
private ContractEndNotificationPlanner $planner,
|
||||||
|
private EntityManagerInterface $entityManager,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(DateTimeImmutable $today): ContractEndNotificationResult
|
||||||
|
{
|
||||||
|
$latestPeriods = $this->periodRepository->findLatestPeriodsForAllEmployees();
|
||||||
|
$notices = $this->planner->plan($latestPeriods, $today);
|
||||||
|
|
||||||
|
if ([] === $notices) {
|
||||||
|
return new ContractEndNotificationResult(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$admins = $this->userRepository->findAllAdmins();
|
||||||
|
$created = 0;
|
||||||
|
|
||||||
|
foreach ($notices as $notice) {
|
||||||
|
if (null === $notice->employeeId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = '/employees/'.$notice->employeeId;
|
||||||
|
|
||||||
|
foreach ($admins as $admin) {
|
||||||
|
if ($this->notificationRepository->existsForRecipientCategoryTargetMessage(
|
||||||
|
$admin,
|
||||||
|
self::CATEGORY,
|
||||||
|
$target,
|
||||||
|
$notice->message,
|
||||||
|
)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$notification = new Notification();
|
||||||
|
$notification->setRecipient($admin)
|
||||||
|
->setMessage($notice->message)
|
||||||
|
->setCategory(self::CATEGORY)
|
||||||
|
->setTarget($target)
|
||||||
|
;
|
||||||
|
|
||||||
|
$this->entityManager->persist($notification);
|
||||||
|
++$created;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return new ContractEndNotificationResult($created, \count($notices));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create the command**
|
||||||
|
|
||||||
|
`src/Command/ContractEndNotificationCommand.php` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Service\Notification\ContractEndNotificationService;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:contract:end-notifications',
|
||||||
|
description: 'Notify admins on the last working day before a contract ends.'
|
||||||
|
)]
|
||||||
|
final class ContractEndNotificationCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ContractEndNotificationService $service,
|
||||||
|
#[Autowire(service: 'monolog.logger.cron')]
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption(
|
||||||
|
'date',
|
||||||
|
null,
|
||||||
|
InputOption::VALUE_REQUIRED,
|
||||||
|
'Override the reference day (YYYY-MM-DD) for testing or manual catch-up.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
|
||||||
|
$dateOption = $input->getOption('date');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$today = \is_string($dateOption) && '' !== $dateOption
|
||||||
|
? new DateTimeImmutable($dateOption)
|
||||||
|
: new DateTimeImmutable('today');
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$io->error(sprintf('Invalid --date value: %s', $exception->getMessage()));
|
||||||
|
|
||||||
|
return Command::INVALID;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->service->run($today);
|
||||||
|
|
||||||
|
$this->logger->info('Contract end notifications generated.', [
|
||||||
|
'date' => $today->format('Y-m-d'),
|
||||||
|
'contractsMatched' => $result->contractsMatched,
|
||||||
|
'notificationsCreated' => $result->notificationsCreated,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$io->success(sprintf(
|
||||||
|
'%d notification(s) créée(s) pour %d fin(s) de contrat (%s).',
|
||||||
|
$result->notificationsCreated,
|
||||||
|
$result->contractsMatched,
|
||||||
|
$today->format('Y-m-d'),
|
||||||
|
));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify the suite still passes and the command is registered**
|
||||||
|
|
||||||
|
Run: `make test`
|
||||||
|
Expected: PASS (suite complète).
|
||||||
|
|
||||||
|
Run: `docker exec -t -u www-data php-sirh-fpm php bin/console list app:contract`
|
||||||
|
Expected: la commande `app:contract:end-notifications` apparaît dans la liste.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Service/Notification/ContractEndNotificationResult.php src/Service/Notification/ContractEndNotificationService.php src/Command/ContractEndNotificationCommand.php
|
||||||
|
git commit -m "feat : add contract end notification service and command"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6 : Vérification manuelle de bout en bout (commande)
|
||||||
|
|
||||||
|
Confirme que la commande crée bien des notifications sur des données réelles, et qu'elle est idempotente.
|
||||||
|
|
||||||
|
**Files:** aucun (vérification).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Repérer un employé dont la dernière période finit bientôt**
|
||||||
|
|
||||||
|
Run (adapter la date au besoin) :
|
||||||
|
```bash
|
||||||
|
docker exec -t -u www-data php-sirh-fpm php bin/console dbal:run-sql \
|
||||||
|
"SELECT employee_id, MAX(start_date) AS s, end_date FROM employee_contract_periods GROUP BY employee_id HAVING end_date IS NOT NULL ORDER BY end_date DESC LIMIT 10"
|
||||||
|
```
|
||||||
|
Expected: liste d'employés avec leur dernière `end_date`. Choisir une `end_date` E pour viser un jour ouvré juste avant.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Lancer la commande sur la veille ouvrée de E**
|
||||||
|
|
||||||
|
Run (remplacer `YYYY-MM-DD` par le dernier jour ouvré avant E) :
|
||||||
|
```bash
|
||||||
|
docker exec -t -u www-data php-sirh-fpm php bin/console app:contract:end-notifications --date=YYYY-MM-DD
|
||||||
|
```
|
||||||
|
Expected: `N notification(s) créée(s) pour M fin(s) de contrat...` avec M ≥ 1.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Vérifier l'idempotence (relancer la même commande)**
|
||||||
|
|
||||||
|
Run: même commande qu'au Step 2.
|
||||||
|
Expected: `0 notification(s) créée(s) pour M fin(s) de contrat...` (aucun doublon).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Vérifier le contenu en base**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
docker exec -t -u www-data php-sirh-fpm php bin/console dbal:run-sql \
|
||||||
|
"SELECT message, category, target, actor_id, is_read FROM notifications WHERE category='Contrat' ORDER BY id DESC LIMIT 5"
|
||||||
|
```
|
||||||
|
Expected: lignes `Fin de … de … le dd/mm/yyyy`, `category=Contrat`, `target=/employees/{id}`, `actor_id=NULL`, `is_read=0`.
|
||||||
|
|
||||||
|
> Aucune commande de commit ici — étape de vérification uniquement. Si un comportement diffère, revenir aux tasks concernées avant de continuer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7 : Front — afficher le message sans acteur
|
||||||
|
|
||||||
|
La notif fin de contrat a `actorName` vide ; supprimer le span gras vide.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/components/AppTopNav.vue` (ligne 65)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remplacer la ligne de rendu du message**
|
||||||
|
|
||||||
|
Remplacer exactement (ligne 65) :
|
||||||
|
|
||||||
|
```html
|
||||||
|
<p class="text-black"><span class="font-semibold capitalize">{{ notification.actorName }}</span> {{ notification.message }}</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
par :
|
||||||
|
|
||||||
|
```html
|
||||||
|
<p class="text-black"><span v-if="notification.actorName" class="font-semibold capitalize">{{ notification.actorName }} </span>{{ notification.message }}</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
> Avec acteur : `**Jean** a validé les heures` (l'espace est dans le span). Sans acteur : `Fin de CDD de … le …` (pas de span, pas d'espace en tête).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Vérifier le typecheck front**
|
||||||
|
|
||||||
|
Run: `cd frontend && npx vue-tsc --noEmit -p tsconfig.json 2>&1 | head -20`
|
||||||
|
Expected: aucune nouvelle erreur liée à `AppTopNav.vue`. (Ne PAS lancer `npm run build`.)
|
||||||
|
|
||||||
|
> Si `vue-tsc` n'est pas disponible / trop lent, vérification visuelle suffisante : la modification est un simple `v-if` sur un span existant.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/components/AppTopNav.vue
|
||||||
|
git commit -m "feat : render actorless notifications without empty bold span"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8 : Documentation
|
||||||
|
|
||||||
|
Mise à jour obligatoire (règles CLAUDE.md) : `doc/`, doc in-app, `CLAUDE.md`.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `doc/contract-end-notifications.md`
|
||||||
|
- Modify: `doc/functional-rules.md` (section 15) Notifications)
|
||||||
|
- Modify: `frontend/data/documentation-content.ts`
|
||||||
|
- Modify: `CLAUDE.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Créer `doc/contract-end-notifications.md`**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Notification de fin de contrat (veille du dernier jour)
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
Prévenir les administrateurs, sur le dernier jour ouvré précédant la fin d'un contrat, qu'un
|
||||||
|
salarié arrive au terme de son emploi.
|
||||||
|
|
||||||
|
## Déclenchement
|
||||||
|
Commande `app:contract:end-notifications`, lancée chaque jour par le crontab de production
|
||||||
|
(ex. `0 6 * * *`). Option `--date=YYYY-MM-DD` pour test/rattrapage. Logger `cron`.
|
||||||
|
|
||||||
|
## Règle métier
|
||||||
|
- **Cible** : la **dernière** période de contrat d'un employé (aucune période ne lui succède).
|
||||||
|
Un changement de contrat enchaîné (ex. CDD → CDI) ne notifie pas.
|
||||||
|
- **Quand** : sur le **dernier jour ouvré strictement avant** `endDate` (`endDate` est inclusif
|
||||||
|
= dernier jour travaillé). Les week-ends ET jours fériés (`PublicHolidayService`, zone
|
||||||
|
`metropole`) sont sautés. Concrètement, le jour J ouvré couvre les fins de contrat dans
|
||||||
|
l'intervalle `]J ; prochain_jour_ouvré(J)]` — un vendredi notifie ainsi les fins du
|
||||||
|
samedi, dimanche et lundi (mardi si lundi férié).
|
||||||
|
- **Destinataires** : tous les `ROLE_ADMIN`.
|
||||||
|
- **Message** : `Fin de {CDI|CDD|Intérim} de {Prénom Nom} le {dd/mm/yyyy}`, catégorie
|
||||||
|
`Contrat`, cible `/employees/{id}`, sans acteur.
|
||||||
|
|
||||||
|
## Idempotence
|
||||||
|
Avant création, on vérifie l'absence d'une notif identique
|
||||||
|
`(recipient, category='Contrat', target, message)`. Le message étant unique par
|
||||||
|
(employé + date + nature), relancer la commande le même jour ne crée aucun doublon.
|
||||||
|
|
||||||
|
## Implémentation
|
||||||
|
- `App\Service\Notification\WorkingDayCalculator` — jour ouvré / prochain jour ouvré.
|
||||||
|
- `App\Service\Notification\ContractEndNotificationPlanner` — sélection + message (pur, testé).
|
||||||
|
- `App\Service\Notification\ContractEndNotificationService` — persistance (1 notif/admin).
|
||||||
|
- `App\Command\ContractEndNotificationCommand` — `app:contract:end-notifications`.
|
||||||
|
- `EmployeeContractPeriodRepository::findLatestPeriodsForAllEmployees`,
|
||||||
|
`NotificationRepository::existsForRecipientCategoryTargetMessage`.
|
||||||
|
- Pas de migration : réutilise la table `notifications`.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Compléter `doc/functional-rules.md` section 15) Notifications**
|
||||||
|
|
||||||
|
Repérer la section `15) Notifications` (vers ligne 475). Ajouter, à la fin de la section, ce paragraphe :
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- **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`.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Ajouter une entrée dans la doc in-app `frontend/data/documentation-content.ts`**
|
||||||
|
|
||||||
|
Localiser la section/article traitant des notifications (rechercher `Notification` dans le
|
||||||
|
fichier) au niveau d'accès `admin`. Y ajouter un bloc décrivant la notif fin de contrat. Si
|
||||||
|
aucun article notifications n'existe au niveau admin, ajouter un article dans la section la
|
||||||
|
plus proche (gestion employés / administration) avec `requiredLevel: 'admin'`. Exemple de bloc
|
||||||
|
à insérer dans le tableau `blocks` de l'article :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
text: "Chaque jour ouvré, l'application prévient les administrateurs (cloche en haut à droite) lorsqu'un salarié atteint le dernier jour ouvré avant la fin de son contrat. Le message indique la nature du contrat, le nom du salarié et la date de fin, et renvoie vers sa fiche. Les week-ends et jours fériés sont pris en compte : une fin de contrat le lundi est signalée dès le vendredi.",
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
> Respecter les types `DocBlock` de `frontend/types/documentation.ts` (vérifier le champ exact :
|
||||||
|
> `text` vs `content`) en s'alignant sur les blocs voisins existants du fichier.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Mettre à jour `CLAUDE.md`**
|
||||||
|
|
||||||
|
Sous la section `## Audit Logging` ou à la suite des sections « Notifications » existantes (il
|
||||||
|
n'y a pas encore de section Notifications dédiée dans CLAUDE.md — l'ajouter), insérer :
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Notifications
|
||||||
|
- Système : entité `Notification` (table `notifications`, `recipient`/`actor`/`message`/`category`/`target`/`isRead`), cloche **admin-only** dans `AppTopNav.vue`, providers `/notifications/{unread,today,history}` + `POST /notifications/mark-all-read`. Création historique : `WorkHourSiteValidationProcessor` (1 notif/admin via `UserRepository::findAllAdmins`).
|
||||||
|
- **Fin de contrat (J-1 ouvré)** : commande cron quotidienne `app:contract:end-notifications` (crontab prod, ~6h ; option `--date`). Notifie les admins sur le **dernier jour ouvré avant** `endDate` (inclusif) de la **dernière** période d'un employé (changement de contrat enchaîné exclu). Week-ends + fériés sautés (`WorkingDayCalculator`). Fenêtre couverte un jour J = `]J ; prochain_jour_ouvré(J)]`. Message « Fin de {nature} de {Nom} le {date} », catégorie `Contrat`, target `/employees/{id}`, acteur null. Idempotent (`NotificationRepository::existsForRecipientCategoryTargetMessage`). Logique pure testée : `ContractEndNotificationPlanner` + `WorkingDayCalculator`. Front : `AppTopNav.vue` masque le span acteur si `actorName` vide. Doc : `doc/contract-end-notifications.md`.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add doc/contract-end-notifications.md doc/functional-rules.md frontend/data/documentation-content.ts CLAUDE.md
|
||||||
|
git commit -m "docs : document contract end notification feature"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (effectuée à la rédaction)
|
||||||
|
|
||||||
|
- **Couverture du spec** : détection (Task 1+3), idempotence (Task 4+5), création/destinataires (Task 5), commande cron (Task 5), front acteur vide (Task 7), tests (Task 1, 3), docs 4 fichiers (Task 8), vérif e2e (Task 6). ✅
|
||||||
|
- **Pas de placeholder** : tout le code est fourni ; les seules zones « à adapter » sont des valeurs runtime (dates réelles en Task 6) et l'emplacement exact de l'article doc in-app (Task 8 Step 3), explicitement cadrées. ✅
|
||||||
|
- **Cohérence des types** : `WorkingDayCalculator::{isWorkingDay,nextWorkingDay}`, `ContractEndNotificationPlanner::plan(array, DateTimeImmutable): ContractEndNotice[]`, `ContractEndNotice{employeeId,message}`, `ContractEndNotificationResult{notificationsCreated,contractsMatched}`, `findLatestPeriodsForAllEmployees()`, `existsForRecipientCategoryTargetMessage()` — noms identiques entre définition et usage. ✅
|
||||||
|
- **Note** : `findLatestPeriodsForAllEmployees` renvoie la période de `startDate` max par employé ; en cas d'égalité exacte de `startDate` (anomalie de données) plusieurs lignes peuvent remonter pour un même employé — sans impact fonctionnel (la dédup par message évite les doublons de notif).
|
||||||
@@ -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.
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
>
|
>
|
||||||
<div class="rounded-full h-[46px] w-[46px] min-w-[46px] bg-primary-500"></div>
|
<div class="rounded-full h-[46px] w-[46px] min-w-[46px] bg-primary-500"></div>
|
||||||
<div class="flex flex-col min-w-0 text-[16px]">
|
<div class="flex flex-col min-w-0 text-[16px]">
|
||||||
<p class="text-black"><span class="font-semibold capitalize">{{ notification.actorName }}</span> {{ notification.message }}</p>
|
<p class="text-black"><span v-if="notification.actorName" class="font-semibold capitalize">{{ notification.actorName }} </span>{{ notification.message }}</p>
|
||||||
<p class="text-black">{{ formatTimeAgo(notification.createdAt) }} - {{ notification.category }}</p>
|
<p class="text-black">{{ formatTimeAgo(notification.createdAt) }} - {{ notification.category }}</p>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="!notification.isRead" class="absolute right-4 bg-primary-500 h-4 w-4 rounded-full"></span>
|
<span v-if="!notification.isRead" class="absolute right-4 bg-primary-500 h-4 w-4 rounded-full"></span>
|
||||||
|
|||||||
@@ -268,6 +268,7 @@ export const documentationSections: DocSection[] = [
|
|||||||
{ type: 'paragraph', content: 'Deux tâches automatiques s\'exécutent quotidiennement pour gérer le report des compteurs.' },
|
{ type: 'paragraph', content: 'Deux tâches automatiques s\'exécutent quotidiennement pour gérer le report des compteurs.' },
|
||||||
{ type: 'list', content: 'Report congés (02h10) : déclenche le report des congés payés le 1er juin (CDI/CDD) et le 1er janvier (forfait)\nReport RTT (02h15) : déclenche le report du solde RTT le 1er juin' },
|
{ type: 'list', content: 'Report congés (02h10) : déclenche le report des congés payés le 1er juin (CDI/CDD) et le 1er janvier (forfait)\nReport RTT (02h15) : déclenche le report du solde RTT le 1er juin' },
|
||||||
{ type: 'note', content: 'Ces tâches sont idempotentes : si elles s\'exécutent plusieurs fois, aucun doublon n\'est créé.' },
|
{ type: 'note', content: 'Ces tâches sont idempotentes : si elles s\'exécutent plusieurs fois, aucun doublon n\'est créé.' },
|
||||||
|
{ type: 'paragraph', content: 'Notification fin de contrat : chaque jour ouvré, les administrateurs sont prévenus (cloche en haut à droite) lorsqu\'un salarié atteint le dernier jour ouvré avant la fin de son contrat. Le message indique la nature du contrat, le nom du salarié et la date de fin, et renvoie vers sa fiche. Les week-ends et jours fériés sont pris en compte : une fin de contrat le lundi est signalée dès le vendredi.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -416,6 +417,8 @@ export const documentationSections: DocSection[] = [
|
|||||||
{ type: 'paragraph', content: 'Les absences peuvent être posées depuis la vue jour des heures ou depuis le calendrier.' },
|
{ type: 'paragraph', content: 'Les absences peuvent être posées depuis la vue jour des heures ou depuis le calendrier.' },
|
||||||
{ type: 'list', content: 'Journée complète : efface toutes les plages horaires\nDemi-journée matin (AM) : efface le créneau matin\nDemi-journée après-midi (PM) : efface les créneaux après-midi et soir' },
|
{ type: 'list', content: 'Journée complète : efface toutes les plages horaires\nDemi-journée matin (AM) : efface le créneau matin\nDemi-journée après-midi (PM) : efface les créneaux après-midi et soir' },
|
||||||
{ type: 'paragraph', content: 'Les absences sont stockées par jour : une absence de plusieurs jours est automatiquement découpée en entrées quotidiennes.' },
|
{ type: 'paragraph', content: 'Les absences sont stockées par jour : une absence de plusieurs jours est automatiquement découpée en entrées quotidiennes.' },
|
||||||
|
{ type: 'note', content: 'Supprimer une plage depuis le calendrier : cliquez sur un jour de la plage (le drawer s\'ouvre sur ce jour), étendez la date de fin (ou de début) pour couvrir toute la plage à effacer, puis cliquez sur « Supprimer ». Tous les jours de congé compris dans la plage sélectionnée sont supprimés en une fois ; les jours sans absence dans cette plage sont simplement ignorés. Un jour déjà validé reste protégé.' },
|
||||||
|
{ type: 'note', content: 'Modifier une plage depuis le calendrier : cliquez sur le premier jour de la plage, ajustez la date de fin (pour la raccourcir ou l\'allonger) puis « Modifier ». La plage est remplacée proprement par la nouvelle : plus de jours « fantômes » qui restaient après un raccourcissement, ni de doublons après un allongement. Les jours situés avant le jour cliqué ne sont jamais modifiés.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+93
-19
@@ -109,11 +109,11 @@ import type {HalfDay} from '~/services/dto/half-day'
|
|||||||
import {HALF_DAYS} from '~/services/dto/half-day'
|
import {HALF_DAYS} from '~/services/dto/half-day'
|
||||||
import {listEmployees, updateEmployeeOrder} from '~/services/employees'
|
import {listEmployees, updateEmployeeOrder} from '~/services/employees'
|
||||||
import {listAbsenceTypes} from '~/services/absence-types'
|
import {listAbsenceTypes} from '~/services/absence-types'
|
||||||
import {createAbsence, deleteAbsence, listAbsences, updateAbsence} from '~/services/absences'
|
import {createAbsence, deleteAbsence, listAbsences} from '~/services/absences'
|
||||||
import {listFormationsByDateRange} from '~/services/formations'
|
import {listFormationsByDateRange} from '~/services/formations'
|
||||||
import type {Formation} from '~/services/dto/formation'
|
import type {Formation} from '~/services/dto/formation'
|
||||||
import {listPublicHolidays} from '~/services/public-holidays'
|
import {listPublicHolidays} from '~/services/public-holidays'
|
||||||
import {getDaysInMonth, normalizeDate, parseYmd, toYmd} from '~/utils/date'
|
import {formatYmdToFr, getDaysInMonth, normalizeDate, parseYmd, shiftYmd, toYmd} from '~/utils/date'
|
||||||
import {compareEmployeesInSite, sortEmployeesBySiteAndOrder} from '~/utils/employee'
|
import {compareEmployeesInSite, sortEmployeesBySiteAndOrder} from '~/utils/employee'
|
||||||
import CalendarGrid from '~/components/CalendarGrid.vue'
|
import CalendarGrid from '~/components/CalendarGrid.vue'
|
||||||
import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue'
|
import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue'
|
||||||
@@ -649,9 +649,68 @@ const handleSubmit = async () => {
|
|||||||
window.alert("La demi-journee de fin ne peut pas etre avant la demi-journee de debut.")
|
window.alert("La demi-journee de fin ne peut pas etre avant la demi-journee de debut.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (editingAbsence.value) {
|
||||||
|
// Modification d'une plage : une absence = une ligne par jour, sans lien en BDD.
|
||||||
|
// On remplace donc tout le bloc contigu (même type) partant du jour cliqué par la
|
||||||
|
// nouvelle plage : suppression de l'ancien bloc + recréation. Évite les jours
|
||||||
|
// fantômes (raccourcissement) et les doublons (l'ancien PATCH ne nettoyait rien).
|
||||||
|
const originalEmployeeId = editingAbsence.value.employee.id
|
||||||
|
const newEmployeeId = Number(form.employeeId)
|
||||||
|
const originalTypeId = editingAbsence.value.type.id
|
||||||
|
const clickedDate = normalizeDate(editingAbsence.value.startDate)
|
||||||
|
|
||||||
|
// Bloc contigu (vers l'avant) depuis le jour cliqué, même employé + même type d'origine.
|
||||||
|
// On ne touche jamais aux jours antérieurs au jour cliqué.
|
||||||
|
const sameLeaveDays = new Set(
|
||||||
|
absences.value
|
||||||
|
.filter((absence) => absence.employee?.id === originalEmployeeId && absence.type?.id === originalTypeId)
|
||||||
|
.map((absence) => normalizeDate(absence.startDate))
|
||||||
|
)
|
||||||
|
const blockDates = new Set<string>()
|
||||||
|
let cursor: string | null = clickedDate
|
||||||
|
while (cursor && sameLeaveDays.has(cursor)) {
|
||||||
|
blockDates.add(cursor)
|
||||||
|
cursor = shiftYmd(cursor, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// À supprimer : l'ancien bloc + toute absence recouverte par la nouvelle plage.
|
||||||
|
const toReplace = absences.value.filter((absence) => {
|
||||||
|
const day = normalizeDate(absence.startDate)
|
||||||
|
const inBlock = absence.employee?.id === originalEmployeeId && blockDates.has(day)
|
||||||
|
const inNewRange = absence.employee?.id === newEmployeeId && day >= start && day <= end
|
||||||
|
return inBlock || inNewRange
|
||||||
|
})
|
||||||
|
|
||||||
|
// Confirmation uniquement si on écrase une absence d'un AUTRE type (vrai chevauchement).
|
||||||
|
const replacesForeign = toReplace.some((absence) => absence.type?.id !== originalTypeId)
|
||||||
|
if (replacesForeign) {
|
||||||
|
const confirmReplace = window.confirm(
|
||||||
|
"Cette absence chevauche une autre. Voulez-vous la remplacer ?"
|
||||||
|
)
|
||||||
|
if (!confirmReplace) return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const absence of toReplace) {
|
||||||
|
await deleteAbsence(absence.id)
|
||||||
|
}
|
||||||
|
await createAbsence({
|
||||||
|
employeeId: newEmployeeId,
|
||||||
|
typeId: Number(form.typeId),
|
||||||
|
startDate: form.startDate,
|
||||||
|
startHalf: form.startHalf,
|
||||||
|
endDate: form.endDate,
|
||||||
|
endHalf: form.endHalf,
|
||||||
|
comment: form.comment
|
||||||
|
})
|
||||||
|
|
||||||
|
closeDrawer()
|
||||||
|
await loadAbsences()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Création : détection de chevauchement (précision demi-journée) puis remplacement.
|
||||||
const overlaps = absences.value.filter((absence) => {
|
const overlaps = absences.value.filter((absence) => {
|
||||||
if (absence.employee?.id !== Number(form.employeeId)) return false
|
if (absence.employee?.id !== Number(form.employeeId)) return false
|
||||||
if (editingAbsence.value && absence.id === editingAbsence.value.id) return false
|
|
||||||
const aStart = normalizeDate(absence.startDate)
|
const aStart = normalizeDate(absence.startDate)
|
||||||
const aEnd = normalizeDate(absence.endDate)
|
const aEnd = normalizeDate(absence.endDate)
|
||||||
if (start > aEnd || end < aStart) return false
|
if (start > aEnd || end < aStart) return false
|
||||||
@@ -701,18 +760,6 @@ const handleSubmit = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (editingAbsence.value) {
|
|
||||||
await updateAbsence({
|
|
||||||
id: editingAbsence.value.id,
|
|
||||||
employeeId: Number(form.employeeId),
|
|
||||||
typeId: Number(form.typeId),
|
|
||||||
startDate: form.startDate,
|
|
||||||
startHalf: form.startHalf,
|
|
||||||
endDate: form.endDate,
|
|
||||||
endHalf: form.endHalf,
|
|
||||||
comment: form.comment
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
await createAbsence({
|
await createAbsence({
|
||||||
employeeId: Number(form.employeeId),
|
employeeId: Number(form.employeeId),
|
||||||
typeId: Number(form.typeId),
|
typeId: Number(form.typeId),
|
||||||
@@ -722,7 +769,6 @@ const handleSubmit = async () => {
|
|||||||
endHalf: form.endHalf,
|
endHalf: form.endHalf,
|
||||||
comment: form.comment
|
comment: form.comment
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
closeDrawer()
|
closeDrawer()
|
||||||
await loadAbsences()
|
await loadAbsences()
|
||||||
@@ -731,14 +777,42 @@ const handleSubmit = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suppression de l'absence en cours d'édition.
|
// Suppression: efface toutes les absences de l'employé comprises dans la plage
|
||||||
|
// sélectionnée (date début → date fin du drawer). Comme une absence = une ligne
|
||||||
|
// par jour en BDD, on supprime chaque jour existant de la plage ; les jours sans
|
||||||
|
// absence (ex. une date hors plage réelle) sont naturellement ignorés.
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!editingAbsence.value) return
|
if (!editingAbsence.value) return
|
||||||
|
|
||||||
const confirmDelete = window.confirm('Supprimer cette absence ?')
|
const employeeId = editingAbsence.value.employee.id
|
||||||
|
const rangeStart = normalizeDate(form.startDate)
|
||||||
|
const rangeEnd = normalizeDate(form.endDate)
|
||||||
|
if (rangeStart > rangeEnd) {
|
||||||
|
window.alert("La date de fin ne peut pas etre avant la date de debut.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const toDelete = absences.value.filter((absence) => {
|
||||||
|
if (absence.employee?.id !== employeeId) return false
|
||||||
|
const day = normalizeDate(absence.startDate)
|
||||||
|
return day >= rangeStart && day <= rangeEnd
|
||||||
|
})
|
||||||
|
|
||||||
|
if (toDelete.length === 0) {
|
||||||
|
closeDrawer()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = window.confirm(
|
||||||
|
toDelete.length === 1
|
||||||
|
? 'Supprimer cette absence ?'
|
||||||
|
: `Supprimer ${toDelete.length} jours de congé du ${formatYmdToFr(rangeStart)} au ${formatYmdToFr(rangeEnd)} ?`
|
||||||
|
)
|
||||||
if (!confirmDelete) return
|
if (!confirmDelete) return
|
||||||
|
|
||||||
await deleteAbsence(editingAbsence.value.id)
|
for (const absence of toDelete) {
|
||||||
|
await deleteAbsence(absence.id)
|
||||||
|
}
|
||||||
closeDrawer()
|
closeDrawer()
|
||||||
await loadAbsences()
|
await loadAbsences()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Service\Notification\ContractEndNotificationService;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
use function is_string;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:contract:end-notifications',
|
||||||
|
description: 'Notify admins on the last working day before a contract ends.'
|
||||||
|
)]
|
||||||
|
final class ContractEndNotificationCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ContractEndNotificationService $service,
|
||||||
|
#[Autowire(service: 'monolog.logger.cron')]
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption(
|
||||||
|
'date',
|
||||||
|
null,
|
||||||
|
InputOption::VALUE_REQUIRED,
|
||||||
|
'Override the reference day (YYYY-MM-DD) for testing or manual catch-up.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
|
||||||
|
$dateOption = $input->getOption('date');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$today = is_string($dateOption) && '' !== $dateOption
|
||||||
|
? new DateTimeImmutable($dateOption)
|
||||||
|
: new DateTimeImmutable('today');
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$io->error(sprintf('Invalid --date value: %s', $exception->getMessage()));
|
||||||
|
|
||||||
|
return Command::INVALID;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->service->run($today);
|
||||||
|
|
||||||
|
$this->logger->info('Contract end notifications generated.', [
|
||||||
|
'date' => $today->format('Y-m-d'),
|
||||||
|
'contractsMatched' => $result->contractsMatched,
|
||||||
|
'notificationsCreated' => $result->notificationsCreated,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$io->success(sprintf(
|
||||||
|
'%d notification(s) créée(s) pour %d fin(s) de contrat (%s).',
|
||||||
|
$result->notificationsCreated,
|
||||||
|
$result->contractsMatched,
|
||||||
|
$today->format('Y-m-d'),
|
||||||
|
));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,6 +72,24 @@ final class EmployeeContractPeriodRepository extends ServiceEntityRepository imp
|
|||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Latest contract period (max startDate) for every employee that has at least one.
|
||||||
|
*
|
||||||
|
* @return EmployeeContractPeriod[]
|
||||||
|
*/
|
||||||
|
public function findLatestPeriodsForAllEmployees(): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('p')
|
||||||
|
->andWhere('p.startDate = (
|
||||||
|
SELECT MAX(p2.startDate)
|
||||||
|
FROM App\Entity\EmployeeContractPeriod p2
|
||||||
|
WHERE p2.employee = p.employee
|
||||||
|
)')
|
||||||
|
->getQuery()
|
||||||
|
->getResult()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
public function closeOpenPeriods(Employee $employee, DateTimeImmutable $endDate): int
|
public function closeOpenPeriods(Employee $employee, DateTimeImmutable $endDate): int
|
||||||
{
|
{
|
||||||
return $this->createQueryBuilder('p')
|
return $this->createQueryBuilder('p')
|
||||||
|
|||||||
@@ -84,4 +84,28 @@ final class NotificationRepository extends ServiceEntityRepository
|
|||||||
->execute()
|
->execute()
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function existsForRecipientCategoryTargetMessage(
|
||||||
|
User $recipient,
|
||||||
|
string $category,
|
||||||
|
string $target,
|
||||||
|
string $message,
|
||||||
|
): bool {
|
||||||
|
$id = $this->createQueryBuilder('n')
|
||||||
|
->select('n.id')
|
||||||
|
->andWhere('n.recipient = :recipient')
|
||||||
|
->andWhere('n.category = :category')
|
||||||
|
->andWhere('n.target = :target')
|
||||||
|
->andWhere('n.message = :message')
|
||||||
|
->setParameter('recipient', $recipient)
|
||||||
|
->setParameter('category', $category)
|
||||||
|
->setParameter('target', $target)
|
||||||
|
->setParameter('message', $message)
|
||||||
|
->setMaxResults(1)
|
||||||
|
->getQuery()
|
||||||
|
->getOneOrNullResult()
|
||||||
|
;
|
||||||
|
|
||||||
|
return null !== $id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Notification;
|
||||||
|
|
||||||
|
final readonly class ContractEndNotice
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public ?int $employeeId,
|
||||||
|
public string $message,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Notification;
|
||||||
|
|
||||||
|
use App\Entity\EmployeeContractPeriod;
|
||||||
|
use App\Enum\ContractNature;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
final readonly class ContractEndNotificationPlanner
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private WorkingDayCalculator $calculator,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param EmployeeContractPeriod[] $latestPeriods
|
||||||
|
*
|
||||||
|
* @return ContractEndNotice[]
|
||||||
|
*/
|
||||||
|
public function plan(array $latestPeriods, DateTimeImmutable $today): array
|
||||||
|
{
|
||||||
|
$today = $today->setTime(0, 0, 0);
|
||||||
|
if (!$this->calculator->isWorkingDay($today)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$upperBound = $this->calculator->nextWorkingDay($today);
|
||||||
|
|
||||||
|
$notices = [];
|
||||||
|
foreach ($latestPeriods as $period) {
|
||||||
|
$endDate = $period->getEndDate();
|
||||||
|
if (null === $endDate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$endDate = $endDate->setTime(0, 0, 0);
|
||||||
|
if ($endDate <= $today || $endDate > $upperBound) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$employee = $period->getEmployee();
|
||||||
|
if (null === $employee) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = sprintf(
|
||||||
|
'Fin de %s de %s %s le %s',
|
||||||
|
$this->natureLabel($period->getContractNatureEnum()),
|
||||||
|
$employee->getFirstName(),
|
||||||
|
$employee->getLastName(),
|
||||||
|
$endDate->format('d/m/Y'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$notices[] = new ContractEndNotice($employee->getId(), $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $notices;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function natureLabel(ContractNature $nature): string
|
||||||
|
{
|
||||||
|
return match ($nature) {
|
||||||
|
ContractNature::CDI => 'CDI',
|
||||||
|
ContractNature::CDD => 'CDD',
|
||||||
|
ContractNature::INTERIM => 'Intérim',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Notification;
|
||||||
|
|
||||||
|
final readonly class ContractEndNotificationResult
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $notificationsCreated,
|
||||||
|
public int $contractsMatched,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Notification;
|
||||||
|
|
||||||
|
use App\Entity\Notification;
|
||||||
|
use App\Repository\EmployeeContractPeriodRepository;
|
||||||
|
use App\Repository\NotificationRepository;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
|
use function count;
|
||||||
|
|
||||||
|
final readonly class ContractEndNotificationService
|
||||||
|
{
|
||||||
|
private const CATEGORY = 'Contrat';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private EmployeeContractPeriodRepository $periodRepository,
|
||||||
|
private NotificationRepository $notificationRepository,
|
||||||
|
private UserRepository $userRepository,
|
||||||
|
private ContractEndNotificationPlanner $planner,
|
||||||
|
private EntityManagerInterface $entityManager,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(DateTimeImmutable $today): ContractEndNotificationResult
|
||||||
|
{
|
||||||
|
$latestPeriods = $this->periodRepository->findLatestPeriodsForAllEmployees();
|
||||||
|
$notices = $this->planner->plan($latestPeriods, $today);
|
||||||
|
|
||||||
|
if ([] === $notices) {
|
||||||
|
return new ContractEndNotificationResult(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$admins = $this->userRepository->findAllAdmins();
|
||||||
|
$created = 0;
|
||||||
|
|
||||||
|
foreach ($notices as $notice) {
|
||||||
|
if (null === $notice->employeeId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = '/employees/'.$notice->employeeId;
|
||||||
|
|
||||||
|
foreach ($admins as $admin) {
|
||||||
|
if ($this->notificationRepository->existsForRecipientCategoryTargetMessage(
|
||||||
|
$admin,
|
||||||
|
self::CATEGORY,
|
||||||
|
$target,
|
||||||
|
$notice->message,
|
||||||
|
)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$notification = new Notification();
|
||||||
|
$notification->setRecipient($admin)
|
||||||
|
->setMessage($notice->message)
|
||||||
|
->setCategory(self::CATEGORY)
|
||||||
|
->setTarget($target)
|
||||||
|
;
|
||||||
|
|
||||||
|
$this->entityManager->persist($notification);
|
||||||
|
++$created;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return new ContractEndNotificationResult($created, count($notices));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Notification;
|
||||||
|
|
||||||
|
use App\Service\PublicHolidayServiceInterface;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final readonly class WorkingDayCalculator
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private PublicHolidayServiceInterface $holidays,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function isWorkingDay(DateTimeImmutable $date): bool
|
||||||
|
{
|
||||||
|
$dayOfWeek = (int) $date->format('N'); // 1 (lundi) .. 7 (dimanche)
|
||||||
|
if ($dayOfWeek >= 6) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !$this->isPublicHoliday($date);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function nextWorkingDay(DateTimeImmutable $date): DateTimeImmutable
|
||||||
|
{
|
||||||
|
$candidate = $date->modify('+1 day')->setTime(0, 0, 0);
|
||||||
|
while (!$this->isWorkingDay($candidate)) {
|
||||||
|
$candidate = $candidate->modify('+1 day');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isPublicHoliday(DateTimeImmutable $date): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$holidays = $this->holidays->getHolidaysDayByYears('metropole', $date->format('Y'));
|
||||||
|
} catch (Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isset($holidays[$date->format('Y-m-d')]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service\Notification;
|
||||||
|
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeContractPeriod;
|
||||||
|
use App\Enum\ContractNature;
|
||||||
|
use App\Service\Notification\ContractEndNotificationPlanner;
|
||||||
|
use App\Service\Notification\WorkingDayCalculator;
|
||||||
|
use App\Service\PublicHolidayServiceInterface;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class ContractEndNotificationPlannerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testNotifiesContractEndingTomorrowOnAWeekday(): void
|
||||||
|
{
|
||||||
|
// Mardi 08/07 -> fin mercredi 09/07
|
||||||
|
$notices = $this->planner()->plan(
|
||||||
|
[$this->period('Jean', 'Dupont', '2025-07-09')],
|
||||||
|
new DateTimeImmutable('2025-07-08'),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertCount(1, $notices);
|
||||||
|
self::assertSame('Fin de CDD de Jean Dupont le 09/07/2025', $notices[0]->message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFridayNotifiesContractsEndingOverTheWeekendAndMonday(): void
|
||||||
|
{
|
||||||
|
// Vendredi 11/07 ; lundi 14/07 férié -> prochain ouvré = mardi 15/07.
|
||||||
|
// Fenêtre ]11/07 ; 15/07] -> samedi 12, dimanche 13, lundi 14, mardi 15.
|
||||||
|
$notices = $this->planner()->plan(
|
||||||
|
[
|
||||||
|
$this->period('A', 'Sat', '2025-07-12'), // samedi -> inclus
|
||||||
|
$this->period('B', 'Mon', '2025-07-14'), // lundi férié -> inclus
|
||||||
|
$this->period('C', 'Tue', '2025-07-15'), // mardi (= borne haute) -> inclus
|
||||||
|
$this->period('D', 'Wed', '2025-07-16'), // mercredi -> hors fenêtre
|
||||||
|
],
|
||||||
|
new DateTimeImmutable('2025-07-11'),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertCount(3, $notices);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIgnoresOpenEndedContract(): void
|
||||||
|
{
|
||||||
|
$notices = $this->planner()->plan(
|
||||||
|
[$this->period('Jean', 'Dupont', null, ContractNature::CDI)],
|
||||||
|
new DateTimeImmutable('2025-07-08'),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame([], $notices);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIgnoresContractEndingToday(): void
|
||||||
|
{
|
||||||
|
// fin = today -> trop tard, pas de notif (on notifie la veille)
|
||||||
|
$notices = $this->planner()->plan(
|
||||||
|
[$this->period('Jean', 'Dupont', '2025-07-08')],
|
||||||
|
new DateTimeImmutable('2025-07-08'),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame([], $notices);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReturnsNothingWhenTodayIsNotAWorkingDay(): void
|
||||||
|
{
|
||||||
|
// Samedi 12/07 -> aucun jour chômé ne génère de notif
|
||||||
|
$notices = $this->planner()->plan(
|
||||||
|
[$this->period('Jean', 'Dupont', '2025-07-14')],
|
||||||
|
new DateTimeImmutable('2025-07-12'),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame([], $notices);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInterimNatureLabel(): void
|
||||||
|
{
|
||||||
|
$notices = $this->planner()->plan(
|
||||||
|
[$this->period('Marie', 'Martin', '2025-07-09', ContractNature::INTERIM)],
|
||||||
|
new DateTimeImmutable('2025-07-08'),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame('Fin de Intérim de Marie Martin le 09/07/2025', $notices[0]->message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function planner(): ContractEndNotificationPlanner
|
||||||
|
{
|
||||||
|
$holidays = $this->createStub(PublicHolidayServiceInterface::class);
|
||||||
|
$holidays->method('getHolidaysDayByYears')->willReturn([
|
||||||
|
'2025-07-14' => 'Fête nationale', // lundi 14/07 férié
|
||||||
|
]);
|
||||||
|
|
||||||
|
return new ContractEndNotificationPlanner(new WorkingDayCalculator($holidays));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function period(
|
||||||
|
string $firstName,
|
||||||
|
string $lastName,
|
||||||
|
?string $endDate,
|
||||||
|
ContractNature $nature = ContractNature::CDD,
|
||||||
|
): EmployeeContractPeriod {
|
||||||
|
$employee = new Employee();
|
||||||
|
$employee->setFirstName($firstName)->setLastName($lastName);
|
||||||
|
|
||||||
|
$period = new EmployeeContractPeriod();
|
||||||
|
$period->setEmployee($employee)
|
||||||
|
->setContractNature($nature)
|
||||||
|
->setEndDate(null === $endDate ? null : new DateTimeImmutable($endDate))
|
||||||
|
;
|
||||||
|
|
||||||
|
return $period;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service\Notification;
|
||||||
|
|
||||||
|
use App\Service\Notification\WorkingDayCalculator;
|
||||||
|
use App\Service\PublicHolidayServiceInterface;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class WorkingDayCalculatorTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testWeekdayIsWorkingDay(): void
|
||||||
|
{
|
||||||
|
// Mardi 08/07/2025
|
||||||
|
self::assertTrue($this->calculator()->isWorkingDay(new DateTimeImmutable('2025-07-08')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSaturdayAndSundayAreNotWorkingDays(): void
|
||||||
|
{
|
||||||
|
self::assertFalse($this->calculator()->isWorkingDay(new DateTimeImmutable('2025-07-12'))); // samedi
|
||||||
|
self::assertFalse($this->calculator()->isWorkingDay(new DateTimeImmutable('2025-07-13'))); // dimanche
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPublicHolidayIsNotWorkingDay(): void
|
||||||
|
{
|
||||||
|
self::assertFalse($this->calculator()->isWorkingDay(new DateTimeImmutable('2025-07-14'))); // lundi férié
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNextWorkingDayFromWeekdayIsTomorrow(): void
|
||||||
|
{
|
||||||
|
// Mardi 08/07 -> Mercredi 09/07
|
||||||
|
self::assertSame(
|
||||||
|
'2025-07-09',
|
||||||
|
$this->calculator()->nextWorkingDay(new DateTimeImmutable('2025-07-08'))->format('Y-m-d')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNextWorkingDayFromFridaySkipsWeekend(): void
|
||||||
|
{
|
||||||
|
// Vendredi 11/07 -> lundi 14/07 est férié -> mardi 15/07
|
||||||
|
self::assertSame(
|
||||||
|
'2025-07-15',
|
||||||
|
$this->calculator()->nextWorkingDay(new DateTimeImmutable('2025-07-11'))->format('Y-m-d')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function calculator(): WorkingDayCalculator
|
||||||
|
{
|
||||||
|
$holidays = $this->createStub(PublicHolidayServiceInterface::class);
|
||||||
|
$holidays->method('getHolidaysDayByYears')->willReturn([
|
||||||
|
// Lundi 14/07/2025 férié
|
||||||
|
'2025-07-14' => 'Fête nationale',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return new WorkingDayCalculator($holidays);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user