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

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

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

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

View File

@@ -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']]);
}
}