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>
2520 lines
82 KiB
Markdown
2520 lines
82 KiB
Markdown
# Outils MCP — Module Absences + trous CRUD — Plan d'implémentation
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Exposer le module Absences (requests/policies/balances) et combler les trous CRUD existants (projets, groupes, métadonnées de tâches, clients, users) via de nouveaux outils MCP, sans contourner la logique métier des soldes.
|
||
|
||
**Architecture:** Chaque outil = une classe avec `#[McpTool]`, auto-découverte par le scan `src/` de `config/packages/mcp.yaml`. Les outils d'absence répliquent l'orchestration des Processors en réutilisant les services `AbsenceDayCalculator` et `AbsenceBalanceService` (et non les Processors, liés à `Security::getUser()`), avec un `userId` explicite pour agir au nom d'un employé. Sérialisation centralisée dans `App\Mcp\Tool\Serializer`.
|
||
|
||
**Tech Stack:** PHP 8.4, Symfony 8, Doctrine ORM, PHPUnit (`KernelTestCase` + `Security` mocké), serveur MCP `mcp` bundle.
|
||
|
||
---
|
||
|
||
## Référence : conventions d'un outil MCP
|
||
|
||
Tout outil suit ce squelette (vu dans `src/Mcp/Tool/Task/CreateTaskTool.php`) :
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Mcp\Tool\<SousDossier>;
|
||
|
||
use Mcp\Capability\Attribute\McpTool;
|
||
use Symfony\Bundle\SecurityBundle\Security;
|
||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||
// + repos/services/entities
|
||
|
||
#[McpTool(name: '<nom-kebab>', description: '<description>')]
|
||
class <Nom>Tool
|
||
{
|
||
public function __construct(/* deps injectées */) {}
|
||
|
||
public function __invoke(/* params */): string
|
||
{
|
||
if (!$this->security->isGranted('<ROLE>')) {
|
||
throw new AccessDeniedException('Access denied: <ROLE> required.');
|
||
}
|
||
// ... logique
|
||
return json_encode(/* tableau */);
|
||
}
|
||
}
|
||
```
|
||
|
||
Règles : `InvalidArgumentException` (avec `use function sprintf;`) quand un ID est introuvable ; helpers de parsing d'enum (voir Task 4) ; `json_encode` en sortie.
|
||
|
||
---
|
||
|
||
## JALON 1 — Module Absences
|
||
|
||
### Task 1 : Helpers de sérialisation Absences
|
||
|
||
**Files:**
|
||
- Modify: `src/Mcp/Tool/Serializer.php`
|
||
|
||
- [ ] **Step 1 : Ajouter les imports d'entités absence**
|
||
|
||
En tête de `Serializer.php`, ajouter aux `use` existants :
|
||
|
||
```php
|
||
use App\Entity\AbsenceBalance;
|
||
use App\Entity\AbsencePolicy;
|
||
use App\Entity\AbsenceRequest;
|
||
use App\Entity\Client;
|
||
```
|
||
|
||
- [ ] **Step 2 : Ajouter les méthodes de sérialisation**
|
||
|
||
Ajouter ces méthodes statiques dans la classe `Serializer` (avant l'accolade de fin) :
|
||
|
||
```php
|
||
/**
|
||
* @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->isEmployee(),
|
||
'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(),
|
||
];
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3 : Vérifier que PHP ne casse pas (lint)**
|
||
|
||
Run: `docker exec php-lesstime-fpm php -l src/Mcp/Tool/Serializer.php`
|
||
Expected: `No syntax errors detected`
|
||
|
||
- [ ] **Step 4 : Commit**
|
||
|
||
```bash
|
||
git add src/Mcp/Tool/Serializer.php
|
||
git commit -m "feat(mcp) : helpers de sérialisation pour absences/client/user"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2 : Outils de policies — `list-absence-policies`, `update-absence-policy`
|
||
|
||
**Files:**
|
||
- Create: `src/Mcp/Tool/Absence/ListAbsencePoliciesTool.php`
|
||
- Create: `src/Mcp/Tool/Absence/UpdateAbsencePolicyTool.php`
|
||
|
||
- [ ] **Step 1 : Créer `ListAbsencePoliciesTool`**
|
||
|
||
```php
|
||
<?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));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2 : Créer `UpdateAbsencePolicyTool`**
|
||
|
||
```php
|
||
<?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));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3 : Lint**
|
||
|
||
Run: `docker exec php-lesstime-fpm php -l src/Mcp/Tool/Absence/ListAbsencePoliciesTool.php && docker exec php-lesstime-fpm php -l src/Mcp/Tool/Absence/UpdateAbsencePolicyTool.php`
|
||
Expected: `No syntax errors detected` ×2
|
||
|
||
- [ ] **Step 4 : Vérifier la découverte MCP**
|
||
|
||
Run: `docker exec php-lesstime-fpm php bin/console debug:container --tag=mcp.tool 2>/dev/null | grep -i absence || docker exec php-lesstime-fpm php bin/console cache:clear`
|
||
Expected : pas d'erreur de cache (la découverte se fait au runtime ; l'absence d'erreur de compilation suffit ici).
|
||
|
||
- [ ] **Step 5 : Commit**
|
||
|
||
```bash
|
||
git add src/Mcp/Tool/Absence/ListAbsencePoliciesTool.php src/Mcp/Tool/Absence/UpdateAbsencePolicyTool.php
|
||
git commit -m "feat(mcp) : outils list/update absence-policy"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3 : Outils de soldes — `list-absence-balances`, `update-absence-balance`
|
||
|
||
**Files:**
|
||
- Create: `src/Mcp/Tool/Absence/ListAbsenceBalancesTool.php`
|
||
- Create: `src/Mcp/Tool/Absence/UpdateAbsenceBalanceTool.php`
|
||
|
||
- [ ] **Step 1 : Créer `ListAbsenceBalancesTool`**
|
||
|
||
```php
|
||
<?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));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2 : Créer `UpdateAbsenceBalanceTool`**
|
||
|
||
```php
|
||
<?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));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3 : Lint**
|
||
|
||
Run: `docker exec php-lesstime-fpm php -l src/Mcp/Tool/Absence/ListAbsenceBalancesTool.php && docker exec php-lesstime-fpm php -l src/Mcp/Tool/Absence/UpdateAbsenceBalanceTool.php`
|
||
Expected: `No syntax errors detected` ×2
|
||
|
||
- [ ] **Step 4 : Commit**
|
||
|
||
```bash
|
||
git add src/Mcp/Tool/Absence/ListAbsenceBalancesTool.php src/Mcp/Tool/Absence/UpdateAbsenceBalanceTool.php
|
||
git commit -m "feat(mcp) : outils list/update absence-balance"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4 : Outils de lecture des demandes — repo filtré + `list/get-absence-request`
|
||
|
||
**Files:**
|
||
- Modify: `src/Repository/AbsenceRequestRepository.php`
|
||
- Create: `src/Mcp/Tool/Absence/ListAbsenceRequestsTool.php`
|
||
- Create: `src/Mcp/Tool/Absence/GetAbsenceRequestTool.php`
|
||
|
||
- [ ] **Step 1 : Ajouter une méthode de filtrage au repo**
|
||
|
||
Dans `AbsenceRequestRepository`, ajouter les imports si absents (`use App\Enum\AbsenceType;` déjà présent ? sinon ajouter) et la méthode :
|
||
|
||
```php
|
||
/**
|
||
* @return AbsenceRequest[]
|
||
*/
|
||
public function findFiltered(
|
||
?User $user = null,
|
||
?AbsenceStatus $status = null,
|
||
?\App\Enum\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();
|
||
}
|
||
```
|
||
|
||
> Note : `AbsenceStatus` est déjà importé dans ce repo. Ajouter `use App\Enum\AbsenceType;` en tête s'il n'y est pas (sinon le FQCN inline ci-dessus suffit, mais préférer l'import et remplacer `\App\Enum\AbsenceType` par `AbsenceType`).
|
||
|
||
- [ ] **Step 2 : Créer `ListAbsenceRequestsTool`**
|
||
|
||
```php
|
||
<?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));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3 : Créer `GetAbsenceRequestTool`**
|
||
|
||
```php
|
||
<?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));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4 : Lint les 3 fichiers**
|
||
|
||
Run: `for f in src/Repository/AbsenceRequestRepository.php src/Mcp/Tool/Absence/ListAbsenceRequestsTool.php src/Mcp/Tool/Absence/GetAbsenceRequestTool.php; do docker exec php-lesstime-fpm php -l $f; done`
|
||
Expected: `No syntax errors detected` ×3
|
||
|
||
- [ ] **Step 5 : Commit**
|
||
|
||
```bash
|
||
git add src/Repository/AbsenceRequestRepository.php src/Mcp/Tool/Absence/ListAbsenceRequestsTool.php src/Mcp/Tool/Absence/GetAbsenceRequestTool.php
|
||
git commit -m "feat(mcp) : outils list/get absence-request + repo findFiltered"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5 : `create-absence-request` (réplique `AbsenceRequestProcessor`)
|
||
|
||
**Files:**
|
||
- Create: `src/Mcp/Tool/Absence/CreateAbsenceRequestTool.php`
|
||
- Create: `tests/Functional/Mcp/AbsenceRequestLifecycleTest.php`
|
||
|
||
- [ ] **Step 1 : Écrire le test du cycle de vie (échoue d'abord) — création**
|
||
|
||
Ce test crée son propre employé et sa policy (auto-suffisant), puis vérifie la création + la réservation de solde. Il sera complété aux Tasks 6 et 7.
|
||
|
||
```php
|
||
<?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\CreateAbsenceRequestTool;
|
||
use App\Repository\AbsenceBalanceRepository;
|
||
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();
|
||
}
|
||
|
||
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(\App\Repository\UserRepository::class),
|
||
$c->get(\App\Repository\AbsencePolicyRepository::class),
|
||
$c->get(\App\Repository\AbsenceRequestRepository::class),
|
||
$c->get(\App\Service\AbsenceDayCalculator::class),
|
||
$c->get(\App\Service\AbsenceBalanceService::class),
|
||
$this->securityFor($actor),
|
||
);
|
||
}
|
||
|
||
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']);
|
||
self::assertSame(5.0, $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());
|
||
}
|
||
}
|
||
```
|
||
|
||
> Période attendue : `referencePeriodStart = '06-01'`, date `2026-06-01` ≥ `06-01` ⇒ période `2026-2027` (cf. `AbsenceBalanceService::periodFor`).
|
||
|
||
- [ ] **Step 2 : Lancer le test — il échoue (classe outil absente)**
|
||
|
||
Run: `docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Mcp/AbsenceRequestLifecycleTest.php`
|
||
Expected: FAIL — `Class "App\Mcp\Tool\Absence\CreateAbsenceRequestTool" not found`.
|
||
|
||
- [ ] **Step 3 : Créer `CreateAbsenceRequestTool`**
|
||
|
||
```php
|
||
<?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));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4 : Relancer le test — il passe**
|
||
|
||
Run: `docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Mcp/AbsenceRequestLifecycleTest.php`
|
||
Expected: PASS (1 test).
|
||
|
||
- [ ] **Step 5 : Commit**
|
||
|
||
```bash
|
||
git add src/Mcp/Tool/Absence/CreateAbsenceRequestTool.php tests/Functional/Mcp/AbsenceRequestLifecycleTest.php
|
||
git commit -m "feat(mcp) : outil create-absence-request + test cycle de vie"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6 : `review-absence-request` (réplique `AbsenceReviewProcessor`)
|
||
|
||
**Files:**
|
||
- Create: `src/Mcp/Tool/Absence/ReviewAbsenceRequestTool.php`
|
||
- Modify: `tests/Functional/Mcp/AbsenceRequestLifecycleTest.php`
|
||
|
||
- [ ] **Step 1 : Ajouter le test d'approbation (échoue d'abord)**
|
||
|
||
Ajouter dans `AbsenceRequestLifecycleTest` une fabrique d'outil review + un test :
|
||
|
||
```php
|
||
private function reviewTool(User $actor): \App\Mcp\Tool\Absence\ReviewAbsenceRequestTool
|
||
{
|
||
$c = self::getContainer();
|
||
|
||
return new \App\Mcp\Tool\Absence\ReviewAbsenceRequestTool(
|
||
$c->get(EntityManagerInterface::class),
|
||
$c->get(\App\Repository\AbsenceRequestRepository::class),
|
||
$c->get(\App\Service\AbsenceBalanceService::class),
|
||
$this->securityFor($actor),
|
||
);
|
||
}
|
||
|
||
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());
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2 : Lancer — échoue (classe absente)**
|
||
|
||
Run: `docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Mcp/AbsenceRequestLifecycleTest.php --filter testApprove`
|
||
Expected: FAIL — `ReviewAbsenceRequestTool` not found.
|
||
|
||
- [ ] **Step 3 : Créer `ReviewAbsenceRequestTool`**
|
||
|
||
```php
|
||
<?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 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));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4 : Relancer — passe**
|
||
|
||
Run: `docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Mcp/AbsenceRequestLifecycleTest.php`
|
||
Expected: PASS (2 tests).
|
||
|
||
- [ ] **Step 5 : Commit**
|
||
|
||
```bash
|
||
git add src/Mcp/Tool/Absence/ReviewAbsenceRequestTool.php tests/Functional/Mcp/AbsenceRequestLifecycleTest.php
|
||
git commit -m "feat(mcp) : outil review-absence-request (approve/reject)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7 : `cancel-absence-request` + `delete-absence-request`
|
||
|
||
**Files:**
|
||
- Create: `src/Mcp/Tool/Absence/CancelAbsenceRequestTool.php`
|
||
- Create: `src/Mcp/Tool/Absence/DeleteAbsenceRequestTool.php`
|
||
- Modify: `tests/Functional/Mcp/AbsenceRequestLifecycleTest.php`
|
||
|
||
- [ ] **Step 1 : Ajouter le test d'annulation d'une demande approuvée (admin) — échoue d'abord**
|
||
|
||
```php
|
||
private function cancelTool(User $actor): \App\Mcp\Tool\Absence\CancelAbsenceRequestTool
|
||
{
|
||
$c = self::getContainer();
|
||
|
||
return new \App\Mcp\Tool\Absence\CancelAbsenceRequestTool(
|
||
$c->get(EntityManagerInterface::class),
|
||
$c->get(\App\Repository\AbsenceRequestRepository::class),
|
||
$c->get(\App\Service\AbsenceBalanceService::class),
|
||
$this->securityFor($actor),
|
||
);
|
||
}
|
||
|
||
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());
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2 : Lancer — échoue (classe absente)**
|
||
|
||
Run: `docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Mcp/AbsenceRequestLifecycleTest.php --filter testAdminCancel`
|
||
Expected: FAIL — `CancelAbsenceRequestTool` not found.
|
||
|
||
- [ ] **Step 3 : Créer `CancelAbsenceRequestTool`**
|
||
|
||
```php
|
||
<?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));
|
||
}
|
||
}
|
||
```
|
||
|
||
> Note : en MCP, l'acteur est le propriétaire du token (souvent admin), donc on ne réplique pas le contrôle « only your own request » du Processor (pas de notion d'employé courant ici). L'annulation d'une PENDING par token non-admin reste autorisée, ce qui est cohérent avec un usage d'administration.
|
||
|
||
- [ ] **Step 4 : Créer `DeleteAbsenceRequestTool`**
|
||
|
||
```php
|
||
<?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)]);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5 : Lancer toute la suite — passe**
|
||
|
||
Run: `docker exec php-lesstime-fpm php bin/phpunit tests/Functional/Mcp/AbsenceRequestLifecycleTest.php`
|
||
Expected: PASS (3 tests).
|
||
|
||
- [ ] **Step 6 : Commit**
|
||
|
||
```bash
|
||
git add src/Mcp/Tool/Absence/CancelAbsenceRequestTool.php src/Mcp/Tool/Absence/DeleteAbsenceRequestTool.php tests/Functional/Mcp/AbsenceRequestLifecycleTest.php
|
||
git commit -m "feat(mcp) : outils cancel/delete absence-request"
|
||
```
|
||
|
||
---
|
||
|
||
## JALON 2 — Trous CRUD : projets, groupes, métadonnées de tâches
|
||
|
||
### Task 8 : `delete-project` + `delete-group`
|
||
|
||
**Files:**
|
||
- Create: `src/Mcp/Tool/Project/DeleteProjectTool.php`
|
||
- Create: `src/Mcp/Tool/TaskMeta/DeleteGroupTool.php`
|
||
|
||
- [ ] **Step 1 : Créer `DeleteProjectTool`**
|
||
|
||
```php
|
||
<?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)]);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2 : Créer `DeleteGroupTool`**
|
||
|
||
```php
|
||
<?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)]);
|
||
}
|
||
}
|
||
```
|
||
|
||
> ⚠️ Vérifier le comportement de suppression de groupe : si la relation `Task.group` n'a pas `onDelete: SET NULL`, Doctrine lèvera une contrainte FK. Étape de vérification ci-dessous.
|
||
|
||
- [ ] **Step 3 : Vérifier la contrainte FK sur Task.group**
|
||
|
||
Run: `grep -nA3 "private.*TaskGroup .group\|class.*group" src/Entity/Task.php | grep -i "joincolumn\|ondelete"`
|
||
Si aucune règle `onDelete: 'SET NULL'` n'apparaît et que la colonne est nullable, le `remove` peut échouer tant que des tâches référencent le groupe. Dans ce cas, dans `DeleteGroupTool::__invoke`, avant `remove`, dissocier les tâches :
|
||
|
||
```php
|
||
foreach ($group->getTasks() as $task) {
|
||
$task->setGroup(null);
|
||
}
|
||
```
|
||
|
||
(N'ajouter ce bloc que si `Task` expose `getTasks()` côté groupe — sinon ignorer car la BDD gère déjà `SET NULL`.)
|
||
|
||
- [ ] **Step 4 : Lint**
|
||
|
||
Run: `docker exec php-lesstime-fpm php -l src/Mcp/Tool/Project/DeleteProjectTool.php && docker exec php-lesstime-fpm php -l src/Mcp/Tool/TaskMeta/DeleteGroupTool.php`
|
||
Expected: `No syntax errors detected` ×2
|
||
|
||
- [ ] **Step 5 : Commit**
|
||
|
||
```bash
|
||
git add src/Mcp/Tool/Project/DeleteProjectTool.php src/Mcp/Tool/TaskMeta/DeleteGroupTool.php
|
||
git commit -m "feat(mcp) : outils delete-project et delete-group"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9 : CRUD `tag` — `create-tag`, `update-tag`, `delete-tag`
|
||
|
||
**Files:**
|
||
- Create: `src/Mcp/Tool/TaskMeta/CreateTagTool.php`
|
||
- Create: `src/Mcp/Tool/TaskMeta/UpdateTagTool.php`
|
||
- Create: `src/Mcp/Tool/TaskMeta/DeleteTagTool.php`
|
||
|
||
Helper de sortie : réutiliser `Serializer::tagsWithColor` n'accepte qu'une Collection ; pour un tag unique, renvoyer un tableau inline `['id' => ..., 'label' => ..., 'color' => ...]`.
|
||
|
||
- [ ] **Step 1 : Créer `CreateTagTool`**
|
||
|
||
```php
|
||
<?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()]);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2 : Créer `UpdateTagTool`**
|
||
|
||
```php
|
||
<?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()]);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3 : Créer `DeleteTagTool`**
|
||
|
||
```php
|
||
<?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)]);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4 : Lint les 3 fichiers**
|
||
|
||
Run: `for f in Create Update Delete; do docker exec php-lesstime-fpm php -l src/Mcp/Tool/TaskMeta/${f}TagTool.php; done`
|
||
Expected: `No syntax errors detected` ×3
|
||
|
||
- [ ] **Step 5 : Commit**
|
||
|
||
```bash
|
||
git add src/Mcp/Tool/TaskMeta/CreateTagTool.php src/Mcp/Tool/TaskMeta/UpdateTagTool.php src/Mcp/Tool/TaskMeta/DeleteTagTool.php
|
||
git commit -m "feat(mcp) : CRUD tag"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10 : CRUD `effort` — `create-effort`, `update-effort`, `delete-effort`
|
||
|
||
`TaskEffort` n'a qu'un champ `label` (length 50), pas de couleur.
|
||
|
||
**Files:**
|
||
- Create: `src/Mcp/Tool/TaskMeta/CreateEffortTool.php`
|
||
- Create: `src/Mcp/Tool/TaskMeta/UpdateEffortTool.php`
|
||
- Create: `src/Mcp/Tool/TaskMeta/DeleteEffortTool.php`
|
||
|
||
- [ ] **Step 1 : Créer `CreateEffortTool`**
|
||
|
||
```php
|
||
<?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()]);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2 : Créer `UpdateEffortTool`**
|
||
|
||
```php
|
||
<?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()]);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3 : Créer `DeleteEffortTool`**
|
||
|
||
```php
|
||
<?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)]);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4 : Lint**
|
||
|
||
Run: `for f in Create Update Delete; do docker exec php-lesstime-fpm php -l src/Mcp/Tool/TaskMeta/${f}EffortTool.php; done`
|
||
Expected: `No syntax errors detected` ×3
|
||
|
||
- [ ] **Step 5 : Commit**
|
||
|
||
```bash
|
||
git add src/Mcp/Tool/TaskMeta/CreateEffortTool.php src/Mcp/Tool/TaskMeta/UpdateEffortTool.php src/Mcp/Tool/TaskMeta/DeleteEffortTool.php
|
||
git commit -m "feat(mcp) : CRUD effort"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11 : CRUD `priority` — `create-priority`, `update-priority`, `delete-priority`
|
||
|
||
`TaskPriority` a `label` + `color`. Mêmes formes que les tags.
|
||
|
||
**Files:**
|
||
- Create: `src/Mcp/Tool/TaskMeta/CreatePriorityTool.php`
|
||
- Create: `src/Mcp/Tool/TaskMeta/UpdatePriorityTool.php`
|
||
- Create: `src/Mcp/Tool/TaskMeta/DeletePriorityTool.php`
|
||
|
||
- [ ] **Step 1 : Créer `CreatePriorityTool`**
|
||
|
||
```php
|
||
<?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()]);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2 : Créer `UpdatePriorityTool`**
|
||
|
||
```php
|
||
<?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()]);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3 : Créer `DeletePriorityTool`**
|
||
|
||
```php
|
||
<?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)]);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4 : Lint**
|
||
|
||
Run: `for f in Create Update Delete; do docker exec php-lesstime-fpm php -l src/Mcp/Tool/TaskMeta/${f}PriorityTool.php; done`
|
||
Expected: `No syntax errors detected` ×3
|
||
|
||
- [ ] **Step 5 : Commit**
|
||
|
||
```bash
|
||
git add src/Mcp/Tool/TaskMeta/CreatePriorityTool.php src/Mcp/Tool/TaskMeta/UpdatePriorityTool.php src/Mcp/Tool/TaskMeta/DeletePriorityTool.php
|
||
git commit -m "feat(mcp) : CRUD priority"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12 : CRUD `status` (couplé au workflow)
|
||
|
||
`TaskStatus` appartient à un `Workflow` (`workflow` non nullable) et porte une `category` (`StatusCategory`: todo|in_progress|blocked|review|done), `label`, `color` (défaut `#222783`), `position`, `isFinal`.
|
||
|
||
**Files:**
|
||
- Create: `src/Mcp/Tool/TaskMeta/CreateStatusTool.php`
|
||
- Create: `src/Mcp/Tool/TaskMeta/UpdateStatusTool.php`
|
||
- Create: `src/Mcp/Tool/TaskMeta/DeleteStatusTool.php`
|
||
|
||
- [ ] **Step 1 : Créer `CreateStatusTool`**
|
||
|
||
```php
|
||
<?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(),
|
||
]);
|
||
}
|
||
}
|
||
```
|
||
|
||
> Vérifier les noms exacts des setters de `TaskStatus` (`setCategory`, `setIsFinal`, `setPosition`, `setColor`, `setWorkflow`) avec : `grep -nE "public function set" src/Entity/TaskStatus.php`. Ajuster si l'un diffère.
|
||
|
||
- [ ] **Step 2 : Créer `UpdateStatusTool`**
|
||
|
||
```php
|
||
<?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(),
|
||
]);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3 : Créer `DeleteStatusTool`**
|
||
|
||
```php
|
||
<?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)]);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4 : Lint**
|
||
|
||
Run: `for f in Create Update Delete; do docker exec php-lesstime-fpm php -l src/Mcp/Tool/TaskMeta/${f}StatusTool.php; done`
|
||
Expected: `No syntax errors detected` ×3
|
||
|
||
- [ ] **Step 5 : Commit**
|
||
|
||
```bash
|
||
git add src/Mcp/Tool/TaskMeta/CreateStatusTool.php src/Mcp/Tool/TaskMeta/UpdateStatusTool.php src/Mcp/Tool/TaskMeta/DeleteStatusTool.php
|
||
git commit -m "feat(mcp) : CRUD status (couplé workflow)"
|
||
```
|
||
|
||
---
|
||
|
||
## JALON 3 — Clients, users, documentation
|
||
|
||
### Task 13 : CRUD `client` — `get/create/update/delete-client`
|
||
|
||
**Files:**
|
||
- Create: `src/Mcp/Tool/Reference/GetClientTool.php`
|
||
- Create: `src/Mcp/Tool/Reference/CreateClientTool.php`
|
||
- Create: `src/Mcp/Tool/Reference/UpdateClientTool.php`
|
||
- Create: `src/Mcp/Tool/Reference/DeleteClientTool.php`
|
||
|
||
- [ ] **Step 1 : Créer `GetClientTool`**
|
||
|
||
```php
|
||
<?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));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2 : Créer `CreateClientTool`**
|
||
|
||
```php
|
||
<?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));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3 : Créer `UpdateClientTool`**
|
||
|
||
```php
|
||
<?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));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4 : Créer `DeleteClientTool`**
|
||
|
||
```php
|
||
<?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)]);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5 : Lint**
|
||
|
||
Run: `for f in Get Create Update Delete; do docker exec php-lesstime-fpm php -l src/Mcp/Tool/Reference/${f}ClientTool.php; done`
|
||
Expected: `No syntax errors detected` ×4
|
||
|
||
- [ ] **Step 6 : Commit**
|
||
|
||
```bash
|
||
git add src/Mcp/Tool/Reference/GetClientTool.php src/Mcp/Tool/Reference/CreateClientTool.php src/Mcp/Tool/Reference/UpdateClientTool.php src/Mcp/Tool/Reference/DeleteClientTool.php
|
||
git commit -m "feat(mcp) : CRUD client"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14 : `get-user` + `update-user` (champs RH/profil)
|
||
|
||
Pas de création ni de gestion mot de passe/rôles (décision utilisateur).
|
||
|
||
**Files:**
|
||
- Create: `src/Mcp/Tool/Reference/GetUserTool.php`
|
||
- Create: `src/Mcp/Tool/Reference/UpdateUserTool.php`
|
||
|
||
- [ ] **Step 1 : Créer `GetUserTool`**
|
||
|
||
```php
|
||
<?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));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2 : Créer `UpdateUserTool`**
|
||
|
||
```php
|
||
<?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));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3 : Lint**
|
||
|
||
Run: `docker exec php-lesstime-fpm php -l src/Mcp/Tool/Reference/GetUserTool.php && docker exec php-lesstime-fpm php -l src/Mcp/Tool/Reference/UpdateUserTool.php`
|
||
Expected: `No syntax errors detected` ×2
|
||
|
||
- [ ] **Step 4 : Commit**
|
||
|
||
```bash
|
||
git add src/Mcp/Tool/Reference/GetUserTool.php src/Mcp/Tool/Reference/UpdateUserTool.php
|
||
git commit -m "feat(mcp) : get-user et update-user (champs RH)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 15 : Mettre à jour les instructions MCP
|
||
|
||
**Files:**
|
||
- Modify: `config/packages/mcp.yaml`
|
||
|
||
- [ ] **Step 1 : Corriger/enrichir le bloc `instructions`**
|
||
|
||
Remplacer la ligne fausse sur les statuts globaux et ajouter le domaine Absences. Dans `config/packages/mcp.yaml`, remplacer :
|
||
|
||
```
|
||
Statuses, priorities, efforts, and tags are GLOBAL (shared across all projects).
|
||
Groups are PER-PROJECT (each group belongs to one project).
|
||
```
|
||
|
||
par :
|
||
|
||
```
|
||
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).
|
||
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).
|
||
```
|
||
|
||
- [ ] **Step 2 : Vérifier le YAML + cache**
|
||
|
||
Run: `docker exec php-lesstime-fpm php bin/console lint:yaml config/packages/mcp.yaml && docker exec php-lesstime-fpm php bin/console cache:clear`
|
||
Expected: `All 1 YAML files contain valid syntax.` puis cache vidé sans erreur.
|
||
|
||
- [ ] **Step 3 : Commit**
|
||
|
||
```bash
|
||
git add config/packages/mcp.yaml
|
||
git commit -m "docs(mcp) : maj instructions (statuts par workflow + domaine absences)"
|
||
```
|
||
|
||
---
|
||
|
||
## Vérification finale (après tous les jalons)
|
||
|
||
- [ ] **Suite de tests complète**
|
||
|
||
Run: `make test`
|
||
Expected: tous les tests passent, dont `tests/Functional/Mcp/AbsenceRequestLifecycleTest.php` (3 tests).
|
||
|
||
- [ ] **Code style PHP**
|
||
|
||
Run: `make php-cs-fixer-allow-risky`
|
||
Expected: fichiers reformatés conformes ; relancer `make test` si des fichiers changent.
|
||
|
||
- [ ] **Découverte MCP en conditions réelles (STDIO)**
|
||
|
||
Run: `docker exec -i php-lesstime-fpm php bin/console mcp:server <<< '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'`
|
||
Expected : la réponse JSON liste les nouveaux outils (`create-absence-request`, `review-absence-request`, `delete-project`, `create-status`, `update-user`, etc.).
|
||
|
||
> ⚠️ Rappel (mémoire projet) : le serveur MCP configuré côté client interroge la **PROD**. Ces outils ne piloteront le module Absences en prod qu'une fois la branche `feat/absence-management` déployée. En local, tester via la commande STDIO ci-dessus contre la base de dev.
|
||
|
||
---
|
||
|
||
## Couverture du spec (auto-revue)
|
||
|
||
| Exigence du spec | Task |
|
||
|---|---|
|
||
| Serializer absences/client/user | 1 |
|
||
| list/update absence-policy | 2 |
|
||
| list/update absence-balance | 3 |
|
||
| list/get absence-request | 4 |
|
||
| create-absence-request (logique métier) | 5 |
|
||
| review-absence-request | 6 |
|
||
| cancel + delete absence-request | 7 |
|
||
| delete-project, delete-group | 8 |
|
||
| CRUD tag | 9 |
|
||
| CRUD effort | 10 |
|
||
| CRUD priority | 11 |
|
||
| CRUD status (workflow + category) | 12 |
|
||
| CRUD client | 13 |
|
||
| get/update user (RH, sans password/roles) | 14 |
|
||
| maj instructions mcp.yaml | 15 |
|
||
|
||
Hors périmètre confirmé : create-user, password/roles, upload justificatif, mail/bookstack/gitea/zimbra/notifications.
|