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:
@@ -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%'
|
||||||
|
|||||||
42
migrations/Version20260521123520.php
Normal file
42
migrations/Version20260521123520.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
49
migrations/Version20260521123521.php
Normal file
49
migrations/Version20260521123521.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
64
migrations/Version20260521123522.php
Normal file
64
migrations/Version20260521123522.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
59
migrations/Version20260521123523.php
Normal file
59
migrations/Version20260521123523.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/Controller/Absence/AbsenceCalendarController.php
Normal file
43
src/Controller/Absence/AbsenceCalendarController.php
Normal 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']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/Controller/Absence/AbsencePreviewController.php
Normal file
95
src/Controller/Absence/AbsencePreviewController.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
163
src/Entity/AbsenceBalance.php
Normal file
163
src/Entity/AbsenceBalance.php
Normal 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
src/Entity/AbsencePolicy.php
Normal file
171
src/Entity/AbsencePolicy.php
Normal 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
src/Entity/AbsenceRequest.php
Normal file
326
src/Entity/AbsenceRequest.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
23
src/Enum/AbsenceStatus.php
Normal file
23
src/Enum/AbsenceStatus.php
Normal 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
34
src/Enum/AbsenceType.php
Normal 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
25
src/Enum/ContractType.php
Normal 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
src/Enum/FamilySituation.php
Normal file
25
src/Enum/FamilySituation.php
Normal 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
19
src/Enum/HalfDay.php
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/Repository/AbsenceBalanceRepository.php
Normal file
31
src/Repository/AbsenceBalanceRepository.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/Repository/AbsencePolicyRepository.php
Normal file
26
src/Repository/AbsencePolicyRepository.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/Repository/AbsenceRequestRepository.php
Normal file
74
src/Repository/AbsenceRequestRepository.php
Normal 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()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
122
src/Service/AbsenceBalanceService.php
Normal file
122
src/Service/AbsenceBalanceService.php
Normal 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
src/Service/AbsenceDayCalculator.php
Normal file
73
src/Service/AbsenceDayCalculator.php
Normal 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
src/Service/PublicHolidayProvider.php
Normal file
86
src/Service/PublicHolidayProvider.php
Normal 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
src/State/AbsenceBalanceProvider.php
Normal file
73
src/State/AbsenceBalanceProvider.php
Normal 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
src/State/AbsenceCancelProcessor.php
Normal file
75
src/State/AbsenceCancelProcessor.php
Normal 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
src/State/AbsenceRequestProcessor.php
Normal file
91
src/State/AbsenceRequestProcessor.php
Normal 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
src/State/AbsenceRequestProvider.php
Normal file
82
src/State/AbsenceRequestProvider.php
Normal 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
src/State/AbsenceReviewProcessor.php
Normal file
80
src/State/AbsenceReviewProcessor.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
92
tests/Unit/Service/AbsenceDayCalculatorTest.php
Normal file
92
tests/Unit/Service/AbsenceDayCalculatorTest.php
Normal 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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
72
tests/Unit/Service/PublicHolidayProviderTest.php
Normal file
72
tests/Unit/Service/PublicHolidayProviderTest.php
Normal 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')));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user