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:
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user