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
@@ -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']]);
}
}
@@ -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;
}
}
@@ -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']]);
}
}
@@ -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,
]);
}
}
+107
View File
@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\DataFixtures;
use App\Entity\AbsenceBalance;
use App\Entity\AbsencePolicy;
use App\Entity\AbsenceRequest;
use App\Entity\Client;
use App\Entity\ClientTicket;
use App\Entity\MailConfiguration;
@@ -19,6 +22,10 @@ use App\Entity\TimeEntry;
use App\Entity\User;
use App\Entity\Workflow;
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\StatusCategory;
use DateTimeImmutable;
@@ -722,6 +729,106 @@ class AppFixtures extends Fixture
$mailConfig->setEnabled(false);
$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();
}
}
+163
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;
}
}
+171
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;
}
}
+326
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;
}
}
+173 -3
View File
@@ -10,6 +10,8 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Enum\ContractType;
use App\Enum\FamilySituation;
use App\Repository\UserRepository;
use App\State\MeProvider;
use App\State\UserPasswordHasherProcessor;
@@ -48,11 +50,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Id]
#[ORM\GeneratedValue]
#[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;
#[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;
/** @var list<string> */
@@ -87,6 +89,54 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[Groups(['me:read', 'user:list', 'user:write'])]
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()
{
$this->createdAt = new DateTimeImmutable();
@@ -217,7 +267,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
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
{
if (null === $this->avatarFileName) {
@@ -243,4 +293,124 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
{
$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;
}
}
+23
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
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
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',
};
}
}
+25
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
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',
};
}
}
@@ -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,
]);
}
}
@@ -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]);
}
}
@@ -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()
;
}
}
+122
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();
}
}
+73
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);
}
}
+86
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));
}
}
+73
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);
}
}
+75
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;
}
}
+91
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;
}
}
+82
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);
}
}
+80
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;
}
}