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