From 4340a0e13ec0b31cbaa3930c51efb554b2f90af9 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 16 Mar 2026 15:00:37 +0100 Subject: [PATCH] =?UTF-8?q?feat(mcp)=20:=20add=20business=20tools=20?= =?UTF-8?q?=E2=80=94=20search,=20history,=20comments,=20custom=20fields,?= =?UTF-8?q?=20documents,=20model=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - search_inventory: global search across all 6 entity types - get_entity_history + get_activity_log: audit trail access - 4 comment tools: list, create, resolve, unresolved count - 3 custom field tools: list values, upsert, delete - 2 document tools: list, delete (upload via REST only) - 6 model type tools: list, get, create, update, delete, sync - 69 MCP tests pass total Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Mcp/Tool/ActivityLogTool.php | 61 ++++++++++ src/Mcp/Tool/Comment/CreateCommentTool.php | 85 +++++++++++++ src/Mcp/Tool/Comment/ListCommentsTool.php | 67 ++++++++++ src/Mcp/Tool/Comment/ResolveCommentTool.php | 65 ++++++++++ src/Mcp/Tool/Comment/UnresolvedCountTool.php | 37 ++++++ .../DeleteCustomFieldValueTool.php | 41 +++++++ .../CustomField/ListCustomFieldValuesTool.php | 61 ++++++++++ .../UpsertCustomFieldValuesTool.php | 114 ++++++++++++++++++ src/Mcp/Tool/Document/DeleteDocumentTool.php | 42 +++++++ src/Mcp/Tool/Document/ListDocumentsTool.php | 62 ++++++++++ src/Mcp/Tool/EntityHistoryTool.php | 57 +++++++++ .../Tool/ModelType/CreateModelTypeTool.php | 57 +++++++++ .../Tool/ModelType/DeleteModelTypeTool.php | 42 +++++++ src/Mcp/Tool/ModelType/GetModelTypeTool.php | 78 ++++++++++++ src/Mcp/Tool/ModelType/ListModelTypesTool.php | 79 ++++++++++++ src/Mcp/Tool/ModelType/SyncModelTypeTool.php | 67 ++++++++++ .../Tool/ModelType/UpdateModelTypeTool.php | 53 ++++++++ src/Mcp/Tool/SearchInventoryTool.php | 113 +++++++++++++++++ tests/Mcp/Tool/Comment/CommentsToolTest.php | 98 +++++++++++++++ .../Tool/CustomField/CustomFieldToolsTest.php | 101 ++++++++++++++++ tests/Mcp/Tool/Document/DocumentToolsTest.php | 41 +++++++ tests/Mcp/Tool/HistoryToolsTest.php | 44 +++++++ .../Mcp/Tool/ModelType/ModelTypeToolsTest.php | 66 ++++++++++ tests/Mcp/Tool/SearchInventoryToolTest.php | 63 ++++++++++ 24 files changed, 1594 insertions(+) create mode 100644 src/Mcp/Tool/ActivityLogTool.php create mode 100644 src/Mcp/Tool/Comment/CreateCommentTool.php create mode 100644 src/Mcp/Tool/Comment/ListCommentsTool.php create mode 100644 src/Mcp/Tool/Comment/ResolveCommentTool.php create mode 100644 src/Mcp/Tool/Comment/UnresolvedCountTool.php create mode 100644 src/Mcp/Tool/CustomField/DeleteCustomFieldValueTool.php create mode 100644 src/Mcp/Tool/CustomField/ListCustomFieldValuesTool.php create mode 100644 src/Mcp/Tool/CustomField/UpsertCustomFieldValuesTool.php create mode 100644 src/Mcp/Tool/Document/DeleteDocumentTool.php create mode 100644 src/Mcp/Tool/Document/ListDocumentsTool.php create mode 100644 src/Mcp/Tool/EntityHistoryTool.php create mode 100644 src/Mcp/Tool/ModelType/CreateModelTypeTool.php create mode 100644 src/Mcp/Tool/ModelType/DeleteModelTypeTool.php create mode 100644 src/Mcp/Tool/ModelType/GetModelTypeTool.php create mode 100644 src/Mcp/Tool/ModelType/ListModelTypesTool.php create mode 100644 src/Mcp/Tool/ModelType/SyncModelTypeTool.php create mode 100644 src/Mcp/Tool/ModelType/UpdateModelTypeTool.php create mode 100644 src/Mcp/Tool/SearchInventoryTool.php create mode 100644 tests/Mcp/Tool/Comment/CommentsToolTest.php create mode 100644 tests/Mcp/Tool/CustomField/CustomFieldToolsTest.php create mode 100644 tests/Mcp/Tool/Document/DocumentToolsTest.php create mode 100644 tests/Mcp/Tool/HistoryToolsTest.php create mode 100644 tests/Mcp/Tool/ModelType/ModelTypeToolsTest.php create mode 100644 tests/Mcp/Tool/SearchInventoryToolTest.php diff --git a/src/Mcp/Tool/ActivityLogTool.php b/src/Mcp/Tool/ActivityLogTool.php new file mode 100644 index 0000000..868c7be --- /dev/null +++ b/src/Mcp/Tool/ActivityLogTool.php @@ -0,0 +1,61 @@ +requireRole($this->security, 'ROLE_VIEWER'); + + $p = $this->paginationParams($page, $limit); + + $filters = []; + if ('' !== $entityType) { + $filters['entityType'] = $entityType; + } + if ('' !== $action) { + $filters['action'] = $action; + } + + $result = $this->auditLogs->findAllPaginated($p['page'], $p['limit'], $filters); + + $items = array_map( + static function ($log) { + $snapshot = $log->getSnapshot(); + + return [ + 'id' => $log->getId(), + 'entityType' => $log->getEntityType(), + 'entityId' => $log->getEntityId(), + 'entityName' => $snapshot['name'] ?? null, + 'action' => $log->getAction(), + 'diff' => $log->getDiff(), + 'actorProfileId' => $log->getActorProfileId(), + 'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM), + ]; + }, + $result['items'], + ); + + return $this->paginatedResponse(array_values($items), $result['total'], $p['page'], $p['limit']); + } +} diff --git a/src/Mcp/Tool/Comment/CreateCommentTool.php b/src/Mcp/Tool/Comment/CreateCommentTool.php new file mode 100644 index 0000000..25b4247 --- /dev/null +++ b/src/Mcp/Tool/Comment/CreateCommentTool.php @@ -0,0 +1,85 @@ +requireRole($this->security, 'ROLE_VIEWER'); + + $content = trim($content); + if ('' === $content) { + $this->mcpError('Validation', 'Le contenu est requis.'); + } + + $allowedTypes = ['machine', 'piece', 'composant', 'product', 'piece_category', 'component_category', 'product_category', 'machine_skeleton']; + if (!in_array($entityType, $allowedTypes, true)) { + $this->mcpError('Validation', "Type d'entité invalide : {$entityType}."); + } + + $entityId = trim($entityId); + if ('' === $entityId) { + $this->mcpError('Validation', "L'identifiant de l'entité est requis."); + } + + $user = $this->security->getUser(); + $profile = $user ? $this->profiles->find($user->getUserIdentifier()) : null; + + $authorName = 'Inconnu'; + $authorId = ''; + if ($profile) { + $authorId = $profile->getId(); + $authorName = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName())); + if ('' === $authorName) { + $authorName = $profile->getEmail() ?? 'Inconnu'; + } + } + + $comment = new Comment(); + $comment->setContent($content); + $comment->setEntityType($entityType); + $comment->setEntityId($entityId); + $comment->setEntityName('' !== $entityName ? $entityName : null); + $comment->setAuthorId($authorId); + $comment->setAuthorName($authorName); + + $this->em->persist($comment); + $this->em->flush(); + + return $this->jsonResponse([ + 'id' => $comment->getId(), + 'content' => $comment->getContent(), + 'entityType' => $comment->getEntityType(), + 'entityId' => $comment->getEntityId(), + 'entityName' => $comment->getEntityName(), + 'authorName' => $comment->getAuthorName(), + 'status' => $comment->getStatus(), + ]); + } +} diff --git a/src/Mcp/Tool/Comment/ListCommentsTool.php b/src/Mcp/Tool/Comment/ListCommentsTool.php new file mode 100644 index 0000000..55a002a --- /dev/null +++ b/src/Mcp/Tool/Comment/ListCommentsTool.php @@ -0,0 +1,67 @@ +paginationParams($page, $limit); + + $repo = $this->em->getRepository(Comment::class); + + $total = (int) $repo->createQueryBuilder('c') + ->select('COUNT(c.id)') + ->andWhere('c.entityType = :entityType') + ->andWhere('c.entityId = :entityId') + ->setParameter('entityType', $entityType) + ->setParameter('entityId', $entityId) + ->getQuery() + ->getSingleScalarResult() + ; + + $items = $repo->createQueryBuilder('c') + ->select( + 'c.id', + 'c.content', + 'c.entityType', + 'c.entityId', + 'c.entityName', + 'c.authorName', + 'c.authorId', + 'c.status', + 'c.resolvedByName', + 'c.resolvedAt', + 'c.createdAt', + ) + ->andWhere('c.entityType = :entityType') + ->andWhere('c.entityId = :entityId') + ->setParameter('entityType', $entityType) + ->setParameter('entityId', $entityId) + ->orderBy('c.createdAt', 'DESC') + ->setFirstResult($p['offset']) + ->setMaxResults($p['limit']) + ->getQuery() + ->getArrayResult() + ; + + return $this->paginatedResponse($items, $total, $p['page'], $p['limit']); + } +} diff --git a/src/Mcp/Tool/Comment/ResolveCommentTool.php b/src/Mcp/Tool/Comment/ResolveCommentTool.php new file mode 100644 index 0000000..5f1eb81 --- /dev/null +++ b/src/Mcp/Tool/Comment/ResolveCommentTool.php @@ -0,0 +1,65 @@ +requireRole($this->security, 'ROLE_GESTIONNAIRE'); + + $comment = $this->em->getRepository(Comment::class)->find($commentId); + if (!$comment) { + $this->mcpError('NotFound', 'Commentaire introuvable.'); + } + + $user = $this->security->getUser(); + $profile = $user ? $this->profiles->find($user->getUserIdentifier()) : null; + + $resolverName = 'Inconnu'; + $resolverId = null; + if ($profile) { + $resolverId = $profile->getId(); + $resolverName = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName())); + if ('' === $resolverName) { + $resolverName = $profile->getEmail() ?? 'Inconnu'; + } + } + + $comment->setStatus('resolved'); + $comment->setResolvedById($resolverId); + $comment->setResolvedByName($resolverName); + $comment->setResolvedAt(new DateTimeImmutable()); + + $this->em->flush(); + + return $this->jsonResponse([ + 'id' => $comment->getId(), + 'status' => $comment->getStatus(), + 'resolvedById' => $comment->getResolvedById(), + 'resolvedByName' => $comment->getResolvedByName(), + ]); + } +} diff --git a/src/Mcp/Tool/Comment/UnresolvedCountTool.php b/src/Mcp/Tool/Comment/UnresolvedCountTool.php new file mode 100644 index 0000000..0859b50 --- /dev/null +++ b/src/Mcp/Tool/Comment/UnresolvedCountTool.php @@ -0,0 +1,37 @@ +em->getRepository(Comment::class) + ->createQueryBuilder('c') + ->select('COUNT(c.id)') + ->andWhere('c.status = :status') + ->setParameter('status', 'open') + ->getQuery() + ->getSingleScalarResult() + ; + + return $this->jsonResponse(['count' => $count]); + } +} diff --git a/src/Mcp/Tool/CustomField/DeleteCustomFieldValueTool.php b/src/Mcp/Tool/CustomField/DeleteCustomFieldValueTool.php new file mode 100644 index 0000000..dc1b735 --- /dev/null +++ b/src/Mcp/Tool/CustomField/DeleteCustomFieldValueTool.php @@ -0,0 +1,41 @@ +requireRole($this->security, 'ROLE_GESTIONNAIRE'); + + $cfv = $this->em->getRepository(CustomFieldValue::class)->find($customFieldValueId); + + if (null === $cfv) { + $this->mcpError('not_found', "CustomFieldValue not found: {$customFieldValueId}"); + } + + $this->em->remove($cfv); + $this->em->flush(); + + return $this->jsonResponse(['deleted' => true, 'id' => $customFieldValueId]); + } +} diff --git a/src/Mcp/Tool/CustomField/ListCustomFieldValuesTool.php b/src/Mcp/Tool/CustomField/ListCustomFieldValuesTool.php new file mode 100644 index 0000000..bd2b5b8 --- /dev/null +++ b/src/Mcp/Tool/CustomField/ListCustomFieldValuesTool.php @@ -0,0 +1,61 @@ +mcpError('validation', "entityType must be one of: machine, composant, piece, product. Got '{$entityType}'."); + } + + $rows = $this->em->createQueryBuilder() + ->select( + 'cfv.id', + 'cfv.value', + 'cf.id AS customFieldId', + 'cf.name AS customFieldName', + 'cf.type AS customFieldType', + 'cf.required AS customFieldRequired', + 'cfv.createdAt', + 'cfv.updatedAt', + ) + ->from(CustomFieldValue::class, 'cfv') + ->join('cfv.customField', 'cf') + ->where("IDENTITY(cfv.{$entityType}) = :entityId") + ->setParameter('entityId', $entityId) + ->orderBy('cf.name', 'ASC') + ->getQuery() + ->getArrayResult() + ; + + return $this->jsonResponse([ + 'entityType' => $entityType, + 'entityId' => $entityId, + 'values' => $rows, + 'total' => count($rows), + ]); + } +} diff --git a/src/Mcp/Tool/CustomField/UpsertCustomFieldValuesTool.php b/src/Mcp/Tool/CustomField/UpsertCustomFieldValuesTool.php new file mode 100644 index 0000000..76d2016 --- /dev/null +++ b/src/Mcp/Tool/CustomField/UpsertCustomFieldValuesTool.php @@ -0,0 +1,114 @@ +requireRole($this->security, 'ROLE_GESTIONNAIRE'); + + $entityType = strtolower($entityType); + + if (!in_array($entityType, self::ALLOWED_TYPES, true)) { + $this->mcpError('validation', "entityType must be one of: machine, composant, piece, product. Got '{$entityType}'."); + } + + $entityClass = match ($entityType) { + 'machine' => Machine::class, + 'composant' => Composant::class, + 'piece' => Piece::class, + 'product' => Product::class, + }; + + $entity = $this->em->getRepository($entityClass)->find($entityId); + + if (null === $entity) { + $this->mcpError('not_found', ucfirst($entityType)." not found: {$entityId}"); + } + + $results = []; + + foreach ($fields as $fieldEntry) { + $customFieldId = $fieldEntry['customFieldId'] ?? null; + $value = $fieldEntry['value'] ?? ''; + + if (null === $customFieldId) { + $this->mcpError('validation', 'Each field entry must have a customFieldId.'); + } + + $customField = $this->em->getRepository(CustomField::class)->find($customFieldId); + + if (null === $customField) { + $this->mcpError('not_found', "CustomField not found: {$customFieldId}"); + } + + $existing = $this->em->getRepository(CustomFieldValue::class)->findOneBy([ + 'customField' => $customField, + $entityType => $entity, + ]); + + if (null !== $existing) { + $existing->setValue((string) $value); + $results[] = [ + 'id' => $existing->getId(), + 'customFieldId' => $customField->getId(), + 'value' => (string) $value, + 'action' => 'updated', + ]; + } else { + $cfv = new CustomFieldValue(); + $cfv->setCustomField($customField); + $cfv->setValue((string) $value); + + $setter = 'set'.ucfirst($entityType); + $cfv->{$setter}($entity); + + $this->em->persist($cfv); + $this->em->flush(); + + $results[] = [ + 'id' => $cfv->getId(), + 'customFieldId' => $customField->getId(), + 'value' => (string) $value, + 'action' => 'created', + ]; + } + } + + $this->em->flush(); + + return $this->jsonResponse([ + 'entityType' => $entityType, + 'entityId' => $entityId, + 'results' => $results, + 'total' => count($results), + ]); + } +} diff --git a/src/Mcp/Tool/Document/DeleteDocumentTool.php b/src/Mcp/Tool/Document/DeleteDocumentTool.php new file mode 100644 index 0000000..41de1c9 --- /dev/null +++ b/src/Mcp/Tool/Document/DeleteDocumentTool.php @@ -0,0 +1,42 @@ +requireRole($this->security, 'ROLE_GESTIONNAIRE'); + + $document = $this->documents->find($documentId); + + if (!$document) { + $this->mcpError('not_found', "Document not found: {$documentId}"); + } + + $this->em->remove($document); + $this->em->flush(); + + return $this->jsonResponse(['deleted' => true, 'id' => $documentId]); + } +} diff --git a/src/Mcp/Tool/Document/ListDocumentsTool.php b/src/Mcp/Tool/Document/ListDocumentsTool.php new file mode 100644 index 0000000..b17364d --- /dev/null +++ b/src/Mcp/Tool/Document/ListDocumentsTool.php @@ -0,0 +1,62 @@ + 'site', + 'machine' => 'machine', + 'composant' => 'composant', + 'piece' => 'piece', + 'product' => 'product', + ]; + + public function __construct( + private readonly DocumentRepository $documents, + ) {} + + public function __invoke(string $entityType, string $entityId): array + { + if (!isset(self::ENTITY_FIELDS[$entityType])) { + $this->mcpError('validation', "Invalid entityType '{$entityType}'. Must be one of: site, machine, composant, piece, product."); + } + + $field = self::ENTITY_FIELDS[$entityType]; + + $docs = $this->documents->findBy([$field => $entityId], ['createdAt' => 'DESC']); + + $items = []; + foreach ($docs as $doc) { + $items[] = [ + 'id' => $doc->getId(), + 'name' => $doc->getName(), + 'filename' => $doc->getFilename(), + 'fileUrl' => '/api/documents/'.$doc->getId().'/file', + 'downloadUrl' => '/api/documents/'.$doc->getId().'/download', + 'mimeType' => $doc->getMimeType(), + 'size' => $doc->getSize(), + 'createdAt' => $doc->getCreatedAt()->format('Y-m-d H:i:s'), + ]; + } + + return $this->jsonResponse([ + 'entityType' => $entityType, + 'entityId' => $entityId, + 'items' => $items, + 'total' => count($items), + ]); + } +} diff --git a/src/Mcp/Tool/EntityHistoryTool.php b/src/Mcp/Tool/EntityHistoryTool.php new file mode 100644 index 0000000..ba9cf46 --- /dev/null +++ b/src/Mcp/Tool/EntityHistoryTool.php @@ -0,0 +1,57 @@ +requireRole($this->security, 'ROLE_VIEWER'); + + if (!in_array($entityType, self::VALID_TYPES, true)) { + $this->mcpError('Validation', sprintf( + 'Invalid entityType "%s". Must be one of: %s', + $entityType, + implode(', ', self::VALID_TYPES), + )); + } + + $logs = $this->auditLogs->findEntityHistory($entityType, $entityId, 200); + + $items = array_map( + static fn ($log) => [ + 'id' => $log->getId(), + 'action' => $log->getAction(), + 'diff' => $log->getDiff(), + 'actorProfileId' => $log->getActorProfileId(), + 'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM), + ], + $logs, + ); + + return $this->jsonResponse([ + 'items' => array_values($items), + 'total' => count($items), + ]); + } +} diff --git a/src/Mcp/Tool/ModelType/CreateModelTypeTool.php b/src/Mcp/Tool/ModelType/CreateModelTypeTool.php new file mode 100644 index 0000000..3c9c731 --- /dev/null +++ b/src/Mcp/Tool/ModelType/CreateModelTypeTool.php @@ -0,0 +1,57 @@ +requireRole($this->security, 'ROLE_GESTIONNAIRE'); + + $enumCategory = match (strtolower($category)) { + 'composant', 'component' => ModelCategory::COMPONENT, + 'piece' => ModelCategory::PIECE, + 'product' => ModelCategory::PRODUCT, + default => null, + }; + + if (null === $enumCategory) { + $this->mcpError('validation', "Invalid category '{$category}'. Must be one of: composant, piece, product."); + } + + $mt = new ModelType(); + $mt->setName($name); + $mt->setCategory($enumCategory); + $mt->setCode('' !== $code ? $code : strtoupper(substr(str_replace(' ', '-', $name), 0, 20)).'-'.bin2hex(random_bytes(3))); + + $this->em->persist($mt); + $this->em->flush(); + + return $this->jsonResponse([ + 'id' => $mt->getId(), + 'name' => $mt->getName(), + 'code' => $mt->getCode(), + 'category' => $mt->getCategory()->value, + ]); + } +} diff --git a/src/Mcp/Tool/ModelType/DeleteModelTypeTool.php b/src/Mcp/Tool/ModelType/DeleteModelTypeTool.php new file mode 100644 index 0000000..7e384a0 --- /dev/null +++ b/src/Mcp/Tool/ModelType/DeleteModelTypeTool.php @@ -0,0 +1,42 @@ +requireRole($this->security, 'ROLE_GESTIONNAIRE'); + + $mt = $this->modelTypes->find($modelTypeId); + + if (!$mt) { + $this->mcpError('not_found', "ModelType not found: {$modelTypeId}"); + } + + $this->em->remove($mt); + $this->em->flush(); + + return $this->jsonResponse(['deleted' => true, 'id' => $modelTypeId]); + } +} diff --git a/src/Mcp/Tool/ModelType/GetModelTypeTool.php b/src/Mcp/Tool/ModelType/GetModelTypeTool.php new file mode 100644 index 0000000..989a9d2 --- /dev/null +++ b/src/Mcp/Tool/ModelType/GetModelTypeTool.php @@ -0,0 +1,78 @@ +modelTypes->find($modelTypeId); + + if (!$mt) { + $this->mcpError('not_found', "ModelType not found: {$modelTypeId}"); + } + + $skeletonPieces = []; + foreach ($mt->getSkeletonPieceRequirements() as $req) { + $skeletonPieces[] = [ + 'id' => $req->getId(), + 'typePieceId' => $req->getTypePiece()->getId(), + 'typePiece' => $req->getTypePiece()->getName(), + 'position' => $req->getPosition(), + ]; + } + + $skeletonProducts = []; + foreach ($mt->getSkeletonProductRequirements() as $req) { + $skeletonProducts[] = [ + 'id' => $req->getId(), + 'typeProductId' => $req->getTypeProduct()->getId(), + 'typeProduct' => $req->getTypeProduct()->getName(), + 'familyCode' => $req->getFamilyCode(), + 'position' => $req->getPosition(), + ]; + } + + $skeletonSubcomponents = []; + foreach ($mt->getSkeletonSubcomponentRequirements() as $req) { + $skeletonSubcomponents[] = [ + 'id' => $req->getId(), + 'alias' => $req->getAlias(), + 'familyCode' => $req->getFamilyCode(), + 'typeComposantId' => $req->getTypeComposant()?->getId(), + 'typeComposant' => $req->getTypeComposant()?->getName(), + 'position' => $req->getPosition(), + ]; + } + + return $this->jsonResponse([ + 'id' => $mt->getId(), + 'name' => $mt->getName(), + 'code' => $mt->getCode(), + 'category' => $mt->getCategory()->value, + 'notes' => $mt->getNotes(), + 'description' => $mt->getDescription(), + 'skeletonPieceRequirements' => $skeletonPieces, + 'skeletonProductRequirements' => $skeletonProducts, + 'skeletonSubcomponentRequirements' => $skeletonSubcomponents, + 'createdAt' => $mt->getCreatedAt()->format('Y-m-d H:i:s'), + 'updatedAt' => $mt->getUpdatedAt()->format('Y-m-d H:i:s'), + ]); + } +} diff --git a/src/Mcp/Tool/ModelType/ListModelTypesTool.php b/src/Mcp/Tool/ModelType/ListModelTypesTool.php new file mode 100644 index 0000000..440bfe2 --- /dev/null +++ b/src/Mcp/Tool/ModelType/ListModelTypesTool.php @@ -0,0 +1,79 @@ +paginationParams($page, $limit); + + $countQb = $this->modelTypes->createQueryBuilder('mt') + ->select('COUNT(mt.id)') + ; + + $qb = $this->modelTypes->createQueryBuilder('mt') + ->select('mt.id', 'mt.name', 'mt.code', 'mt.category') + ->orderBy('mt.name', 'ASC') + ; + + if ('' !== $category) { + $enumCategory = $this->resolveCategory($category); + + if (null === $enumCategory) { + $this->mcpError('validation', "Invalid category '{$category}'. Must be one of: composant, piece, product."); + } + + $countQb->andWhere('mt.category = :category') + ->setParameter('category', $enumCategory) + ; + $qb->andWhere('mt.category = :category') + ->setParameter('category', $enumCategory) + ; + } + + $total = (int) $countQb->getQuery()->getSingleScalarResult(); + + $items = $qb->setFirstResult($p['offset']) + ->setMaxResults($p['limit']) + ->getQuery() + ->getArrayResult() + ; + + // Convert ModelCategory enum to string in results + foreach ($items as &$item) { + if ($item['category'] instanceof ModelCategory) { + $item['category'] = $item['category']->value; + } + } + + return $this->paginatedResponse($items, $total, $p['page'], $p['limit']); + } + + private function resolveCategory(string $category): ?ModelCategory + { + return match (strtolower($category)) { + 'composant', 'component' => ModelCategory::COMPONENT, + 'piece' => ModelCategory::PIECE, + 'product' => ModelCategory::PRODUCT, + default => null, + }; + } +} diff --git a/src/Mcp/Tool/ModelType/SyncModelTypeTool.php b/src/Mcp/Tool/ModelType/SyncModelTypeTool.php new file mode 100644 index 0000000..3c58e12 --- /dev/null +++ b/src/Mcp/Tool/ModelType/SyncModelTypeTool.php @@ -0,0 +1,67 @@ +requireRole($this->security, 'ROLE_GESTIONNAIRE'); + + if (!in_array($action, ['preview', 'sync'], true)) { + $this->mcpError('validation', "Invalid action '{$action}'. Must be 'preview' or 'sync'."); + } + + $mt = $this->modelTypes->find($modelTypeId); + + if (!$mt) { + $this->mcpError('not_found', "ModelType not found: {$modelTypeId}"); + } + + if ('preview' === $action) { + $result = $this->syncService->preview($mt, $structure ?? []); + + return $this->jsonResponse($result->jsonSerialize()); + } + + // sync action + $confirmation = new SyncConfirmation( + confirmDeletions: $confirmDeletions, + confirmTypeChanges: $confirmTypeChanges, + ); + + $result = $this->em->wrapInTransaction(function () use ($mt, $confirmation) { + return $this->syncService->execute($mt, $confirmation); + }); + + return $this->jsonResponse($result->jsonSerialize()); + } +} diff --git a/src/Mcp/Tool/ModelType/UpdateModelTypeTool.php b/src/Mcp/Tool/ModelType/UpdateModelTypeTool.php new file mode 100644 index 0000000..4520dc3 --- /dev/null +++ b/src/Mcp/Tool/ModelType/UpdateModelTypeTool.php @@ -0,0 +1,53 @@ +requireRole($this->security, 'ROLE_GESTIONNAIRE'); + + $mt = $this->modelTypes->find($modelTypeId); + + if (!$mt) { + $this->mcpError('not_found', "ModelType not found: {$modelTypeId}"); + } + + if (null !== $name) { + $mt->setName($name); + } + if (null !== $code) { + $mt->setCode($code); + } + + $this->em->flush(); + + return $this->jsonResponse([ + 'id' => $mt->getId(), + 'name' => $mt->getName(), + 'code' => $mt->getCode(), + 'category' => $mt->getCategory()->value, + ]); + } +} diff --git a/src/Mcp/Tool/SearchInventoryTool.php b/src/Mcp/Tool/SearchInventoryTool.php new file mode 100644 index 0000000..f7db620 --- /dev/null +++ b/src/Mcp/Tool/SearchInventoryTool.php @@ -0,0 +1,113 @@ +jsonResponse([]); + } + + $limit = min(100, max(1, $limit)); + $searchTypes = $this->resolveTypes($types); + $results = []; + + foreach ($searchTypes as $type) { + $results = array_merge($results, match ($type) { + 'machine' => $this->searchWithReference($this->machines, 'm', 'machine', $query), + 'piece' => $this->searchWithReference($this->pieces, 'p', 'piece', $query), + 'composant' => $this->searchWithReference($this->composants, 'c', 'composant', $query), + 'product' => $this->searchWithReference($this->products, 'p', 'product', $query), + 'site' => $this->searchNameOnly($this->sites, 's', 'site', $query), + 'constructeur' => $this->searchNameOnly($this->constructeurs, 'c', 'constructeur', $query), + }); + } + + $results = array_slice($results, 0, $limit); + + return $this->jsonResponse($results); + } + + /** + * @return list + */ + private function resolveTypes(string $types): array + { + if ('' === trim($types)) { + return self::ALLOWED_TYPES; + } + + $requested = array_map('trim', explode(',', strtolower($types))); + + return array_values(array_intersect($requested, self::ALLOWED_TYPES)); + } + + private function searchWithReference(object $repository, string $alias, string $type, string $search): array + { + $qb = $repository->createQueryBuilder($alias) + ->select("{$alias}.id", "{$alias}.name", "{$alias}.reference") + ->where("LOWER({$alias}.name) LIKE LOWER(:search)") + ->orWhere("LOWER({$alias}.reference) LIKE LOWER(:search)") + ->setParameter('search', "%{$search}%") + ->orderBy("{$alias}.name", 'ASC') + ; + + $rows = $qb->getQuery()->getArrayResult(); + + return array_map(fn (array $row) => [ + 'type' => $type, + 'id' => $row['id'], + 'name' => $row['name'], + 'reference' => $row['reference'] ?? null, + ], $rows); + } + + private function searchNameOnly(object $repository, string $alias, string $type, string $search): array + { + $qb = $repository->createQueryBuilder($alias) + ->select("{$alias}.id", "{$alias}.name") + ->where("LOWER({$alias}.name) LIKE LOWER(:search)") + ->setParameter('search', "%{$search}%") + ->orderBy("{$alias}.name", 'ASC') + ; + + $rows = $qb->getQuery()->getArrayResult(); + + return array_map(fn (array $row) => [ + 'type' => $type, + 'id' => $row['id'], + 'name' => $row['name'], + 'reference' => null, + ], $rows); + } +} diff --git a/tests/Mcp/Tool/Comment/CommentsToolTest.php b/tests/Mcp/Tool/Comment/CommentsToolTest.php new file mode 100644 index 0000000..7365c80 --- /dev/null +++ b/tests/Mcp/Tool/Comment/CommentsToolTest.php @@ -0,0 +1,98 @@ +createComment('First comment', 'machine', $entityId); + $this->createComment('Second comment', 'machine', $entityId); + $this->createComment('Other entity', 'machine', 'other-id'); + + $session = $this->createMcpClient('ROLE_VIEWER'); + + $data = $this->callMcpTool($session, 'list_comments', [ + 'entityType' => 'machine', + 'entityId' => $entityId, + ]); + + $this->assertArrayHasKey('_parsed', $data); + $this->assertSame(2, $data['_parsed']['total']); + $this->assertCount(2, $data['_parsed']['items']); + } + + public function testCreateComment(): void + { + $session = $this->createMcpClient('ROLE_VIEWER'); + + $data = $this->callMcpTool($session, 'create_comment', [ + 'content' => 'A new comment', + 'entityType' => 'machine', + 'entityId' => 'some-machine-id', + 'entityName' => 'Machine Alpha', + ]); + + $this->assertArrayHasKey('_parsed', $data); + $this->assertNotEmpty($data['_parsed']['id']); + $this->assertSame('A new comment', $data['_parsed']['content']); + $this->assertSame('machine', $data['_parsed']['entityType']); + $this->assertSame('open', $data['_parsed']['status']); + $this->assertSame('Machine Alpha', $data['_parsed']['entityName']); + } + + public function testResolveComment(): void + { + $comment = $this->createComment('To resolve', 'piece', 'piece-123'); + $session = $this->createMcpClient('ROLE_GESTIONNAIRE'); + + $data = $this->callMcpTool($session, 'resolve_comment', [ + 'commentId' => $comment->getId(), + ]); + + $this->assertArrayHasKey('_parsed', $data); + $this->assertSame('resolved', $data['_parsed']['status']); + $this->assertNotEmpty($data['_parsed']['resolvedByName']); + } + + public function testUnresolvedCount(): void + { + $entityId = 'entity-'.uniqid(); + $this->createComment('Open 1', 'machine', $entityId, 'open'); + $this->createComment('Open 2', 'machine', $entityId, 'open'); + $this->createComment('Resolved', 'machine', $entityId, 'resolved'); + + $session = $this->createMcpClient('ROLE_VIEWER'); + + $data = $this->callMcpTool($session, 'get_unresolved_comments_count'); + + $this->assertArrayHasKey('_parsed', $data); + $this->assertGreaterThanOrEqual(2, $data['_parsed']['count']); + } + + private function createComment(string $content, string $entityType, string $entityId, string $status = 'open'): Comment + { + $comment = new Comment(); + $comment->setContent($content); + $comment->setEntityType($entityType); + $comment->setEntityId($entityId); + $comment->setAuthorId('test-author-id'); + $comment->setAuthorName('Test Author'); + $comment->setStatus($status); + + $em = $this->getEntityManager(); + $em->persist($comment); + $em->flush(); + + return $comment; + } +} diff --git a/tests/Mcp/Tool/CustomField/CustomFieldToolsTest.php b/tests/Mcp/Tool/CustomField/CustomFieldToolsTest.php new file mode 100644 index 0000000..6888890 --- /dev/null +++ b/tests/Mcp/Tool/CustomField/CustomFieldToolsTest.php @@ -0,0 +1,101 @@ +createMachine(name: 'Machine CF'); + $customField = $this->createCustomField(name: 'Serial Number', type: 'text', machine: $machine); + $this->createCustomFieldValue(customField: $customField, value: 'SN-12345', machine: $machine); + + $session = $this->createMcpClient('ROLE_VIEWER'); + + $data = $this->callMcpTool($session, 'list_custom_field_values', [ + 'entityType' => 'machine', + 'entityId' => $machine->getId(), + ]); + + $this->assertArrayHasKey('_parsed', $data); + $parsed = $data['_parsed']; + $this->assertSame('machine', $parsed['entityType']); + $this->assertSame($machine->getId(), $parsed['entityId']); + $this->assertSame(1, $parsed['total']); + $this->assertSame('SN-12345', $parsed['values'][0]['value']); + $this->assertSame('Serial Number', $parsed['values'][0]['customFieldName']); + $this->assertSame('text', $parsed['values'][0]['customFieldType']); + } + + public function testUpsertCustomFieldValues(): void + { + $machine = $this->createMachine(name: 'Machine Upsert'); + $customField = $this->createCustomField(name: 'Voltage', type: 'text', machine: $machine); + + $session = $this->createMcpClient('ROLE_GESTIONNAIRE'); + + // Create + $data = $this->callMcpTool($session, 'upsert_custom_field_values', [ + 'entityType' => 'machine', + 'entityId' => $machine->getId(), + 'fields' => [ + ['customFieldId' => $customField->getId(), 'value' => '220V'], + ], + ]); + + $this->assertArrayHasKey('_parsed', $data); + $parsed = $data['_parsed']; + $this->assertSame(1, $parsed['total']); + $this->assertSame('created', $parsed['results'][0]['action']); + $this->assertSame('220V', $parsed['results'][0]['value']); + + $createdId = $parsed['results'][0]['id']; + + // Update (upsert same field) + $data = $this->callMcpTool($session, 'upsert_custom_field_values', [ + 'entityType' => 'machine', + 'entityId' => $machine->getId(), + 'fields' => [ + ['customFieldId' => $customField->getId(), 'value' => '380V'], + ], + ]); + + $parsed = $data['_parsed']; + $this->assertSame('updated', $parsed['results'][0]['action']); + $this->assertSame('380V', $parsed['results'][0]['value']); + $this->assertSame($createdId, $parsed['results'][0]['id']); + } + + public function testDeleteCustomFieldValue(): void + { + $machine = $this->createMachine(name: 'Machine Delete CF'); + $customField = $this->createCustomField(name: 'Weight', type: 'text', machine: $machine); + $cfv = $this->createCustomFieldValue(customField: $customField, value: '150kg', machine: $machine); + + $session = $this->createMcpClient('ROLE_GESTIONNAIRE'); + + $data = $this->callMcpTool($session, 'delete_custom_field_value', [ + 'customFieldValueId' => $cfv->getId(), + ]); + + $this->assertArrayHasKey('_parsed', $data); + $parsed = $data['_parsed']; + $this->assertTrue($parsed['deleted']); + $this->assertSame($cfv->getId(), $parsed['id']); + + // Verify it's gone + $listData = $this->callMcpTool($session, 'list_custom_field_values', [ + 'entityType' => 'machine', + 'entityId' => $machine->getId(), + ]); + + $this->assertSame(0, $listData['_parsed']['total']); + } +} diff --git a/tests/Mcp/Tool/Document/DocumentToolsTest.php b/tests/Mcp/Tool/Document/DocumentToolsTest.php new file mode 100644 index 0000000..b2d4978 --- /dev/null +++ b/tests/Mcp/Tool/Document/DocumentToolsTest.php @@ -0,0 +1,41 @@ +createSite(name: 'Doc Site'); + $session = $this->createMcpClient('ROLE_VIEWER'); + + $data = $this->callMcpTool($session, 'list_documents', [ + 'entityType' => 'site', + 'entityId' => $site->getId(), + ]); + + $this->assertArrayHasKey('_parsed', $data); + $this->assertSame('site', $data['_parsed']['entityType']); + $this->assertSame($site->getId(), $data['_parsed']['entityId']); + $this->assertIsArray($data['_parsed']['items']); + $this->assertSame(0, $data['_parsed']['total']); + } + + public function testDeleteDocumentRequiresGestionnaire(): void + { + $session = $this->createMcpClient('ROLE_VIEWER'); + + $data = $this->callMcpTool($session, 'delete_document', [ + 'documentId' => 'nonexistent-id', + ]); + + $this->assertArrayHasKey('error', $data); + } +} diff --git a/tests/Mcp/Tool/HistoryToolsTest.php b/tests/Mcp/Tool/HistoryToolsTest.php new file mode 100644 index 0000000..1972c2d --- /dev/null +++ b/tests/Mcp/Tool/HistoryToolsTest.php @@ -0,0 +1,44 @@ +createMcpClient('ROLE_VIEWER'); + + $data = $this->callMcpTool($session, 'get_activity_log'); + + $this->assertArrayHasKey('_parsed', $data); + $this->assertArrayHasKey('items', $data['_parsed']); + $this->assertArrayHasKey('total', $data['_parsed']); + $this->assertArrayHasKey('page', $data['_parsed']); + $this->assertArrayHasKey('limit', $data['_parsed']); + $this->assertArrayHasKey('pageCount', $data['_parsed']); + $this->assertIsArray($data['_parsed']['items']); + } + + public function testGetEntityHistory(): void + { + $machine = $this->createMachine(name: 'History Machine'); + $session = $this->createMcpClient('ROLE_VIEWER'); + + $data = $this->callMcpTool($session, 'get_entity_history', [ + 'entityType' => 'machine', + 'entityId' => $machine->getId(), + ]); + + $this->assertArrayHasKey('_parsed', $data); + $this->assertArrayHasKey('items', $data['_parsed']); + $this->assertArrayHasKey('total', $data['_parsed']); + $this->assertIsArray($data['_parsed']['items']); + } +} diff --git a/tests/Mcp/Tool/ModelType/ModelTypeToolsTest.php b/tests/Mcp/Tool/ModelType/ModelTypeToolsTest.php new file mode 100644 index 0000000..ae83a77 --- /dev/null +++ b/tests/Mcp/Tool/ModelType/ModelTypeToolsTest.php @@ -0,0 +1,66 @@ +createModelType(name: 'MT Alpha', code: 'MTA-'.bin2hex(random_bytes(3)), category: ModelCategory::COMPONENT); + $this->createModelType(name: 'MT Beta', code: 'MTB-'.bin2hex(random_bytes(3)), category: ModelCategory::PIECE); + $session = $this->createMcpClient('ROLE_VIEWER'); + + $data = $this->callMcpTool($session, 'list_model_types'); + + $this->assertArrayHasKey('_parsed', $data); + $this->assertGreaterThanOrEqual(2, $data['_parsed']['total']); + } + + public function testGetModelType(): void + { + $mt = $this->createModelType(name: 'MT Detail', code: 'MTD-'.bin2hex(random_bytes(3))); + $session = $this->createMcpClient('ROLE_VIEWER'); + + $data = $this->callMcpTool($session, 'get_model_type', ['modelTypeId' => $mt->getId()]); + + $this->assertArrayHasKey('_parsed', $data); + $this->assertSame('MT Detail', $data['_parsed']['name']); + $this->assertSame('COMPONENT', $data['_parsed']['category']); + $this->assertIsArray($data['_parsed']['skeletonPieceRequirements']); + } + + public function testCreateModelType(): void + { + $session = $this->createMcpClient('ROLE_GESTIONNAIRE'); + + $data = $this->callMcpTool($session, 'create_model_type', [ + 'name' => 'MT Nouveau', + 'category' => 'composant', + 'code' => 'MTN-'.bin2hex(random_bytes(3)), + ]); + + $this->assertArrayHasKey('_parsed', $data); + $this->assertSame('MT Nouveau', $data['_parsed']['name']); + $this->assertSame('COMPONENT', $data['_parsed']['category']); + $this->assertNotEmpty($data['_parsed']['id']); + } + + public function testDeleteModelType(): void + { + $mt = $this->createModelType(name: 'MT To Delete', code: 'MTDEL-'.bin2hex(random_bytes(3))); + $session = $this->createMcpClient('ROLE_GESTIONNAIRE'); + + $data = $this->callMcpTool($session, 'delete_model_type', ['modelTypeId' => $mt->getId()]); + + $this->assertArrayHasKey('_parsed', $data); + $this->assertTrue($data['_parsed']['deleted']); + } +} diff --git a/tests/Mcp/Tool/SearchInventoryToolTest.php b/tests/Mcp/Tool/SearchInventoryToolTest.php new file mode 100644 index 0000000..b81c2b6 --- /dev/null +++ b/tests/Mcp/Tool/SearchInventoryToolTest.php @@ -0,0 +1,63 @@ +createMachine(name: 'Alpha Machine'); + $this->createPiece(name: 'Alpha Piece'); + $this->createSite(name: 'Alpha Site'); + + $session = $this->createMcpClient('ROLE_VIEWER'); + + $data = $this->callMcpTool($session, 'search_inventory', ['query' => 'Alpha']); + + $this->assertArrayHasKey('_parsed', $data); + $results = $data['_parsed']; + $this->assertIsArray($results); + + $types = array_unique(array_column($results, 'type')); + $this->assertContains('machine', $types); + $this->assertContains('piece', $types); + $this->assertContains('site', $types); + + foreach ($results as $result) { + $this->assertArrayHasKey('type', $result); + $this->assertArrayHasKey('id', $result); + $this->assertArrayHasKey('name', $result); + $this->assertArrayHasKey('reference', $result); + $this->assertStringContainsStringIgnoringCase('Alpha', $result['name']); + } + } + + public function testSearchFiltersByType(): void + { + $this->createMachine(name: 'Beta Machine'); + $this->createPiece(name: 'Beta Piece'); + $this->createSite(name: 'Beta Site'); + + $session = $this->createMcpClient('ROLE_VIEWER'); + + $data = $this->callMcpTool($session, 'search_inventory', [ + 'query' => 'Beta', + 'types' => 'machine', + ]); + + $this->assertArrayHasKey('_parsed', $data); + $results = $data['_parsed']; + $this->assertIsArray($results); + $this->assertNotEmpty($results); + + $types = array_unique(array_column($results, 'type')); + $this->assertSame(['machine'], $types); + } +}