feat(absences) : fondation backend du module de gestion des absences

Module type Payfit (étapes 1+2 de la spec V1) : demande d'absence, validation
admin, soldes à jour.

- Enums : AbsenceType, AbsenceStatus, HalfDay, ContractType, FamilySituation
- Entités : AbsencePolicy, AbsenceBalance, AbsenceRequest + champs RH sur User
- Services : PublicHolidayProvider (fériés FR métropole en PHP pur, Computus),
  AbsenceDayCalculator (décompte jours ouvrés/ouvrables + demi-journées, TDD),
  AbsenceBalanceService (périodes + pending/taken/recrédit)
- API Platform : providers/processors (création, approve/reject/cancel) + RBAC
  me/admin, contrôleurs preview (dry-run), upload/download justificatif, calendrier
- Migrations : une par table + colonnes RH user (DEFAULT puis DROP DEFAULT)
- Fixtures : 5 policies par défaut, salariés démo, soldes et demandes
- Tests unitaires : PublicHolidayProvider, AbsenceDayCalculator (12 tests)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-05-21 14:45:14 +02:00
parent 325a7b07f9
commit de98924fd3
32 changed files with 2554 additions and 3 deletions

View File

@@ -9,6 +9,7 @@
parameters: parameters:
task_document_upload_dir: '%kernel.project_dir%/var/uploads/documents' task_document_upload_dir: '%kernel.project_dir%/var/uploads/documents'
avatar_upload_dir: '%kernel.project_dir%/var/uploads/avatars' avatar_upload_dir: '%kernel.project_dir%/var/uploads/avatars'
absence_justification_upload_dir: '%kernel.project_dir%/var/uploads/justificatifs'
imports: imports:
- { resource: version.yaml } - { resource: version.yaml }
@@ -44,3 +45,11 @@ services:
App\Controller\UserAvatarController: App\Controller\UserAvatarController:
arguments: arguments:
$avatarUploadDir: '%avatar_upload_dir%' $avatarUploadDir: '%avatar_upload_dir%'
App\Controller\Absence\AbsenceJustificationUploadController:
arguments:
$uploadDir: '%absence_justification_upload_dir%'
App\Controller\Absence\AbsenceJustificationDownloadController:
arguments:
$uploadDir: '%absence_justification_upload_dir%'

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Absence management: create the absence_policy table.
*/
final class Version20260521123520 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create absence_policy table';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE absence_policy (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
type VARCHAR(32) NOT NULL,
days_per_year DOUBLE PRECISION DEFAULT NULL,
days_per_event DOUBLE PRECISION DEFAULT NULL,
justification_required BOOLEAN NOT NULL,
notice_days INT NOT NULL,
count_working_days_only BOOLEAN NOT NULL,
active BOOLEAN NOT NULL,
PRIMARY KEY (id)
)
SQL);
$this->addSql('CREATE UNIQUE INDEX uniq_absence_policy_type ON absence_policy (type)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE absence_policy');
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Absence management: create the absence_balance table.
*/
final class Version20260521123521 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create absence_balance table';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE absence_balance (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
type VARCHAR(32) NOT NULL,
period VARCHAR(16) NOT NULL,
acquired DOUBLE PRECISION NOT NULL,
taken DOUBLE PRECISION NOT NULL,
pending DOUBLE PRECISION NOT NULL,
user_id INT NOT NULL,
PRIMARY KEY (id)
)
SQL);
$this->addSql('CREATE INDEX IDX_65723A76A76ED395 ON absence_balance (user_id)');
$this->addSql('CREATE UNIQUE INDEX uniq_absence_balance_user_type_period ON absence_balance (user_id, type, period)');
$this->addSql(<<<'SQL'
ALTER TABLE
absence_balance
ADD
CONSTRAINT FK_65723A76A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE
SQL);
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE absence_balance DROP CONSTRAINT FK_65723A76A76ED395');
$this->addSql('DROP TABLE absence_balance');
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Absence management: create the absence_request table.
*/
final class Version20260521123522 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create absence_request table';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE absence_request (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
type VARCHAR(32) NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
start_half_day VARCHAR(16) DEFAULT NULL,
end_half_day VARCHAR(16) DEFAULT NULL,
counted_days DOUBLE PRECISION NOT NULL,
reason TEXT DEFAULT NULL,
justification_file_name VARCHAR(255) DEFAULT NULL,
status VARCHAR(16) NOT NULL,
rejection_reason TEXT DEFAULT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
reviewed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
user_id INT NOT NULL,
reviewed_by_id INT DEFAULT NULL,
PRIMARY KEY (id)
)
SQL);
$this->addSql('CREATE INDEX IDX_F211AA17A76ED395 ON absence_request (user_id)');
$this->addSql('CREATE INDEX IDX_F211AA17FC6B21F1 ON absence_request (reviewed_by_id)');
$this->addSql(<<<'SQL'
ALTER TABLE
absence_request
ADD
CONSTRAINT FK_F211AA17A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE
absence_request
ADD
CONSTRAINT FK_F211AA17FC6B21F1 FOREIGN KEY (reviewed_by_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE
SQL);
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE absence_request DROP CONSTRAINT FK_F211AA17A76ED395');
$this->addSql('ALTER TABLE absence_request DROP CONSTRAINT FK_F211AA17FC6B21F1');
$this->addSql('DROP TABLE absence_request');
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Absence management: add HR fields to the user table.
*
* Columns are created with DEFAULTs so the migration applies cleanly on an
* already-populated user table (production).
*/
final class Version20260521123523 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add HR / absence fields to user';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE "user" ADD is_employee BOOLEAN NOT NULL DEFAULT false');
$this->addSql('ALTER TABLE "user" ADD hire_date DATE DEFAULT NULL');
$this->addSql('ALTER TABLE "user" ADD end_date DATE DEFAULT NULL');
$this->addSql('ALTER TABLE "user" ADD contract_type VARCHAR(16) DEFAULT NULL');
$this->addSql('ALTER TABLE "user" ADD work_time_ratio DOUBLE PRECISION NOT NULL DEFAULT 1.0');
$this->addSql('ALTER TABLE "user" ADD annual_leave_days DOUBLE PRECISION NOT NULL DEFAULT 25.0');
$this->addSql("ALTER TABLE \"user\" ADD reference_period_start VARCHAR(5) NOT NULL DEFAULT '06-01'");
$this->addSql('ALTER TABLE "user" ADD initial_leave_balance DOUBLE PRECISION NOT NULL DEFAULT 0');
$this->addSql('ALTER TABLE "user" ADD family_situation VARCHAR(16) DEFAULT NULL');
$this->addSql('ALTER TABLE "user" ADD nb_children INT NOT NULL DEFAULT 0');
// Defaults were only needed to backfill existing rows; the ORM mapping
// carries no DB default, so drop them to keep the schema in sync.
$this->addSql('ALTER TABLE "user" ALTER is_employee DROP DEFAULT');
$this->addSql('ALTER TABLE "user" ALTER work_time_ratio DROP DEFAULT');
$this->addSql('ALTER TABLE "user" ALTER annual_leave_days DROP DEFAULT');
$this->addSql('ALTER TABLE "user" ALTER reference_period_start DROP DEFAULT');
$this->addSql('ALTER TABLE "user" ALTER initial_leave_balance DROP DEFAULT');
$this->addSql('ALTER TABLE "user" ALTER nb_children DROP DEFAULT');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE "user" DROP is_employee');
$this->addSql('ALTER TABLE "user" DROP hire_date');
$this->addSql('ALTER TABLE "user" DROP end_date');
$this->addSql('ALTER TABLE "user" DROP contract_type');
$this->addSql('ALTER TABLE "user" DROP work_time_ratio');
$this->addSql('ALTER TABLE "user" DROP annual_leave_days');
$this->addSql('ALTER TABLE "user" DROP reference_period_start');
$this->addSql('ALTER TABLE "user" DROP initial_leave_balance');
$this->addSql('ALTER TABLE "user" DROP family_situation');
$this->addSql('ALTER TABLE "user" DROP nb_children');
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Controller\Absence;
use App\Repository\AbsenceRequestRepository;
use DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Admin calendar view: all pending/approved absences overlapping a date range.
*/
class AbsenceCalendarController extends AbstractController
{
public function __construct(
private readonly AbsenceRequestRepository $requestRepository,
) {}
#[Route('/api/admin/absences/calendar', name: 'absence_calendar', methods: ['GET'], priority: 1)]
#[IsGranted('ROLE_ADMIN')]
public function __invoke(Request $request): JsonResponse
{
$fromRaw = (string) $request->query->get('from', '');
$toRaw = (string) $request->query->get('to', '');
if ('' === $fromRaw || '' === $toRaw) {
throw new UnprocessableEntityHttpException('Query parameters "from" and "to" are required.');
}
$from = new DateTimeImmutable($fromRaw);
$to = new DateTimeImmutable($toRaw);
$absences = $this->requestRepository->findInRange($from, $to);
return $this->json($absences, context: ['groups' => ['absence_request:read']]);
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Controller\Absence;
use App\Entity\AbsenceRequest;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Streams the justification file of an absence request. Owner or admin only.
*/
class AbsenceJustificationDownloadController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
private readonly string $uploadDir,
) {}
#[Route('/api/absence_requests/{id}/justificatif', name: 'absence_justification_download', methods: ['GET'], priority: 1)]
#[IsGranted('ROLE_USER')]
public function __invoke(int $id): BinaryFileResponse
{
$absence = $this->entityManager->getRepository(AbsenceRequest::class)->find($id);
if (null === $absence) {
throw new NotFoundHttpException('Absence request not found.');
}
if (!$this->security->isGranted('ROLE_ADMIN') && $absence->getUser() !== $this->security->getUser()) {
throw new AccessDeniedHttpException('You do not have access to this file.');
}
$fileName = $absence->getJustificationFileName();
if (null === $fileName) {
throw new NotFoundHttpException('No justification file for this request.');
}
$filePath = $this->uploadDir.'/'.$fileName;
if (!file_exists($filePath)) {
throw new NotFoundHttpException('File not found on disk.');
}
$response = new BinaryFileResponse($filePath);
$mimeType = mime_content_type($filePath) ?: 'application/octet-stream';
$disposition = (str_starts_with($mimeType, 'image/') || 'application/pdf' === $mimeType)
? ResponseHeaderBag::DISPOSITION_INLINE
: ResponseHeaderBag::DISPOSITION_ATTACHMENT;
$response->setContentDisposition($disposition, $fileName);
$response->headers->set('Content-Type', $mimeType);
return $response;
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Controller\Absence;
use App\Entity\AbsenceRequest;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
/**
* Uploads a justification file (PDF / image) for an absence request. The owner
* or an admin may upload; the server-detected MIME type is validated.
*/
class AbsenceJustificationUploadController extends AbstractController
{
private const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
private const MIME_TO_EXTENSION = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
'application/pdf' => 'pdf',
];
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
private readonly string $uploadDir,
) {}
#[Route('/api/absence_requests/{id}/justificatif', name: 'absence_justification_upload', methods: ['POST'], priority: 1)]
#[IsGranted('ROLE_USER')]
public function __invoke(int $id, Request $request): JsonResponse
{
$absence = $this->entityManager->getRepository(AbsenceRequest::class)->find($id);
if (null === $absence) {
throw new NotFoundHttpException('Absence request not found.');
}
if (!$this->security->isGranted('ROLE_ADMIN') && $absence->getUser() !== $this->security->getUser()) {
throw new AccessDeniedHttpException('You can only attach a file to your own request.');
}
$file = $request->files->get('file');
if (null === $file || !$file->isValid()) {
throw new BadRequestHttpException('No valid file uploaded.');
}
if ($file->getSize() > self::MAX_FILE_SIZE) {
throw new BadRequestHttpException('File size exceeds 10 MB limit.');
}
$mimeType = $file->getMimeType() ?: 'application/octet-stream';
if (!isset(self::MIME_TO_EXTENSION[$mimeType])) {
throw new BadRequestHttpException(sprintf('File type "%s" is not allowed (PDF or image only).', $mimeType));
}
$fileName = Uuid::v4()->toRfc4122().'.'.self::MIME_TO_EXTENSION[$mimeType];
if (!is_dir($this->uploadDir)) {
mkdir($this->uploadDir, 0o775, true);
}
// Remove a previously uploaded file if any
$previous = $absence->getJustificationFileName();
if (null !== $previous && file_exists($this->uploadDir.'/'.$previous)) {
unlink($this->uploadDir.'/'.$previous);
}
$file->move($this->uploadDir, $fileName);
$absence->setJustificationFileName($fileName);
$this->entityManager->flush();
return $this->json($absence, context: ['groups' => ['absence_request:read']]);
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Controller\Absence;
use App\Entity\User;
use App\Enum\AbsenceType;
use App\Enum\HalfDay;
use App\Repository\AbsenceBalanceRepository;
use App\Repository\AbsencePolicyRepository;
use App\Service\AbsenceBalanceService;
use App\Service\AbsenceDayCalculator;
use DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Dry-run endpoint for the "new request" form: returns the number of deducted
* days and the projected balance without creating anything. Required because
* public holidays are computed server-side.
*/
class AbsencePreviewController extends AbstractController
{
public function __construct(
private readonly Security $security,
private readonly AbsenceDayCalculator $calculator,
private readonly AbsencePolicyRepository $policyRepository,
private readonly AbsenceBalanceRepository $balanceRepository,
private readonly AbsenceBalanceService $balanceService,
) {}
#[Route('/api/absence_requests/preview', name: 'absence_request_preview', methods: ['POST'], priority: 1)]
#[IsGranted('ROLE_USER')]
public function __invoke(Request $request): JsonResponse
{
/** @var array<string, mixed> $payload */
$payload = json_decode($request->getContent(), true) ?? [];
$type = AbsenceType::tryFrom((string) ($payload['type'] ?? ''));
if (null === $type) {
throw new UnprocessableEntityHttpException('Unknown absence type.');
}
$startRaw = (string) ($payload['startDate'] ?? '');
$endRaw = (string) ($payload['endDate'] ?? '');
if ('' === $startRaw || '' === $endRaw) {
throw new UnprocessableEntityHttpException('Start date and end date are required.');
}
$start = new DateTimeImmutable($startRaw);
$end = new DateTimeImmutable($endRaw);
if ($end < $start) {
throw new UnprocessableEntityHttpException('End date must be on or after start date.');
}
$policy = $this->policyRepository->findOneByType($type);
$workingDaysOnly = $policy?->isCountWorkingDaysOnly() ?? true;
$countedDays = $this->calculator->countWorkingDays(
$start,
$end,
isset($payload['startHalfDay']) ? HalfDay::tryFrom((string) $payload['startHalfDay']) : null,
isset($payload['endHalfDay']) ? HalfDay::tryFrom((string) $payload['endHalfDay']) : null,
$workingDaysOnly,
);
$user = $this->security->getUser();
assert($user instanceof User);
$available = null;
$projectedAvailable = null;
$period = null;
if ($type->decrementsBalance()) {
$period = $this->balanceService->periodFor($user, $type, $start);
$balance = $this->balanceRepository->findOneForPeriod($user, $type, $period);
$available = $balance?->getAvailable() ?? 0.0;
$projectedAvailable = $available - $countedDays;
}
return $this->json([
'countedDays' => $countedDays,
'period' => $period,
'available' => $available,
'projectedAvailable' => $projectedAvailable,
'justificationRequired' => $policy?->isJustificationRequired() ?? false,
]);
}
}

View File

@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\DataFixtures; namespace App\DataFixtures;
use App\Entity\AbsenceBalance;
use App\Entity\AbsencePolicy;
use App\Entity\AbsenceRequest;
use App\Entity\Client; use App\Entity\Client;
use App\Entity\ClientTicket; use App\Entity\ClientTicket;
use App\Entity\MailConfiguration; use App\Entity\MailConfiguration;
@@ -19,6 +22,10 @@ use App\Entity\TimeEntry;
use App\Entity\User; use App\Entity\User;
use App\Entity\Workflow; use App\Entity\Workflow;
use App\Entity\ZimbraConfiguration; use App\Entity\ZimbraConfiguration;
use App\Enum\AbsenceStatus;
use App\Enum\AbsenceType;
use App\Enum\ContractType;
use App\Enum\FamilySituation;
use App\Enum\RecurrenceType; use App\Enum\RecurrenceType;
use App\Enum\StatusCategory; use App\Enum\StatusCategory;
use DateTimeImmutable; use DateTimeImmutable;
@@ -722,6 +729,106 @@ class AppFixtures extends Fixture
$mailConfig->setEnabled(false); $mailConfig->setEnabled(false);
$manager->persist($mailConfig); $manager->persist($mailConfig);
// =============================================
// Absence management — policies, employees, balances, requests
// =============================================
// Default policies for the 5 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::ParentalLeave, null, null, true, 30, true],
[AbsenceType::Bereavement, null, 3.0, true, 0, true],
[AbsenceType::SickLeave, null, null, true, 0, true],
];
foreach ($policyData as [$type, $daysPerYear, $daysPerEvent, $justif, $notice, $workingDaysOnly]) {
$policy = new AbsencePolicy();
$policy->setType($type);
$policy->setDaysPerYear($daysPerYear);
$policy->setDaysPerEvent($daysPerEvent);
$policy->setJustificationRequired($justif);
$policy->setNoticeDays($notice);
$policy->setCountWorkingDaysOnly($workingDaysOnly);
$policy->setActive(true);
$manager->persist($policy);
}
// Mark internal users as employees
$admin->setIsEmployee(true);
$admin->setHireDate(new DateTimeImmutable('2020-01-15'));
$admin->setContractType(ContractType::Cdi);
$admin->setFamilySituation(FamilySituation::Married);
$admin->setNbChildren(2);
$userAlice->setIsEmployee(true);
$userAlice->setHireDate(new DateTimeImmutable('2022-09-01'));
$userAlice->setContractType(ContractType::Cdi);
$userAlice->setFamilySituation(FamilySituation::Single);
$userBob->setIsEmployee(true);
$userBob->setHireDate(new DateTimeImmutable('2023-03-10'));
$userBob->setContractType(ContractType::Cdd);
$userBob->setWorkTimeRatio(0.8);
$userBob->setFamilySituation(FamilySituation::Pacsed);
$userBob->setNbChildren(1);
// Paid-leave balances for the current reference period (June 1st → May 31st)
$cpPeriod = '2025-2026';
$balanceData = [
// [user, acquired, taken, pending]
[$admin, 22.5, 5.0, 0.0],
[$userAlice, 18.0, 2.0, 5.0],
[$userBob, 14.0, 0.0, 0.0],
];
foreach ($balanceData as [$bUser, $acquired, $taken, $pending]) {
$balance = new AbsenceBalance();
$balance->setUser($bUser);
$balance->setType(AbsenceType::PaidLeave);
$balance->setPeriod($cpPeriod);
$balance->setAcquired($acquired);
$balance->setTaken($taken);
$balance->setPending($pending);
$manager->persist($balance);
}
// Demo requests
$approvedCp = new AbsenceRequest();
$approvedCp->setUser($admin);
$approvedCp->setType(AbsenceType::PaidLeave);
$approvedCp->setStartDate(new DateTimeImmutable('2026-04-13'));
$approvedCp->setEndDate(new DateTimeImmutable('2026-04-17'));
$approvedCp->setCountedDays(5.0);
$approvedCp->setReason('Vacances de printemps');
$approvedCp->setStatus(AbsenceStatus::Approved);
$approvedCp->setCreatedAt(new DateTimeImmutable('2026-03-10'));
$approvedCp->setReviewedAt(new DateTimeImmutable('2026-03-11'));
$approvedCp->setReviewedBy($admin);
$manager->persist($approvedCp);
$pendingCp = new AbsenceRequest();
$pendingCp->setUser($userAlice);
$pendingCp->setType(AbsenceType::PaidLeave);
$pendingCp->setStartDate(new DateTimeImmutable('2026-06-15'));
$pendingCp->setEndDate(new DateTimeImmutable('2026-06-19'));
$pendingCp->setCountedDays(5.0);
$pendingCp->setStatus(AbsenceStatus::Pending);
$pendingCp->setCreatedAt(new DateTimeImmutable('-2 days'));
$manager->persist($pendingCp);
$pendingMarriage = new AbsenceRequest();
$pendingMarriage->setUser($userBob);
$pendingMarriage->setType(AbsenceType::MarriagePacs);
$pendingMarriage->setStartDate(new DateTimeImmutable('2026-07-06'));
$pendingMarriage->setEndDate(new DateTimeImmutable('2026-07-09'));
$pendingMarriage->setCountedDays(4.0);
$pendingMarriage->setReason('Mariage');
$pendingMarriage->setStatus(AbsenceStatus::Pending);
$pendingMarriage->setCreatedAt(new DateTimeImmutable('-1 day'));
$manager->persist($pendingMarriage);
$manager->flush(); $manager->flush();
} }
} }

View File

@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use App\Enum\AbsenceType;
use App\Repository\AbsenceBalanceRepository;
use App\State\AbsenceBalanceProvider;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Per-employee, per-type leave balance for a given reference period.
*/
#[ApiResource(
operations: [
new GetCollection(
paginationEnabled: false,
security: "is_granted('ROLE_USER')",
provider: AbsenceBalanceProvider::class,
),
new Get(
security: "is_granted('ROLE_USER')",
provider: AbsenceBalanceProvider::class,
),
new Patch(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['absence_balance:read']],
denormalizationContext: ['groups' => ['absence_balance:write']],
)]
#[ORM\Entity(repositoryClass: AbsenceBalanceRepository::class)]
#[ORM\Table(name: 'absence_balance')]
#[ORM\UniqueConstraint(name: 'uniq_absence_balance_user_type_period', columns: ['user_id', 'type', 'period'])]
class AbsenceBalance
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['absence_balance:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['absence_balance:read'])]
private ?User $user = null;
#[ORM\Column(type: Types::STRING, length: 32, enumType: AbsenceType::class)]
#[Groups(['absence_balance:read'])]
private AbsenceType $type;
/** Reference period, e.g. "2025-2026" for paid leave or "2025" for yearly. */
#[ORM\Column(length: 16)]
#[Groups(['absence_balance:read'])]
private ?string $period = null;
#[ORM\Column(type: Types::FLOAT)]
#[Groups(['absence_balance:read', 'absence_balance:write'])]
private float $acquired = 0.0;
#[ORM\Column(type: Types::FLOAT)]
#[Groups(['absence_balance:read', 'absence_balance:write'])]
private float $taken = 0.0;
/** Sum of days in PENDING requests, for information. */
#[ORM\Column(type: Types::FLOAT)]
#[Groups(['absence_balance:read'])]
private float $pending = 0.0;
#[Groups(['absence_balance:read'])]
public function getAvailable(): float
{
return $this->acquired - $this->taken;
}
#[Groups(['absence_balance:read'])]
public function getLabel(): string
{
return $this->type->label();
}
public function getId(): ?int
{
return $this->id;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
public function getType(): AbsenceType
{
return $this->type;
}
public function setType(AbsenceType $type): static
{
$this->type = $type;
return $this;
}
public function getPeriod(): ?string
{
return $this->period;
}
public function setPeriod(string $period): static
{
$this->period = $period;
return $this;
}
public function getAcquired(): float
{
return $this->acquired;
}
public function setAcquired(float $acquired): static
{
$this->acquired = $acquired;
return $this;
}
public function getTaken(): float
{
return $this->taken;
}
public function setTaken(float $taken): static
{
$this->taken = $taken;
return $this;
}
public function getPending(): float
{
return $this->pending;
}
public function setPending(float $pending): static
{
$this->pending = $pending;
return $this;
}
}

View File

@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use App\Enum\AbsenceType;
use App\Repository\AbsencePolicyRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Per-type configuration of absence rules. Overrides the legal defaults and
* lets an admin tune days/year, days/event, notice period, etc.
*/
#[ApiResource(
operations: [
new GetCollection(
paginationEnabled: false,
security: "is_granted('ROLE_USER')",
),
new Get(security: "is_granted('ROLE_USER')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['absence_policy:read']],
denormalizationContext: ['groups' => ['absence_policy:write']],
order: ['type' => 'ASC'],
)]
#[ORM\Entity(repositoryClass: AbsencePolicyRepository::class)]
#[ORM\Table(name: 'absence_policy')]
#[ORM\UniqueConstraint(name: 'uniq_absence_policy_type', columns: ['type'])]
class AbsencePolicy
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['absence_policy:read'])]
private ?int $id = null;
#[ORM\Column(type: Types::STRING, length: 32, enumType: AbsenceType::class)]
#[Groups(['absence_policy:read', 'absence_balance:read', 'absence_request:read'])]
private AbsenceType $type;
/** Yearly entitlement (e.g. 25 for paid leave); null when not relevant. */
#[ORM\Column(type: Types::FLOAT, nullable: true)]
#[Groups(['absence_policy:read', 'absence_policy:write'])]
private ?float $daysPerYear = null;
/** Days granted per event (e.g. 4 for marriage); null when not relevant. */
#[ORM\Column(type: Types::FLOAT, nullable: true)]
#[Groups(['absence_policy:read', 'absence_policy:write'])]
private ?float $daysPerEvent = null;
#[ORM\Column]
#[Groups(['absence_policy:read', 'absence_policy:write'])]
private bool $justificationRequired = false;
/** Minimum notice period in days (e.g. 30 for paid leave, 0 for sick leave). */
#[ORM\Column]
#[Groups(['absence_policy:read', 'absence_policy:write'])]
private int $noticeDays = 0;
/** true => "jours ouvrés" (Mon-Fri), false => "jours ouvrables" (Mon-Sat). */
#[ORM\Column]
#[Groups(['absence_policy:read', 'absence_policy:write'])]
private bool $countWorkingDaysOnly = true;
#[ORM\Column]
#[Groups(['absence_policy:read', 'absence_policy:write'])]
private bool $active = true;
#[Groups(['absence_policy:read'])]
public function getLabel(): string
{
return $this->type->label();
}
public function getId(): ?int
{
return $this->id;
}
public function getType(): AbsenceType
{
return $this->type;
}
public function setType(AbsenceType $type): static
{
$this->type = $type;
return $this;
}
public function getDaysPerYear(): ?float
{
return $this->daysPerYear;
}
public function setDaysPerYear(?float $daysPerYear): static
{
$this->daysPerYear = $daysPerYear;
return $this;
}
public function getDaysPerEvent(): ?float
{
return $this->daysPerEvent;
}
public function setDaysPerEvent(?float $daysPerEvent): static
{
$this->daysPerEvent = $daysPerEvent;
return $this;
}
public function isJustificationRequired(): bool
{
return $this->justificationRequired;
}
public function setJustificationRequired(bool $justificationRequired): static
{
$this->justificationRequired = $justificationRequired;
return $this;
}
public function getNoticeDays(): int
{
return $this->noticeDays;
}
public function setNoticeDays(int $noticeDays): static
{
$this->noticeDays = $noticeDays;
return $this;
}
public function isCountWorkingDaysOnly(): bool
{
return $this->countWorkingDaysOnly;
}
public function setCountWorkingDaysOnly(bool $countWorkingDaysOnly): static
{
$this->countWorkingDaysOnly = $countWorkingDaysOnly;
return $this;
}
public function isActive(): bool
{
return $this->active;
}
public function setActive(bool $active): static
{
$this->active = $active;
return $this;
}
}

View File

@@ -0,0 +1,326 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Enum\AbsenceStatus;
use App\Enum\AbsenceType;
use App\Enum\HalfDay;
use App\Repository\AbsenceRequestRepository;
use App\State\AbsenceCancelProcessor;
use App\State\AbsenceRequestProcessor;
use App\State\AbsenceRequestProvider;
use App\State\AbsenceReviewProcessor;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
new GetCollection(
paginationEnabled: false,
security: "is_granted('ROLE_USER')",
provider: AbsenceRequestProvider::class,
),
new Get(
security: "is_granted('ROLE_USER')",
provider: AbsenceRequestProvider::class,
),
new Post(
security: "is_granted('ROLE_USER')",
processor: AbsenceRequestProcessor::class,
),
new Patch(
uriTemplate: '/absence_requests/{id}/approve',
security: "is_granted('ROLE_ADMIN')",
processor: AbsenceReviewProcessor::class,
provider: AbsenceRequestProvider::class,
),
new Patch(
uriTemplate: '/absence_requests/{id}/reject',
security: "is_granted('ROLE_ADMIN')",
processor: AbsenceReviewProcessor::class,
provider: AbsenceRequestProvider::class,
),
new Patch(
uriTemplate: '/absence_requests/{id}/cancel',
security: "is_granted('ROLE_USER')",
processor: AbsenceCancelProcessor::class,
provider: AbsenceRequestProvider::class,
),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['absence_request:read']],
denormalizationContext: ['groups' => ['absence_request:write']],
order: ['createdAt' => 'DESC'],
)]
#[ORM\Entity(repositoryClass: AbsenceRequestRepository::class)]
#[ORM\Table(name: 'absence_request')]
class AbsenceRequest
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['absence_request:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['absence_request:read'])]
private ?User $user = null;
#[ORM\Column(type: Types::STRING, length: 32, enumType: AbsenceType::class)]
#[Groups(['absence_request:read', 'absence_request:write'])]
#[Assert\NotNull]
private ?AbsenceType $type = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
#[Groups(['absence_request:read', 'absence_request:write'])]
#[Assert\NotNull]
private ?DateTimeImmutable $startDate = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
#[Groups(['absence_request:read', 'absence_request:write'])]
#[Assert\NotNull]
private ?DateTimeImmutable $endDate = null;
#[ORM\Column(type: Types::STRING, length: 16, nullable: true, enumType: HalfDay::class)]
#[Groups(['absence_request:read', 'absence_request:write'])]
private ?HalfDay $startHalfDay = null;
#[ORM\Column(type: Types::STRING, length: 16, nullable: true, enumType: HalfDay::class)]
#[Groups(['absence_request:read', 'absence_request:write'])]
private ?HalfDay $endHalfDay = null;
/** Number of deducted days, computed server-side at creation. */
#[ORM\Column(type: Types::FLOAT)]
#[Groups(['absence_request:read'])]
private float $countedDays = 0.0;
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Groups(['absence_request:read', 'absence_request:write'])]
private ?string $reason = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['absence_request:read'])]
private ?string $justificationFileName = null;
#[ORM\Column(type: Types::STRING, length: 16, enumType: AbsenceStatus::class)]
#[Groups(['absence_request:read'])]
private AbsenceStatus $status = AbsenceStatus::Pending;
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Groups(['absence_request:read', 'absence_request:write'])]
private ?string $rejectionReason = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
#[Groups(['absence_request:read'])]
private ?DateTimeImmutable $createdAt = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
#[Groups(['absence_request:read'])]
private ?DateTimeImmutable $reviewedAt = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['absence_request:read'])]
private ?User $reviewedBy = null;
#[Groups(['absence_request:read'])]
public function getLabel(): ?string
{
return $this->type?->label();
}
#[Groups(['absence_request:read'])]
public function getJustificationUrl(): ?string
{
if (null === $this->justificationFileName) {
return null;
}
return '/api/absence_requests/'.$this->id.'/justificatif';
}
public function getId(): ?int
{
return $this->id;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
public function getType(): ?AbsenceType
{
return $this->type;
}
public function setType(?AbsenceType $type): static
{
$this->type = $type;
return $this;
}
public function getStartDate(): ?DateTimeImmutable
{
return $this->startDate;
}
public function setStartDate(?DateTimeImmutable $startDate): static
{
$this->startDate = $startDate;
return $this;
}
public function getEndDate(): ?DateTimeImmutable
{
return $this->endDate;
}
public function setEndDate(?DateTimeImmutable $endDate): static
{
$this->endDate = $endDate;
return $this;
}
public function getStartHalfDay(): ?HalfDay
{
return $this->startHalfDay;
}
public function setStartHalfDay(?HalfDay $startHalfDay): static
{
$this->startHalfDay = $startHalfDay;
return $this;
}
public function getEndHalfDay(): ?HalfDay
{
return $this->endHalfDay;
}
public function setEndHalfDay(?HalfDay $endHalfDay): static
{
$this->endHalfDay = $endHalfDay;
return $this;
}
public function getCountedDays(): float
{
return $this->countedDays;
}
public function setCountedDays(float $countedDays): static
{
$this->countedDays = $countedDays;
return $this;
}
public function getReason(): ?string
{
return $this->reason;
}
public function setReason(?string $reason): static
{
$this->reason = $reason;
return $this;
}
public function getJustificationFileName(): ?string
{
return $this->justificationFileName;
}
public function setJustificationFileName(?string $justificationFileName): static
{
$this->justificationFileName = $justificationFileName;
return $this;
}
public function getStatus(): AbsenceStatus
{
return $this->status;
}
public function setStatus(AbsenceStatus $status): static
{
$this->status = $status;
return $this;
}
public function getRejectionReason(): ?string
{
return $this->rejectionReason;
}
public function setRejectionReason(?string $rejectionReason): static
{
$this->rejectionReason = $rejectionReason;
return $this;
}
public function getCreatedAt(): ?DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(DateTimeImmutable $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
public function getReviewedAt(): ?DateTimeImmutable
{
return $this->reviewedAt;
}
public function setReviewedAt(?DateTimeImmutable $reviewedAt): static
{
$this->reviewedAt = $reviewedAt;
return $this;
}
public function getReviewedBy(): ?User
{
return $this->reviewedBy;
}
public function setReviewedBy(?User $reviewedBy): static
{
$this->reviewedBy = $reviewedBy;
return $this;
}
}

View File

@@ -10,6 +10,8 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use App\Enum\ContractType;
use App\Enum\FamilySituation;
use App\Repository\UserRepository; use App\Repository\UserRepository;
use App\State\MeProvider; use App\State\MeProvider;
use App\State\UserPasswordHasherProcessor; use App\State\UserPasswordHasherProcessor;
@@ -48,11 +50,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column] #[ORM\Column]
#[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'client_ticket:read'])] #[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'client_ticket:read', 'absence_request:read', 'absence_balance:read'])]
private ?int $id = null; private ?int $id = null;
#[ORM\Column(length: 180, unique: true)] #[ORM\Column(length: 180, unique: true)]
#[Groups(['me:read', 'task:read', 'user:list', 'user:write', 'time_entry:read', 'client_ticket:read'])] #[Groups(['me:read', 'task:read', 'user:list', 'user:write', 'time_entry:read', 'client_ticket:read', 'absence_request:read', 'absence_balance:read'])]
private ?string $username = null; private ?string $username = null;
/** @var list<string> */ /** @var list<string> */
@@ -87,6 +89,54 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[Groups(['me:read', 'user:list', 'user:write'])] #[Groups(['me:read', 'user:list', 'user:write'])]
private Collection $allowedProjects; private Collection $allowedProjects;
// --- HR / absence management fields ---
/** Whether this user is an employee subject to absence management. */
#[ORM\Column]
#[Groups(['me:read', 'user:list', 'user:write'])]
private bool $isEmployee = false;
/** Hiring date — start of paid-leave acquisition. */
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
#[Groups(['me:read', 'user:list', 'user:write'])]
private ?DateTimeImmutable $hireDate = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
#[Groups(['me:read', 'user:list', 'user:write'])]
private ?DateTimeImmutable $endDate = null;
#[ORM\Column(type: Types::STRING, length: 16, nullable: true, enumType: ContractType::class)]
#[Groups(['me:read', 'user:list', 'user:write'])]
private ?ContractType $contractType = null;
/** Work-time ratio: 1.0 = full time, 0.8 = 4 days out of 5. */
#[ORM\Column(type: Types::FLOAT)]
#[Groups(['me:read', 'user:list', 'user:write'])]
private float $workTimeRatio = 1.0;
/** Yearly paid-leave entitlement in worked days (default 25 = jours ouvrés). */
#[ORM\Column(type: Types::FLOAT)]
#[Groups(['me:read', 'user:list', 'user:write'])]
private float $annualLeaveDays = 25.0;
/** Reference period start as MM-DD (default 06-01, 1st of June). */
#[ORM\Column(length: 5)]
#[Groups(['me:read', 'user:list', 'user:write'])]
private string $referencePeriodStart = '06-01';
/** Paid-leave already acquired when the module is rolled out. */
#[ORM\Column(type: Types::FLOAT)]
#[Groups(['me:read', 'user:list', 'user:write'])]
private float $initialLeaveBalance = 0.0;
#[ORM\Column(type: Types::STRING, length: 16, nullable: true, enumType: FamilySituation::class)]
#[Groups(['me:read', 'user:list', 'user:write'])]
private ?FamilySituation $familySituation = null;
#[ORM\Column]
#[Groups(['me:read', 'user:list', 'user:write'])]
private int $nbChildren = 0;
public function __construct() public function __construct()
{ {
$this->createdAt = new DateTimeImmutable(); $this->createdAt = new DateTimeImmutable();
@@ -217,7 +267,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this; return $this;
} }
#[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'client_ticket:read'])] #[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'client_ticket:read', 'absence_request:read', 'absence_balance:read'])]
public function getAvatarUrl(): ?string public function getAvatarUrl(): ?string
{ {
if (null === $this->avatarFileName) { if (null === $this->avatarFileName) {
@@ -243,4 +293,124 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
{ {
$this->plainPassword = null; $this->plainPassword = null;
} }
public function isEmployee(): bool
{
return $this->isEmployee;
}
public function setIsEmployee(bool $isEmployee): static
{
$this->isEmployee = $isEmployee;
return $this;
}
public function getHireDate(): ?DateTimeImmutable
{
return $this->hireDate;
}
public function setHireDate(?DateTimeImmutable $hireDate): static
{
$this->hireDate = $hireDate;
return $this;
}
public function getEndDate(): ?DateTimeImmutable
{
return $this->endDate;
}
public function setEndDate(?DateTimeImmutable $endDate): static
{
$this->endDate = $endDate;
return $this;
}
public function getContractType(): ?ContractType
{
return $this->contractType;
}
public function setContractType(?ContractType $contractType): static
{
$this->contractType = $contractType;
return $this;
}
public function getWorkTimeRatio(): float
{
return $this->workTimeRatio;
}
public function setWorkTimeRatio(float $workTimeRatio): static
{
$this->workTimeRatio = $workTimeRatio;
return $this;
}
public function getAnnualLeaveDays(): float
{
return $this->annualLeaveDays;
}
public function setAnnualLeaveDays(float $annualLeaveDays): static
{
$this->annualLeaveDays = $annualLeaveDays;
return $this;
}
public function getReferencePeriodStart(): string
{
return $this->referencePeriodStart;
}
public function setReferencePeriodStart(string $referencePeriodStart): static
{
$this->referencePeriodStart = $referencePeriodStart;
return $this;
}
public function getInitialLeaveBalance(): float
{
return $this->initialLeaveBalance;
}
public function setInitialLeaveBalance(float $initialLeaveBalance): static
{
$this->initialLeaveBalance = $initialLeaveBalance;
return $this;
}
public function getFamilySituation(): ?FamilySituation
{
return $this->familySituation;
}
public function setFamilySituation(?FamilySituation $familySituation): static
{
$this->familySituation = $familySituation;
return $this;
}
public function getNbChildren(): int
{
return $this->nbChildren;
}
public function setNbChildren(int $nbChildren): static
{
$this->nbChildren = $nbChildren;
return $this;
}
} }

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Enum;
enum AbsenceStatus: string
{
case Pending = 'pending';
case Approved = 'approved';
case Rejected = 'rejected';
case Cancelled = 'cancelled';
public function label(): string
{
return match ($this) {
self::Pending => 'En attente',
self::Approved => 'Approuvée',
self::Rejected => 'Refusée',
self::Cancelled => 'Annulée',
};
}
}

34
src/Enum/AbsenceType.php Normal file
View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Enum;
enum AbsenceType: string
{
case PaidLeave = 'cp';
case MarriagePacs = 'mariage_pacs';
case ParentalLeave = 'conge_parental';
case Bereavement = 'deces';
case SickLeave = 'maladie';
public function label(): string
{
return match ($this) {
self::PaidLeave => 'Congés payés',
self::MarriagePacs => 'Mariage / PACS',
self::ParentalLeave => 'Congé parental',
self::Bereavement => 'Décès proche',
self::SickLeave => 'Arrêt maladie',
};
}
/**
* Whether taking this absence decrements a balance.
* Sick leave is managed by social security and has no balance.
*/
public function decrementsBalance(): bool
{
return self::SickLeave !== $this;
}
}

25
src/Enum/ContractType.php Normal file
View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Enum;
enum ContractType: string
{
case Cdi = 'CDI';
case Cdd = 'CDD';
case Internship = 'STAGE';
case Apprentice = 'ALTERNANCE';
case Other = 'AUTRE';
public function label(): string
{
return match ($this) {
self::Cdi => 'CDI',
self::Cdd => 'CDD',
self::Internship => 'Stage',
self::Apprentice => 'Alternance',
self::Other => 'Autre',
};
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Enum;
enum FamilySituation: string
{
case Single = 'CELIBATAIRE';
case Married = 'MARIE';
case Pacsed = 'PACSE';
case Divorced = 'DIVORCE';
case Widowed = 'VEUF';
public function label(): string
{
return match ($this) {
self::Single => 'Célibataire',
self::Married => 'Marié(e)',
self::Pacsed => 'Pacsé(e)',
self::Divorced => 'Divorcé(e)',
self::Widowed => 'Veuf(ve)',
};
}
}

19
src/Enum/HalfDay.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Enum;
enum HalfDay: string
{
case Morning = 'matin';
case Afternoon = 'apres_midi';
public function label(): string
{
return match ($this) {
self::Morning => 'Matin',
self::Afternoon => 'Après-midi',
};
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\AbsenceBalance;
use App\Entity\User;
use App\Enum\AbsenceType;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AbsenceBalance>
*/
class AbsenceBalanceRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AbsenceBalance::class);
}
public function findOneForPeriod(User $user, AbsenceType $type, string $period): ?AbsenceBalance
{
return $this->findOneBy([
'user' => $user,
'type' => $type,
'period' => $period,
]);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\AbsencePolicy;
use App\Enum\AbsenceType;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AbsencePolicy>
*/
class AbsencePolicyRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AbsencePolicy::class);
}
public function findOneByType(AbsenceType $type): ?AbsencePolicy
{
return $this->findOneBy(['type' => $type]);
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\AbsenceRequest;
use App\Entity\User;
use App\Enum\AbsenceStatus;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AbsenceRequest>
*/
class AbsenceRequestRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AbsenceRequest::class);
}
/**
* Whether the user already has a PENDING or APPROVED absence that overlaps
* the given date range. Two ranges overlap when start_a <= end_b and
* end_a >= start_b.
*/
public function hasOverlap(
User $user,
DateTimeInterface $startDate,
DateTimeInterface $endDate,
?int $excludeId = null,
): bool {
$qb = $this->createQueryBuilder('a')
->select('COUNT(a.id)')
->andWhere('a.user = :user')
->andWhere('a.status IN (:statuses)')
->andWhere('a.startDate <= :endDate')
->andWhere('a.endDate >= :startDate')
->setParameter('user', $user)
->setParameter('statuses', [AbsenceStatus::Pending, AbsenceStatus::Approved])
->setParameter('startDate', $startDate->format('Y-m-d'))
->setParameter('endDate', $endDate->format('Y-m-d'))
;
if (null !== $excludeId) {
$qb->andWhere('a.id != :excludeId')->setParameter('excludeId', $excludeId);
}
return (int) $qb->getQuery()->getSingleScalarResult() > 0;
}
/**
* Absences (approved or pending) overlapping a date range, all employees —
* used by the admin calendar view.
*
* @return AbsenceRequest[]
*/
public function findInRange(DateTimeInterface $from, DateTimeInterface $to): array
{
return $this->createQueryBuilder('a')
->andWhere('a.status IN (:statuses)')
->andWhere('a.startDate <= :to')
->andWhere('a.endDate >= :from')
->setParameter('statuses', [AbsenceStatus::Pending, AbsenceStatus::Approved])
->setParameter('from', $from->format('Y-m-d'))
->setParameter('to', $to->format('Y-m-d'))
->orderBy('a.startDate', 'ASC')
->getQuery()
->getResult()
;
}
}

View File

@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\AbsenceBalance;
use App\Entity\AbsenceRequest;
use App\Entity\User;
use App\Enum\AbsenceType;
use App\Repository\AbsenceBalanceRepository;
use DateTimeInterface;
use Doctrine\ORM\EntityManagerInterface;
/**
* Maintains per-employee leave balances as absence requests move through their
* lifecycle: a PENDING request reserves days in `pending`, an APPROVED one
* moves them to `taken`, and a cancellation gives them back.
*/
final readonly class AbsenceBalanceService
{
public function __construct(
private EntityManagerInterface $entityManager,
private AbsenceBalanceRepository $balanceRepository,
) {}
/**
* Reference period string for a request: paid leave follows the employee's
* reference period (e.g. "2025-2026"), other types are tracked yearly.
*/
public function periodFor(User $user, AbsenceType $type, DateTimeInterface $date): string
{
if (AbsenceType::PaidLeave !== $type) {
return $date->format('Y');
}
$year = (int) $date->format('Y');
$startMonthDay = $user->getReferencePeriodStart(); // e.g. "06-01"
$currentMonthDay = $date->format('m-d');
$startYear = $currentMonthDay >= $startMonthDay ? $year : $year - 1;
return sprintf('%d-%d', $startYear, $startYear + 1);
}
public function getOrCreateBalance(User $user, AbsenceType $type, string $period): AbsenceBalance
{
$balance = $this->balanceRepository->findOneForPeriod($user, $type, $period);
if (null === $balance) {
$balance = new AbsenceBalance()
->setUser($user)
->setType($type)
->setPeriod($period)
;
$this->entityManager->persist($balance);
}
return $balance;
}
/** Reserve the requested days in the PENDING bucket. */
public function reservePending(AbsenceRequest $request): void
{
if (!$this->shouldTrack($request)) {
return;
}
$balance = $this->balanceForRequest($request);
$balance->setPending($balance->getPending() + $request->getCountedDays());
}
/** Move reserved days from PENDING to TAKEN on approval. */
public function applyApproval(AbsenceRequest $request): void
{
if (!$this->shouldTrack($request)) {
return;
}
$balance = $this->balanceForRequest($request);
$balance->setPending(max(0.0, $balance->getPending() - $request->getCountedDays()));
$balance->setTaken($balance->getTaken() + $request->getCountedDays());
}
/**
* Give days back when a request is cancelled or rejected.
*
* @param bool $wasApproved true if the request had already been approved
* (days were in TAKEN), false if still PENDING
*/
public function release(AbsenceRequest $request, bool $wasApproved): void
{
if (!$this->shouldTrack($request)) {
return;
}
$balance = $this->balanceForRequest($request);
if ($wasApproved) {
$balance->setTaken(max(0.0, $balance->getTaken() - $request->getCountedDays()));
} else {
$balance->setPending(max(0.0, $balance->getPending() - $request->getCountedDays()));
}
}
private function balanceForRequest(AbsenceRequest $request): AbsenceBalance
{
/** @var User $user */
$user = $request->getUser();
$type = $request->getType();
$period = $this->periodFor($user, $type, $request->getStartDate());
return $this->getOrCreateBalance($user, $type, $period);
}
private function shouldTrack(AbsenceRequest $request): bool
{
$type = $request->getType();
return null !== $type && $type->decrementsBalance() && null !== $request->getUser();
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Enum\HalfDay;
use DateInterval;
use DatePeriod;
use DateTimeImmutable;
/**
* Computes the number of days deducted for an absence request, following the
* business rules of the spec (§5.1): weekends and public holidays are skipped,
* and half-days on the boundaries subtract 0.5 each.
*/
final readonly class AbsenceDayCalculator
{
public function __construct(
private PublicHolidayProvider $holidayProvider,
) {}
/**
* @param bool $workingDaysOnly true => "jours ouvrés" (Mon-Fri),
* false => "jours ouvrables" (Mon-Sat, Sunday excluded)
*/
public function countWorkingDays(
DateTimeImmutable $start,
DateTimeImmutable $end,
?HalfDay $startHalfDay = null,
?HalfDay $endHalfDay = null,
bool $workingDaysOnly = true,
): float {
$start = $start->setTime(0, 0);
$end = $end->setTime(0, 0);
if ($end < $start) {
return 0.0;
}
$days = 0.0;
$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 (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) {
$days -= 0.5;
}
if (null !== $endHalfDay) {
$days -= 0.5;
}
return max(0.0, $days);
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Service;
use DateTimeImmutable;
use DateTimeInterface;
/**
* Provides French métropole public holidays.
*
* Dates are computed in pure PHP: fixed-date holidays are hardcoded and
* Easter-based ones are derived from the Computus (Meeus/Jones/Butcher
* Gregorian algorithm), so the provider has no runtime dependency and is
* fully deterministic. Alsace-Moselle / DOM specifics are out of scope.
*/
final class PublicHolidayProvider
{
/** @var array<int, array<string, string>> cache of holidays per year */
private array $cache = [];
/**
* @return array<string, string> map of 'Y-m-d' => label, sorted by date
*/
public function getHolidays(int $year): array
{
if (isset($this->cache[$year])) {
return $this->cache[$year];
}
$easter = $this->easterSunday($year);
$easterMonday = $easter->modify('+1 day');
$ascension = $easter->modify('+39 days');
$whitMonday = $easter->modify('+50 days');
$holidays = [
sprintf('%d-01-01', $year) => 'Jour de l\'an',
$easterMonday->format('Y-m-d') => 'Lundi de Pâques',
sprintf('%d-05-01', $year) => 'Fête du Travail',
sprintf('%d-05-08', $year) => 'Victoire 1945',
$ascension->format('Y-m-d') => 'Ascension',
$whitMonday->format('Y-m-d') => 'Lundi de Pentecôte',
sprintf('%d-07-14', $year) => 'Fête nationale',
sprintf('%d-08-15', $year) => 'Assomption',
sprintf('%d-11-01', $year) => 'Toussaint',
sprintf('%d-11-11', $year) => 'Armistice 1918',
sprintf('%d-12-25', $year) => 'Noël',
];
ksort($holidays);
return $this->cache[$year] = $holidays;
}
public function isHoliday(DateTimeInterface $date): bool
{
$holidays = $this->getHolidays((int) $date->format('Y'));
return isset($holidays[$date->format('Y-m-d')]);
}
/**
* Easter Sunday date for the given year (Gregorian Computus).
*/
private function easterSunday(int $year): DateTimeImmutable
{
$a = $year % 19;
$b = intdiv($year, 100);
$c = $year % 100;
$d = intdiv($b, 4);
$e = $b % 4;
$f = intdiv($b + 8, 25);
$g = intdiv($b - $f + 1, 3);
$h = (19 * $a + $b - $d - $g + 15) % 30;
$i = intdiv($c, 4);
$k = $c % 4;
$l = (32 + 2 * $e + 2 * $i - $h - $k) % 7;
$m = intdiv($a + 11 * $h + 22 * $l, 451);
$month = intdiv($h + $l - 7 * $m + 114, 31);
$day = (($h + $l - 7 * $m + 114) % 31) + 1;
return new DateTimeImmutable(sprintf('%04d-%02d-%02d', $year, $month, $day));
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\AbsenceBalance;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
/**
* @implements ProviderInterface<AbsenceBalance>
*/
final readonly class AbsenceBalanceProvider implements ProviderInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): AbsenceBalance|array|null
{
$user = $this->security->getUser();
assert($user instanceof User);
$repo = $this->entityManager->getRepository(AbsenceBalance::class);
$isAdmin = $this->security->isGranted('ROLE_ADMIN');
if (isset($uriVariables['id'])) {
$balance = $repo->find($uriVariables['id']);
if (null === $balance) {
return null;
}
if (!$isAdmin && $balance->getUser() !== $user) {
return null;
}
return $balance;
}
$qb = $repo->createQueryBuilder('b')
->orderBy('b.type', 'ASC')
;
if (!$isAdmin) {
$qb->andWhere('b.user = :user')->setParameter('user', $user);
}
$filters = $context['filters'] ?? [];
if (isset($filters['type'])) {
$qb->andWhere('b.type = :type')->setParameter('type', $filters['type']);
}
if (isset($filters['period'])) {
$qb->andWhere('b.period = :period')->setParameter('period', $filters['period']);
}
if ($isAdmin && isset($filters['user'])) {
$qb->andWhere('b.user = :filterUser')
->setParameter('filterUser', self::extractId($filters['user']))
;
}
return $qb->getQuery()->getResult();
}
private static function extractId(string $value): int
{
return is_numeric($value) ? (int) $value : (int) basename($value);
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\AbsenceRequest;
use App\Enum\AbsenceStatus;
use App\Service\AbsenceBalanceService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
/**
* Cancellation of an absence request. An employee may cancel their own PENDING
* request; an admin may additionally cancel an APPROVED one, which credits the
* deducted days back to the balance.
*
* @implements ProcessorInterface<AbsenceRequest, AbsenceRequest>
*/
final readonly class AbsenceCancelProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
private AbsenceBalanceService $balanceService,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): AbsenceRequest
{
assert($data instanceof AbsenceRequest);
// Cancellation carries no payload: keep the persisted content intact.
$previous = $context['previous_data'] ?? null;
if ($previous instanceof AbsenceRequest) {
$data->setType($previous->getType());
$data->setStartDate($previous->getStartDate());
$data->setEndDate($previous->getEndDate());
$data->setStartHalfDay($previous->getStartHalfDay());
$data->setEndHalfDay($previous->getEndHalfDay());
$data->setReason($previous->getReason());
$data->setCountedDays($previous->getCountedDays());
$data->setStatus($previous->getStatus());
}
$user = $this->security->getUser();
$isAdmin = $this->security->isGranted('ROLE_ADMIN');
$status = $data->getStatus();
if (AbsenceStatus::Pending === $status) {
$this->balanceService->release($data, false);
} elseif (AbsenceStatus::Approved === $status) {
if (!$isAdmin) {
throw new AccessDeniedHttpException('Only an admin can cancel an approved request.');
}
$this->balanceService->release($data, true);
} else {
throw new ConflictHttpException('This request can no longer be cancelled.');
}
// An employee may only cancel their own request (admins can cancel any).
if (!$isAdmin && $data->getUser() !== $user) {
throw new AccessDeniedHttpException('You can only cancel your own requests.');
}
$data->setStatus(AbsenceStatus::Cancelled);
$this->entityManager->flush();
return $data;
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\AbsenceRequest;
use App\Entity\User;
use App\Enum\AbsenceStatus;
use App\Repository\AbsencePolicyRepository;
use App\Repository\AbsenceRequestRepository;
use App\Service\AbsenceBalanceService;
use App\Service\AbsenceDayCalculator;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* Handles creation of an absence request: computes the deducted days, enforces
* the overlap rule, and reserves the days in the employee's pending balance.
*
* @implements ProcessorInterface<AbsenceRequest, AbsenceRequest>
*/
final readonly class AbsenceRequestProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
private AbsenceDayCalculator $calculator,
private AbsencePolicyRepository $policyRepository,
private AbsenceRequestRepository $requestRepository,
private AbsenceBalanceService $balanceService,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): AbsenceRequest
{
assert($data instanceof AbsenceRequest);
$user = $this->security->getUser();
assert($user instanceof User);
$type = $data->getType();
$startDate = $data->getStartDate();
$endDate = $data->getEndDate();
if (null === $type || null === $startDate || null === $endDate) {
throw new UnprocessableEntityHttpException('Type, start date and end date are required.');
}
if ($endDate < $startDate) {
throw new UnprocessableEntityHttpException('End date must be on or after start date.');
}
$policy = $this->policyRepository->findOneByType($type);
if (null === $policy || !$policy->isActive()) {
throw new UnprocessableEntityHttpException('This absence type is not available.');
}
if ($this->requestRepository->hasOverlap($user, $startDate, $endDate)) {
throw new ConflictHttpException('This request overlaps an existing absence.');
}
$countedDays = $this->calculator->countWorkingDays(
$startDate,
$endDate,
$data->getStartHalfDay(),
$data->getEndHalfDay(),
$policy->isCountWorkingDaysOnly(),
);
if ($countedDays <= 0.0) {
throw new UnprocessableEntityHttpException('The selected range contains no working day.');
}
$data->setUser($user);
$data->setCountedDays($countedDays);
$data->setStatus(AbsenceStatus::Pending);
$data->setRejectionReason(null);
$data->setCreatedAt(new DateTimeImmutable());
$this->entityManager->persist($data);
$this->balanceService->reservePending($data);
$this->entityManager->flush();
return $data;
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\AbsenceRequest;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
/**
* @implements ProviderInterface<AbsenceRequest>
*/
final readonly class AbsenceRequestProvider implements ProviderInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): AbsenceRequest|array|null
{
$user = $this->security->getUser();
assert($user instanceof User);
$repo = $this->entityManager->getRepository(AbsenceRequest::class);
$isAdmin = $this->security->isGranted('ROLE_ADMIN');
// Single item: owner or admin only
if (isset($uriVariables['id'])) {
$request = $repo->find($uriVariables['id']);
if (null === $request) {
return null;
}
if (!$isAdmin && $request->getUser() !== $user) {
return null;
}
return $request;
}
$qb = $repo->createQueryBuilder('a')
->orderBy('a.createdAt', 'DESC')
;
if (!$isAdmin) {
$qb->andWhere('a.user = :user')->setParameter('user', $user);
}
$filters = $context['filters'] ?? [];
if (isset($filters['status'])) {
$qb->andWhere('a.status = :status')->setParameter('status', $filters['status']);
}
if (isset($filters['type'])) {
$qb->andWhere('a.type = :type')->setParameter('type', $filters['type']);
}
if (isset($filters['year']) && is_numeric($filters['year'])) {
$year = (int) $filters['year'];
$qb->andWhere('a.startDate <= :yearEnd')
->andWhere('a.endDate >= :yearStart')
->setParameter('yearStart', sprintf('%d-01-01', $year))
->setParameter('yearEnd', sprintf('%d-12-31', $year))
;
}
if ($isAdmin && isset($filters['user'])) {
$qb->andWhere('a.user = :filterUser')
->setParameter('filterUser', self::extractId($filters['user']))
;
}
return $qb->getQuery()->getResult();
}
private static function extractId(string $value): int
{
return is_numeric($value) ? (int) $value : (int) basename($value);
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\AbsenceRequest;
use App\Entity\User;
use App\Enum\AbsenceStatus;
use App\Service\AbsenceBalanceService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* Admin approval / rejection of a pending absence request. The target status
* is derived from the operation URI (.../approve or .../reject).
*
* @implements ProcessorInterface<AbsenceRequest, AbsenceRequest>
*/
final readonly class AbsenceReviewProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
private AbsenceBalanceService $balanceService,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): AbsenceRequest
{
assert($data instanceof AbsenceRequest);
$isApprove = str_contains((string) $operation->getUriTemplate(), 'approve');
$newRejectionReason = $data->getRejectionReason();
// Reviewing must never alter the request content: restore everything
// from the persisted state, only status/review fields may change.
$previous = $context['previous_data'] ?? null;
if ($previous instanceof AbsenceRequest) {
$data->setType($previous->getType());
$data->setStartDate($previous->getStartDate());
$data->setEndDate($previous->getEndDate());
$data->setStartHalfDay($previous->getStartHalfDay());
$data->setEndHalfDay($previous->getEndHalfDay());
$data->setReason($previous->getReason());
$data->setCountedDays($previous->getCountedDays());
}
if (AbsenceStatus::Pending !== $data->getStatus()) {
throw new ConflictHttpException('Only a pending request can be reviewed.');
}
$admin = $this->security->getUser();
assert($admin instanceof User);
if ($isApprove) {
$data->setStatus(AbsenceStatus::Approved);
$data->setRejectionReason(null);
$this->balanceService->applyApproval($data);
} else {
if (null === $newRejectionReason || '' === trim($newRejectionReason)) {
throw new UnprocessableEntityHttpException('A reason is required when rejecting a request.');
}
$data->setStatus(AbsenceStatus::Rejected);
$data->setRejectionReason($newRejectionReason);
$this->balanceService->release($data, false);
}
$data->setReviewedAt(new DateTimeImmutable());
$data->setReviewedBy($admin);
$this->entityManager->flush();
return $data;
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Enum\HalfDay;
use App\Service\AbsenceDayCalculator;
use App\Service\PublicHolidayProvider;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
class AbsenceDayCalculatorTest extends TestCase
{
private AbsenceDayCalculator $calculator;
protected function setUp(): void
{
$this->calculator = new AbsenceDayCalculator(new PublicHolidayProvider());
}
public function testFullWeekIsFiveWorkingDays(): void
{
// Mon 2026-06-01 to Fri 2026-06-05, no holidays that week
self::assertSame(5.0, $this->calculator->countWorkingDays(
new DateTimeImmutable('2026-06-01'),
new DateTimeImmutable('2026-06-05'),
));
}
public function testWeekendIsSkipped(): void
{
// Fri 2026-06-05 to Mon 2026-06-08 => Fri + Mon = 2
self::assertSame(2.0, $this->calculator->countWorkingDays(
new DateTimeImmutable('2026-06-05'),
new DateTimeImmutable('2026-06-08'),
));
}
public function testHolidayIsSkipped(): void
{
// Thu 2026-05-07 to Fri 2026-05-08 (Victoire 1945) => Thu only = 1
self::assertSame(1.0, $this->calculator->countWorkingDays(
new DateTimeImmutable('2026-05-07'),
new DateTimeImmutable('2026-05-08'),
));
}
public function testHalfDayStartSubtractsHalf(): void
{
self::assertSame(4.5, $this->calculator->countWorkingDays(
new DateTimeImmutable('2026-06-01'),
new DateTimeImmutable('2026-06-05'),
HalfDay::Afternoon,
));
}
public function testBothHalfDaysSubtractOne(): void
{
self::assertSame(4.0, $this->calculator->countWorkingDays(
new DateTimeImmutable('2026-06-01'),
new DateTimeImmutable('2026-06-05'),
HalfDay::Afternoon,
HalfDay::Morning,
));
}
public function testSingleHalfDay(): void
{
self::assertSame(0.5, $this->calculator->countWorkingDays(
new DateTimeImmutable('2026-06-01'),
new DateTimeImmutable('2026-06-01'),
HalfDay::Morning,
));
}
public function testWorkingDaysVsOpenDays(): void
{
// Fri 2026-06-05 to Mon 2026-06-08, "ouvrables" includes Saturday
// => Fri + Sat + Mon = 3 (Sunday always skipped)
self::assertSame(3.0, $this->calculator->countWorkingDays(
new DateTimeImmutable('2026-06-05'),
new DateTimeImmutable('2026-06-08'),
null,
null,
false,
));
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Service\PublicHolidayProvider;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
class PublicHolidayProviderTest extends TestCase
{
private PublicHolidayProvider $provider;
protected function setUp(): void
{
$this->provider = new PublicHolidayProvider();
}
public function testReturnsElevenHolidaysForMetropole(): void
{
self::assertCount(11, $this->provider->getHolidays(2026));
}
public function testFixedHolidaysHaveCorrectLabels(): void
{
$holidays = $this->provider->getHolidays(2026);
self::assertSame('Jour de l\'an', $holidays['2026-01-01']);
self::assertSame('Fête du Travail', $holidays['2026-05-01']);
self::assertSame('Victoire 1945', $holidays['2026-05-08']);
self::assertSame('Fête nationale', $holidays['2026-07-14']);
self::assertSame('Assomption', $holidays['2026-08-15']);
self::assertSame('Toussaint', $holidays['2026-11-01']);
self::assertSame('Armistice 1918', $holidays['2026-11-11']);
self::assertSame('Noël', $holidays['2026-12-25']);
}
/**
* Easter 2026 is April 5th, so Easter Monday is April 6th,
* Ascension is May 14th and Whit Monday is May 25th.
*/
public function testEasterBasedHolidays2026(): void
{
$holidays = $this->provider->getHolidays(2026);
self::assertSame('Lundi de Pâques', $holidays['2026-04-06']);
self::assertSame('Ascension', $holidays['2026-05-14']);
self::assertSame('Lundi de Pentecôte', $holidays['2026-05-25']);
}
/**
* Easter 2025 is April 20th, so Easter Monday is April 21st.
*/
public function testEasterBasedHolidays2025(): void
{
$holidays = $this->provider->getHolidays(2025);
self::assertSame('Lundi de Pâques', $holidays['2025-04-21']);
}
public function testIsHoliday(): void
{
self::assertTrue($this->provider->isHoliday(new DateTimeImmutable('2026-05-01')));
self::assertTrue($this->provider->isHoliday(new DateTimeImmutable('2026-05-14')));
self::assertFalse($this->provider->isHoliday(new DateTimeImmutable('2026-05-02')));
self::assertFalse($this->provider->isHoliday(new DateTimeImmutable('2026-06-01')));
}
}