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