From 2b148fa65afb7ef0b8cfb1a2e93516081d7bb97d Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 22 May 2026 14:10:56 +0200 Subject: [PATCH] feat(absences) : outils MCP CRUD pour les absences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- config/packages/mcp.yaml | 8 +- .../2026-05-22-mcp-absence-crud-tools.md | 2519 +++++++++++++++++ ...026-05-22-mcp-absence-crud-tools-design.md | 141 + .../Tool/Absence/CancelAbsenceRequestTool.php | 59 + .../Tool/Absence/CreateAbsenceRequestTool.php | 104 + .../Tool/Absence/DeleteAbsenceRequestTool.php | 41 + .../Tool/Absence/GetAbsenceRequestTool.php | 37 + .../Tool/Absence/ListAbsenceBalancesTool.php | 53 + .../Tool/Absence/ListAbsencePoliciesTool.php | 31 + .../Tool/Absence/ListAbsenceRequestsTool.php | 68 + .../Tool/Absence/ReviewAbsenceRequestTool.php | 74 + .../Tool/Absence/UpdateAbsenceBalanceTool.php | 55 + .../Tool/Absence/UpdateAbsencePolicyTool.php | 67 + src/Mcp/Tool/Project/DeleteProjectTool.php | 42 + src/Mcp/Tool/Reference/CreateClientTool.php | 47 + src/Mcp/Tool/Reference/DeleteClientTool.php | 42 + src/Mcp/Tool/Reference/GetClientTool.php | 37 + src/Mcp/Tool/Reference/GetUserTool.php | 37 + src/Mcp/Tool/Reference/UpdateClientTool.php | 67 + src/Mcp/Tool/Reference/UpdateUserTool.php | 92 + src/Mcp/Tool/Serializer.php | 106 + src/Mcp/Tool/TaskMeta/CreateEffortTool.php | 34 + src/Mcp/Tool/TaskMeta/CreatePriorityTool.php | 37 + src/Mcp/Tool/TaskMeta/CreateStatusTool.php | 74 + src/Mcp/Tool/TaskMeta/CreateTagTool.php | 38 + src/Mcp/Tool/TaskMeta/DeleteEffortTool.php | 42 + src/Mcp/Tool/TaskMeta/DeleteGroupTool.php | 42 + src/Mcp/Tool/TaskMeta/DeletePriorityTool.php | 42 + src/Mcp/Tool/TaskMeta/DeleteStatusTool.php | 42 + src/Mcp/Tool/TaskMeta/DeleteTagTool.php | 42 + src/Mcp/Tool/TaskMeta/UpdateEffortTool.php | 41 + src/Mcp/Tool/TaskMeta/UpdatePriorityTool.php | 46 + src/Mcp/Tool/TaskMeta/UpdateStatusTool.php | 74 + src/Mcp/Tool/TaskMeta/UpdateTagTool.php | 47 + src/Repository/AbsenceRequestRepository.php | 32 + .../Mcp/AbsenceRequestLifecycleTest.php | 177 ++ 36 files changed, 4536 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-05-22-mcp-absence-crud-tools.md create mode 100644 docs/superpowers/specs/2026-05-22-mcp-absence-crud-tools-design.md create mode 100644 src/Mcp/Tool/Absence/CancelAbsenceRequestTool.php create mode 100644 src/Mcp/Tool/Absence/CreateAbsenceRequestTool.php create mode 100644 src/Mcp/Tool/Absence/DeleteAbsenceRequestTool.php create mode 100644 src/Mcp/Tool/Absence/GetAbsenceRequestTool.php create mode 100644 src/Mcp/Tool/Absence/ListAbsenceBalancesTool.php create mode 100644 src/Mcp/Tool/Absence/ListAbsencePoliciesTool.php create mode 100644 src/Mcp/Tool/Absence/ListAbsenceRequestsTool.php create mode 100644 src/Mcp/Tool/Absence/ReviewAbsenceRequestTool.php create mode 100644 src/Mcp/Tool/Absence/UpdateAbsenceBalanceTool.php create mode 100644 src/Mcp/Tool/Absence/UpdateAbsencePolicyTool.php create mode 100644 src/Mcp/Tool/Project/DeleteProjectTool.php create mode 100644 src/Mcp/Tool/Reference/CreateClientTool.php create mode 100644 src/Mcp/Tool/Reference/DeleteClientTool.php create mode 100644 src/Mcp/Tool/Reference/GetClientTool.php create mode 100644 src/Mcp/Tool/Reference/GetUserTool.php create mode 100644 src/Mcp/Tool/Reference/UpdateClientTool.php create mode 100644 src/Mcp/Tool/Reference/UpdateUserTool.php create mode 100644 src/Mcp/Tool/TaskMeta/CreateEffortTool.php create mode 100644 src/Mcp/Tool/TaskMeta/CreatePriorityTool.php create mode 100644 src/Mcp/Tool/TaskMeta/CreateStatusTool.php create mode 100644 src/Mcp/Tool/TaskMeta/CreateTagTool.php create mode 100644 src/Mcp/Tool/TaskMeta/DeleteEffortTool.php create mode 100644 src/Mcp/Tool/TaskMeta/DeleteGroupTool.php create mode 100644 src/Mcp/Tool/TaskMeta/DeletePriorityTool.php create mode 100644 src/Mcp/Tool/TaskMeta/DeleteStatusTool.php create mode 100644 src/Mcp/Tool/TaskMeta/DeleteTagTool.php create mode 100644 src/Mcp/Tool/TaskMeta/UpdateEffortTool.php create mode 100644 src/Mcp/Tool/TaskMeta/UpdatePriorityTool.php create mode 100644 src/Mcp/Tool/TaskMeta/UpdateStatusTool.php create mode 100644 src/Mcp/Tool/TaskMeta/UpdateTagTool.php create mode 100644 tests/Functional/Mcp/AbsenceRequestLifecycleTest.php diff --git a/config/packages/mcp.yaml b/config/packages/mcp.yaml index 50179be..d901762 100644 --- a/config/packages/mcp.yaml +++ b/config/packages/mcp.yaml @@ -6,8 +6,14 @@ mcp: This server provides access to the Lesstime project management system. You can list/create/update/delete projects, tasks, and time entries. 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). + 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. Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover available metadata before creating or updating tasks. diff --git a/docs/superpowers/plans/2026-05-22-mcp-absence-crud-tools.md b/docs/superpowers/plans/2026-05-22-mcp-absence-crud-tools.md new file mode 100644 index 0000000..534adf9 --- /dev/null +++ b/docs/superpowers/plans/2026-05-22-mcp-absence-crud-tools.md @@ -0,0 +1,2519 @@ +# 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 +; + +use Mcp\Capability\Attribute\McpTool; +use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\Security\Core\Exception\AccessDeniedException; +// + repos/services/entities + +#[McpTool(name: '', description: '')] +class Tool +{ + public function __construct(/* deps injectées */) {} + + public function __invoke(/* params */): string + { + if (!$this->security->isGranted('')) { + throw new AccessDeniedException('Access denied: 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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. diff --git a/docs/superpowers/specs/2026-05-22-mcp-absence-crud-tools-design.md b/docs/superpowers/specs/2026-05-22-mcp-absence-crud-tools-design.md new file mode 100644 index 0000000..e3ee771 --- /dev/null +++ b/docs/superpowers/specs/2026-05-22-mcp-absence-crud-tools-design.md @@ -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. diff --git a/src/Mcp/Tool/Absence/CancelAbsenceRequestTool.php b/src/Mcp/Tool/Absence/CancelAbsenceRequestTool.php new file mode 100644 index 0000000..d946bae --- /dev/null +++ b/src/Mcp/Tool/Absence/CancelAbsenceRequestTool.php @@ -0,0 +1,59 @@ +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)); + } +} diff --git a/src/Mcp/Tool/Absence/CreateAbsenceRequestTool.php b/src/Mcp/Tool/Absence/CreateAbsenceRequestTool.php new file mode 100644 index 0000000..80d6658 --- /dev/null +++ b/src/Mcp/Tool/Absence/CreateAbsenceRequestTool.php @@ -0,0 +1,104 @@ +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)); + } +} diff --git a/src/Mcp/Tool/Absence/DeleteAbsenceRequestTool.php b/src/Mcp/Tool/Absence/DeleteAbsenceRequestTool.php new file mode 100644 index 0000000..2048ba3 --- /dev/null +++ b/src/Mcp/Tool/Absence/DeleteAbsenceRequestTool.php @@ -0,0 +1,41 @@ +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)]); + } +} diff --git a/src/Mcp/Tool/Absence/GetAbsenceRequestTool.php b/src/Mcp/Tool/Absence/GetAbsenceRequestTool.php new file mode 100644 index 0000000..eb0d2aa --- /dev/null +++ b/src/Mcp/Tool/Absence/GetAbsenceRequestTool.php @@ -0,0 +1,37 @@ +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)); + } +} diff --git a/src/Mcp/Tool/Absence/ListAbsenceBalancesTool.php b/src/Mcp/Tool/Absence/ListAbsenceBalancesTool.php new file mode 100644 index 0000000..f0baf6a --- /dev/null +++ b/src/Mcp/Tool/Absence/ListAbsenceBalancesTool.php @@ -0,0 +1,53 @@ +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)); + } +} diff --git a/src/Mcp/Tool/Absence/ListAbsencePoliciesTool.php b/src/Mcp/Tool/Absence/ListAbsencePoliciesTool.php new file mode 100644 index 0000000..491aed0 --- /dev/null +++ b/src/Mcp/Tool/Absence/ListAbsencePoliciesTool.php @@ -0,0 +1,31 @@ +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)); + } +} diff --git a/src/Mcp/Tool/Absence/ListAbsenceRequestsTool.php b/src/Mcp/Tool/Absence/ListAbsenceRequestsTool.php new file mode 100644 index 0000000..485f730 --- /dev/null +++ b/src/Mcp/Tool/Absence/ListAbsenceRequestsTool.php @@ -0,0 +1,68 @@ +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)); + } +} diff --git a/src/Mcp/Tool/Absence/ReviewAbsenceRequestTool.php b/src/Mcp/Tool/Absence/ReviewAbsenceRequestTool.php new file mode 100644 index 0000000..3391781 --- /dev/null +++ b/src/Mcp/Tool/Absence/ReviewAbsenceRequestTool.php @@ -0,0 +1,74 @@ +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)); + } +} diff --git a/src/Mcp/Tool/Absence/UpdateAbsenceBalanceTool.php b/src/Mcp/Tool/Absence/UpdateAbsenceBalanceTool.php new file mode 100644 index 0000000..069d764 --- /dev/null +++ b/src/Mcp/Tool/Absence/UpdateAbsenceBalanceTool.php @@ -0,0 +1,55 @@ +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)); + } +} diff --git a/src/Mcp/Tool/Absence/UpdateAbsencePolicyTool.php b/src/Mcp/Tool/Absence/UpdateAbsencePolicyTool.php new file mode 100644 index 0000000..7ff6274 --- /dev/null +++ b/src/Mcp/Tool/Absence/UpdateAbsencePolicyTool.php @@ -0,0 +1,67 @@ +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)); + } +} diff --git a/src/Mcp/Tool/Project/DeleteProjectTool.php b/src/Mcp/Tool/Project/DeleteProjectTool.php new file mode 100644 index 0000000..ed0f488 --- /dev/null +++ b/src/Mcp/Tool/Project/DeleteProjectTool.php @@ -0,0 +1,42 @@ +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)]); + } +} diff --git a/src/Mcp/Tool/Reference/CreateClientTool.php b/src/Mcp/Tool/Reference/CreateClientTool.php new file mode 100644 index 0000000..3218610 --- /dev/null +++ b/src/Mcp/Tool/Reference/CreateClientTool.php @@ -0,0 +1,47 @@ +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)); + } +} diff --git a/src/Mcp/Tool/Reference/DeleteClientTool.php b/src/Mcp/Tool/Reference/DeleteClientTool.php new file mode 100644 index 0000000..b3af640 --- /dev/null +++ b/src/Mcp/Tool/Reference/DeleteClientTool.php @@ -0,0 +1,42 @@ +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)]); + } +} diff --git a/src/Mcp/Tool/Reference/GetClientTool.php b/src/Mcp/Tool/Reference/GetClientTool.php new file mode 100644 index 0000000..163070f --- /dev/null +++ b/src/Mcp/Tool/Reference/GetClientTool.php @@ -0,0 +1,37 @@ +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)); + } +} diff --git a/src/Mcp/Tool/Reference/GetUserTool.php b/src/Mcp/Tool/Reference/GetUserTool.php new file mode 100644 index 0000000..4ef1791 --- /dev/null +++ b/src/Mcp/Tool/Reference/GetUserTool.php @@ -0,0 +1,37 @@ +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)); + } +} diff --git a/src/Mcp/Tool/Reference/UpdateClientTool.php b/src/Mcp/Tool/Reference/UpdateClientTool.php new file mode 100644 index 0000000..fc03cb8 --- /dev/null +++ b/src/Mcp/Tool/Reference/UpdateClientTool.php @@ -0,0 +1,67 @@ +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)); + } +} diff --git a/src/Mcp/Tool/Reference/UpdateUserTool.php b/src/Mcp/Tool/Reference/UpdateUserTool.php new file mode 100644 index 0000000..8158f23 --- /dev/null +++ b/src/Mcp/Tool/Reference/UpdateUserTool.php @@ -0,0 +1,92 @@ +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)); + } +} diff --git a/src/Mcp/Tool/Serializer.php b/src/Mcp/Tool/Serializer.php index c45d8fd..7f97043 100644 --- a/src/Mcp/Tool/Serializer.php +++ b/src/Mcp/Tool/Serializer.php @@ -4,6 +4,10 @@ declare(strict_types=1); 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\Task; use App\Entity\TaskDocument; @@ -287,4 +291,106 @@ final class Serializer 'uploadedBy' => self::user($doc->getUploadedBy()), ])->toArray(); } + + /** + * @return array + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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(), + ]; + } } diff --git a/src/Mcp/Tool/TaskMeta/CreateEffortTool.php b/src/Mcp/Tool/TaskMeta/CreateEffortTool.php new file mode 100644 index 0000000..5c2fb95 --- /dev/null +++ b/src/Mcp/Tool/TaskMeta/CreateEffortTool.php @@ -0,0 +1,34 @@ +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()]); + } +} diff --git a/src/Mcp/Tool/TaskMeta/CreatePriorityTool.php b/src/Mcp/Tool/TaskMeta/CreatePriorityTool.php new file mode 100644 index 0000000..9e324f9 --- /dev/null +++ b/src/Mcp/Tool/TaskMeta/CreatePriorityTool.php @@ -0,0 +1,37 @@ +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()]); + } +} diff --git a/src/Mcp/Tool/TaskMeta/CreateStatusTool.php b/src/Mcp/Tool/TaskMeta/CreateStatusTool.php new file mode 100644 index 0000000..ae978f3 --- /dev/null +++ b/src/Mcp/Tool/TaskMeta/CreateStatusTool.php @@ -0,0 +1,74 @@ +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(), + ]); + } +} diff --git a/src/Mcp/Tool/TaskMeta/CreateTagTool.php b/src/Mcp/Tool/TaskMeta/CreateTagTool.php new file mode 100644 index 0000000..867ea25 --- /dev/null +++ b/src/Mcp/Tool/TaskMeta/CreateTagTool.php @@ -0,0 +1,38 @@ +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()]); + } +} diff --git a/src/Mcp/Tool/TaskMeta/DeleteEffortTool.php b/src/Mcp/Tool/TaskMeta/DeleteEffortTool.php new file mode 100644 index 0000000..92dc8e1 --- /dev/null +++ b/src/Mcp/Tool/TaskMeta/DeleteEffortTool.php @@ -0,0 +1,42 @@ +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)]); + } +} diff --git a/src/Mcp/Tool/TaskMeta/DeleteGroupTool.php b/src/Mcp/Tool/TaskMeta/DeleteGroupTool.php new file mode 100644 index 0000000..ae65342 --- /dev/null +++ b/src/Mcp/Tool/TaskMeta/DeleteGroupTool.php @@ -0,0 +1,42 @@ +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)]); + } +} diff --git a/src/Mcp/Tool/TaskMeta/DeletePriorityTool.php b/src/Mcp/Tool/TaskMeta/DeletePriorityTool.php new file mode 100644 index 0000000..4c9b25a --- /dev/null +++ b/src/Mcp/Tool/TaskMeta/DeletePriorityTool.php @@ -0,0 +1,42 @@ +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)]); + } +} diff --git a/src/Mcp/Tool/TaskMeta/DeleteStatusTool.php b/src/Mcp/Tool/TaskMeta/DeleteStatusTool.php new file mode 100644 index 0000000..d5aab15 --- /dev/null +++ b/src/Mcp/Tool/TaskMeta/DeleteStatusTool.php @@ -0,0 +1,42 @@ +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)]); + } +} diff --git a/src/Mcp/Tool/TaskMeta/DeleteTagTool.php b/src/Mcp/Tool/TaskMeta/DeleteTagTool.php new file mode 100644 index 0000000..78e2660 --- /dev/null +++ b/src/Mcp/Tool/TaskMeta/DeleteTagTool.php @@ -0,0 +1,42 @@ +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)]); + } +} diff --git a/src/Mcp/Tool/TaskMeta/UpdateEffortTool.php b/src/Mcp/Tool/TaskMeta/UpdateEffortTool.php new file mode 100644 index 0000000..b317d6c --- /dev/null +++ b/src/Mcp/Tool/TaskMeta/UpdateEffortTool.php @@ -0,0 +1,41 @@ +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()]); + } +} diff --git a/src/Mcp/Tool/TaskMeta/UpdatePriorityTool.php b/src/Mcp/Tool/TaskMeta/UpdatePriorityTool.php new file mode 100644 index 0000000..992feff --- /dev/null +++ b/src/Mcp/Tool/TaskMeta/UpdatePriorityTool.php @@ -0,0 +1,46 @@ +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()]); + } +} diff --git a/src/Mcp/Tool/TaskMeta/UpdateStatusTool.php b/src/Mcp/Tool/TaskMeta/UpdateStatusTool.php new file mode 100644 index 0000000..09e12cb --- /dev/null +++ b/src/Mcp/Tool/TaskMeta/UpdateStatusTool.php @@ -0,0 +1,74 @@ +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(), + ]); + } +} diff --git a/src/Mcp/Tool/TaskMeta/UpdateTagTool.php b/src/Mcp/Tool/TaskMeta/UpdateTagTool.php new file mode 100644 index 0000000..60cff41 --- /dev/null +++ b/src/Mcp/Tool/TaskMeta/UpdateTagTool.php @@ -0,0 +1,47 @@ +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()]); + } +} diff --git a/src/Repository/AbsenceRequestRepository.php b/src/Repository/AbsenceRequestRepository.php index 620755b..ffd8d3f 100644 --- a/src/Repository/AbsenceRequestRepository.php +++ b/src/Repository/AbsenceRequestRepository.php @@ -7,6 +7,7 @@ namespace App\Repository; use App\Entity\AbsenceRequest; use App\Entity\User; use App\Enum\AbsenceStatus; +use App\Enum\AbsenceType; use DateTimeInterface; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; @@ -71,4 +72,35 @@ class AbsenceRequestRepository extends ServiceEntityRepository ->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(); + } } diff --git a/tests/Functional/Mcp/AbsenceRequestLifecycleTest.php b/tests/Functional/Mcp/AbsenceRequestLifecycleTest.php new file mode 100644 index 0000000..2f4335a --- /dev/null +++ b/tests/Functional/Mcp/AbsenceRequestLifecycleTest.php @@ -0,0 +1,177 @@ +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), + ); + } +}