feat(mcp) : add Slots, Machine Links, Structure, and Clone tools

- list_slots + update_slots for composant/piece slots
- list/add/update/remove machine links (component, piece, product)
- get_machine_structure with full hierarchy
- clone_machine with all links and custom fields
- 52 MCP tests pass total

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-03-16 14:49:55 +01:00
parent 2f173e766d
commit bd7259ed05
11 changed files with 1724 additions and 0 deletions

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Machine;
use App\Entity\MachineComponentLink;
use App\Entity\MachinePieceLink;
use App\Entity\MachineProductLink;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\ComposantRepository;
use App\Repository\MachineComponentLinkRepository;
use App\Repository\MachinePieceLinkRepository;
use App\Repository\MachineRepository;
use App\Repository\PieceRepository;
use App\Repository\ProductRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'add_machine_links',
description: 'Add one or more links (composant, piece, product) to a machine. Each link specifies a type, entityId, and optional parentLinkId / overrides. Requires ROLE_GESTIONNAIRE.',
)]
class AddMachineLinksTool
{
use McpToolHelper;
public function __construct(
private readonly EntityManagerInterface $em,
private readonly Security $security,
private readonly MachineRepository $machines,
private readonly ComposantRepository $composants,
private readonly PieceRepository $pieces,
private readonly ProductRepository $products,
private readonly MachineComponentLinkRepository $componentLinks,
private readonly MachinePieceLinkRepository $pieceLinks,
) {}
public function __invoke(string $machineId, array $links): array
{
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$machine = $this->machines->find($machineId);
if (null === $machine) {
$this->mcpError('NotFound', "Machine {$machineId} not found.");
}
$created = [];
foreach ($links as $linkData) {
$type = $linkData['type'] ?? '';
$entityId = $linkData['entityId'] ?? '';
switch ($type) {
case 'composant':
$composant = $this->composants->find($entityId);
if (null === $composant) {
$this->mcpError('NotFound', "Composant {$entityId} not found.");
}
$link = new MachineComponentLink();
$link->setMachine($machine);
$link->setComposant($composant);
if (!empty($linkData['parentLinkId'])) {
$parent = $this->componentLinks->find($linkData['parentLinkId']);
if (null !== $parent) {
$link->setParentLink($parent);
}
}
if (isset($linkData['nameOverride'])) {
$link->setNameOverride($linkData['nameOverride']);
}
if (isset($linkData['referenceOverride'])) {
$link->setReferenceOverride($linkData['referenceOverride']);
}
if (isset($linkData['prixOverride'])) {
$link->setPrixOverride($linkData['prixOverride']);
}
$this->em->persist($link);
$created[] = ['id' => $link->getId(), 'type' => 'composant', 'entityId' => $entityId];
break;
case 'piece':
$piece = $this->pieces->find($entityId);
if (null === $piece) {
$this->mcpError('NotFound', "Piece {$entityId} not found.");
}
$link = new MachinePieceLink();
$link->setMachine($machine);
$link->setPiece($piece);
$link->setQuantity((int) ($linkData['quantity'] ?? 1));
if (!empty($linkData['parentLinkId'])) {
$parent = $this->componentLinks->find($linkData['parentLinkId']);
if (null !== $parent) {
$link->setParentLink($parent);
}
}
if (isset($linkData['nameOverride'])) {
$link->setNameOverride($linkData['nameOverride']);
}
if (isset($linkData['referenceOverride'])) {
$link->setReferenceOverride($linkData['referenceOverride']);
}
if (isset($linkData['prixOverride'])) {
$link->setPrixOverride($linkData['prixOverride']);
}
$this->em->persist($link);
$created[] = ['id' => $link->getId(), 'type' => 'piece', 'entityId' => $entityId];
break;
case 'product':
$product = $this->products->find($entityId);
if (null === $product) {
$this->mcpError('NotFound', "Product {$entityId} not found.");
}
$link = new MachineProductLink();
$link->setMachine($machine);
$link->setProduct($product);
if (!empty($linkData['parentLinkId'])) {
$parentProduct = $this->em->getRepository(MachineProductLink::class)->find($linkData['parentLinkId']);
if (null !== $parentProduct) {
$link->setParentLink($parentProduct);
}
}
if (!empty($linkData['parentComponentLinkId'])) {
$parentComp = $this->componentLinks->find($linkData['parentComponentLinkId']);
if (null !== $parentComp) {
$link->setParentComponentLink($parentComp);
}
}
if (!empty($linkData['parentPieceLinkId'])) {
$parentPiece = $this->pieceLinks->find($linkData['parentPieceLinkId']);
if (null !== $parentPiece) {
$link->setParentPieceLink($parentPiece);
}
}
$this->em->persist($link);
$created[] = ['id' => $link->getId(), 'type' => 'product', 'entityId' => $entityId];
break;
default:
$this->mcpError('Validation', "Unknown link type '{$type}'. Expected composant, piece, or product.");
}
}
$this->em->flush();
return $this->jsonResponse(['created' => $created]);
}
}

View File

@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Machine;
use App\Entity\CustomField;
use App\Entity\CustomFieldValue;
use App\Entity\Machine;
use App\Entity\MachineComponentLink;
use App\Entity\MachinePieceLink;
use App\Entity\MachineProductLink;
use App\Entity\Site;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\MachineComponentLinkRepository;
use App\Repository\MachinePieceLinkRepository;
use App\Repository\MachineProductLinkRepository;
use App\Repository\MachineRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'clone_machine',
description: 'Clone an existing machine with all its links (components, pieces, products), custom fields, and constructeurs. Requires ROLE_GESTIONNAIRE.',
)]
class CloneMachineTool
{
use McpToolHelper;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly MachineRepository $machineRepository,
private readonly MachineComponentLinkRepository $machineComponentLinkRepository,
private readonly MachinePieceLinkRepository $machinePieceLinkRepository,
private readonly MachineProductLinkRepository $machineProductLinkRepository,
private readonly Security $security,
) {}
public function __invoke(
string $machineId,
string $name,
string $siteId,
string $reference = '',
): array {
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$source = $this->machineRepository->find($machineId);
if (!$source instanceof Machine) {
$this->mcpError('not_found', "Machine not found: {$machineId}");
}
$site = $this->entityManager->getRepository(Site::class)->find($siteId);
if (!$site) {
$this->mcpError('not_found', "Site not found: {$siteId}");
}
// Create new machine
$newMachine = new Machine();
$newMachine->setName($name);
$newMachine->setSite($site);
if ('' !== $reference) {
$newMachine->setReference($reference);
}
$newMachine->setPrix($source->getPrix());
// Copy constructeurs
foreach ($source->getConstructeurs() as $constructeur) {
$newMachine->getConstructeurs()->add($constructeur);
}
$this->entityManager->persist($newMachine);
// Copy custom fields and values
$this->cloneCustomFields($source, $newMachine);
// Copy component links (preserving hierarchy with two-pass)
$componentLinkMap = $this->cloneComponentLinks($source, $newMachine);
// Copy piece links
$pieceLinkMap = $this->clonePieceLinks($source, $newMachine, $componentLinkMap);
// Copy product links
$this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap);
$this->entityManager->flush();
return $this->jsonResponse([
'id' => $newMachine->getId(),
'name' => $newMachine->getName(),
'reference' => $newMachine->getReference(),
'siteId' => $site->getId(),
'clonedFrom' => $source->getId(),
]);
}
private function cloneCustomFields(Machine $source, Machine $target): void
{
foreach ($source->getCustomFields() as $cf) {
$newCf = new CustomField();
$newCf->setName($cf->getName());
$newCf->setType($cf->getType());
$newCf->setRequired($cf->isRequired());
$newCf->setDefaultValue($cf->getDefaultValue());
$newCf->setOptions($cf->getOptions());
$newCf->setOrderIndex($cf->getOrderIndex());
$newCf->setMachine($target);
$this->entityManager->persist($newCf);
}
foreach ($source->getCustomFieldValues() as $cfv) {
$newValue = new CustomFieldValue();
$newValue->setMachine($target);
$newValue->setCustomField($cfv->getCustomField());
$newValue->setValue($cfv->getValue());
$this->entityManager->persist($newValue);
}
}
/**
* @return array<string, MachineComponentLink> Map of old link ID to new link
*/
private function cloneComponentLinks(Machine $source, Machine $target): array
{
$sourceLinks = $this->machineComponentLinkRepository->findBy(['machine' => $source]);
$linkMap = [];
// First pass: create all links without parent relationships
foreach ($sourceLinks as $link) {
$newLink = new MachineComponentLink();
$newLink->setMachine($target);
$newLink->setComposant($link->getComposant());
$newLink->setNameOverride($link->getNameOverride());
$newLink->setReferenceOverride($link->getReferenceOverride());
$newLink->setPrixOverride($link->getPrixOverride());
$this->entityManager->persist($newLink);
$linkMap[$link->getId()] = $newLink;
}
// Second pass: set parent relationships
foreach ($sourceLinks as $link) {
$parent = $link->getParentLink();
if ($parent && isset($linkMap[$parent->getId()])) {
$linkMap[$link->getId()]->setParentLink($linkMap[$parent->getId()]);
}
}
return $linkMap;
}
/**
* @param array<string, MachineComponentLink> $componentLinkMap
*
* @return array<string, MachinePieceLink> Map of old link ID to new link
*/
private function clonePieceLinks(Machine $source, Machine $target, array $componentLinkMap): array
{
$sourceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $source]);
$linkMap = [];
foreach ($sourceLinks as $link) {
$newLink = new MachinePieceLink();
$newLink->setMachine($target);
$newLink->setPiece($link->getPiece());
$newLink->setNameOverride($link->getNameOverride());
$newLink->setReferenceOverride($link->getReferenceOverride());
$newLink->setPrixOverride($link->getPrixOverride());
$newLink->setQuantity($link->getQuantity());
$parent = $link->getParentLink();
if ($parent && isset($componentLinkMap[$parent->getId()])) {
$newLink->setParentLink($componentLinkMap[$parent->getId()]);
}
$this->entityManager->persist($newLink);
$linkMap[$link->getId()] = $newLink;
}
return $linkMap;
}
/**
* @param array<string, MachineComponentLink> $componentLinkMap
* @param array<string, MachinePieceLink> $pieceLinkMap
*/
private function cloneProductLinks(
Machine $source,
Machine $target,
array $componentLinkMap,
array $pieceLinkMap,
): void {
$sourceLinks = $this->machineProductLinkRepository->findBy(['machine' => $source]);
$linkMap = [];
// First pass: create all links
foreach ($sourceLinks as $link) {
$newLink = new MachineProductLink();
$newLink->setMachine($target);
$newLink->setProduct($link->getProduct());
$parentComponent = $link->getParentComponentLink();
if ($parentComponent && isset($componentLinkMap[$parentComponent->getId()])) {
$newLink->setParentComponentLink($componentLinkMap[$parentComponent->getId()]);
}
$parentPiece = $link->getParentPieceLink();
if ($parentPiece && isset($pieceLinkMap[$parentPiece->getId()])) {
$newLink->setParentPieceLink($pieceLinkMap[$parentPiece->getId()]);
}
$this->entityManager->persist($newLink);
$linkMap[$link->getId()] = $newLink;
}
// Second pass: set parent product link relationships
foreach ($sourceLinks as $link) {
$parent = $link->getParentLink();
if ($parent && isset($linkMap[$parent->getId()])) {
$linkMap[$link->getId()]->setParentLink($linkMap[$parent->getId()]);
}
}
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Machine;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\MachineComponentLinkRepository;
use App\Repository\MachinePieceLinkRepository;
use App\Repository\MachineProductLinkRepository;
use App\Repository\MachineRepository;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(
name: 'list_machine_links',
description: 'List all links (component, piece, product) for a given machine, grouped by type.',
)]
class ListMachineLinksTool
{
use McpToolHelper;
public function __construct(
private readonly MachineRepository $machines,
private readonly MachineComponentLinkRepository $componentLinks,
private readonly MachinePieceLinkRepository $pieceLinks,
private readonly MachineProductLinkRepository $productLinks,
) {}
public function __invoke(string $machineId): array
{
$machine = $this->machines->find($machineId);
if (null === $machine) {
$this->mcpError('NotFound', "Machine {$machineId} not found.");
}
$compRows = $this->componentLinks->createQueryBuilder('cl')
->select('cl.id', 'IDENTITY(cl.composant) AS entityId', 'IDENTITY(cl.parentLink) AS parentLinkId', 'cl.nameOverride', 'cl.referenceOverride', 'cl.prixOverride')
->where('cl.machine = :machine')
->setParameter('machine', $machine)
->orderBy('cl.id', 'ASC')
->getQuery()
->getArrayResult()
;
$pieceRows = $this->pieceLinks->createQueryBuilder('pl')
->select('pl.id', 'IDENTITY(pl.piece) AS entityId', 'IDENTITY(pl.parentLink) AS parentLinkId', 'pl.nameOverride', 'pl.referenceOverride', 'pl.prixOverride', 'pl.quantity')
->where('pl.machine = :machine')
->setParameter('machine', $machine)
->orderBy('pl.id', 'ASC')
->getQuery()
->getArrayResult()
;
$productRows = $this->productLinks->createQueryBuilder('prl')
->select('prl.id', 'IDENTITY(prl.product) AS entityId', 'IDENTITY(prl.parentLink) AS parentLinkId', 'IDENTITY(prl.parentComponentLink) AS parentComponentLinkId', 'IDENTITY(prl.parentPieceLink) AS parentPieceLinkId')
->where('prl.machine = :machine')
->setParameter('machine', $machine)
->orderBy('prl.id', 'ASC')
->getQuery()
->getArrayResult()
;
return $this->jsonResponse([
'machineId' => $machineId,
'componentLinks' => $compRows,
'pieceLinks' => $pieceRows,
'productLinks' => $productRows,
]);
}
}

View File

@@ -0,0 +1,469 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Machine;
use App\Entity\Composant;
use App\Entity\CustomField;
use App\Entity\CustomFieldValue;
use App\Entity\Machine;
use App\Entity\MachineComponentLink;
use App\Entity\MachinePieceLink;
use App\Entity\MachineProductLink;
use App\Entity\ModelType;
use App\Entity\Piece;
use App\Entity\Product;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\MachineComponentLinkRepository;
use App\Repository\MachinePieceLinkRepository;
use App\Repository\MachineProductLinkRepository;
use App\Repository\MachineRepository;
use Doctrine\Common\Collections\Collection;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(
name: 'get_machine_structure',
description: 'Get the full machine hierarchy: machine info, component links (with composant details, slots, overrides), piece links (with piece details, quantity, overrides), and product links.',
)]
class MachineStructureTool
{
use McpToolHelper;
public function __construct(
private readonly MachineRepository $machineRepository,
private readonly MachineComponentLinkRepository $machineComponentLinkRepository,
private readonly MachinePieceLinkRepository $machinePieceLinkRepository,
private readonly MachineProductLinkRepository $machineProductLinkRepository,
) {}
public function __invoke(string $machineId): array
{
$machine = $this->machineRepository->find($machineId);
if (!$machine instanceof Machine) {
$this->mcpError('not_found', "Machine not found: {$machineId}");
}
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $machine]);
$pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $machine]);
$productLinks = $this->machineProductLinkRepository->findBy(['machine' => $machine]);
return $this->jsonResponse($this->normalizeStructureResponse(
$machine,
$componentLinks,
$pieceLinks,
$productLinks,
));
}
/**
* @param MachineComponentLink[] $componentLinks
* @param MachinePieceLink[] $pieceLinks
* @param MachineProductLink[] $productLinks
*/
private function normalizeStructureResponse(
Machine $machine,
array $componentLinks,
array $pieceLinks,
array $productLinks,
): array {
$normalizedComponentLinks = $this->normalizeComponentLinks($componentLinks);
$componentIndex = $this->indexById($normalizedComponentLinks);
$normalizedPieceLinks = $this->normalizePieceLinks($pieceLinks);
$childIds = [];
foreach ($normalizedComponentLinks as $link) {
$parentId = $link['parentComponentLinkId'] ?? null;
if ($parentId && isset($componentIndex[$parentId])) {
$componentIndex[$parentId]['childLinks'][] = $link;
$childIds[$link['id']] = true;
}
}
$this->attachPiecesToComponents($componentIndex, $normalizedPieceLinks);
$rootComponents = array_filter(
$componentIndex,
static fn (array $link) => !isset($childIds[$link['id']]),
);
return [
'machine' => $this->normalizeMachine($machine),
'componentLinks' => array_values($rootComponents),
'pieceLinks' => $normalizedPieceLinks,
'productLinks' => $this->normalizeProductLinks($productLinks),
];
}
private function attachPiecesToComponents(array &$componentIndex, array $pieceLinks): void
{
foreach ($pieceLinks as $pieceLink) {
$parentId = $pieceLink['parentComponentLinkId'] ?? null;
if ($parentId && isset($componentIndex[$parentId])) {
$componentIndex[$parentId]['pieceLinks'][] = $pieceLink;
}
}
foreach ($componentIndex as &$component) {
if (!empty($component['childLinks'])) {
$this->attachPiecesToChildComponents($component['childLinks'], $pieceLinks);
}
}
}
private function attachPiecesToChildComponents(array &$childLinks, array $pieceLinks): void
{
foreach ($childLinks as &$child) {
$childId = $child['id'] ?? null;
if ($childId) {
foreach ($pieceLinks as $pieceLink) {
if (($pieceLink['parentComponentLinkId'] ?? null) === $childId) {
$child['pieceLinks'][] = $pieceLink;
}
}
}
if (!empty($child['childLinks'])) {
$this->attachPiecesToChildComponents($child['childLinks'], $pieceLinks);
}
}
}
private function normalizeMachine(Machine $machine): array
{
$site = $machine->getSite();
return [
'id' => $machine->getId(),
'name' => $machine->getName(),
'reference' => $machine->getReference(),
'prix' => $machine->getPrix(),
'siteId' => $site->getId(),
'site' => [
'id' => $site->getId(),
'name' => $site->getName(),
],
'constructeurs' => $this->normalizeConstructeurs($machine->getConstructeurs()),
'customFields' => $this->normalizeCustomFields($machine->getCustomFields()),
'customFieldValues' => $this->normalizeCustomFieldValues($machine->getCustomFieldValues()),
];
}
/**
* @param MachineComponentLink[] $links
*/
private function normalizeComponentLinks(array $links): array
{
return array_map(function (MachineComponentLink $link): array {
$composant = $link->getComposant();
$parentLink = $link->getParentLink();
return [
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'composantId' => $composant->getId(),
'composant' => $this->normalizeComposant($composant),
'parentLinkId' => $parentLink?->getId(),
'parentComponentLinkId' => $parentLink?->getId(),
'parentComponentId' => $parentLink?->getComposant()->getId(),
'overrides' => $this->normalizeOverrides($link),
'childLinks' => [],
'pieceLinks' => [],
];
}, $links);
}
/**
* @param MachinePieceLink[] $links
*/
private function normalizePieceLinks(array $links): array
{
return array_map(function (MachinePieceLink $link): array {
$piece = $link->getPiece();
$parentLink = $link->getParentLink();
return [
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'pieceId' => $piece->getId(),
'piece' => $this->normalizePiece($piece),
'parentLinkId' => $parentLink?->getId(),
'parentComponentLinkId' => $parentLink?->getId(),
'parentComponentId' => $parentLink?->getComposant()->getId(),
'overrides' => $this->normalizeOverrides($link),
'quantity' => $this->resolvePieceQuantity($link),
];
}, $links);
}
private function resolvePieceQuantity(MachinePieceLink $link): int
{
$parentLink = $link->getParentLink();
if (!$parentLink) {
return $link->getQuantity();
}
$composant = $parentLink->getComposant();
$piece = $link->getPiece();
foreach ($composant->getPieceSlots() as $slot) {
if ($slot->getSelectedPiece()?->getId() === $piece->getId()) {
return $slot->getQuantity();
}
}
return $link->getQuantity();
}
/**
* @param MachineProductLink[] $links
*/
private function normalizeProductLinks(array $links): array
{
return array_map(function (MachineProductLink $link): array {
$product = $link->getProduct();
return [
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'productId' => $product->getId(),
'product' => $this->normalizeProduct($product),
'parentLinkId' => $link->getParentLink()?->getId(),
'parentComponentLinkId' => $link->getParentComponentLink()?->getId(),
'parentPieceLinkId' => $link->getParentPieceLink()?->getId(),
];
}, $links);
}
private function normalizeComposant(Composant $composant): array
{
$type = $composant->getTypeComposant();
return [
'id' => $composant->getId(),
'name' => $composant->getName(),
'reference' => $composant->getReference(),
'prix' => $composant->getPrix(),
'typeComposantId' => $type?->getId(),
'typeComposant' => $this->normalizeModelType($type),
'productId' => $composant->getProduct()?->getId(),
'product' => $composant->getProduct() ? $this->normalizeProduct($composant->getProduct()) : null,
'structure' => $this->buildStructureFromSlots($composant),
'constructeurs' => $this->normalizeConstructeurs($composant->getConstructeurs()),
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getComponentCustomFields()) : [],
'customFieldValues' => $this->normalizeCustomFieldValues($composant->getCustomFieldValues()),
];
}
private function buildStructureFromSlots(Composant $composant): array
{
$pieces = [];
foreach ($composant->getPieceSlots() as $slot) {
$pieceData = [
'slotId' => $slot->getId(),
'typePieceId' => $slot->getTypePiece()?->getId(),
'quantity' => $slot->getQuantity(),
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
];
if ($slot->getSelectedPiece()) {
$pieceData['resolvedPiece'] = $this->normalizePiece($slot->getSelectedPiece());
}
$pieces[] = $pieceData;
}
$subcomponents = [];
foreach ($composant->getSubcomponentSlots() as $slot) {
$subcomponents[] = [
'alias' => $slot->getAlias(),
'familyCode' => $slot->getFamilyCode(),
'typeComposantId' => $slot->getTypeComposant()?->getId(),
'selectedComponentId' => $slot->getSelectedComposant()?->getId(),
];
}
$products = [];
foreach ($composant->getProductSlots() as $slot) {
$products[] = [
'typeProductId' => $slot->getTypeProduct()?->getId(),
'familyCode' => $slot->getFamilyCode(),
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
];
}
return [
'pieces' => $pieces,
'subcomponents' => $subcomponents,
'products' => $products,
];
}
private function normalizePiece(Piece $piece): array
{
$type = $piece->getTypePiece();
return [
'id' => $piece->getId(),
'name' => $piece->getName(),
'reference' => $piece->getReference(),
'prix' => $piece->getPrix(),
'typePieceId' => $type?->getId(),
'typePiece' => $this->normalizeModelType($type),
'productId' => $piece->getProduct()?->getId(),
'product' => $piece->getProduct() ? $this->normalizeProduct($piece->getProduct()) : null,
'constructeurs' => $this->normalizeConstructeurs($piece->getConstructeurs()),
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getPieceCustomFields()) : [],
'customFieldValues' => $this->normalizeCustomFieldValues($piece->getCustomFieldValues()),
];
}
private function normalizeProduct(Product $product): array
{
$type = $product->getTypeProduct();
return [
'id' => $product->getId(),
'name' => $product->getName(),
'reference' => $product->getReference(),
'supplierPrice' => $product->getSupplierPrice(),
'typeProductId' => $type?->getId(),
'typeProduct' => $this->normalizeModelType($type),
'constructeurs' => $this->normalizeConstructeurs($product->getConstructeurs()),
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getProductCustomFields()) : [],
'customFieldValues' => $this->normalizeCustomFieldValues($product->getCustomFieldValues()),
];
}
private function normalizeModelType(?ModelType $type): ?array
{
if (!$type instanceof ModelType) {
return null;
}
return [
'id' => $type->getId(),
'name' => $type->getName(),
'code' => $type->getCode(),
'category' => $type->getCategory()->value,
'structure' => $type->getStructure(),
];
}
private function normalizeConstructeurs(Collection $constructeurs): array
{
$items = [];
foreach ($constructeurs as $constructeur) {
$items[] = [
'id' => $constructeur->getId(),
'name' => $constructeur->getName(),
'email' => $constructeur->getEmail(),
'phone' => $constructeur->getPhone(),
];
}
return $items;
}
private function normalizeCustomFields(Collection $customFields): array
{
$items = [];
foreach ($customFields as $customField) {
if (!$customField instanceof CustomField) {
continue;
}
$items[] = [
'id' => $customField->getId(),
'name' => $customField->getName(),
'type' => $customField->getType(),
'required' => $customField->isRequired(),
'options' => $customField->getOptions(),
'defaultValue' => $customField->getDefaultValue(),
'orderIndex' => $customField->getOrderIndex(),
];
}
return $items;
}
private function normalizeCustomFieldDefinitions(Collection $customFields): array
{
$items = [];
foreach ($customFields as $cf) {
if (!$cf instanceof CustomField) {
continue;
}
$items[] = [
'id' => $cf->getId(),
'name' => $cf->getName(),
'type' => $cf->getType(),
'required' => $cf->isRequired(),
'options' => $cf->getOptions(),
'defaultValue' => $cf->getDefaultValue(),
'orderIndex' => $cf->getOrderIndex(),
];
}
usort($items, static fn (array $a, array $b) => $a['orderIndex'] <=> $b['orderIndex']);
return $items;
}
private function normalizeCustomFieldValues(Collection $customFieldValues): array
{
$items = [];
foreach ($customFieldValues as $cfv) {
if (!$cfv instanceof CustomFieldValue) {
continue;
}
$cf = $cfv->getCustomField();
$items[] = [
'id' => $cfv->getId(),
'value' => $cfv->getValue(),
'customField' => [
'id' => $cf->getId(),
'name' => $cf->getName(),
'type' => $cf->getType(),
'required' => $cf->isRequired(),
'options' => $cf->getOptions(),
'defaultValue' => $cf->getDefaultValue(),
'orderIndex' => $cf->getOrderIndex(),
],
];
}
return $items;
}
private function normalizeOverrides(object $link): ?array
{
$name = method_exists($link, 'getNameOverride') ? $link->getNameOverride() : null;
$reference = method_exists($link, 'getReferenceOverride') ? $link->getReferenceOverride() : null;
$prix = method_exists($link, 'getPrixOverride') ? $link->getPrixOverride() : null;
if (null === $name && null === $reference && null === $prix) {
return null;
}
return [
'name' => $name,
'reference' => $reference,
'prix' => $prix,
];
}
private function indexById(array $links): array
{
$indexed = [];
foreach ($links as $link) {
if (is_array($link) && isset($link['id'])) {
$indexed[$link['id']] = $link;
}
}
return $indexed;
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Machine;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\MachineComponentLinkRepository;
use App\Repository\MachinePieceLinkRepository;
use App\Repository\MachineProductLinkRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'remove_machine_link',
description: 'Remove a machine link by id and type (composant, piece, or product). Requires ROLE_GESTIONNAIRE.',
)]
class RemoveMachineLinkTool
{
use McpToolHelper;
public function __construct(
private readonly EntityManagerInterface $em,
private readonly Security $security,
private readonly MachineComponentLinkRepository $componentLinks,
private readonly MachinePieceLinkRepository $pieceLinks,
private readonly MachineProductLinkRepository $productLinks,
) {}
public function __invoke(string $linkId, string $linkType): array
{
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$link = match ($linkType) {
'composant' => $this->componentLinks->find($linkId),
'piece' => $this->pieceLinks->find($linkId),
'product' => $this->productLinks->find($linkId),
default => $this->mcpError('Validation', "Unknown link type '{$linkType}'. Expected composant, piece, or product."),
};
if (null === $link) {
$this->mcpError('NotFound', "Link {$linkId} of type {$linkType} not found.");
}
$this->em->remove($link);
$this->em->flush();
return $this->jsonResponse(['deleted' => true, 'id' => $linkId, 'type' => $linkType]);
}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Machine;
use App\Entity\MachineComponentLink;
use App\Entity\MachinePieceLink;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\MachineComponentLinkRepository;
use App\Repository\MachinePieceLinkRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'update_machine_link',
description: 'Update overrides (nameOverride, referenceOverride, prixOverride) or quantity on an existing machine link. Requires ROLE_GESTIONNAIRE.',
)]
class UpdateMachineLinkTool
{
use McpToolHelper;
public function __construct(
private readonly EntityManagerInterface $em,
private readonly Security $security,
private readonly MachineComponentLinkRepository $componentLinks,
private readonly MachinePieceLinkRepository $pieceLinks,
) {}
public function __invoke(
string $linkId,
string $linkType,
?string $nameOverride = null,
?string $referenceOverride = null,
?string $prixOverride = null,
?int $quantity = null,
): array {
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
switch ($linkType) {
case 'composant':
$link = $this->componentLinks->find($linkId);
if (null === $link) {
$this->mcpError('NotFound', "MachineComponentLink {$linkId} not found.");
}
$this->applyOverrides($link, $nameOverride, $referenceOverride, $prixOverride);
$this->em->flush();
return $this->jsonResponse([
'id' => $link->getId(),
'type' => 'composant',
'nameOverride' => $link->getNameOverride(),
'referenceOverride' => $link->getReferenceOverride(),
'prixOverride' => $link->getPrixOverride(),
]);
case 'piece':
$link = $this->pieceLinks->find($linkId);
if (null === $link) {
$this->mcpError('NotFound', "MachinePieceLink {$linkId} not found.");
}
$this->applyOverrides($link, $nameOverride, $referenceOverride, $prixOverride);
if (null !== $quantity) {
$link->setQuantity($quantity);
}
$this->em->flush();
return $this->jsonResponse([
'id' => $link->getId(),
'type' => 'piece',
'nameOverride' => $link->getNameOverride(),
'referenceOverride' => $link->getReferenceOverride(),
'prixOverride' => $link->getPrixOverride(),
'quantity' => $link->getQuantity(),
]);
case 'product':
$this->mcpError('Validation', 'Product links do not have updatable overrides.');
// no break
default:
$this->mcpError('Validation', "Unknown link type '{$linkType}'. Expected composant, piece, or product.");
}
}
private function applyOverrides(MachineComponentLink|MachinePieceLink $link, ?string $nameOverride, ?string $referenceOverride, ?string $prixOverride): void
{
if (null !== $nameOverride) {
$link->setNameOverride($nameOverride);
}
if (null !== $referenceOverride) {
$link->setReferenceOverride($referenceOverride);
}
if (null !== $prixOverride) {
$link->setPrixOverride($prixOverride);
}
}
}

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Slot;
use App\Entity\ComposantPieceSlot;
use App\Entity\ComposantProductSlot;
use App\Entity\ComposantSubcomponentSlot;
use App\Entity\PieceProductSlot;
use App\Mcp\Tool\McpToolHelper;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(
name: 'list_slots',
description: 'List all slots for a composant or piece. Composants have piece/product/subcomponent slots. Pieces have product slots only.',
)]
class ListSlotsTool
{
use McpToolHelper;
public function __construct(
private readonly EntityManagerInterface $em,
) {}
public function __invoke(string $entityType, string $entityId): array
{
if ('composant' === $entityType) {
return $this->listComposantSlots($entityId);
}
if ('piece' === $entityType) {
return $this->listPieceSlots($entityId);
}
$this->mcpError('validation', "entityType must be 'composant' or 'piece', got '{$entityType}'.");
}
private function listComposantSlots(string $composantId): array
{
$pieceSlots = $this->em->createQueryBuilder()
->select(
'ps.id',
"'piece' AS slotType",
'ps.position',
'ps.quantity',
'tp.name AS typeName',
'sp.id AS selectedEntityId',
'sp.name AS selectedEntityName',
)
->from(ComposantPieceSlot::class, 'ps')
->leftJoin('ps.typePiece', 'tp')
->leftJoin('ps.selectedPiece', 'sp')
->where('IDENTITY(ps.composant) = :cid')
->setParameter('cid', $composantId)
->orderBy('ps.position', 'ASC')
->getQuery()
->getArrayResult()
;
$productSlots = $this->em->createQueryBuilder()
->select(
'prs.id',
"'product' AS slotType",
'prs.position',
'tp.name AS typeName',
'sp.id AS selectedEntityId',
'sp.name AS selectedEntityName',
)
->from(ComposantProductSlot::class, 'prs')
->leftJoin('prs.typeProduct', 'tp')
->leftJoin('prs.selectedProduct', 'sp')
->where('IDENTITY(prs.composant) = :cid')
->setParameter('cid', $composantId)
->orderBy('prs.position', 'ASC')
->getQuery()
->getArrayResult()
;
$subSlots = $this->em->createQueryBuilder()
->select(
'ss.id',
"'subcomponent' AS slotType",
'ss.position',
'ss.alias',
'tc.name AS typeName',
'sc.id AS selectedEntityId',
'sc.name AS selectedEntityName',
)
->from(ComposantSubcomponentSlot::class, 'ss')
->leftJoin('ss.typeComposant', 'tc')
->leftJoin('ss.selectedComposant', 'sc')
->where('IDENTITY(ss.composant) = :cid')
->setParameter('cid', $composantId)
->orderBy('ss.position', 'ASC')
->getQuery()
->getArrayResult()
;
$slots = array_merge($pieceSlots, $productSlots, $subSlots);
return $this->jsonResponse([
'entityType' => 'composant',
'entityId' => $composantId,
'slots' => $slots,
'total' => count($slots),
]);
}
private function listPieceSlots(string $pieceId): array
{
$slots = $this->em->createQueryBuilder()
->select(
'pps.id',
"'product' AS slotType",
'pps.position',
'tp.name AS typeName',
'sp.id AS selectedEntityId',
'sp.name AS selectedEntityName',
)
->from(PieceProductSlot::class, 'pps')
->leftJoin('pps.typeProduct', 'tp')
->leftJoin('pps.selectedProduct', 'sp')
->where('IDENTITY(pps.piece) = :pid')
->setParameter('pid', $pieceId)
->orderBy('pps.position', 'ASC')
->getQuery()
->getArrayResult()
;
return $this->jsonResponse([
'entityType' => 'piece',
'entityId' => $pieceId,
'slots' => $slots,
'total' => count($slots),
]);
}
}

View File

@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Slot;
use App\Entity\Composant;
use App\Entity\ComposantPieceSlot;
use App\Entity\ComposantProductSlot;
use App\Entity\ComposantSubcomponentSlot;
use App\Entity\Piece;
use App\Entity\PieceProductSlot;
use App\Entity\Product;
use App\Mcp\Tool\McpToolHelper;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'update_slots',
description: 'Update selected entities on one or more slots. Each slot entry needs slotId, slotType, and the selected entity ID (or null to clear). Requires ROLE_GESTIONNAIRE.',
)]
class UpdateSlotsTool
{
use McpToolHelper;
public function __construct(
private readonly EntityManagerInterface $em,
private readonly Security $security,
) {}
public function __invoke(array $slots): array
{
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
$results = [];
foreach ($slots as $entry) {
$slotId = $entry['slotId'] ?? null;
$slotType = $entry['slotType'] ?? null;
if (null === $slotId || null === $slotType) {
$this->mcpError('validation', 'Each slot entry must have slotId and slotType.');
}
$results[] = match ($slotType) {
'composant_piece' => $this->updateComposantPieceSlot($slotId, $entry),
'composant_product' => $this->updateComposantProductSlot($slotId, $entry),
'composant_subcomponent' => $this->updateComposantSubcomponentSlot($slotId, $entry),
'piece_product' => $this->updatePieceProductSlot($slotId, $entry),
default => $this->mcpError('validation', "Unknown slotType '{$slotType}'."),
};
}
$this->em->flush();
return $this->jsonResponse([
'updated' => $results,
'total' => count($results),
]);
}
private function updateComposantPieceSlot(string $slotId, array $entry): array
{
$slot = $this->em->getRepository(ComposantPieceSlot::class)->find($slotId);
if (null === $slot) {
$this->mcpError('not_found', "ComposantPieceSlot '{$slotId}' not found.");
}
$selectedPieceId = $entry['selectedPieceId'] ?? null;
$selectedPiece = null;
if (null !== $selectedPieceId) {
$selectedPiece = $this->em->getRepository(Piece::class)->find($selectedPieceId);
if (null === $selectedPiece) {
$this->mcpError('not_found', "Piece '{$selectedPieceId}' not found.");
}
}
$slot->setSelectedPiece($selectedPiece);
return [
'slotId' => $slot->getId(),
'slotType' => 'composant_piece',
'selectedEntityId' => $selectedPiece?->getId(),
'selectedEntityName' => $selectedPiece?->getName(),
];
}
private function updateComposantProductSlot(string $slotId, array $entry): array
{
$slot = $this->em->getRepository(ComposantProductSlot::class)->find($slotId);
if (null === $slot) {
$this->mcpError('not_found', "ComposantProductSlot '{$slotId}' not found.");
}
$selectedProductId = $entry['selectedProductId'] ?? null;
$selectedProduct = null;
if (null !== $selectedProductId) {
$selectedProduct = $this->em->getRepository(Product::class)->find($selectedProductId);
if (null === $selectedProduct) {
$this->mcpError('not_found', "Product '{$selectedProductId}' not found.");
}
}
$slot->setSelectedProduct($selectedProduct);
return [
'slotId' => $slot->getId(),
'slotType' => 'composant_product',
'selectedEntityId' => $selectedProduct?->getId(),
'selectedEntityName' => $selectedProduct?->getName(),
];
}
private function updateComposantSubcomponentSlot(string $slotId, array $entry): array
{
$slot = $this->em->getRepository(ComposantSubcomponentSlot::class)->find($slotId);
if (null === $slot) {
$this->mcpError('not_found', "ComposantSubcomponentSlot '{$slotId}' not found.");
}
$selectedComposantId = $entry['selectedComposantId'] ?? null;
$selectedComposant = null;
if (null !== $selectedComposantId) {
$selectedComposant = $this->em->getRepository(Composant::class)->find($selectedComposantId);
if (null === $selectedComposant) {
$this->mcpError('not_found', "Composant '{$selectedComposantId}' not found.");
}
}
$slot->setSelectedComposant($selectedComposant);
return [
'slotId' => $slot->getId(),
'slotType' => 'composant_subcomponent',
'selectedEntityId' => $selectedComposant?->getId(),
'selectedEntityName' => $selectedComposant?->getName(),
];
}
private function updatePieceProductSlot(string $slotId, array $entry): array
{
$slot = $this->em->getRepository(PieceProductSlot::class)->find($slotId);
if (null === $slot) {
$this->mcpError('not_found', "PieceProductSlot '{$slotId}' not found.");
}
$selectedProductId = $entry['selectedProductId'] ?? null;
$selectedProduct = null;
if (null !== $selectedProductId) {
$selectedProduct = $this->em->getRepository(Product::class)->find($selectedProductId);
if (null === $selectedProduct) {
$this->mcpError('not_found', "Product '{$selectedProductId}' not found.");
}
}
$slot->setSelectedProduct($selectedProduct);
return [
'slotId' => $slot->getId(),
'slotType' => 'piece_product',
'selectedEntityId' => $selectedProduct?->getId(),
'selectedEntityName' => $selectedProduct?->getName(),
];
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Tests\Mcp\Tool\Machine;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class MachineLinksToolTest extends AbstractApiTestCase
{
public function testListMachineLinks(): void
{
$machine = $this->createMachine(name: 'Machine Links Test');
$composant = $this->createComposant(name: 'Comp A');
$piece = $this->createPiece(name: 'Piece A');
$compLink = $this->createMachineComponentLink($machine, $composant);
$pieceLink = $this->createMachinePieceLink($machine, $piece, parentLink: $compLink, quantity: 3);
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'list_machine_links', ['machineId' => $machine->getId()]);
$this->assertArrayHasKey('_parsed', $data);
$parsed = $data['_parsed'];
$this->assertSame($machine->getId(), $parsed['machineId']);
$this->assertCount(1, $parsed['componentLinks']);
$this->assertCount(1, $parsed['pieceLinks']);
$this->assertSame($compLink->getId(), $parsed['componentLinks'][0]['id']);
$this->assertSame($pieceLink->getId(), $parsed['pieceLinks'][0]['id']);
$this->assertSame(3, $parsed['pieceLinks'][0]['quantity']);
}
public function testAddMachineComponentLink(): void
{
$machine = $this->createMachine(name: 'Machine Add Test');
$composant = $this->createComposant(name: 'Comp B');
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
$data = $this->callMcpTool($session, 'add_machine_links', [
'machineId' => $machine->getId(),
'links' => [
[
'type' => 'composant',
'entityId' => $composant->getId(),
'nameOverride' => 'Custom Name',
],
],
]);
$this->assertArrayHasKey('_parsed', $data);
$created = $data['_parsed']['created'];
$this->assertCount(1, $created);
$this->assertSame('composant', $created[0]['type']);
$this->assertSame($composant->getId(), $created[0]['entityId']);
$this->assertNotEmpty($created[0]['id']);
}
public function testRemoveMachineLink(): void
{
$machine = $this->createMachine(name: 'Machine Remove Test');
$composant = $this->createComposant(name: 'Comp C');
$link = $this->createMachineComponentLink($machine, $composant);
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
$data = $this->callMcpTool($session, 'remove_machine_link', [
'linkId' => $link->getId(),
'linkType' => 'composant',
]);
$this->assertArrayHasKey('_parsed', $data);
$this->assertTrue($data['_parsed']['deleted']);
// Verify the link is gone by listing
$listData = $this->callMcpTool($session, 'list_machine_links', ['machineId' => $machine->getId()]);
$this->assertCount(0, $listData['_parsed']['componentLinks']);
}
}

View File

@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace App\Tests\Mcp\Tool\Machine;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class MachineStructureToolTest extends AbstractApiTestCase
{
public function testGetMachineStructure(): void
{
$site = $this->createSite(name: 'Site Structure');
$machine = $this->createMachine(name: 'Machine Structure', site: $site, reference: 'REF-STRUCT');
$composant = $this->createComposant(name: 'Composant Alpha');
$piece = $this->createPiece(name: 'Piece Alpha', reference: 'REF-P1');
$product = $this->createProduct(name: 'Product Alpha', reference: 'REF-PR1');
$componentLink = $this->createMachineComponentLink($machine, $composant);
$this->createMachinePieceLink($machine, $piece, $componentLink, quantity: 3);
$this->createMachineProductLink($machine, $product);
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'get_machine_structure', [
'machineId' => $machine->getId(),
]);
$this->assertArrayHasKey('_parsed', $data);
$parsed = $data['_parsed'];
// Machine info
$this->assertSame('Machine Structure', $parsed['machine']['name']);
$this->assertSame('REF-STRUCT', $parsed['machine']['reference']);
$this->assertSame($site->getId(), $parsed['machine']['siteId']);
// Component links
$this->assertCount(1, $parsed['componentLinks']);
$this->assertSame($composant->getId(), $parsed['componentLinks'][0]['composantId']);
$this->assertSame('Composant Alpha', $parsed['componentLinks'][0]['composant']['name']);
// Piece links
$this->assertCount(1, $parsed['pieceLinks']);
$this->assertSame($piece->getId(), $parsed['pieceLinks'][0]['pieceId']);
$this->assertSame('Piece Alpha', $parsed['pieceLinks'][0]['piece']['name']);
$this->assertSame(3, $parsed['pieceLinks'][0]['quantity']);
// Product links
$this->assertCount(1, $parsed['productLinks']);
$this->assertSame($product->getId(), $parsed['productLinks'][0]['productId']);
$this->assertSame('Product Alpha', $parsed['productLinks'][0]['product']['name']);
}
public function testGetMachineStructureNotFound(): void
{
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'get_machine_structure', [
'machineId' => 'nonexistent-id',
]);
$this->assertArrayHasKey('error', $data);
}
public function testGetMachineStructureWithOverrides(): void
{
$site = $this->createSite(name: 'Site Overrides');
$machine = $this->createMachine(name: 'Machine Overrides', site: $site);
$composant = $this->createComposant(name: 'Composant Override');
$componentLink = $this->createMachineComponentLink($machine, $composant);
$componentLink->setNameOverride('Custom Name');
$componentLink->setReferenceOverride('CUSTOM-REF');
$this->getEntityManager()->flush();
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'get_machine_structure', [
'machineId' => $machine->getId(),
]);
$this->assertArrayHasKey('_parsed', $data);
$overrides = $data['_parsed']['componentLinks'][0]['overrides'];
$this->assertSame('Custom Name', $overrides['name']);
$this->assertSame('CUSTOM-REF', $overrides['reference']);
}
public function testCloneMachine(): void
{
$site = $this->createSite(name: 'Site Source');
$targetSite = $this->createSite(name: 'Site Target');
$machine = $this->createMachine(name: 'Machine Source', site: $site, reference: 'REF-SRC');
$composant = $this->createComposant(name: 'Composant Clone');
$piece = $this->createPiece(name: 'Piece Clone');
$product = $this->createProduct(name: 'Product Clone');
$componentLink = $this->createMachineComponentLink($machine, $composant);
$this->createMachinePieceLink($machine, $piece, $componentLink, quantity: 2);
$this->createMachineProductLink($machine, $product);
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
$data = $this->callMcpTool($session, 'clone_machine', [
'machineId' => $machine->getId(),
'name' => 'Machine Cloned',
'siteId' => $targetSite->getId(),
'reference' => 'REF-CLN',
]);
$this->assertArrayHasKey('_parsed', $data);
$parsed = $data['_parsed'];
$this->assertSame('Machine Cloned', $parsed['name']);
$this->assertSame('REF-CLN', $parsed['reference']);
$this->assertSame($targetSite->getId(), $parsed['siteId']);
$this->assertSame($machine->getId(), $parsed['clonedFrom']);
$this->assertNotSame($machine->getId(), $parsed['id']);
// Verify the cloned machine has links by fetching its structure
$structureData = $this->callMcpTool($session, 'get_machine_structure', [
'machineId' => $parsed['id'],
]);
$this->assertArrayHasKey('_parsed', $structureData);
$structure = $structureData['_parsed'];
$this->assertCount(1, $structure['componentLinks']);
$this->assertSame($composant->getId(), $structure['componentLinks'][0]['composantId']);
$this->assertCount(1, $structure['pieceLinks']);
$this->assertCount(1, $structure['productLinks']);
}
public function testCloneMachineRequiresGestionnaire(): void
{
$site = $this->createSite(name: 'Site Forbidden');
$machine = $this->createMachine(name: 'Machine Forbidden', site: $site);
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'clone_machine', [
'machineId' => $machine->getId(),
'name' => 'Should Fail',
'siteId' => $site->getId(),
]);
$this->assertArrayHasKey('error', $data, 'Should fail with VIEWER role');
}
public function testCloneMachineNotFound(): void
{
$site = $this->createSite(name: 'Site Clone NF');
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
$data = $this->callMcpTool($session, 'clone_machine', [
'machineId' => 'nonexistent-id',
'name' => 'Should Fail',
'siteId' => $site->getId(),
]);
$this->assertArrayHasKey('error', $data);
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Tests\Mcp\Tool\Slot;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class SlotsToolTest extends AbstractApiTestCase
{
public function testListSlotsForComposant(): void
{
$composant = $this->createComposant(name: 'Comp Slots');
$this->createComposantPieceSlot(composant: $composant, position: 0);
$this->createComposantProductSlot(composant: $composant, position: 1);
$this->createComposantSubcomponentSlot(composant: $composant, alias: 'Sub A', position: 2);
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'list_slots', [
'entityType' => 'composant',
'entityId' => $composant->getId(),
]);
$this->assertArrayHasKey('_parsed', $data);
$parsed = $data['_parsed'];
$this->assertSame('composant', $parsed['entityType']);
$this->assertSame(3, $parsed['total']);
$slotTypes = array_column($parsed['slots'], 'slotType');
$this->assertContains('piece', $slotTypes);
$this->assertContains('product', $slotTypes);
$this->assertContains('subcomponent', $slotTypes);
}
public function testListSlotsForPiece(): void
{
$piece = $this->createPiece(name: 'Piece Slots');
$this->createPieceProductSlot(piece: $piece, position: 0);
$this->createPieceProductSlot(piece: $piece, position: 1);
$session = $this->createMcpClient('ROLE_VIEWER');
$data = $this->callMcpTool($session, 'list_slots', [
'entityType' => 'piece',
'entityId' => $piece->getId(),
]);
$this->assertArrayHasKey('_parsed', $data);
$parsed = $data['_parsed'];
$this->assertSame('piece', $parsed['entityType']);
$this->assertSame(2, $parsed['total']);
foreach ($parsed['slots'] as $slot) {
$this->assertSame('product', $slot['slotType']);
}
}
public function testUpdateSlotSelectsPiece(): void
{
$composant = $this->createComposant(name: 'Comp Update');
$piece = $this->createPiece(name: 'Selected Piece');
$slot = $this->createComposantPieceSlot(composant: $composant, position: 0);
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
$data = $this->callMcpTool($session, 'update_slots', [
'slots' => [
[
'slotId' => $slot->getId(),
'slotType' => 'composant_piece',
'selectedPieceId' => $piece->getId(),
],
],
]);
$this->assertArrayHasKey('_parsed', $data);
$parsed = $data['_parsed'];
$this->assertSame(1, $parsed['total']);
$this->assertSame($piece->getId(), $parsed['updated'][0]['selectedEntityId']);
$this->assertSame('Selected Piece', $parsed['updated'][0]['selectedEntityName']);
}
}