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