diff --git a/src/Mcp/Tool/Machine/AddMachineLinksTool.php b/src/Mcp/Tool/Machine/AddMachineLinksTool.php new file mode 100644 index 0000000..f0b9f6b --- /dev/null +++ b/src/Mcp/Tool/Machine/AddMachineLinksTool.php @@ -0,0 +1,162 @@ +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]); + } +} diff --git a/src/Mcp/Tool/Machine/CloneMachineTool.php b/src/Mcp/Tool/Machine/CloneMachineTool.php new file mode 100644 index 0000000..fb09283 --- /dev/null +++ b/src/Mcp/Tool/Machine/CloneMachineTool.php @@ -0,0 +1,223 @@ +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 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 $componentLinkMap + * + * @return array 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 $componentLinkMap + * @param array $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()]); + } + } + } +} diff --git a/src/Mcp/Tool/Machine/ListMachineLinksTool.php b/src/Mcp/Tool/Machine/ListMachineLinksTool.php new file mode 100644 index 0000000..907fb5c --- /dev/null +++ b/src/Mcp/Tool/Machine/ListMachineLinksTool.php @@ -0,0 +1,70 @@ +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, + ]); + } +} diff --git a/src/Mcp/Tool/Machine/MachineStructureTool.php b/src/Mcp/Tool/Machine/MachineStructureTool.php new file mode 100644 index 0000000..ed44bd5 --- /dev/null +++ b/src/Mcp/Tool/Machine/MachineStructureTool.php @@ -0,0 +1,469 @@ +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; + } +} diff --git a/src/Mcp/Tool/Machine/RemoveMachineLinkTool.php b/src/Mcp/Tool/Machine/RemoveMachineLinkTool.php new file mode 100644 index 0000000..c28c215 --- /dev/null +++ b/src/Mcp/Tool/Machine/RemoveMachineLinkTool.php @@ -0,0 +1,51 @@ +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]); + } +} diff --git a/src/Mcp/Tool/Machine/UpdateMachineLinkTool.php b/src/Mcp/Tool/Machine/UpdateMachineLinkTool.php new file mode 100644 index 0000000..e447d37 --- /dev/null +++ b/src/Mcp/Tool/Machine/UpdateMachineLinkTool.php @@ -0,0 +1,101 @@ +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); + } + } +} diff --git a/src/Mcp/Tool/Slot/ListSlotsTool.php b/src/Mcp/Tool/Slot/ListSlotsTool.php new file mode 100644 index 0000000..3fa8078 --- /dev/null +++ b/src/Mcp/Tool/Slot/ListSlotsTool.php @@ -0,0 +1,139 @@ +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), + ]); + } +} diff --git a/src/Mcp/Tool/Slot/UpdateSlotsTool.php b/src/Mcp/Tool/Slot/UpdateSlotsTool.php new file mode 100644 index 0000000..5912662 --- /dev/null +++ b/src/Mcp/Tool/Slot/UpdateSlotsTool.php @@ -0,0 +1,178 @@ +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(), + ]; + } +} diff --git a/tests/Mcp/Tool/Machine/MachineLinksToolTest.php b/tests/Mcp/Tool/Machine/MachineLinksToolTest.php new file mode 100644 index 0000000..9d301ec --- /dev/null +++ b/tests/Mcp/Tool/Machine/MachineLinksToolTest.php @@ -0,0 +1,81 @@ +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']); + } +} diff --git a/tests/Mcp/Tool/Machine/MachineStructureToolTest.php b/tests/Mcp/Tool/Machine/MachineStructureToolTest.php new file mode 100644 index 0000000..9363421 --- /dev/null +++ b/tests/Mcp/Tool/Machine/MachineStructureToolTest.php @@ -0,0 +1,164 @@ +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); + } +} diff --git a/tests/Mcp/Tool/Slot/SlotsToolTest.php b/tests/Mcp/Tool/Slot/SlotsToolTest.php new file mode 100644 index 0000000..e563ec7 --- /dev/null +++ b/tests/Mcp/Tool/Slot/SlotsToolTest.php @@ -0,0 +1,86 @@ +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']); + } +}