Files
SIRH/docs/superpowers/plans/2026-06-24-contract-end-notification.md
T
tristan 029a09dc09
Auto Tag Develop / tag (push) Successful in 17s
feat : notification de fin de contrat (veille ouvrée du dernier jour) (#35)
## 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>
2026-06-24 14:04:50 +00:00

36 KiB

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.phpfindLatestPeriodsForAllEmployees().
  • src/Repository/NotificationRepository.phpexistsForRecipientCategoryTargetMessage().

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

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

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
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

declare(strict_types=1);

namespace App\Service\Notification;

final readonly class ContractEndNotice
{
    public function __construct(
        public ?int $employeeId,
        public string $message,
    ) {}
}
  • Step 2: Commit
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

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

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
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) :

    /**
     * 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) :

    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
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

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

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

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
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) :

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) :

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:

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) :

                                    <p class="text-black"><span class="font-semibold capitalize">{{ notification.actorName }}</span> {{ notification.message }}</p>

par :

                                    <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
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

# 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 :

- **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 :

{
  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 :

## 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
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).