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:
@@ -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 }
|
||||
|
||||
@@ -20,6 +20,7 @@ const STATUS_ICONS: Record<AbsenceStatus, string> = {
|
||||
const TYPE_COLORS: Record<AbsenceType, string> = {
|
||||
cp: '#4A90D9',
|
||||
mariage_pacs: '#E91E63',
|
||||
naissance: '#26A69A',
|
||||
conge_parental: '#9C27B0',
|
||||
deces: '#607D8B',
|
||||
maladie: '#C62828',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user