feat(absences) : outils MCP CRUD pour les absences

Expose le module Absences via le serveur MCP et comble les trous CRUD
existants (projets, groupes, métadonnées de tâches, clients, users RH).

Absences (réutilise AbsenceDayCalculator + AbsenceBalanceService pour ne
pas contourner la logique de soldes) :
- list/get/create/review/cancel/delete-absence-request
- list/update-absence-policy, list/update-absence-balance
- create-absence-request prend un userId explicite (agir au nom d'un employé) ;
  review/cancel maintiennent les soldes (pending/taken) cohérents
- AbsenceRequestRepository::findFiltered pour les filtres de liste

Trous CRUD comblés :
- delete-project, delete-group
- CRUD tag, effort, priority
- CRUD status (couplé au workflow, avec category)
- CRUD client, get/update-user (champs RH, sans password ni roles)

Sérialisation centralisée (Serializer::absenceRequest/Policy/Balance/client/userFull).
Instructions MCP (mcp.yaml) mises à jour : statuts par workflow + domaine absences.

Tests : tests/Functional/Mcp/AbsenceRequestLifecycleTest (création / approbation /
annulation admin) vérifient le cycle complet et la cohérence des soldes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-05-22 14:10:56 +02:00
parent 2a0b202d32
commit 2b148fa65a
36 changed files with 4536 additions and 1 deletions

View File

@@ -6,8 +6,14 @@ mcp:
This server provides access to the Lesstime project management system. This server provides access to the Lesstime project management system.
You can list/create/update/delete projects, tasks, and time entries. You can list/create/update/delete projects, tasks, and time entries.
Tasks belong to projects and have statuses, priorities, efforts, tags, and groups. Tasks belong to projects and have statuses, priorities, efforts, tags, and groups.
Statuses, priorities, efforts, and tags are GLOBAL (shared across all projects). Priorities, efforts, and tags are GLOBAL (shared across all projects).
Statuses belong to a WORKFLOW (not global) — use list-workflows and list-statuses
to see how they group; create-status requires a workflowId and a category.
Groups are PER-PROJECT (each group belongs to one project). Groups are PER-PROJECT (each group belongs to one project).
Absences: manage employee leave with absence-request tools (list/get/create/review/
cancel/delete), absence-policy tools (list/update) and absence-balance tools
(list/update). create-absence-request takes an explicit userId to act on behalf of
an employee; review/cancel keep the leave balances consistent (pending/taken).
Time entries track work duration and can be linked to projects and tasks. Time entries track work duration and can be linked to projects and tasks.
Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover
available metadata before creating or updating tasks. available metadata before creating or updating tasks.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,141 @@
# Spec — Extension des outils MCP : module Absences + trous CRUD
Date : 2026-05-22
Branche : `feat/absence-management`
## Contexte & objectif
Le serveur MCP Lesstime (`src/Mcp/Tool/`) expose aujourd'hui les projets, tâches,
métadonnées de tâches, time tracking et workflows. Le nouveau **module Absences**
(`AbsenceRequest`, `AbsencePolicy`, `AbsenceBalance`) n'est pas exposé, et plusieurs
entités existantes n'ont qu'une couverture partielle (souvent `list` sans
create/update/delete).
Objectif : permettre de piloter l'app via MCP (assistant) sur ces domaines, en
respectant strictement la logique métier déjà en place.
## Conventions reprises de l'existant
- Une classe par outil, attribut `#[McpTool(name, description)]` sur la **classe**.
- Discovery automatique : `config/packages/mcp.yaml` scanne `src/` (exclut `DataFixtures`).
Aucune config à ajouter — créer la classe suffit.
- Constructeur : injection de repos/services + `Security`.
- `__invoke(...)` : check de rôle en première ligne (`AccessDeniedException` sinon),
validation des IDs (`InvalidArgumentException` si introuvable), retour `json_encode(...)`.
- Sérialisation centralisée dans `App\Mcp\Tool\Serializer`.
- Rôle : `ROLE_USER` pour la lecture/écriture courante ; `ROLE_ADMIN` pour les
opérations sensibles (déjà le cas pour `list-clients`/`list-users`, et pour les
opérations admin du module absences).
## Décision clé — réutilisation de la logique métier (pas les Processors)
Les Processors API Platform (`AbsenceRequestProcessor`, `AbsenceReviewProcessor`,
`AbsenceCancelProcessor`) sont liés à `Security::getUser()` (l'utilisateur courant)
et à l'`Operation` HTTP. En MCP, l'utilisateur courant est le **propriétaire du
token** (admin), or on veut pouvoir agir **au nom d'un employé**.
→ Les outils MCP **n'appellent pas les Processors** ; ils répliquent leur
orchestration en réutilisant les **services partagés** qui portent la vraie règle
métier :
- `AbsenceDayCalculator::countWorkingDays(...)` — calcul des jours décomptés.
- `AbsenceBalanceService``reservePending`, `applyApproval`, `release`, `periodFor`.
- `AbsencePolicyRepository::findOneByType(...)` — politique active du type.
- `AbsenceRequestRepository::hasOverlap(...)` — règle anti-chevauchement.
`create-absence-request` prend un `userId` explicite (l'employé cible) ;
`review`/`cancel` posent `reviewedBy` = utilisateur du token MCP.
Ainsi soldes (`pending`/`taken`/`acquired`) et statuts restent cohérents avec ce
que produit l'UI.
## Inventaire des outils
### Module Absences — `src/Mcp/Tool/Absence/` (10 outils)
| Outil | Rôle | Paramètres | Logique |
|---|---|---|---|
| `list-absence-requests` | USER | `userId?`, `status?`, `type?`, `from?`, `to?` | Filtre ; sans `userId` renvoie tout (token admin). |
| `get-absence-request` | USER | `id` | — |
| `create-absence-request` | USER | `userId`, `type`, `startDate`, `endDate`, `startHalfDay?`, `endHalfDay?`, `reason?` | Vérifie policy active + overlap, calcule `countedDays`, refuse si ≤ 0, statut `Pending`, `reservePending`. |
| `review-absence-request` | ADMIN | `id`, `decision` (`approve`\|`reject`), `rejectionReason?` | Seulement si `Pending`. Approve → `applyApproval` ; reject → `rejectionReason` requis + `release(false)`. Pose `reviewedAt`/`reviewedBy`. |
| `cancel-absence-request` | USER | `id` | `Pending``release(false)` ; `Approved` → ADMIN requis + `release(true)` ; sinon conflit. Statut `Cancelled`. |
| `delete-absence-request` | ADMIN | `id` | Suppression définitive. |
| `list-absence-policies` | USER | — | Toutes les policies (ordre `type`). |
| `update-absence-policy` | ADMIN | `id`, `daysPerYear?`, `daysPerEvent?`, `justificationRequired?`, `noticeDays?`, `countWorkingDaysOnly?`, `active?` | Seuls les champs fournis changent. |
| `list-absence-balances` | USER | `userId?`, `type?`, `period?` | Soldes filtrés. |
| `update-absence-balance` | ADMIN | `id`, `acquired?`, `acquiring?`, `taken?` | Ajustement manuel (régularisation). |
`type` et `status` acceptés en valeur d'enum string (ex. `cp`, `maladie`,
`pending`) ; erreur de validation explicite si invalide. `startDate`/`endDate`
au format `YYYY-MM-DD`.
### Trous CRUD sur l'existant
**Projets / groupes**
- `delete-project` (ADMIN) — `id`.
- `delete-group` (USER) — `id`.
**Métadonnées de tâches**`src/Mcp/Tool/TaskMeta/`
- `create-tag` / `update-tag` / `delete-tag` (USER) — `label`, `color?`.
- `create-effort` / `update-effort` / `delete-effort` (USER) — `label` (+ ordre éventuel).
- `create-priority` / `update-priority` / `delete-priority` (USER) — `label`, `color?`.
- `create-status` / `update-status` / `delete-status` (ADMIN) — **`workflowId` requis**
+ `category` (`todo`|`in_progress`|`blocked`|`review`|`done`), `label`, `color?`,
`position?`, `isFinal?`. (Les statuts ne sont PAS globaux : ils appartiennent à un workflow.)
**Clients**`src/Mcp/Tool/Reference/` (ADMIN, aligné sur `list-clients`)
- `get-client``id`.
- `create-client``name` (+ `email?`, `phone?`, `street?`, `city?`, `postalCode?`).
- `update-client``id` + champs optionnels.
- `delete-client``id`.
**Utilisateurs**`src/Mcp/Tool/Reference/` (ADMIN)
- `get-user``id` (profil complet RH).
- `update-user``id` + champs RH/profil : `isEmployee?`, `hireDate?`, `endDate?`,
`contractType?`, `workTimeRatio?`, `annualLeaveDays?`, `referencePeriodStart?`,
`initialLeaveBalance?`, `familySituation?`, `nbChildren?`.
**Hors périmètre (décision utilisateur) : pas de `create-user`, pas de modification
de `password` ni `roles` via MCP.**
## Sérialisation — ajouts à `Serializer.php`
- `absenceRequest(AbsenceRequest)` : id, user{id,username}, type{value,label},
startDate, endDate, startHalfDay, endHalfDay, countedDays, reason, status{value,label},
rejectionReason, createdAt, reviewedAt, reviewedBy, justificationFileName.
- `absencePolicy(AbsencePolicy)` : id, type{value,label}, daysPerYear, daysPerEvent,
justificationRequired, noticeDays, countWorkingDaysOnly, active.
- `absenceBalance(AbsenceBalance)` : id, user, type{value,label}, period, acquired,
acquiring, taken, pending, acquiredTotal, available.
- `client(Client)` : id, name, email, phone, street, city, postalCode.
- `userFull(User)` : id, username, roles, isEmployee, hireDate, endDate, contractType,
workTimeRatio, annualLeaveDays, referencePeriodStart, initialLeaveBalance,
familySituation, nbChildren.
## Mise à jour de la doc MCP
`config/packages/mcp.yaml` — bloc `instructions` :
- corriger la mention « statuses … are GLOBAL » (faux : par workflow) ;
- ajouter une phrase sur le domaine Absences (requests/policies/balances, lifecycle
approve/reject/cancel, `userId` pour agir au nom d'un employé).
## Découpage du plan d'implémentation (jalons)
1. **Absences** : Serializer + 10 outils + tests de cohérence des soldes.
2. **Métadonnées tâches** : delete-project/group, CRUD tag/effort/priority/status.
3. **Clients & users** : CRUD clients, get/update user + maj `mcp.yaml`.
Chaque jalon est livrable et testable indépendamment.
## Tests
Tests fonctionnels MCP (si une infra de test MCP existe) ou tests unitaires sur la
réplication de la logique de solde : créer → review(approve) → cancel et vérifier
`pending`/`taken` à chaque étape ; vérifier le refus sur chevauchement et sur
plage sans jour ouvré.
## Hors périmètre
- Mail, BookStack, Gitea, Zimbra, Notifications, TaskDocument (non demandés).
- Création d'utilisateurs et gestion mot de passe/rôles via MCP.
- Upload de justificatif d'absence via MCP.

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
use App\Enum\AbsenceStatus;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsenceRequestRepository;
use App\Service\AbsenceBalanceService;
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 AbsenceRequestRepository $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->find($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));
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
use App\Entity\AbsenceRequest;
use App\Enum\AbsenceStatus;
use App\Enum\AbsenceType;
use App\Enum\HalfDay;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsencePolicyRepository;
use App\Repository\AbsenceRequestRepository;
use App\Repository\UserRepository;
use App\Service\AbsenceBalanceService;
use App\Service\AbsenceDayCalculator;
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 UserRepository $userRepository,
private readonly AbsencePolicyRepository $policyRepository,
private readonly AbsenceRequestRepository $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->find($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.');
}
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));
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
use App\Repository\AbsenceRequestRepository;
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 AbsenceRequestRepository $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->find($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)]);
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsenceRequestRepository;
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 AbsenceRequestRepository $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->find($id);
if (null === $request) {
throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id));
}
return json_encode(Serializer::absenceRequest($request));
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
use App\Enum\AbsenceType;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsenceBalanceRepository;
use App\Repository\UserRepository;
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 AbsenceBalanceRepository $balanceRepository,
private readonly UserRepository $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->find($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));
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsencePolicyRepository;
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 AbsencePolicyRepository $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));
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
use App\Enum\AbsenceStatus;
use App\Enum\AbsenceType;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsenceRequestRepository;
use App\Repository\UserRepository;
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 AbsenceRequestRepository $requestRepository,
private readonly UserRepository $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->find($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));
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
use App\Entity\User;
use App\Enum\AbsenceStatus;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsenceRequestRepository;
use App\Service\AbsenceBalanceService;
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 AbsenceRequestRepository $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->find($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 User);
if ('approve' === $decision) {
$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));
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsenceBalanceRepository;
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 AbsenceBalanceRepository $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->find($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));
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsencePolicyRepository;
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 AbsencePolicyRepository $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->find($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));
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Project;
use App\Repository\ProjectRepository;
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-project', description: 'Permanently delete a project and all its tasks (admin). Irreversible.')]
class DeleteProjectTool
{
public function __construct(
private readonly ProjectRepository $projectRepository,
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.');
}
$project = $this->projectRepository->find($id);
if (null === $project) {
throw new InvalidArgumentException(sprintf('Project with ID %d not found.', $id));
}
$name = $project->getName();
$this->entityManager->remove($project);
$this->entityManager->flush();
return json_encode(['success' => true, 'message' => sprintf('Project "%s" deleted.', $name)]);
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Reference;
use App\Entity\Client;
use App\Mcp\Tool\Serializer;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'create-client', description: 'Create a client (admin). Only name is required.')]
class CreateClientTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
string $name,
?string $email = null,
?string $phone = null,
?string $street = null,
?string $city = null,
?string $postalCode = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$client = new Client();
$client->setName($name);
$client->setEmail($email);
$client->setPhone($phone);
$client->setStreet($street);
$client->setCity($city);
$client->setPostalCode($postalCode);
$this->entityManager->persist($client);
$this->entityManager->flush();
return json_encode(Serializer::client($client));
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Reference;
use App\Repository\ClientRepository;
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-client', description: 'Delete a client (admin). Fails if the client still has projects attached.')]
class DeleteClientTool
{
public function __construct(
private readonly ClientRepository $clientRepository,
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.');
}
$client = $this->clientRepository->find($id);
if (null === $client) {
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $id));
}
$name = $client->getName();
$this->entityManager->remove($client);
$this->entityManager->flush();
return json_encode(['success' => true, 'message' => sprintf('Client "%s" deleted.', $name)]);
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Reference;
use App\Mcp\Tool\Serializer;
use App\Repository\ClientRepository;
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-client', description: 'Get a client by ID with full contact details.')]
class GetClientTool
{
public function __construct(
private readonly ClientRepository $clientRepository,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$client = $this->clientRepository->find($id);
if (null === $client) {
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $id));
}
return json_encode(Serializer::client($client));
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Reference;
use App\Mcp\Tool\Serializer;
use App\Repository\UserRepository;
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-user', description: 'Get a user by ID with full HR profile (employee flag, hire/end date, contract, work-time ratio, leave entitlement, reference period, family situation).')]
class GetUserTool
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$user = $this->userRepository->find($id);
if (null === $user) {
throw new InvalidArgumentException(sprintf('User with ID %d not found.', $id));
}
return json_encode(Serializer::userFull($user));
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Reference;
use App\Mcp\Tool\Serializer;
use App\Repository\ClientRepository;
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-client', description: 'Update a client (admin). Only provided fields change.')]
class UpdateClientTool
{
public function __construct(
private readonly ClientRepository $clientRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
int $id,
?string $name = null,
?string $email = null,
?string $phone = null,
?string $street = null,
?string $city = null,
?string $postalCode = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$client = $this->clientRepository->find($id);
if (null === $client) {
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $id));
}
if (null !== $name) {
$client->setName($name);
}
if (null !== $email) {
$client->setEmail($email);
}
if (null !== $phone) {
$client->setPhone($phone);
}
if (null !== $street) {
$client->setStreet($street);
}
if (null !== $city) {
$client->setCity($city);
}
if (null !== $postalCode) {
$client->setPostalCode($postalCode);
}
$this->entityManager->flush();
return json_encode(Serializer::client($client));
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Reference;
use App\Enum\ContractType;
use App\Enum\FamilySituation;
use App\Mcp\Tool\Serializer;
use App\Repository\UserRepository;
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: 'update-user', description: 'Update a user HR/profile fields (admin). Does NOT change password or roles. contractType = CDI|CDD|STAGE|ALTERNANCE|AUTRE. familySituation = CELIBATAIRE|MARIE|PACSE|DIVORCE|VEUF. hireDate/endDate as YYYY-MM-DD. referencePeriodStart as MM-DD (e.g. 06-01).')]
class UpdateUserTool
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
int $id,
?bool $isEmployee = null,
?string $hireDate = null,
?string $endDate = null,
?string $contractType = null,
?float $workTimeRatio = null,
?float $annualLeaveDays = null,
?string $referencePeriodStart = null,
?float $initialLeaveBalance = null,
?string $familySituation = null,
?int $nbChildren = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$user = $this->userRepository->find($id);
if (null === $user) {
throw new InvalidArgumentException(sprintf('User with ID %d not found.', $id));
}
if (null !== $isEmployee) {
$user->setIsEmployee($isEmployee);
}
if (null !== $hireDate) {
$user->setHireDate(new DateTimeImmutable($hireDate));
}
if (null !== $endDate) {
$user->setEndDate(new DateTimeImmutable($endDate));
}
if (null !== $contractType) {
$user->setContractType(
ContractType::tryFrom($contractType)
?? throw new InvalidArgumentException(sprintf('Unknown contract type "%s".', $contractType)),
);
}
if (null !== $workTimeRatio) {
$user->setWorkTimeRatio($workTimeRatio);
}
if (null !== $annualLeaveDays) {
$user->setAnnualLeaveDays($annualLeaveDays);
}
if (null !== $referencePeriodStart) {
$user->setReferencePeriodStart($referencePeriodStart);
}
if (null !== $initialLeaveBalance) {
$user->setInitialLeaveBalance($initialLeaveBalance);
}
if (null !== $familySituation) {
$user->setFamilySituation(
FamilySituation::tryFrom($familySituation)
?? throw new InvalidArgumentException(sprintf('Unknown family situation "%s".', $familySituation)),
);
}
if (null !== $nbChildren) {
$user->setNbChildren($nbChildren);
}
$this->entityManager->flush();
return json_encode(Serializer::userFull($user));
}
}

View File

@@ -4,6 +4,10 @@ declare(strict_types=1);
namespace App\Mcp\Tool; namespace App\Mcp\Tool;
use App\Entity\AbsenceBalance;
use App\Entity\AbsencePolicy;
use App\Entity\AbsenceRequest;
use App\Entity\Client;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\Task; use App\Entity\Task;
use App\Entity\TaskDocument; use App\Entity\TaskDocument;
@@ -287,4 +291,106 @@ final class Serializer
'uploadedBy' => self::user($doc->getUploadedBy()), 'uploadedBy' => self::user($doc->getUploadedBy()),
])->toArray(); ])->toArray();
} }
/**
* @return array<string, mixed>
*/
public static function absenceRequest(AbsenceRequest $r): array
{
return [
'id' => $r->getId(),
'user' => self::user($r->getUser()),
'type' => $r->getType()?->value,
'typeLabel' => $r->getType()?->label(),
'startDate' => $r->getStartDate()?->format('Y-m-d'),
'endDate' => $r->getEndDate()?->format('Y-m-d'),
'startHalfDay' => $r->getStartHalfDay()?->value,
'endHalfDay' => $r->getEndHalfDay()?->value,
'countedDays' => $r->getCountedDays(),
'reason' => $r->getReason(),
'status' => $r->getStatus()->value,
'statusLabel' => $r->getStatus()->label(),
'rejectionReason' => $r->getRejectionReason(),
'justificationFileName' => $r->getJustificationFileName(),
'createdAt' => $r->getCreatedAt()?->format('c'),
'reviewedAt' => $r->getReviewedAt()?->format('c'),
'reviewedBy' => self::user($r->getReviewedBy()),
];
}
/**
* @return array<string, mixed>
*/
public static function absencePolicy(AbsencePolicy $p): array
{
return [
'id' => $p->getId(),
'type' => $p->getType()->value,
'typeLabel' => $p->getType()->label(),
'daysPerYear' => $p->getDaysPerYear(),
'daysPerEvent' => $p->getDaysPerEvent(),
'justificationRequired' => $p->isJustificationRequired(),
'noticeDays' => $p->getNoticeDays(),
'countWorkingDaysOnly' => $p->isCountWorkingDaysOnly(),
'active' => $p->isActive(),
];
}
/**
* @return array<string, mixed>
*/
public static function absenceBalance(AbsenceBalance $b): array
{
return [
'id' => $b->getId(),
'user' => self::user($b->getUser()),
'type' => $b->getType()->value,
'typeLabel' => $b->getType()->label(),
'period' => $b->getPeriod(),
'acquired' => $b->getAcquired(),
'acquiring' => $b->getAcquiring(),
'taken' => $b->getTaken(),
'pending' => $b->getPending(),
'acquiredTotal' => $b->getAcquiredTotal(),
'available' => $b->getAvailable(),
];
}
/**
* @return array<string, mixed>
*/
public static function client(Client $c): array
{
return [
'id' => $c->getId(),
'name' => $c->getName(),
'email' => $c->getEmail(),
'phone' => $c->getPhone(),
'street' => $c->getStreet(),
'city' => $c->getCity(),
'postalCode' => $c->getPostalCode(),
];
}
/**
* @return array<string, mixed>
*/
public static function userFull(User $u): array
{
return [
'id' => $u->getId(),
'username' => $u->getUsername(),
'roles' => $u->getRoles(),
'isEmployee' => $u->getIsEmployee(),
'hireDate' => $u->getHireDate()?->format('Y-m-d'),
'endDate' => $u->getEndDate()?->format('Y-m-d'),
'contractType' => $u->getContractType()?->value,
'workTimeRatio' => $u->getWorkTimeRatio(),
'annualLeaveDays' => $u->getAnnualLeaveDays(),
'referencePeriodStart' => $u->getReferencePeriodStart(),
'initialLeaveBalance' => $u->getInitialLeaveBalance(),
'familySituation' => $u->getFamilySituation()?->value,
'nbChildren' => $u->getNbChildren(),
];
}
} }

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Entity\TaskEffort;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'create-effort', description: 'Create a global task effort level (label only).')]
class CreateEffortTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(string $label): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$effort = new TaskEffort();
$effort->setLabel($label);
$this->entityManager->persist($effort);
$this->entityManager->flush();
return json_encode(['id' => $effort->getId(), 'label' => $effort->getLabel()]);
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Entity\TaskPriority;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'create-priority', description: 'Create a global task priority (label + color).')]
class CreatePriorityTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(string $label, ?string $color = null): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$priority = new TaskPriority();
$priority->setLabel($label);
if (null !== $color) {
$priority->setColor($color);
}
$this->entityManager->persist($priority);
$this->entityManager->flush();
return json_encode(['id' => $priority->getId(), 'label' => $priority->getLabel(), 'color' => $priority->getColor()]);
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Entity\TaskStatus;
use App\Enum\StatusCategory;
use App\Repository\WorkflowRepository;
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-status', description: 'Create a task status inside a workflow (admin). Statuses are NOT global: each belongs to a workflow (use list-workflows for IDs). category = todo|in_progress|blocked|review|done.')]
class CreateStatusTool
{
public function __construct(
private readonly WorkflowRepository $workflowRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
int $workflowId,
string $label,
string $category,
?string $color = null,
?int $position = null,
?bool $isFinal = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$workflow = $this->workflowRepository->find($workflowId);
if (null === $workflow) {
throw new InvalidArgumentException(sprintf('Workflow with ID %d not found.', $workflowId));
}
$categoryEnum = StatusCategory::tryFrom($category)
?? throw new InvalidArgumentException(sprintf('Unknown status category "%s".', $category));
$status = new TaskStatus();
$status->setWorkflow($workflow);
$status->setLabel($label);
$status->setCategory($categoryEnum);
if (null !== $color) {
$status->setColor($color);
}
if (null !== $position) {
$status->setPosition($position);
}
if (null !== $isFinal) {
$status->setIsFinal($isFinal);
}
$this->entityManager->persist($status);
$this->entityManager->flush();
return json_encode([
'id' => $status->getId(),
'label' => $status->getLabel(),
'color' => $status->getColor(),
'position' => $status->getPosition(),
'isFinal' => $status->getIsFinal(),
'category' => $status->getCategory()->value,
'workflowId' => $workflow->getId(),
]);
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Entity\TaskTag;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'create-tag', description: 'Create a global task tag. Tags are shared across all projects.')]
class CreateTagTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(string $label, ?string $color = null): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$tag = new TaskTag();
$tag->setLabel($label);
if (null !== $color) {
$tag->setColor($color);
}
$this->entityManager->persist($tag);
$this->entityManager->flush();
return json_encode(['id' => $tag->getId(), 'label' => $tag->getLabel(), 'color' => $tag->getColor()]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskEffortRepository;
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-effort', description: 'Delete a task effort level.')]
class DeleteEffortTool
{
public function __construct(
private readonly TaskEffortRepository $effortRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$effort = $this->effortRepository->find($id);
if (null === $effort) {
throw new InvalidArgumentException(sprintf('TaskEffort with ID %d not found.', $id));
}
$label = $effort->getLabel();
$this->entityManager->remove($effort);
$this->entityManager->flush();
return json_encode(['success' => true, 'message' => sprintf('Effort "%s" deleted.', $label)]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskGroupRepository;
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-group', description: 'Delete a task group. Tasks in the group are not deleted; they become ungrouped.')]
class DeleteGroupTool
{
public function __construct(
private readonly TaskGroupRepository $taskGroupRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$group = $this->taskGroupRepository->find($id);
if (null === $group) {
throw new InvalidArgumentException(sprintf('TaskGroup with ID %d not found.', $id));
}
$title = $group->getTitle();
$this->entityManager->remove($group);
$this->entityManager->flush();
return json_encode(['success' => true, 'message' => sprintf('Group "%s" deleted.', $title)]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskPriorityRepository;
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-priority', description: 'Delete a task priority.')]
class DeletePriorityTool
{
public function __construct(
private readonly TaskPriorityRepository $priorityRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$priority = $this->priorityRepository->find($id);
if (null === $priority) {
throw new InvalidArgumentException(sprintf('TaskPriority with ID %d not found.', $id));
}
$label = $priority->getLabel();
$this->entityManager->remove($priority);
$this->entityManager->flush();
return json_encode(['success' => true, 'message' => sprintf('Priority "%s" deleted.', $label)]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskStatusRepository;
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-status', description: 'Delete a task status from its workflow (admin). Fails at the database level if tasks still use it.')]
class DeleteStatusTool
{
public function __construct(
private readonly TaskStatusRepository $statusRepository,
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.');
}
$status = $this->statusRepository->find($id);
if (null === $status) {
throw new InvalidArgumentException(sprintf('TaskStatus with ID %d not found.', $id));
}
$label = $status->getLabel();
$this->entityManager->remove($status);
$this->entityManager->flush();
return json_encode(['success' => true, 'message' => sprintf('Status "%s" deleted.', $label)]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskTagRepository;
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-tag', description: 'Delete a global task tag. It is removed from all tasks that use it.')]
class DeleteTagTool
{
public function __construct(
private readonly TaskTagRepository $tagRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$tag = $this->tagRepository->find($id);
if (null === $tag) {
throw new InvalidArgumentException(sprintf('TaskTag with ID %d not found.', $id));
}
$label = $tag->getLabel();
$this->entityManager->remove($tag);
$this->entityManager->flush();
return json_encode(['success' => true, 'message' => sprintf('Tag "%s" deleted.', $label)]);
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskEffortRepository;
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-effort', description: 'Rename a task effort level.')]
class UpdateEffortTool
{
public function __construct(
private readonly TaskEffortRepository $effortRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id, string $label): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$effort = $this->effortRepository->find($id);
if (null === $effort) {
throw new InvalidArgumentException(sprintf('TaskEffort with ID %d not found.', $id));
}
$effort->setLabel($label);
$this->entityManager->flush();
return json_encode(['id' => $effort->getId(), 'label' => $effort->getLabel()]);
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskPriorityRepository;
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-priority', description: 'Update a task priority. Only provided fields change.')]
class UpdatePriorityTool
{
public function __construct(
private readonly TaskPriorityRepository $priorityRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id, ?string $label = null, ?string $color = null): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$priority = $this->priorityRepository->find($id);
if (null === $priority) {
throw new InvalidArgumentException(sprintf('TaskPriority with ID %d not found.', $id));
}
if (null !== $label) {
$priority->setLabel($label);
}
if (null !== $color) {
$priority->setColor($color);
}
$this->entityManager->flush();
return json_encode(['id' => $priority->getId(), 'label' => $priority->getLabel(), 'color' => $priority->getColor()]);
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Enum\StatusCategory;
use App\Repository\TaskStatusRepository;
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-status', description: 'Update a task status (admin). Only provided fields change. category = todo|in_progress|blocked|review|done.')]
class UpdateStatusTool
{
public function __construct(
private readonly TaskStatusRepository $statusRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
int $id,
?string $label = null,
?string $category = null,
?string $color = null,
?int $position = null,
?bool $isFinal = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$status = $this->statusRepository->find($id);
if (null === $status) {
throw new InvalidArgumentException(sprintf('TaskStatus with ID %d not found.', $id));
}
if (null !== $label) {
$status->setLabel($label);
}
if (null !== $category) {
$status->setCategory(
StatusCategory::tryFrom($category)
?? throw new InvalidArgumentException(sprintf('Unknown status category "%s".', $category)),
);
}
if (null !== $color) {
$status->setColor($color);
}
if (null !== $position) {
$status->setPosition($position);
}
if (null !== $isFinal) {
$status->setIsFinal($isFinal);
}
$this->entityManager->flush();
return json_encode([
'id' => $status->getId(),
'label' => $status->getLabel(),
'color' => $status->getColor(),
'position' => $status->getPosition(),
'isFinal' => $status->getIsFinal(),
'category' => $status->getCategory()->value,
'workflowId' => $status->getWorkflow()?->getId(),
]);
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskTagRepository;
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-tag', description: 'Update a task tag. Only provided fields change.')]
class UpdateTagTool
{
public function __construct(
private readonly TaskTagRepository $tagRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id, ?string $label = null, ?string $color = null): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$tag = $this->tagRepository->find($id);
if (null === $tag) {
throw new InvalidArgumentException(sprintf('TaskTag with ID %d not found.', $id));
}
if (null !== $label) {
$tag->setLabel($label);
}
if (null !== $color) {
$tag->setColor($color);
}
$this->entityManager->flush();
return json_encode(['id' => $tag->getId(), 'label' => $tag->getLabel(), 'color' => $tag->getColor()]);
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Repository;
use App\Entity\AbsenceRequest; use App\Entity\AbsenceRequest;
use App\Entity\User; use App\Entity\User;
use App\Enum\AbsenceStatus; use App\Enum\AbsenceStatus;
use App\Enum\AbsenceType;
use DateTimeInterface; use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
@@ -71,4 +72,35 @@ class AbsenceRequestRepository extends ServiceEntityRepository
->getResult() ->getResult()
; ;
} }
/**
* @return AbsenceRequest[]
*/
public function findFiltered(
?User $user = null,
?AbsenceStatus $status = null,
?AbsenceType $type = null,
?DateTimeInterface $from = null,
?DateTimeInterface $to = null,
): array {
$qb = $this->createQueryBuilder('a')->orderBy('a.startDate', 'DESC');
if (null !== $user) {
$qb->andWhere('a.user = :user')->setParameter('user', $user);
}
if (null !== $status) {
$qb->andWhere('a.status = :status')->setParameter('status', $status);
}
if (null !== $type) {
$qb->andWhere('a.type = :type')->setParameter('type', $type);
}
if (null !== $from) {
$qb->andWhere('a.endDate >= :from')->setParameter('from', $from->format('Y-m-d'));
}
if (null !== $to) {
$qb->andWhere('a.startDate <= :to')->setParameter('to', $to->format('Y-m-d'));
}
return $qb->getQuery()->getResult();
}
} }

View File

@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Mcp;
use App\Entity\AbsencePolicy;
use App\Entity\User;
use App\Enum\AbsenceType;
use App\Mcp\Tool\Absence\CancelAbsenceRequestTool;
use App\Mcp\Tool\Absence\CreateAbsenceRequestTool;
use App\Mcp\Tool\Absence\ReviewAbsenceRequestTool;
use App\Repository\AbsenceBalanceRepository;
use App\Repository\AbsencePolicyRepository;
use App\Repository\AbsenceRequestRepository;
use App\Repository\UserRepository;
use App\Service\AbsenceBalanceService;
use App\Service\AbsenceDayCalculator;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Bundle\SecurityBundle\Security;
/**
* @internal
*/
class AbsenceRequestLifecycleTest extends KernelTestCase
{
private EntityManagerInterface $em;
private User $employee;
private User $admin;
protected function setUp(): void
{
self::bootKernel();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
// Employé dédié au test.
$this->employee = new User();
$this->employee->setUsername('mcp-test-employee-'.uniqid());
$this->employee->setPassword('x');
$this->employee->setRoles(['ROLE_USER']);
$this->employee->setIsEmployee(true);
$this->employee->setReferencePeriodStart('06-01');
$this->em->persist($this->employee);
$this->admin = new User();
$this->admin->setUsername('mcp-test-admin-'.uniqid());
$this->admin->setPassword('x');
$this->admin->setRoles(['ROLE_ADMIN']);
$this->em->persist($this->admin);
// Policy CP active (créée si absente — findOneByType peut déjà exister via fixtures).
$policy = $this->em->getRepository(AbsencePolicy::class)->findOneBy(['type' => AbsenceType::PaidLeave]);
if (null === $policy) {
$policy = new AbsencePolicy();
$policy->setType(AbsenceType::PaidLeave);
$policy->setCountWorkingDaysOnly(true);
$policy->setActive(true);
$this->em->persist($policy);
} else {
$policy->setActive(true);
}
$this->em->flush();
}
public function testCreateReservesPendingDays(): void
{
$tool = $this->createTool($this->admin);
// Lundi 2026-06-01 → vendredi 2026-06-05 = 5 jours ouvrés.
$json = ($tool)(
$this->employee->getId(),
'cp',
'2026-06-01',
'2026-06-05',
);
$data = json_decode($json, true);
self::assertSame('pending', $data['status']);
// JSON ne préserve pas le type float pour un entier (5.0 -> 5) : on compare la valeur.
self::assertSame(5.0, (float) $data['countedDays']);
self::assertSame($this->employee->getId(), $data['user']['id']);
$balance = self::getContainer()->get(AbsenceBalanceRepository::class)
->findOneForPeriod($this->employee, AbsenceType::PaidLeave, '2026-2027')
;
self::assertNotNull($balance);
self::assertSame(5.0, $balance->getPending());
}
public function testApproveMovesPendingToTaken(): void
{
$created = json_decode(
($this->createTool($this->admin))($this->employee->getId(), 'cp', '2026-06-01', '2026-06-05'),
true,
);
$json = ($this->reviewTool($this->admin))($created['id'], 'approve');
$data = json_decode($json, true);
self::assertSame('approved', $data['status']);
self::assertSame($this->admin->getId(), $data['reviewedBy']['id']);
$balance = self::getContainer()->get(AbsenceBalanceRepository::class)
->findOneForPeriod($this->employee, AbsenceType::PaidLeave, '2026-2027')
;
self::assertSame(0.0, $balance->getPending());
self::assertSame(5.0, $balance->getTaken());
}
public function testAdminCancelApprovedReleasesTaken(): void
{
$created = json_decode(
($this->createTool($this->admin))($this->employee->getId(), 'cp', '2026-06-01', '2026-06-05'),
true,
);
($this->reviewTool($this->admin))($created['id'], 'approve');
$data = json_decode(($this->cancelTool($this->admin))($created['id']), true);
self::assertSame('cancelled', $data['status']);
$balance = self::getContainer()->get(AbsenceBalanceRepository::class)
->findOneForPeriod($this->employee, AbsenceType::PaidLeave, '2026-2027')
;
self::assertSame(0.0, $balance->getTaken());
self::assertSame(0.0, $balance->getPending());
}
private function securityFor(User $user): Security
{
$security = $this->createMock(Security::class);
$security->method('isGranted')->willReturn(true);
$security->method('getUser')->willReturn($user);
return $security;
}
private function createTool(User $actor): CreateAbsenceRequestTool
{
$c = self::getContainer();
return new CreateAbsenceRequestTool(
$c->get(EntityManagerInterface::class),
$c->get(UserRepository::class),
$c->get(AbsencePolicyRepository::class),
$c->get(AbsenceRequestRepository::class),
$c->get(AbsenceDayCalculator::class),
$c->get(AbsenceBalanceService::class),
$this->securityFor($actor),
);
}
private function reviewTool(User $actor): ReviewAbsenceRequestTool
{
$c = self::getContainer();
return new ReviewAbsenceRequestTool(
$c->get(EntityManagerInterface::class),
$c->get(AbsenceRequestRepository::class),
$c->get(AbsenceBalanceService::class),
$this->securityFor($actor),
);
}
private function cancelTool(User $actor): CancelAbsenceRequestTool
{
$c = self::getContainer();
return new CancelAbsenceRequestTool(
$c->get(EntityManagerInterface::class),
$c->get(AbsenceRequestRepository::class),
$c->get(AbsenceBalanceService::class),
$this->securityFor($actor),
);
}
}