From f9773b3a5e2296bec0445dc8ab777daf892c5950 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 22 May 2026 16:00:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(absences)=20:=20mise=20en=20conformit?= =?UTF-8?q?=C3=A9=20l=C3=A9gale=20(=C3=A9v=C3=A9nements=20familiaux,=20dem?= =?UTF-8?q?i-journ=C3=A9e,=20CCN)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- config/services.yaml | 3 ++ frontend/composables/useAbsenceHelpers.ts | 1 + frontend/content/help/06-absences.md | 14 ++++--- frontend/i18n/locales/fr.json | 3 +- frontend/services/dto/absence.ts | 2 +- src/DataFixtures/AppFixtures.php | 7 +++- src/Enum/AbsenceType.php | 10 ++++- .../Tool/Absence/CreateAbsenceRequestTool.php | 6 +++ src/Service/AbsenceDayCalculator.php | 35 +++++++++++------- src/State/AbsenceRequestProcessor.php | 7 ++++ .../Mcp/AbsenceRequestLifecycleTest.php | 37 +++++++++++++++++++ .../Unit/Service/AbsenceDayCalculatorTest.php | 23 ++++++++++++ 12 files changed, 123 insertions(+), 25 deletions(-) diff --git a/config/services.yaml b/config/services.yaml index 1708a92..b0f5649 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -10,6 +10,9 @@ parameters: task_document_upload_dir: '%kernel.project_dir%/var/uploads/documents' avatar_upload_dir: '%kernel.project_dir%/var/uploads/avatars' absence_justification_upload_dir: '%kernel.project_dir%/var/uploads/justificatifs' + # Reference collective agreement for the absence module's legal defaults. + # To confirm against the company's APE/NAF code (a CCN is not derived from activity alone). + app.absence.convention: 'Syntec (IDCC 1486)' imports: - { resource: version.yaml } diff --git a/frontend/composables/useAbsenceHelpers.ts b/frontend/composables/useAbsenceHelpers.ts index c8bc305..d4dadb7 100644 --- a/frontend/composables/useAbsenceHelpers.ts +++ b/frontend/composables/useAbsenceHelpers.ts @@ -20,6 +20,7 @@ const STATUS_ICONS: Record = { const TYPE_COLORS: Record = { cp: '#4A90D9', mariage_pacs: '#E91E63', + naissance: '#26A69A', conge_parental: '#9C27B0', deces: '#607D8B', maladie: '#C62828', diff --git a/frontend/content/help/06-absences.md b/frontend/content/help/06-absences.md index 0c748ab..e517eb1 100644 --- a/frontend/content/help/06-absences.md +++ b/frontend/content/help/06-absences.md @@ -1,6 +1,8 @@ # Absences -Le module **Absences** gère les congés des salariés : demande, validation, et suivi des soldes de congés payés (CP) et des congés pour événements (mariage/PACS, décès, congé parental, maladie). +Le module **Absences** gère les congés des salariés : demande, validation, et suivi du **solde de congés payés (CP)**. Les congés pour événements familiaux (mariage/PACS, naissance, décès) sont des **droits par événement** : ils sont enregistrés et validés mais **ne se déduisent pas d'un solde**. Le congé parental et l'arrêt maladie sont des suspensions, sans impact sur les soldes. + +> Convention de référence pour les valeurs par défaut : **Syntec (IDCC 1486)** — à confirmer selon le code APE de l'entreprise (une CCN ne se déduit pas de la seule activité). Il y a deux espaces : @@ -13,11 +15,13 @@ Il y a deux espaces : Depuis **Mes absences** → bouton *Nouvelle demande* : -1. **Type** : Congés payés, Mariage/PACS, Congé parental, Décès, ou Maladie. -2. **Dates** : début et fin. Une **demi-journée** (matin / après-midi) peut être posée sur le premier ou le dernier jour (décompte −0,5). -3. **Motif** (optionnel) et **justificatif** (selon le type). +1. **Type** : Congés payés, Mariage/PACS, Naissance, Congé parental, Décès, ou Maladie. +2. **Dates** : début et fin. Une **demi-journée** (matin / après-midi) peut être posée sur le premier ou le dernier jour (décompte −0,5, uniquement si ce jour-borne est un jour décompté). +3. **Motif** et **justificatif** (selon le type). Le **motif est obligatoire pour le décès** : il sert à préciser le lien de parenté, qui détermine le nombre de jours légal. -La demande passe au statut **En attente**. Les jours sont immédiatement **réservés** dans le solde « en attente » pour éviter de poser deux fois les mêmes congés. Un administrateur valide ou refuse ensuite la demande. +La demande passe au statut **En attente**, puis un administrateur la valide ou la refuse. Pour les CP uniquement, les jours sont immédiatement **réservés** dans le solde « en attente » pour éviter de poser deux fois les mêmes congés. + +> **Congés pour événements familiaux — minimums légaux (rappel).** Mariage/PACS : 4 jours. Naissance : 3 jours (hors congé paternité). Décès : selon le lien — **enfant : au moins 5 jours + 8 jours de congé de deuil**, conjoint/partenaire/parent/frère/sœur : 3 jours. L'administrateur accorde le nombre de jours légal en validant les dates. La convention Syntec peut prévoir des durées supérieures. ## Lire ses soldes diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index bcef698..4391b3f 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -577,6 +577,7 @@ "types": { "cp": "Congés payés", "mariage_pacs": "Mariage / PACS", + "naissance": "Naissance", "conge_parental": "Congé parental", "deces": "Décès proche", "maladie": "Arrêt maladie" @@ -719,7 +720,7 @@ }, "policies": { "title": "Politiques d'absence", - "subtitle": "Réglez les défauts par type d'absence (convention collective).", + "subtitle": "Réglez les défauts par type d'absence — convention de référence : Syntec (IDCC 1486), à confirmer selon le code APE.", "type": "Type", "daysPerYear": "Jours / an", "daysPerEvent": "Jours / événement", diff --git a/frontend/services/dto/absence.ts b/frontend/services/dto/absence.ts index fb1a2cb..d32a1ad 100644 --- a/frontend/services/dto/absence.ts +++ b/frontend/services/dto/absence.ts @@ -1,4 +1,4 @@ -export type AbsenceType = 'cp' | 'mariage_pacs' | 'conge_parental' | 'deces' | 'maladie' +export type AbsenceType = 'cp' | 'mariage_pacs' | 'naissance' | 'conge_parental' | 'deces' | 'maladie' export type AbsenceStatus = 'pending' | 'approved' | 'rejected' | 'cancelled' export type HalfDay = 'matin' | 'apres_midi' diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php index a6ef14f..c7ebabf 100644 --- a/src/DataFixtures/AppFixtures.php +++ b/src/DataFixtures/AppFixtures.php @@ -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], ]; diff --git a/src/Enum/AbsenceType.php b/src/Enum/AbsenceType.php index c868e32..0406dcb 100644 --- a/src/Enum/AbsenceType.php +++ b/src/Enum/AbsenceType.php @@ -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; } } diff --git a/src/Mcp/Tool/Absence/CreateAbsenceRequestTool.php b/src/Mcp/Tool/Absence/CreateAbsenceRequestTool.php index 80d6658..8a7af97 100644 --- a/src/Mcp/Tool/Absence/CreateAbsenceRequestTool.php +++ b/src/Mcp/Tool/Absence/CreateAbsenceRequestTool.php @@ -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.'); } diff --git a/src/Service/AbsenceDayCalculator.php b/src/Service/AbsenceDayCalculator.php index fd8ed5a..2a4d667 100644 --- a/src/Service/AbsenceDayCalculator.php +++ b/src/Service/AbsenceDayCalculator.php @@ -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); + } } diff --git a/src/State/AbsenceRequestProcessor.php b/src/State/AbsenceRequestProcessor.php index 4322195..e65ec99 100644 --- a/src/State/AbsenceRequestProcessor.php +++ b/src/State/AbsenceRequestProcessor.php @@ -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.'); } diff --git a/tests/Functional/Mcp/AbsenceRequestLifecycleTest.php b/tests/Functional/Mcp/AbsenceRequestLifecycleTest.php index 2f4335a..a2b37f5 100644 --- a/tests/Functional/Mcp/AbsenceRequestLifecycleTest.php +++ b/tests/Functional/Mcp/AbsenceRequestLifecycleTest.php @@ -17,6 +17,7 @@ use App\Repository\UserRepository; use App\Service\AbsenceBalanceService; use App\Service\AbsenceDayCalculator; use Doctrine\ORM\EntityManagerInterface; +use InvalidArgumentException; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Bundle\SecurityBundle\Security; @@ -127,6 +128,42 @@ class AbsenceRequestLifecycleTest extends KernelTestCase self::assertSame(0.0, $balance->getPending()); } + public function testBereavementRequiresReasonAndDoesNotTouchBalance(): void + { + // Ensure an active bereavement policy exists (no fixed entitlement). + $policy = $this->em->getRepository(AbsencePolicy::class)->findOneBy(['type' => AbsenceType::Bereavement]); + if (null === $policy) { + $policy = new AbsencePolicy(); + $policy->setType(AbsenceType::Bereavement); + $policy->setCountWorkingDaysOnly(true); + $this->em->persist($policy); + } + $policy->setActive(true); + $this->em->flush(); + + $tool = $this->createTool($this->admin); + + // Without a reason → rejected. + try { + ($tool)($this->employee->getId(), 'deces', '2026-06-10', '2026-06-12'); + self::fail('Expected an exception for bereavement without a reason.'); + } catch (InvalidArgumentException $e) { + self::assertStringContainsString('reason', $e->getMessage()); + } + + // With a reason → accepted, and no balance row is created (per-event right). + $data = json_decode( + ($tool)($this->employee->getId(), 'deces', '2026-06-10', '2026-06-12', null, null, 'Décès grand-parent'), + true, + ); + self::assertSame('pending', $data['status']); + + $balance = self::getContainer()->get(AbsenceBalanceRepository::class) + ->findOneForPeriod($this->employee, AbsenceType::Bereavement, '2026') + ; + self::assertNull($balance, 'Bereavement must not create or touch any balance.'); + } + private function securityFor(User $user): Security { $security = $this->createMock(Security::class); diff --git a/tests/Unit/Service/AbsenceDayCalculatorTest.php b/tests/Unit/Service/AbsenceDayCalculatorTest.php index 591763e..0eecdcd 100644 --- a/tests/Unit/Service/AbsenceDayCalculatorTest.php +++ b/tests/Unit/Service/AbsenceDayCalculatorTest.php @@ -77,6 +77,29 @@ class AbsenceDayCalculatorTest extends TestCase )); } + public function testHalfDayOnNonCountedStartIsIgnored(): void + { + // Sat 2026-06-06 → Mon 2026-06-08, jours ouvrés : Sat & Sun skipped, Mon = 1. + // startHalfDay sits on Saturday (not counted) => must NOT subtract 0.5. + self::assertSame(1.0, $this->calculator->countWorkingDays( + new DateTimeImmutable('2026-06-06'), + new DateTimeImmutable('2026-06-08'), + HalfDay::Afternoon, + )); + } + + public function testHalfDayOnNonCountedEndIsIgnored(): void + { + // Fri 2026-06-05 → Sun 2026-06-07, jours ouvrés : Fri = 1, weekend skipped. + // endHalfDay sits on Sunday (not counted) => must NOT subtract 0.5. + self::assertSame(1.0, $this->calculator->countWorkingDays( + new DateTimeImmutable('2026-06-05'), + new DateTimeImmutable('2026-06-07'), + null, + HalfDay::Morning, + )); + } + public function testWorkingDaysVsOpenDays(): void { // Fri 2026-06-05 to Mon 2026-06-08, "ouvrables" includes Saturday