feat : notification de fin de contrat (veille ouvrée du dernier jour) (#35)
Auto Tag Develop / tag (push) Successful in 17s
Auto Tag Develop / tag (push) Successful in 17s
## Objectif
Prévenir automatiquement les administrateurs, sur le **dernier jour ouvré précédant la fin d'un contrat**, qu'un salarié arrive au terme de son emploi.
## Fonctionnement
- Commande quotidienne `app:contract:end-notifications` (à brancher sur le crontab prod, ~6h ; option `--date=YYYY-MM-DD` pour test/rattrapage).
- Cible **la dernière période de contrat** d'un employé (un changement de contrat enchaîné, ex. CDD→CDI, ne notifie pas).
- Notifie sur le **dernier jour ouvré strictement avant** `endDate` (inclusif). Week-ends **et fériés** sautés → une fin de contrat le lundi est signalée dès le vendredi. Le Lundi de Pentecôte reste un jour ouvré (cohérent avec le reste de l'app).
- Une notification par admin : message « Fin de {nature} de {Nom} le {date} », catégorie `Contrat`, lien `/employees/{id}`, sans acteur.
- **Idempotent** : déduplication par `(recipient, category, target, message)`.
- Front : la cloche (déjà admin-only) affiche proprement les notifs sans acteur.
- **Aucune migration** (réutilise la table `notifications`).
## Architecture
Logique pure isolée et testée : `WorkingDayCalculator` (week-end + férié) + `ContractEndNotificationPlanner` (fenêtre + message). Persistance dans `ContractEndNotificationService`, exposée par `ContractEndNotificationCommand`. Méthodes repo `findLatestPeriodsForAllEmployees` + `existsForRecipientCategoryTargetMessage`.
## Tests & vérification
- 11 tests unitaires ajoutés ; suite complète verte (264 tests, 564 assertions).
- Vérif e2e manuelle : run du vendredi → 6 notifs/1 contrat finissant le lundi (saut de week-end OK), relance idempotente (0), contenu BDD correct.
## Documentation
`doc/contract-end-notifications.md`, `doc/functional-rules.md` (§15), doc in-app (`documentation-content.ts`), `CLAUDE.md`.
## ⚠️ Tâche infra
Ajouter la ligne crontab prod : `0 6 * * * … bin/console app:contract:end-notifications`
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Reviewed-on: #35
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #35.
This commit is contained in:
@@ -0,0 +1,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
|
||||
{
|
||||
return $this->createQueryBuilder('p')
|
||||
|
||||
@@ -84,4 +84,28 @@ final class NotificationRepository extends ServiceEntityRepository
|
||||
->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')]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user