feat(absence) : migrate Absence domain into module (back)

LST-66 (2.3) backend. Behaviour-preserving move of the absences domain into
src/Module/Absence/. API operations, securities, routes and the 10 MCP tool
names are unchanged.

- 3 entities + 3 enums moved to Domain/{Entity,Enum}; user relations stay on
  UserInterface. 3 repositories split into Domain/Repository interfaces +
  Doctrine impls (bound in services.yaml); find() kept off interfaces
  (findById instead).
- Pure services (AbsenceDayCalculator, PublicHolidayProvider) -> Domain/Service;
  AbsenceBalanceService -> Application/Service; State (5), controllers (5),
  10 MCP tools and AccrueLeaveCommand -> Infrastructure/.
- New LeaveProfileInterface contract (Shared) exposes the HR getters used by
  AbsenceBalanceService/AccrueLeaveCommand; User implements it -> Absence no
  longer imports the concrete Core User. MCP tools/command inject
  UserRepositoryInterface (findById) instead of the concrete repository.
- Timestampable/Blamable added to AbsenceBalance and AbsencePolicy (additive
  migration: created_at/updated_at + created_by/updated_by FK ON DELETE SET
  NULL + COMMENT). AbsenceRequest untouched (already has createdAt/reviewedAt).
- AbsenceModule registered (id absence, 4 RBAC perms, not re-wired); doctrine
  mapping added; team-absences sidebar item gated by the module.

161 tests green, mapping valid, no API route regression, cs-fixer clean.
This commit is contained in:
Matthieu
2026-06-20 18:32:02 +02:00
parent 7446b7dca9
commit 306cfd34cd
51 changed files with 514 additions and 209 deletions
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Mcp\Tool\Serializer;
use App\Module\Absence\Application\Service\AbsenceBalanceService;
use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'cancel-absence-request', description: 'Cancel an absence request. A PENDING request releases its reserved days; an APPROVED request can only be cancelled by an admin and credits the taken days back. Already cancelled/rejected requests cannot be cancelled.')]
class CancelAbsenceRequestTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly AbsenceRequestRepositoryInterface $requestRepository,
private readonly AbsenceBalanceService $balanceService,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$request = $this->requestRepository->findById($id);
if (null === $request) {
throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id));
}
$isAdmin = $this->security->isGranted('ROLE_ADMIN');
$status = $request->getStatus();
if (AbsenceStatus::Pending === $status) {
$this->balanceService->release($request, false);
} elseif (AbsenceStatus::Approved === $status) {
if (!$isAdmin) {
throw new AccessDeniedException('Only an admin can cancel an approved request.');
}
$this->balanceService->release($request, true);
} else {
throw new InvalidArgumentException('This request can no longer be cancelled.');
}
$request->setStatus(AbsenceStatus::Cancelled);
$this->entityManager->flush();
return json_encode(Serializer::absenceRequest($request));
}
}
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Mcp\Tool\Serializer;
use App\Module\Absence\Application\Service\AbsenceBalanceService;
use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Domain\Enum\HalfDay;
use App\Module\Absence\Domain\Repository\AbsencePolicyRepositoryInterface;
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use App\Module\Absence\Domain\Service\AbsenceDayCalculator;
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'create-absence-request', description: 'Create an absence request on behalf of an employee (userId). Validates active policy + no overlap, computes deducted working days, sets status=pending and reserves the days in the pending balance. type: cp|mariage_pacs|conge_parental|deces|maladie. Dates YYYY-MM-DD. halfDay (matin|apres_midi) on a boundary subtracts 0.5.')]
class CreateAbsenceRequestTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly UserRepositoryInterface $userRepository,
private readonly AbsencePolicyRepositoryInterface $policyRepository,
private readonly AbsenceRequestRepositoryInterface $requestRepository,
private readonly AbsenceDayCalculator $calculator,
private readonly AbsenceBalanceService $balanceService,
private readonly Security $security,
) {}
public function __invoke(
int $userId,
string $type,
string $startDate,
string $endDate,
?string $startHalfDay = null,
?string $endHalfDay = null,
?string $reason = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$user = $this->userRepository->findById($userId)
?? throw new InvalidArgumentException(sprintf('User with ID %d not found.', $userId));
$typeEnum = AbsenceType::tryFrom($type)
?? throw new InvalidArgumentException(sprintf('Unknown absence type "%s".', $type));
$start = new DateTimeImmutable($startDate);
$end = new DateTimeImmutable($endDate);
if ($end < $start) {
throw new InvalidArgumentException('End date must be on or after start date.');
}
$startHd = null !== $startHalfDay
? (HalfDay::tryFrom($startHalfDay) ?? throw new InvalidArgumentException(sprintf('Unknown half day "%s".', $startHalfDay)))
: null;
$endHd = null !== $endHalfDay
? (HalfDay::tryFrom($endHalfDay) ?? throw new InvalidArgumentException(sprintf('Unknown half day "%s".', $endHalfDay)))
: null;
$policy = $this->policyRepository->findOneByType($typeEnum);
if (null === $policy || !$policy->isActive()) {
throw new InvalidArgumentException('This absence type is not available.');
}
// Bereavement has no fixed entitlement: the relationship to the deceased
// drives the legal number of days, so the reason is mandatory.
if (AbsenceType::Bereavement === $typeEnum && '' === trim((string) $reason)) {
throw new InvalidArgumentException('A reason (relationship to the deceased) is required for bereavement leave.');
}
if ($this->requestRepository->hasOverlap($user, $start, $end)) {
throw new InvalidArgumentException('This request overlaps an existing absence.');
}
$countedDays = $this->calculator->countWorkingDays($start, $end, $startHd, $endHd, $policy->isCountWorkingDaysOnly());
if ($countedDays <= 0.0) {
throw new InvalidArgumentException('The selected range contains no working day.');
}
$request = new AbsenceRequest();
$request->setUser($user);
$request->setType($typeEnum);
$request->setStartDate($start);
$request->setEndDate($end);
$request->setStartHalfDay($startHd);
$request->setEndHalfDay($endHd);
$request->setReason($reason);
$request->setCountedDays($countedDays);
$request->setStatus(AbsenceStatus::Pending);
$request->setRejectionReason(null);
$request->setCreatedAt(new DateTimeImmutable());
$this->entityManager->persist($request);
$this->balanceService->reservePending($request);
$this->entityManager->flush();
return json_encode(Serializer::absenceRequest($request));
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'delete-absence-request', description: 'Permanently delete an absence request (admin). Does not adjust balances — use cancel-absence-request first if the days must be credited back.')]
class DeleteAbsenceRequestTool
{
public function __construct(
private readonly AbsenceRequestRepositoryInterface $requestRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$request = $this->requestRepository->findById($id);
if (null === $request) {
throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id));
}
$this->entityManager->remove($request);
$this->entityManager->flush();
return json_encode(['success' => true, 'message' => sprintf('AbsenceRequest %d deleted.', $id)]);
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Mcp\Tool\Serializer;
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'get-absence-request', description: 'Get a single absence request by ID.')]
class GetAbsenceRequestTool
{
public function __construct(
private readonly AbsenceRequestRepositoryInterface $requestRepository,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$request = $this->requestRepository->findById($id);
if (null === $request) {
throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id));
}
return json_encode(Serializer::absenceRequest($request));
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Mcp\Tool\Serializer;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Domain\Repository\AbsenceBalanceRepositoryInterface;
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'list-absence-balances', description: 'List leave balances. Optional filters: userId, type (cp, mariage_pacs, conge_parental, deces, maladie), period (e.g. "2025-2026" or "2025").')]
class ListAbsenceBalancesTool
{
public function __construct(
private readonly AbsenceBalanceRepositoryInterface $balanceRepository,
private readonly UserRepositoryInterface $userRepository,
private readonly Security $security,
) {}
public function __invoke(?int $userId = null, ?string $type = null, ?string $period = null): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$criteria = [];
if (null !== $userId) {
$user = $this->userRepository->findById($userId);
if (null === $user) {
throw new InvalidArgumentException(sprintf('User with ID %d not found.', $userId));
}
$criteria['user'] = $user;
}
if (null !== $type) {
$criteria['type'] = AbsenceType::tryFrom($type)
?? throw new InvalidArgumentException(sprintf('Unknown absence type "%s".', $type));
}
if (null !== $period) {
$criteria['period'] = $period;
}
$balances = $this->balanceRepository->findBy($criteria, ['period' => 'DESC']);
return json_encode(array_map(Serializer::absenceBalance(...), $balances));
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Mcp\Tool\Serializer;
use App\Module\Absence\Domain\Repository\AbsencePolicyRepositoryInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-absence-policies', description: 'List all absence policies (one per absence type) with their rules: days/year, days/event, notice period, working-days mode, active flag.')]
class ListAbsencePoliciesTool
{
public function __construct(
private readonly AbsencePolicyRepositoryInterface $policyRepository,
private readonly Security $security,
) {}
public function __invoke(): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$policies = $this->policyRepository->findBy([], ['type' => 'ASC']);
return json_encode(array_map(Serializer::absencePolicy(...), $policies));
}
}
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Mcp\Tool\Serializer;
use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
use DateTimeImmutable;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'list-absence-requests', description: 'List absence requests. Optional filters: userId, status (pending, approved, rejected, cancelled), type (cp, mariage_pacs, conge_parental, deces, maladie), from/to (YYYY-MM-DD, overlap window). Without userId returns all employees.')]
class ListAbsenceRequestsTool
{
public function __construct(
private readonly AbsenceRequestRepositoryInterface $requestRepository,
private readonly UserRepositoryInterface $userRepository,
private readonly Security $security,
) {}
public function __invoke(
?int $userId = null,
?string $status = null,
?string $type = null,
?string $from = null,
?string $to = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$user = null;
if (null !== $userId) {
$user = $this->userRepository->findById($userId)
?? throw new InvalidArgumentException(sprintf('User with ID %d not found.', $userId));
}
$statusEnum = null;
if (null !== $status) {
$statusEnum = AbsenceStatus::tryFrom($status)
?? throw new InvalidArgumentException(sprintf('Unknown status "%s".', $status));
}
$typeEnum = null;
if (null !== $type) {
$typeEnum = AbsenceType::tryFrom($type)
?? throw new InvalidArgumentException(sprintf('Unknown absence type "%s".', $type));
}
$requests = $this->requestRepository->findFiltered(
$user,
$statusEnum,
$typeEnum,
null !== $from ? new DateTimeImmutable($from) : null,
null !== $to ? new DateTimeImmutable($to) : null,
);
return json_encode(array_map(Serializer::absenceRequest(...), $requests));
}
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Mcp\Tool\Serializer;
use App\Module\Absence\Application\Service\AbsenceBalanceService;
use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function assert;
use function sprintf;
#[McpTool(name: 'review-absence-request', description: 'Approve or reject a PENDING absence request (admin). decision = "approve" or "reject"; rejectionReason is required when rejecting. Approving moves the days from pending to taken; rejecting releases the reserved days.')]
class ReviewAbsenceRequestTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly AbsenceRequestRepositoryInterface $requestRepository,
private readonly AbsenceBalanceService $balanceService,
private readonly Security $security,
) {}
public function __invoke(int $id, string $decision, ?string $rejectionReason = null): string
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
if (!in_array($decision, ['approve', 'reject'], true)) {
throw new InvalidArgumentException('decision must be "approve" or "reject".');
}
$request = $this->requestRepository->findById($id);
if (null === $request) {
throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id));
}
if (AbsenceStatus::Pending !== $request->getStatus()) {
throw new InvalidArgumentException('Only a pending request can be reviewed.');
}
$admin = $this->security->getUser();
assert($admin instanceof UserInterface);
if ('approve' === $decision) {
// Never let an approval push the balance below zero (CP only).
$available = $this->balanceService->availableForRequest($request);
if (null !== $available && $request->getCountedDays() > $available + 1e-9) {
throw new InvalidArgumentException(sprintf(
'Approving this request would put the balance below zero: %g day(s) requested but only %g available.',
$request->getCountedDays(),
$available,
));
}
$request->setStatus(AbsenceStatus::Approved);
$request->setRejectionReason(null);
$this->balanceService->applyApproval($request);
} else {
if (null === $rejectionReason || '' === trim($rejectionReason)) {
throw new InvalidArgumentException('A reason is required when rejecting a request.');
}
$request->setStatus(AbsenceStatus::Rejected);
$request->setRejectionReason($rejectionReason);
$this->balanceService->release($request, false);
}
$request->setReviewedAt(new DateTimeImmutable());
$request->setReviewedBy($admin);
$this->entityManager->flush();
return json_encode(Serializer::absenceRequest($request));
}
}
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Mcp\Tool\Serializer;
use App\Module\Absence\Domain\Repository\AbsenceBalanceRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'update-absence-balance', description: 'Manually adjust a leave balance (admin regularisation). Only provided buckets change: acquired (N-1), acquiring (N), taken.')]
class UpdateAbsenceBalanceTool
{
public function __construct(
private readonly AbsenceBalanceRepositoryInterface $balanceRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
int $id,
?float $acquired = null,
?float $acquiring = null,
?float $taken = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$balance = $this->balanceRepository->findById($id);
if (null === $balance) {
throw new InvalidArgumentException(sprintf('AbsenceBalance with ID %d not found.', $id));
}
if (null !== $acquired) {
$balance->setAcquired($acquired);
}
if (null !== $acquiring) {
$balance->setAcquiring($acquiring);
}
if (null !== $taken) {
$balance->setTaken($taken);
}
$this->entityManager->flush();
return json_encode(Serializer::absenceBalance($balance));
}
}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Mcp\Tool\Serializer;
use App\Module\Absence\Domain\Repository\AbsencePolicyRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'update-absence-policy', description: 'Update an absence policy (admin). Only provided fields change.')]
class UpdateAbsencePolicyTool
{
public function __construct(
private readonly AbsencePolicyRepositoryInterface $policyRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
int $id,
?float $daysPerYear = null,
?float $daysPerEvent = null,
?bool $justificationRequired = null,
?int $noticeDays = null,
?bool $countWorkingDaysOnly = null,
?bool $active = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$policy = $this->policyRepository->findById($id);
if (null === $policy) {
throw new InvalidArgumentException(sprintf('AbsencePolicy with ID %d not found.', $id));
}
if (null !== $daysPerYear) {
$policy->setDaysPerYear($daysPerYear);
}
if (null !== $daysPerEvent) {
$policy->setDaysPerEvent($daysPerEvent);
}
if (null !== $justificationRequired) {
$policy->setJustificationRequired($justificationRequired);
}
if (null !== $noticeDays) {
$policy->setNoticeDays($noticeDays);
}
if (null !== $countWorkingDaysOnly) {
$policy->setCountWorkingDaysOnly($countWorkingDaysOnly);
}
if (null !== $active) {
$policy->setActive($active);
}
$this->entityManager->flush();
return json_encode(Serializer::absencePolicy($policy));
}
}