306cfd34cd
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.
85 lines
3.4 KiB
PHP
85 lines
3.4 KiB
PHP
<?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));
|
|
}
|
|
}
|