From de98924fd392028c2cb93d7802f9b557b639ad27 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 21 May 2026 14:45:14 +0200 Subject: [PATCH] feat(absences) : fondation backend du module de gestion des absences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- config/services.yaml | 9 + migrations/Version20260521123520.php | 42 +++ migrations/Version20260521123521.php | 49 +++ migrations/Version20260521123522.php | 64 ++++ migrations/Version20260521123523.php | 59 ++++ .../Absence/AbsenceCalendarController.php | 43 +++ ...AbsenceJustificationDownloadController.php | 64 ++++ .../AbsenceJustificationUploadController.php | 86 +++++ .../Absence/AbsencePreviewController.php | 95 +++++ src/DataFixtures/AppFixtures.php | 107 ++++++ src/Entity/AbsenceBalance.php | 163 +++++++++ src/Entity/AbsencePolicy.php | 171 +++++++++ src/Entity/AbsenceRequest.php | 326 ++++++++++++++++++ src/Entity/User.php | 176 +++++++++- src/Enum/AbsenceStatus.php | 23 ++ src/Enum/AbsenceType.php | 34 ++ src/Enum/ContractType.php | 25 ++ src/Enum/FamilySituation.php | 25 ++ src/Enum/HalfDay.php | 19 + src/Repository/AbsenceBalanceRepository.php | 31 ++ src/Repository/AbsencePolicyRepository.php | 26 ++ src/Repository/AbsenceRequestRepository.php | 74 ++++ src/Service/AbsenceBalanceService.php | 122 +++++++ src/Service/AbsenceDayCalculator.php | 73 ++++ src/Service/PublicHolidayProvider.php | 86 +++++ src/State/AbsenceBalanceProvider.php | 73 ++++ src/State/AbsenceCancelProcessor.php | 75 ++++ src/State/AbsenceRequestProcessor.php | 91 +++++ src/State/AbsenceRequestProvider.php | 82 +++++ src/State/AbsenceReviewProcessor.php | 80 +++++ .../Unit/Service/AbsenceDayCalculatorTest.php | 92 +++++ .../Service/PublicHolidayProviderTest.php | 72 ++++ 32 files changed, 2554 insertions(+), 3 deletions(-) create mode 100644 migrations/Version20260521123520.php create mode 100644 migrations/Version20260521123521.php create mode 100644 migrations/Version20260521123522.php create mode 100644 migrations/Version20260521123523.php create mode 100644 src/Controller/Absence/AbsenceCalendarController.php create mode 100644 src/Controller/Absence/AbsenceJustificationDownloadController.php create mode 100644 src/Controller/Absence/AbsenceJustificationUploadController.php create mode 100644 src/Controller/Absence/AbsencePreviewController.php create mode 100644 src/Entity/AbsenceBalance.php create mode 100644 src/Entity/AbsencePolicy.php create mode 100644 src/Entity/AbsenceRequest.php create mode 100644 src/Enum/AbsenceStatus.php create mode 100644 src/Enum/AbsenceType.php create mode 100644 src/Enum/ContractType.php create mode 100644 src/Enum/FamilySituation.php create mode 100644 src/Enum/HalfDay.php create mode 100644 src/Repository/AbsenceBalanceRepository.php create mode 100644 src/Repository/AbsencePolicyRepository.php create mode 100644 src/Repository/AbsenceRequestRepository.php create mode 100644 src/Service/AbsenceBalanceService.php create mode 100644 src/Service/AbsenceDayCalculator.php create mode 100644 src/Service/PublicHolidayProvider.php create mode 100644 src/State/AbsenceBalanceProvider.php create mode 100644 src/State/AbsenceCancelProcessor.php create mode 100644 src/State/AbsenceRequestProcessor.php create mode 100644 src/State/AbsenceRequestProvider.php create mode 100644 src/State/AbsenceReviewProcessor.php create mode 100644 tests/Unit/Service/AbsenceDayCalculatorTest.php create mode 100644 tests/Unit/Service/PublicHolidayProviderTest.php diff --git a/config/services.yaml b/config/services.yaml index f2713cc..1708a92 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -9,6 +9,7 @@ parameters: task_document_upload_dir: '%kernel.project_dir%/var/uploads/documents' avatar_upload_dir: '%kernel.project_dir%/var/uploads/avatars' + absence_justification_upload_dir: '%kernel.project_dir%/var/uploads/justificatifs' imports: - { resource: version.yaml } @@ -44,3 +45,11 @@ services: App\Controller\UserAvatarController: arguments: $avatarUploadDir: '%avatar_upload_dir%' + + App\Controller\Absence\AbsenceJustificationUploadController: + arguments: + $uploadDir: '%absence_justification_upload_dir%' + + App\Controller\Absence\AbsenceJustificationDownloadController: + arguments: + $uploadDir: '%absence_justification_upload_dir%' diff --git a/migrations/Version20260521123520.php b/migrations/Version20260521123520.php new file mode 100644 index 0000000..8a5fe59 --- /dev/null +++ b/migrations/Version20260521123520.php @@ -0,0 +1,42 @@ +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'); + } +} diff --git a/migrations/Version20260521123521.php b/migrations/Version20260521123521.php new file mode 100644 index 0000000..ea7f4fb --- /dev/null +++ b/migrations/Version20260521123521.php @@ -0,0 +1,49 @@ +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'); + } +} diff --git a/migrations/Version20260521123522.php b/migrations/Version20260521123522.php new file mode 100644 index 0000000..c41736b --- /dev/null +++ b/migrations/Version20260521123522.php @@ -0,0 +1,64 @@ +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'); + } +} diff --git a/migrations/Version20260521123523.php b/migrations/Version20260521123523.php new file mode 100644 index 0000000..0cbb8d1 --- /dev/null +++ b/migrations/Version20260521123523.php @@ -0,0 +1,59 @@ +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'); + } +} diff --git a/src/Controller/Absence/AbsenceCalendarController.php b/src/Controller/Absence/AbsenceCalendarController.php new file mode 100644 index 0000000..aec8f07 --- /dev/null +++ b/src/Controller/Absence/AbsenceCalendarController.php @@ -0,0 +1,43 @@ +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']]); + } +} diff --git a/src/Controller/Absence/AbsenceJustificationDownloadController.php b/src/Controller/Absence/AbsenceJustificationDownloadController.php new file mode 100644 index 0000000..3a48dd8 --- /dev/null +++ b/src/Controller/Absence/AbsenceJustificationDownloadController.php @@ -0,0 +1,64 @@ +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; + } +} diff --git a/src/Controller/Absence/AbsenceJustificationUploadController.php b/src/Controller/Absence/AbsenceJustificationUploadController.php new file mode 100644 index 0000000..5cfaa20 --- /dev/null +++ b/src/Controller/Absence/AbsenceJustificationUploadController.php @@ -0,0 +1,86 @@ + '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']]); + } +} diff --git a/src/Controller/Absence/AbsencePreviewController.php b/src/Controller/Absence/AbsencePreviewController.php new file mode 100644 index 0000000..c313cc2 --- /dev/null +++ b/src/Controller/Absence/AbsencePreviewController.php @@ -0,0 +1,95 @@ + $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, + ]); + } +} diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php index a6a1c03..90efbe8 100644 --- a/src/DataFixtures/AppFixtures.php +++ b/src/DataFixtures/AppFixtures.php @@ -4,6 +4,9 @@ declare(strict_types=1); namespace App\DataFixtures; +use App\Entity\AbsenceBalance; +use App\Entity\AbsencePolicy; +use App\Entity\AbsenceRequest; use App\Entity\Client; use App\Entity\ClientTicket; use App\Entity\MailConfiguration; @@ -19,6 +22,10 @@ use App\Entity\TimeEntry; use App\Entity\User; use App\Entity\Workflow; use App\Entity\ZimbraConfiguration; +use App\Enum\AbsenceStatus; +use App\Enum\AbsenceType; +use App\Enum\ContractType; +use App\Enum\FamilySituation; use App\Enum\RecurrenceType; use App\Enum\StatusCategory; use DateTimeImmutable; @@ -722,6 +729,106 @@ class AppFixtures extends Fixture $mailConfig->setEnabled(false); $manager->persist($mailConfig); + // ============================================= + // Absence management — policies, employees, balances, requests + // ============================================= + + // Default policies for the 5 absence types (legal defaults, editable by admin) + $policyData = [ + // [type, daysPerYear, daysPerEvent, justifRequired, noticeDays, workingDaysOnly] + [AbsenceType::PaidLeave, 25.0, null, false, 30, true], + [AbsenceType::MarriagePacs, null, 4.0, true, 0, true], + [AbsenceType::ParentalLeave, null, null, true, 30, true], + [AbsenceType::Bereavement, null, 3.0, true, 0, true], + [AbsenceType::SickLeave, null, null, true, 0, true], + ]; + + foreach ($policyData as [$type, $daysPerYear, $daysPerEvent, $justif, $notice, $workingDaysOnly]) { + $policy = new AbsencePolicy(); + $policy->setType($type); + $policy->setDaysPerYear($daysPerYear); + $policy->setDaysPerEvent($daysPerEvent); + $policy->setJustificationRequired($justif); + $policy->setNoticeDays($notice); + $policy->setCountWorkingDaysOnly($workingDaysOnly); + $policy->setActive(true); + $manager->persist($policy); + } + + // Mark internal users as employees + $admin->setIsEmployee(true); + $admin->setHireDate(new DateTimeImmutable('2020-01-15')); + $admin->setContractType(ContractType::Cdi); + $admin->setFamilySituation(FamilySituation::Married); + $admin->setNbChildren(2); + + $userAlice->setIsEmployee(true); + $userAlice->setHireDate(new DateTimeImmutable('2022-09-01')); + $userAlice->setContractType(ContractType::Cdi); + $userAlice->setFamilySituation(FamilySituation::Single); + + $userBob->setIsEmployee(true); + $userBob->setHireDate(new DateTimeImmutable('2023-03-10')); + $userBob->setContractType(ContractType::Cdd); + $userBob->setWorkTimeRatio(0.8); + $userBob->setFamilySituation(FamilySituation::Pacsed); + $userBob->setNbChildren(1); + + // Paid-leave balances for the current reference period (June 1st → May 31st) + $cpPeriod = '2025-2026'; + $balanceData = [ + // [user, acquired, taken, pending] + [$admin, 22.5, 5.0, 0.0], + [$userAlice, 18.0, 2.0, 5.0], + [$userBob, 14.0, 0.0, 0.0], + ]; + + foreach ($balanceData as [$bUser, $acquired, $taken, $pending]) { + $balance = new AbsenceBalance(); + $balance->setUser($bUser); + $balance->setType(AbsenceType::PaidLeave); + $balance->setPeriod($cpPeriod); + $balance->setAcquired($acquired); + $balance->setTaken($taken); + $balance->setPending($pending); + $manager->persist($balance); + } + + // Demo requests + $approvedCp = new AbsenceRequest(); + $approvedCp->setUser($admin); + $approvedCp->setType(AbsenceType::PaidLeave); + $approvedCp->setStartDate(new DateTimeImmutable('2026-04-13')); + $approvedCp->setEndDate(new DateTimeImmutable('2026-04-17')); + $approvedCp->setCountedDays(5.0); + $approvedCp->setReason('Vacances de printemps'); + $approvedCp->setStatus(AbsenceStatus::Approved); + $approvedCp->setCreatedAt(new DateTimeImmutable('2026-03-10')); + $approvedCp->setReviewedAt(new DateTimeImmutable('2026-03-11')); + $approvedCp->setReviewedBy($admin); + $manager->persist($approvedCp); + + $pendingCp = new AbsenceRequest(); + $pendingCp->setUser($userAlice); + $pendingCp->setType(AbsenceType::PaidLeave); + $pendingCp->setStartDate(new DateTimeImmutable('2026-06-15')); + $pendingCp->setEndDate(new DateTimeImmutable('2026-06-19')); + $pendingCp->setCountedDays(5.0); + $pendingCp->setStatus(AbsenceStatus::Pending); + $pendingCp->setCreatedAt(new DateTimeImmutable('-2 days')); + $manager->persist($pendingCp); + + $pendingMarriage = new AbsenceRequest(); + $pendingMarriage->setUser($userBob); + $pendingMarriage->setType(AbsenceType::MarriagePacs); + $pendingMarriage->setStartDate(new DateTimeImmutable('2026-07-06')); + $pendingMarriage->setEndDate(new DateTimeImmutable('2026-07-09')); + $pendingMarriage->setCountedDays(4.0); + $pendingMarriage->setReason('Mariage'); + $pendingMarriage->setStatus(AbsenceStatus::Pending); + $pendingMarriage->setCreatedAt(new DateTimeImmutable('-1 day')); + $manager->persist($pendingMarriage); + $manager->flush(); } } diff --git a/src/Entity/AbsenceBalance.php b/src/Entity/AbsenceBalance.php new file mode 100644 index 0000000..32977a6 --- /dev/null +++ b/src/Entity/AbsenceBalance.php @@ -0,0 +1,163 @@ + ['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; + } +} diff --git a/src/Entity/AbsencePolicy.php b/src/Entity/AbsencePolicy.php new file mode 100644 index 0000000..00abb17 --- /dev/null +++ b/src/Entity/AbsencePolicy.php @@ -0,0 +1,171 @@ + ['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; + } +} diff --git a/src/Entity/AbsenceRequest.php b/src/Entity/AbsenceRequest.php new file mode 100644 index 0000000..5045bdf --- /dev/null +++ b/src/Entity/AbsenceRequest.php @@ -0,0 +1,326 @@ + ['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; + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index 11067e0..e796ae4 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -10,6 +10,8 @@ use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; +use App\Enum\ContractType; +use App\Enum\FamilySituation; use App\Repository\UserRepository; use App\State\MeProvider; use App\State\UserPasswordHasherProcessor; @@ -48,11 +50,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] - #[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'client_ticket:read'])] + #[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'client_ticket:read', 'absence_request:read', 'absence_balance:read'])] private ?int $id = null; #[ORM\Column(length: 180, unique: true)] - #[Groups(['me:read', 'task:read', 'user:list', 'user:write', 'time_entry:read', 'client_ticket:read'])] + #[Groups(['me:read', 'task:read', 'user:list', 'user:write', 'time_entry:read', 'client_ticket:read', 'absence_request:read', 'absence_balance:read'])] private ?string $username = null; /** @var list */ @@ -87,6 +89,54 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[Groups(['me:read', 'user:list', 'user:write'])] private Collection $allowedProjects; + // --- HR / absence management fields --- + + /** Whether this user is an employee subject to absence management. */ + #[ORM\Column] + #[Groups(['me:read', 'user:list', 'user:write'])] + private bool $isEmployee = false; + + /** Hiring date — start of paid-leave acquisition. */ + #[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)] + #[Groups(['me:read', 'user:list', 'user:write'])] + private ?DateTimeImmutable $hireDate = null; + + #[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)] + #[Groups(['me:read', 'user:list', 'user:write'])] + private ?DateTimeImmutable $endDate = null; + + #[ORM\Column(type: Types::STRING, length: 16, nullable: true, enumType: ContractType::class)] + #[Groups(['me:read', 'user:list', 'user:write'])] + private ?ContractType $contractType = null; + + /** Work-time ratio: 1.0 = full time, 0.8 = 4 days out of 5. */ + #[ORM\Column(type: Types::FLOAT)] + #[Groups(['me:read', 'user:list', 'user:write'])] + private float $workTimeRatio = 1.0; + + /** Yearly paid-leave entitlement in worked days (default 25 = jours ouvrés). */ + #[ORM\Column(type: Types::FLOAT)] + #[Groups(['me:read', 'user:list', 'user:write'])] + private float $annualLeaveDays = 25.0; + + /** Reference period start as MM-DD (default 06-01, 1st of June). */ + #[ORM\Column(length: 5)] + #[Groups(['me:read', 'user:list', 'user:write'])] + private string $referencePeriodStart = '06-01'; + + /** Paid-leave already acquired when the module is rolled out. */ + #[ORM\Column(type: Types::FLOAT)] + #[Groups(['me:read', 'user:list', 'user:write'])] + private float $initialLeaveBalance = 0.0; + + #[ORM\Column(type: Types::STRING, length: 16, nullable: true, enumType: FamilySituation::class)] + #[Groups(['me:read', 'user:list', 'user:write'])] + private ?FamilySituation $familySituation = null; + + #[ORM\Column] + #[Groups(['me:read', 'user:list', 'user:write'])] + private int $nbChildren = 0; + public function __construct() { $this->createdAt = new DateTimeImmutable(); @@ -217,7 +267,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return $this; } - #[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'client_ticket:read'])] + #[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'client_ticket:read', 'absence_request:read', 'absence_balance:read'])] public function getAvatarUrl(): ?string { if (null === $this->avatarFileName) { @@ -243,4 +293,124 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface { $this->plainPassword = null; } + + public function isEmployee(): bool + { + return $this->isEmployee; + } + + public function setIsEmployee(bool $isEmployee): static + { + $this->isEmployee = $isEmployee; + + return $this; + } + + public function getHireDate(): ?DateTimeImmutable + { + return $this->hireDate; + } + + public function setHireDate(?DateTimeImmutable $hireDate): static + { + $this->hireDate = $hireDate; + + return $this; + } + + public function getEndDate(): ?DateTimeImmutable + { + return $this->endDate; + } + + public function setEndDate(?DateTimeImmutable $endDate): static + { + $this->endDate = $endDate; + + return $this; + } + + public function getContractType(): ?ContractType + { + return $this->contractType; + } + + public function setContractType(?ContractType $contractType): static + { + $this->contractType = $contractType; + + return $this; + } + + public function getWorkTimeRatio(): float + { + return $this->workTimeRatio; + } + + public function setWorkTimeRatio(float $workTimeRatio): static + { + $this->workTimeRatio = $workTimeRatio; + + return $this; + } + + public function getAnnualLeaveDays(): float + { + return $this->annualLeaveDays; + } + + public function setAnnualLeaveDays(float $annualLeaveDays): static + { + $this->annualLeaveDays = $annualLeaveDays; + + return $this; + } + + public function getReferencePeriodStart(): string + { + return $this->referencePeriodStart; + } + + public function setReferencePeriodStart(string $referencePeriodStart): static + { + $this->referencePeriodStart = $referencePeriodStart; + + return $this; + } + + public function getInitialLeaveBalance(): float + { + return $this->initialLeaveBalance; + } + + public function setInitialLeaveBalance(float $initialLeaveBalance): static + { + $this->initialLeaveBalance = $initialLeaveBalance; + + return $this; + } + + public function getFamilySituation(): ?FamilySituation + { + return $this->familySituation; + } + + public function setFamilySituation(?FamilySituation $familySituation): static + { + $this->familySituation = $familySituation; + + return $this; + } + + public function getNbChildren(): int + { + return $this->nbChildren; + } + + public function setNbChildren(int $nbChildren): static + { + $this->nbChildren = $nbChildren; + + return $this; + } } diff --git a/src/Enum/AbsenceStatus.php b/src/Enum/AbsenceStatus.php new file mode 100644 index 0000000..ce45714 --- /dev/null +++ b/src/Enum/AbsenceStatus.php @@ -0,0 +1,23 @@ + 'En attente', + self::Approved => 'Approuvée', + self::Rejected => 'Refusée', + self::Cancelled => 'Annulée', + }; + } +} diff --git a/src/Enum/AbsenceType.php b/src/Enum/AbsenceType.php new file mode 100644 index 0000000..c868e32 --- /dev/null +++ b/src/Enum/AbsenceType.php @@ -0,0 +1,34 @@ + '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; + } +} diff --git a/src/Enum/ContractType.php b/src/Enum/ContractType.php new file mode 100644 index 0000000..fe1d7b1 --- /dev/null +++ b/src/Enum/ContractType.php @@ -0,0 +1,25 @@ + 'CDI', + self::Cdd => 'CDD', + self::Internship => 'Stage', + self::Apprentice => 'Alternance', + self::Other => 'Autre', + }; + } +} diff --git a/src/Enum/FamilySituation.php b/src/Enum/FamilySituation.php new file mode 100644 index 0000000..938498b --- /dev/null +++ b/src/Enum/FamilySituation.php @@ -0,0 +1,25 @@ + 'Célibataire', + self::Married => 'Marié(e)', + self::Pacsed => 'Pacsé(e)', + self::Divorced => 'Divorcé(e)', + self::Widowed => 'Veuf(ve)', + }; + } +} diff --git a/src/Enum/HalfDay.php b/src/Enum/HalfDay.php new file mode 100644 index 0000000..fa868f3 --- /dev/null +++ b/src/Enum/HalfDay.php @@ -0,0 +1,19 @@ + 'Matin', + self::Afternoon => 'Après-midi', + }; + } +} diff --git a/src/Repository/AbsenceBalanceRepository.php b/src/Repository/AbsenceBalanceRepository.php new file mode 100644 index 0000000..9e74b73 --- /dev/null +++ b/src/Repository/AbsenceBalanceRepository.php @@ -0,0 +1,31 @@ + + */ +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, + ]); + } +} diff --git a/src/Repository/AbsencePolicyRepository.php b/src/Repository/AbsencePolicyRepository.php new file mode 100644 index 0000000..b1467a7 --- /dev/null +++ b/src/Repository/AbsencePolicyRepository.php @@ -0,0 +1,26 @@ + + */ +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]); + } +} diff --git a/src/Repository/AbsenceRequestRepository.php b/src/Repository/AbsenceRequestRepository.php new file mode 100644 index 0000000..620755b --- /dev/null +++ b/src/Repository/AbsenceRequestRepository.php @@ -0,0 +1,74 @@ + + */ +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() + ; + } +} diff --git a/src/Service/AbsenceBalanceService.php b/src/Service/AbsenceBalanceService.php new file mode 100644 index 0000000..399cb64 --- /dev/null +++ b/src/Service/AbsenceBalanceService.php @@ -0,0 +1,122 @@ +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(); + } +} diff --git a/src/Service/AbsenceDayCalculator.php b/src/Service/AbsenceDayCalculator.php new file mode 100644 index 0000000..fd8ed5a --- /dev/null +++ b/src/Service/AbsenceDayCalculator.php @@ -0,0 +1,73 @@ + "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); + } +} diff --git a/src/Service/PublicHolidayProvider.php b/src/Service/PublicHolidayProvider.php new file mode 100644 index 0000000..1897757 --- /dev/null +++ b/src/Service/PublicHolidayProvider.php @@ -0,0 +1,86 @@ +> cache of holidays per year */ + private array $cache = []; + + /** + * @return array 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)); + } +} diff --git a/src/State/AbsenceBalanceProvider.php b/src/State/AbsenceBalanceProvider.php new file mode 100644 index 0000000..d155efa --- /dev/null +++ b/src/State/AbsenceBalanceProvider.php @@ -0,0 +1,73 @@ + + */ +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); + } +} diff --git a/src/State/AbsenceCancelProcessor.php b/src/State/AbsenceCancelProcessor.php new file mode 100644 index 0000000..59b9e0f --- /dev/null +++ b/src/State/AbsenceCancelProcessor.php @@ -0,0 +1,75 @@ + + */ +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; + } +} diff --git a/src/State/AbsenceRequestProcessor.php b/src/State/AbsenceRequestProcessor.php new file mode 100644 index 0000000..4322195 --- /dev/null +++ b/src/State/AbsenceRequestProcessor.php @@ -0,0 +1,91 @@ + + */ +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; + } +} diff --git a/src/State/AbsenceRequestProvider.php b/src/State/AbsenceRequestProvider.php new file mode 100644 index 0000000..0e05508 --- /dev/null +++ b/src/State/AbsenceRequestProvider.php @@ -0,0 +1,82 @@ + + */ +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); + } +} diff --git a/src/State/AbsenceReviewProcessor.php b/src/State/AbsenceReviewProcessor.php new file mode 100644 index 0000000..9e8a881 --- /dev/null +++ b/src/State/AbsenceReviewProcessor.php @@ -0,0 +1,80 @@ + + */ +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; + } +} diff --git a/tests/Unit/Service/AbsenceDayCalculatorTest.php b/tests/Unit/Service/AbsenceDayCalculatorTest.php new file mode 100644 index 0000000..591763e --- /dev/null +++ b/tests/Unit/Service/AbsenceDayCalculatorTest.php @@ -0,0 +1,92 @@ +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, + )); + } +} diff --git a/tests/Unit/Service/PublicHolidayProviderTest.php b/tests/Unit/Service/PublicHolidayProviderTest.php new file mode 100644 index 0000000..ff1716c --- /dev/null +++ b/tests/Unit/Service/PublicHolidayProviderTest.php @@ -0,0 +1,72 @@ +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'))); + } +}