feat(absences) : mise en conformité légale (événements familiaux, demi-journée, CCN)

Périmètre 1-6 du design 2026-05-22-absence-legal-compliance-fixes (points
lourds — ancienneté, CP pendant maladie, rétention — reportés en backlog).

- Événements familiaux sans solde : AbsenceType::decrementsBalance() ne vaut
  true que pour les CP. Mariage/PACS, naissance, décès = droits par événement ;
  congé parental = suspension ; maladie = Sécu. Plus de solde fantôme.
- Décès : daysPerEvent = null (selon lien de parenté) + motif obligatoire à la
  création (REST + MCP), les minimums légaux étant rappelés dans l'aide.
- Ajout du congé naissance (type, policy 3 j, justificatif, libellés/couleur front).
- Garde-fou demi-journée : -0,5 appliqué uniquement si le jour-borne est
  réellement décompté (corrige un sous-décompte week-end/férié) — TDD.
- CCN documentée : paramètre app.absence.convention = "Syntec (IDCC 1486)",
  rappelée en sous-titre admin et dans l'aide /help.

Tests : AbsenceDayCalculatorTest (garde-fou demi-journée), AbsenceRequestLifecycle
(motif décès obligatoire + aucun solde touché). make test 52/52, build Nuxt OK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-05-22 16:00:28 +02:00
parent e9aaccc62c
commit f9773b3a5e
12 changed files with 123 additions and 25 deletions
+5 -2
View File
@@ -643,13 +643,16 @@ class AppFixtures extends Fixture
// Absence management — policies, employees, balances, requests
// =============================================
// Default policies for the 5 absence types (legal defaults, editable by admin)
// Default policies for the absence types (legal defaults, editable by admin)
$policyData = [
// [type, daysPerYear, daysPerEvent, justifRequired, noticeDays, workingDaysOnly]
[AbsenceType::PaidLeave, 25.0, null, false, 30, true],
[AbsenceType::MarriagePacs, null, 4.0, true, 0, true],
[AbsenceType::Birth, null, 3.0, true, 0, true],
// Bereavement: no fixed entitlement — depends on the relationship to
// the deceased (legal minimums recalled in the help), reason required.
[AbsenceType::Bereavement, null, null, true, 0, true],
[AbsenceType::ParentalLeave, null, null, true, 30, true],
[AbsenceType::Bereavement, null, 3.0, true, 0, true],
[AbsenceType::SickLeave, null, null, true, 0, true],
];
+8 -2
View File
@@ -8,6 +8,7 @@ enum AbsenceType: string
{
case PaidLeave = 'cp';
case MarriagePacs = 'mariage_pacs';
case Birth = 'naissance';
case ParentalLeave = 'conge_parental';
case Bereavement = 'deces';
case SickLeave = 'maladie';
@@ -17,6 +18,7 @@ enum AbsenceType: string
return match ($this) {
self::PaidLeave => 'Congés payés',
self::MarriagePacs => 'Mariage / PACS',
self::Birth => 'Naissance',
self::ParentalLeave => 'Congé parental',
self::Bereavement => 'Décès proche',
self::SickLeave => 'Arrêt maladie',
@@ -25,10 +27,14 @@ enum AbsenceType: string
/**
* Whether taking this absence decrements a balance.
* Sick leave is managed by social security and has no balance.
*
* Only paid leave (CP) draws down an acquired balance. Family-event leaves
* (marriage/PACS, birth, bereavement) are per-event entitlements, parental
* leave is a contract suspension, and sick leave is handled by social
* security — none of them decrement a balance.
*/
public function decrementsBalance(): bool
{
return self::SickLeave !== $this;
return self::PaidLeave === $this;
}
}
@@ -73,6 +73,12 @@ class CreateAbsenceRequestTool
throw new InvalidArgumentException('This absence type is not available.');
}
// Bereavement has no fixed entitlement: the relationship to the deceased
// drives the legal number of days, so the reason is mandatory.
if (AbsenceType::Bereavement === $typeEnum && '' === trim((string) $reason)) {
throw new InvalidArgumentException('A reason (relationship to the deceased) is required for bereavement leave.');
}
if ($this->requestRepository->hasOverlap($user, $start, $end)) {
throw new InvalidArgumentException('This request overlaps an existing absence.');
}
+21 -14
View File
@@ -42,32 +42,39 @@ final readonly class AbsenceDayCalculator
$period = new DatePeriod($start, new DateInterval('P1D'), $end->modify('+1 day'));
foreach ($period as $day) {
$weekday = (int) $day->format('N'); // 1 (Mon) .. 7 (Sun)
if (7 === $weekday) {
continue; // Sunday: never counted
if ($this->isCountedDay($day, $workingDaysOnly)) {
++$days;
}
if (6 === $weekday && $workingDaysOnly) {
continue; // Saturday: only counted for "jours ouvrables"
}
if ($this->holidayProvider->isHoliday($day)) {
continue;
}
++$days;
}
if ($days <= 0.0) {
return 0.0;
}
if (null !== $startHalfDay) {
// A half-day only subtracts 0.5 when its boundary day is actually
// counted (otherwise a half-day posted on a weekend/holiday would
// wrongly under-count the absence).
if (null !== $startHalfDay && $this->isCountedDay($start, $workingDaysOnly)) {
$days -= 0.5;
}
if (null !== $endHalfDay) {
if (null !== $endHalfDay && $this->isCountedDay($end, $workingDaysOnly)) {
$days -= 0.5;
}
return max(0.0, $days);
}
private function isCountedDay(DateTimeImmutable $day, bool $workingDaysOnly): bool
{
$weekday = (int) $day->format('N'); // 1 (Mon) .. 7 (Sun)
if (7 === $weekday) {
return false; // Sunday: never counted
}
if (6 === $weekday && $workingDaysOnly) {
return false; // Saturday: only counted for "jours ouvrables"
}
return !$this->holidayProvider->isHoliday($day);
}
}
+7
View File
@@ -9,6 +9,7 @@ use ApiPlatform\State\ProcessorInterface;
use App\Entity\AbsenceRequest;
use App\Entity\User;
use App\Enum\AbsenceStatus;
use App\Enum\AbsenceType;
use App\Repository\AbsencePolicyRepository;
use App\Repository\AbsenceRequestRepository;
use App\Service\AbsenceBalanceService;
@@ -60,6 +61,12 @@ final readonly class AbsenceRequestProcessor implements ProcessorInterface
throw new UnprocessableEntityHttpException('This absence type is not available.');
}
// Bereavement has no fixed entitlement: the relationship to the deceased
// drives the legal number of days, so the reason is mandatory.
if (AbsenceType::Bereavement === $type && '' === trim((string) $data->getReason())) {
throw new UnprocessableEntityHttpException('A reason (relationship to the deceased) is required for bereavement leave.');
}
if ($this->requestRepository->hasOverlap($user, $startDate, $endDate)) {
throw new ConflictHttpException('This request overlaps an existing absence.');
}