feat : notification de fin de contrat (veille ouvrée du dernier jour) (#35)
Auto Tag Develop / tag (push) Successful in 17s

## Objectif
Prévenir automatiquement les administrateurs, sur le **dernier jour ouvré précédant la fin d'un contrat**, qu'un salarié arrive au terme de son emploi.

## Fonctionnement
- Commande quotidienne `app:contract:end-notifications` (à brancher sur le crontab prod, ~6h ; option `--date=YYYY-MM-DD` pour test/rattrapage).
- Cible **la dernière période de contrat** d'un employé (un changement de contrat enchaîné, ex. CDD→CDI, ne notifie pas).
- Notifie sur le **dernier jour ouvré strictement avant** `endDate` (inclusif). Week-ends **et fériés** sautés → une fin de contrat le lundi est signalée dès le vendredi. Le Lundi de Pentecôte reste un jour ouvré (cohérent avec le reste de l'app).
- Une notification par admin : message « Fin de {nature} de {Nom} le {date} », catégorie `Contrat`, lien `/employees/{id}`, sans acteur.
- **Idempotent** : déduplication par `(recipient, category, target, message)`.
- Front : la cloche (déjà admin-only) affiche proprement les notifs sans acteur.
- **Aucune migration** (réutilise la table `notifications`).

## Architecture
Logique pure isolée et testée : `WorkingDayCalculator` (week-end + férié) + `ContractEndNotificationPlanner` (fenêtre + message). Persistance dans `ContractEndNotificationService`, exposée par `ContractEndNotificationCommand`. Méthodes repo `findLatestPeriodsForAllEmployees` + `existsForRecipientCategoryTargetMessage`.

## Tests & vérification
- 11 tests unitaires ajoutés ; suite complète verte (264 tests, 564 assertions).
- Vérif e2e manuelle : run du vendredi → 6 notifs/1 contrat finissant le lundi (saut de week-end OK), relance idempotente (0), contenu BDD correct.

## Documentation
`doc/contract-end-notifications.md`, `doc/functional-rules.md` (§15), doc in-app (`documentation-content.ts`), `CLAUDE.md`.

## ⚠️ Tâche infra
Ajouter la ligne crontab prod : `0 6 * * * … bin/console app:contract:end-notifications`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #35
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #35.
This commit is contained in:
2026-06-24 14:04:50 +00:00
committed by Autin
parent 45c32989e5
commit 029a09dc09
17 changed files with 1688 additions and 1 deletions
@@ -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')]);
}
}