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:
@@ -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,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.');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user