From b7546c8e8ac2ec658b36ef6b8a993fee00230827 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 15 Jun 2026 11:51:03 +0200 Subject: [PATCH] refactor(machines) : extrait MachineStructureController en services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Découpe le God controller (1158 LOC) en trois services dédiés sous src/Service/MachineStructure/ : - MachineStructureNormalizer : lecture/sérialisation de la structure, partagé par les routes GET, PATCH et clone (forme JSON unique) - MachineStructureUpdater : application du payload PATCH (links + hiérarchie) - MachineCloner : clonage full/structure Le controller tombe à 91 LOC et ne dépend plus que de MachineRepository et des trois services. Les erreurs de validation passent par une MachineStructureException remappée à l'identique (mêmes messages/codes). Comportement inchangé : 76 tests verts. --- src/Controller/MachineStructureController.php | 1109 +---------------- .../MachineStructure/MachineCloner.php | 279 +++++ .../MachineStructureException.php | 27 + .../MachineStructureNormalizer.php | 559 +++++++++ .../MachineStructureUpdater.php | 337 +++++ 5 files changed, 1223 insertions(+), 1088 deletions(-) create mode 100644 src/Service/MachineStructure/MachineCloner.php create mode 100644 src/Service/MachineStructure/MachineStructureException.php create mode 100644 src/Service/MachineStructure/MachineStructureNormalizer.php create mode 100644 src/Service/MachineStructure/MachineStructureUpdater.php diff --git a/src/Controller/MachineStructureController.php b/src/Controller/MachineStructureController.php index 3e81365..2d81046 100644 --- a/src/Controller/MachineStructureController.php +++ b/src/Controller/MachineStructureController.php @@ -4,29 +4,12 @@ declare(strict_types=1); namespace App\Controller; -use App\Entity\Composant; -use App\Entity\Constructeur; -use App\Entity\CustomField; -use App\Entity\CustomFieldValue; use App\Entity\Machine; -use App\Entity\MachineComponentLink; -use App\Entity\MachineConstructeurLink; -use App\Entity\MachinePieceLink; -use App\Entity\MachineProductLink; -use App\Entity\ModelType; -use App\Entity\Piece; -use App\Entity\Product; -use App\Entity\Site; -use App\Repository\ComposantRepository; -use App\Repository\MachineComponentLinkRepository; -use App\Repository\MachinePieceLinkRepository; -use App\Repository\MachineProductLinkRepository; use App\Repository\MachineRepository; -use App\Repository\PieceRepository; -use App\Repository\ProductRepository; -use Doctrine\Common\Collections\Collection; -use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\EntityNotFoundException; +use App\Service\MachineStructure\MachineCloner; +use App\Service\MachineStructure\MachineStructureException; +use App\Service\MachineStructure\MachineStructureNormalizer; +use App\Service\MachineStructure\MachineStructureUpdater; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -36,14 +19,10 @@ use Symfony\Component\Routing\Attribute\Route; class MachineStructureController extends AbstractController { 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 ComposantRepository $composantRepository, - private readonly PieceRepository $pieceRepository, - private readonly ProductRepository $productRepository, + private readonly MachineStructureNormalizer $normalizer, + private readonly MachineStructureUpdater $updater, + private readonly MachineCloner $cloner, ) {} #[Route('/{id}/structure', name: 'machine_structure_get', methods: ['GET'])] @@ -56,16 +35,7 @@ class MachineStructureController extends AbstractController return $this->json(['success' => false, 'error' => 'Machine not found.'], 404); } - $componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']); - $pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']); - $productLinks = $this->machineProductLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']); - - return $this->json($this->normalizeStructureResponse( - $machine, - $componentLinks, - $pieceLinks, - $productLinks - )); + return $this->json($this->normalizer->normalize($machine)); } #[Route('/{id}/structure', name: 'machine_structure_update', methods: ['PATCH'])] @@ -83,32 +53,17 @@ class MachineStructureController extends AbstractController return $this->json(['success' => false, 'error' => 'Invalid JSON payload.'], 400); } - $componentLinksPayload = $this->normalizePayloadList($payload['componentLinks'] ?? []); - $pieceLinksPayload = $this->normalizePayloadList($payload['pieceLinks'] ?? []); - $productLinksPayload = $this->normalizePayloadList($payload['productLinks'] ?? []); - - $componentLinks = $this->applyComponentLinks($machine, $componentLinksPayload); - if ($componentLinks instanceof JsonResponse) { - return $componentLinks; + try { + $links = $this->updater->apply($machine, $payload); + } catch (MachineStructureException $e) { + return $this->json(['success' => false, 'error' => $e->getMessage()], $e->getStatusCode()); } - $pieceLinks = $this->applyPieceLinks($machine, $pieceLinksPayload, $componentLinks); - if ($pieceLinks instanceof JsonResponse) { - return $pieceLinks; - } - - $productLinks = $this->applyProductLinks($machine, $productLinksPayload, $componentLinks, $pieceLinks); - if ($productLinks instanceof JsonResponse) { - return $productLinks; - } - - $this->entityManager->flush(); - - return $this->json($this->normalizeStructureResponse( + return $this->json($this->normalizer->normalizeStructureResponse( $machine, - $componentLinks, - $pieceLinks, - $productLinks + $links['componentLinks'], + $links['pieceLinks'], + $links['productLinks'], )); } @@ -123,1036 +78,14 @@ class MachineStructureController extends AbstractController } $payload = json_decode($request->getContent(), true); - if (!is_array($payload) || empty($payload['name']) || empty($payload['siteId'])) { - return $this->json(['success' => false, 'error' => 'name et siteId sont requis.'], 400); - } - - $site = $this->entityManager->getRepository(Site::class)->find($payload['siteId']); - if (!$site) { - return $this->json(['success' => false, 'error' => 'Site introuvable.'], 404); - } - - // Clone mode: 'full' copies concrete components/pieces/products; 'structure' - // only keeps the slots' categories (modelType) with empty concrete entities. - $mode = $payload['mode'] ?? 'full'; - if (!in_array($mode, ['full', 'structure'], true)) { - return $this->json(['success' => false, 'error' => 'mode invalide (valeurs autorisées : full, structure).'], 400); - } - $structureOnly = 'structure' === $mode; - - // Create new machine - $newMachine = new Machine(); - $newMachine->setName($payload['name']); - $newMachine->setSite($site); - if (!empty($payload['reference'])) { - $newMachine->setReference($payload['reference']); - } - $newMachine->setPrix($source->getPrix()); - - // Copy constructeur links - foreach ($source->getConstructeurLinks() as $link) { - $newLink = new MachineConstructeurLink(); - $newLink->setMachine($newMachine); - $newLink->setConstructeur($link->getConstructeur()); - $newLink->setSupplierReference($link->getSupplierReference()); - $this->entityManager->persist($newLink); - } - - $this->entityManager->persist($newMachine); - - // Copy custom fields and values - $this->cloneCustomFields($source, $newMachine); - - // Copy component links (preserving hierarchy) - $componentLinkMap = $this->cloneComponentLinks($source, $newMachine, $structureOnly); - - // Copy piece links - $pieceLinkMap = $this->clonePieceLinks($source, $newMachine, $componentLinkMap, $structureOnly); - - // Copy product links - $this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap, $structureOnly); - - $this->entityManager->flush(); - - $componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $newMachine], ['createdAt' => 'ASC']); - $pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $newMachine], ['createdAt' => 'ASC']); - $productLinks = $this->machineProductLinkRepository->findBy(['machine' => $newMachine], ['createdAt' => 'ASC']); - - return $this->json($this->normalizeStructureResponse( - $newMachine, - $componentLinks, - $pieceLinks, - $productLinks - ), 201); - } - - private function cloneCustomFields(Machine $source, Machine $target): void - { - $cfMap = []; - - 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->setMachineContextOnly($cf->isMachineContextOnly()); - $newCf->setMachine($target); - $this->entityManager->persist($newCf); - - $cfMap[$cf->getId()] = $newCf; - } - - foreach ($source->getCustomFieldValues() as $cfv) { - $originalCf = $cfv->getCustomField(); - $newCf = $cfMap[$originalCf->getId()] ?? null; - if (!$newCf) { - continue; - } - - $newValue = new CustomFieldValue(); - $newValue->setMachine($target); - $newValue->setCustomField($newCf); - $newValue->setValue($cfv->getValue()); - $this->entityManager->persist($newValue); - } - } - - /** - * @return array Map of old link ID → new link - */ - private function cloneComponentLinks(Machine $source, Machine $target, bool $structureOnly = false): array - { - $sourceLinks = $this->machineComponentLinkRepository->findBy(['machine' => $source], ['createdAt' => 'ASC']); - $linkMap = []; - - // First pass: create all links without parent relationships - foreach ($sourceLinks as $link) { - $newLink = new MachineComponentLink(); - $newLink->setMachine($target); - - if ($structureOnly) { - // Keep only the slot category; leave the concrete component empty. - $newLink->setModelType($link->getModelType() ?? $link->getComposant()?->getTypeComposant()); - $this->entityManager->persist($newLink); - $linkMap[$link->getId()] = $newLink; - - continue; - } - - $newLink->setComposant($link->getComposant()); - $newLink->setNameOverride($link->getNameOverride()); - $newLink->setReferenceOverride($link->getReferenceOverride()); - $newLink->setPrixOverride($link->getPrixOverride()); - $this->entityManager->persist($newLink); - - foreach ($link->getContextFieldValues() as $cfv) { - $newValue = new CustomFieldValue(); - $newValue->setCustomField($cfv->getCustomField()); - $newValue->setValue($cfv->getValue()); - $newValue->setMachineComponentLink($newLink); - $newValue->setComposant($newLink->getComposant()); - $this->entityManager->persist($newValue); - $newLink->getContextFieldValues()->add($newValue); - } - - $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 → new link - */ - private function clonePieceLinks(Machine $source, Machine $target, array $componentLinkMap, bool $structureOnly = false): array - { - $sourceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $source], ['createdAt' => 'ASC']); - $linkMap = []; - - foreach ($sourceLinks as $link) { - $newLink = new MachinePieceLink(); - $newLink->setMachine($target); - - $parent = $link->getParentLink(); - if ($parent && isset($componentLinkMap[$parent->getId()])) { - $newLink->setParentLink($componentLinkMap[$parent->getId()]); - } - - if ($structureOnly) { - // Keep only the slot category; leave the concrete piece empty. - $newLink->setModelType($link->getModelType() ?? $link->getPiece()?->getTypePiece()); - $this->entityManager->persist($newLink); - $linkMap[$link->getId()] = $newLink; - - continue; - } - - $newLink->setPiece($link->getPiece()); - $newLink->setNameOverride($link->getNameOverride()); - $newLink->setReferenceOverride($link->getReferenceOverride()); - $newLink->setPrixOverride($link->getPrixOverride()); - $newLink->setQuantity($link->getQuantity()); - - $this->entityManager->persist($newLink); - - foreach ($link->getContextFieldValues() as $cfv) { - $newValue = new CustomFieldValue(); - $newValue->setCustomField($cfv->getCustomField()); - $newValue->setValue($cfv->getValue()); - $newValue->setMachinePieceLink($newLink); - $newValue->setPiece($newLink->getPiece()); - $this->entityManager->persist($newValue); - $newLink->getContextFieldValues()->add($newValue); - } - - $linkMap[$link->getId()] = $newLink; - } - - return $linkMap; - } - - /** - * @param array $componentLinkMap - * @param array $pieceLinkMap - */ - private function cloneProductLinks( - Machine $source, - Machine $target, - array $componentLinkMap, - array $pieceLinkMap, - bool $structureOnly = false, - ): void { - $sourceLinks = $this->machineProductLinkRepository->findBy(['machine' => $source], ['createdAt' => 'ASC']); - $linkMap = []; - - // First pass: create all links - foreach ($sourceLinks as $link) { - $newLink = new MachineProductLink(); - $newLink->setMachine($target); - - if ($structureOnly) { - // Keep only the slot category; leave the concrete product empty. - $newLink->setModelType($link->getModelType() ?? $link->getProduct()?->getTypeProduct()); - } else { - $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()]); - } - } - } - - private function normalizePayloadList(mixed $value): array - { - if (!is_array($value)) { - return []; - } - - return array_values(array_filter($value, static fn ($item) => is_array($item))); - } - - private function applyComponentLinks(Machine $machine, array $payload): array|JsonResponse - { - $existing = $this->indexLinksById($this->machineComponentLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC'])); - $keepIds = []; - $pendingParents = []; - $links = []; - - foreach ($payload as $entry) { - $linkId = $this->resolveIdentifier($entry, ['id', 'linkId']); - $link = $linkId && isset($existing[$linkId]) ? $existing[$linkId] : new MachineComponentLink(); - if (!$linkId) { - $linkId = $this->generateCuid(); - } - if (!$link->getId()) { - $link->setId($linkId); - } - - $composantId = $this->resolveIdentifier($entry, ['composantId', 'componentId', 'idComposant']); - if (!$composantId) { - return $this->json(['success' => false, 'error' => 'Composant requis.'], 400); - } - $composant = $this->composantRepository->find($composantId); - if (!$composant instanceof Composant) { - return $this->json(['success' => false, 'error' => 'Composant introuvable.'], 404); - } - - $link->setMachine($machine); - $link->setComposant($composant); - - $this->applyOverrides($link, $entry['overrides'] ?? null); - - $pendingParents[$linkId] = $this->resolveIdentifier($entry, [ - 'parentComponentLinkId', - 'parentLinkId', - 'parentMachineComponentLinkId', - ]); - - $this->entityManager->persist($link); - $links[$linkId] = $link; - $keepIds[] = $linkId; - } - - foreach ($pendingParents as $linkId => $parentId) { - if (!$parentId || !isset($links[$linkId])) { - continue; - } - $parent = $links[$parentId] ?? $existing[$parentId] ?? null; - if ($parent instanceof MachineComponentLink) { - $links[$linkId]->setParentLink($parent); - } - } - - $this->removeMissingLinks($existing, $keepIds); - - return array_values($links); - } - - private function applyPieceLinks(Machine $machine, array $payload, array $componentLinks): array|JsonResponse - { - $existing = $this->indexLinksById($this->machinePieceLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC'])); - $componentIndex = $this->indexLinksById($componentLinks); - $keepIds = []; - $pendingParents = []; - $links = []; - - foreach ($payload as $entry) { - $linkId = $this->resolveIdentifier($entry, ['id', 'linkId']); - $link = $linkId && isset($existing[$linkId]) ? $existing[$linkId] : new MachinePieceLink(); - if (!$linkId) { - $linkId = $this->generateCuid(); - } - if (!$link->getId()) { - $link->setId($linkId); - } - - $pieceId = $this->resolveIdentifier($entry, ['pieceId']); - if (!$pieceId) { - return $this->json(['success' => false, 'error' => 'Pièce requise.'], 400); - } - $piece = $this->pieceRepository->find($pieceId); - if (!$piece instanceof Piece) { - return $this->json(['success' => false, 'error' => 'Pièce introuvable.'], 404); - } - - $link->setMachine($machine); - $link->setPiece($piece); - - $this->applyOverrides($link, $entry['overrides'] ?? null); - - if (!isset($entry['parentComponentLinkId']) && !isset($entry['parentLinkId'])) { - $quantity = isset($entry['quantity']) ? (int) $entry['quantity'] : $link->getQuantity(); - $link->setQuantity(max(1, $quantity)); - } - - $pendingParents[$linkId] = $this->resolveIdentifier($entry, [ - 'parentComponentLinkId', - 'parentLinkId', - 'parentMachineComponentLinkId', - ]); - - $this->entityManager->persist($link); - $links[$linkId] = $link; - $keepIds[] = $linkId; - } - - foreach ($pendingParents as $linkId => $parentId) { - if (!$parentId || !isset($links[$linkId])) { - continue; - } - $parent = $componentIndex[$parentId] ?? null; - if ($parent instanceof MachineComponentLink) { - $links[$linkId]->setParentLink($parent); - } - } - - $this->removeMissingLinks($existing, $keepIds); - - return array_values($links); - } - - private function applyProductLinks( - Machine $machine, - array $payload, - array $componentLinks, - array $pieceLinks, - ): array|JsonResponse { - $existing = $this->indexLinksById($this->machineProductLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC'])); - $componentIndex = $this->indexLinksById($componentLinks); - $pieceIndex = $this->indexLinksById($pieceLinks); - $keepIds = []; - $pendingParents = []; - $links = []; - - foreach ($payload as $entry) { - $linkId = $this->resolveIdentifier($entry, ['id', 'linkId']); - $link = $linkId && isset($existing[$linkId]) ? $existing[$linkId] : new MachineProductLink(); - if (!$linkId) { - $linkId = $this->generateCuid(); - } - if (!$link->getId()) { - $link->setId($linkId); - } - - $productId = $this->resolveIdentifier($entry, ['productId']); - if (!$productId) { - return $this->json(['success' => false, 'error' => 'Produit requis.'], 400); - } - $product = $this->productRepository->find($productId); - if (!$product instanceof Product) { - return $this->json(['success' => false, 'error' => 'Produit introuvable.'], 404); - } - - $link->setMachine($machine); - $link->setProduct($product); - - $pendingParents[$linkId] = [ - 'parentComponentLinkId' => $this->resolveIdentifier($entry, ['parentComponentLinkId']), - 'parentPieceLinkId' => $this->resolveIdentifier($entry, ['parentPieceLinkId']), - 'parentLinkId' => $this->resolveIdentifier($entry, ['parentLinkId']), - ]; - - $this->entityManager->persist($link); - $links[$linkId] = $link; - $keepIds[] = $linkId; - } - - foreach ($pendingParents as $linkId => $parentIds) { - if (!isset($links[$linkId])) { - continue; - } - if (!empty($parentIds['parentComponentLinkId']) && isset($componentIndex[$parentIds['parentComponentLinkId']])) { - $links[$linkId]->setParentComponentLink($componentIndex[$parentIds['parentComponentLinkId']]); - } - if (!empty($parentIds['parentPieceLinkId']) && isset($pieceIndex[$parentIds['parentPieceLinkId']])) { - $links[$linkId]->setParentPieceLink($pieceIndex[$parentIds['parentPieceLinkId']]); - } - if (!empty($parentIds['parentLinkId']) && isset($links[$parentIds['parentLinkId']])) { - $links[$linkId]->setParentLink($links[$parentIds['parentLinkId']]); - } - } - - $this->removeMissingLinks($existing, $keepIds); - - return array_values($links); - } - - private function normalizeStructureResponse( - Machine $machine, - array $componentLinks, - array $pieceLinks, - array $productLinks, - ): array { - $normalizedComponentLinks = $this->normalizeComponentLinks($componentLinks); - $componentIndex = $this->indexNormalizedLinks($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'] ?? $child['linkId'] ?? null; - if ($childId) { - foreach ($pieceLinks as $pieceLink) { - $parentId = $pieceLink['parentComponentLinkId'] ?? null; - if ($parentId === $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->normalizeConstructeurLinks($machine->getConstructeurLinks()), - 'customFields' => $this->normalizeCustomFields($machine->getCustomFields()), - 'documents' => null, - 'customFieldValues' => $this->normalizeCustomFieldValues($machine->getCustomFieldValues()), - ]; - } - - 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(), - 'machineContextOnly' => $customField->isMachineContextOnly(), - ]; - } - - return $items; - } - - private function normalizeComponentLinks(array $links): array - { - return array_map(function (MachineComponentLink $link): array { - $composant = $link->getComposant(); - $modelType = $link->getModelType(); - $parentLink = $link->getParentLink(); - $type = $composant?->getTypeComposant(); - - return [ - 'id' => $link->getId(), - 'linkId' => $link->getId(), - 'machineId' => $link->getMachine()->getId(), - 'composantId' => $composant?->getId(), - 'composant' => $composant ? $this->normalizeComposant($composant) : null, - 'modelTypeId' => $modelType?->getId(), - 'modelType' => $modelType ? $this->normalizeModelType($modelType) : null, - 'pendingEntity' => null === $composant, - 'parentLinkId' => $parentLink?->getId(), - 'parentComponentLinkId' => $parentLink?->getId(), - 'parentComponentId' => $parentLink?->getComposant()?->getId(), - 'overrides' => $this->normalizeOverrides($link), - 'childLinks' => [], - 'pieceLinks' => [], - 'contextCustomFields' => $type ? $this->normalizeContextCustomFieldDefinitions($type->getComponentCustomFields()) : [], - 'contextCustomFieldValues' => $this->normalizeCustomFieldValues($link->getContextFieldValues()), - ]; - }, $links); - } - - private function normalizePieceLinks(array $links): array - { - return array_map(function (MachinePieceLink $link): array { - $piece = $this->ensurePieceExists($link->getPiece()); - $modelType = $link->getModelType(); - $parentLink = $link->getParentLink(); - $type = $piece?->getTypePiece(); - - return [ - 'id' => $link->getId(), - 'linkId' => $link->getId(), - 'machineId' => $link->getMachine()->getId(), - 'pieceId' => $piece?->getId(), - 'piece' => $piece ? $this->normalizePiece($piece) : null, - 'modelTypeId' => $modelType?->getId(), - 'modelType' => $modelType ? $this->normalizeModelType($modelType) : null, - 'pendingEntity' => null === $piece, - 'parentLinkId' => $parentLink?->getId(), - 'parentComponentLinkId' => $parentLink?->getId(), - 'parentComponentId' => $parentLink?->getComposant()?->getId(), - 'overrides' => $this->normalizeOverrides($link), - 'quantity' => $piece ? $this->resolvePieceQuantity($link) : 1, - 'contextCustomFields' => $type ? $this->normalizeContextCustomFieldDefinitions($type->getPieceCustomFields()) : [], - 'contextCustomFieldValues' => $this->normalizeCustomFieldValues($link->getContextFieldValues()), - ]; - }, $links); - } - - private function resolvePieceQuantity(MachinePieceLink $link): int - { - $parentLink = $link->getParentLink(); - $piece = $this->ensurePieceExists($link->getPiece()); - - if (!$parentLink || !$piece) { - return $link->getQuantity(); - } - - $composant = $parentLink->getComposant(); - if (!$composant) { - return $link->getQuantity(); - } - - foreach ($composant->getPieceSlots() as $slot) { - $selected = $this->ensurePieceExists($slot->getSelectedPiece()); - if ($selected?->getId() === $piece->getId()) { - return $slot->getQuantity(); - } - } - - return $link->getQuantity(); - } - - private function normalizeProductLinks(array $links): array - { - return array_map(function (MachineProductLink $link): array { - $product = $link->getProduct(); - $modelType = $link->getModelType(); - - return [ - 'id' => $link->getId(), - 'linkId' => $link->getId(), - 'machineId' => $link->getMachine()->getId(), - 'productId' => $product?->getId(), - 'product' => $product ? $this->normalizeProduct($product) : null, - 'modelTypeId' => $modelType?->getId(), - 'modelType' => $modelType ? $this->normalizeModelType($modelType) : null, - 'pendingEntity' => null === $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->normalizeConstructeurLinks($composant->getConstructeurLinks()), - 'documents' => [], - 'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getComponentCustomFields()) : [], - 'customFieldValues' => $this->normalizeCustomFieldValues($composant->getCustomFieldValues()), - ]; - } - - private function buildStructureFromSlots(Composant $composant): array - { - $pieces = []; - foreach ($composant->getPieceSlots() as $slot) { - $selectedPiece = $this->ensurePieceExists($slot->getSelectedPiece()); - $pieceData = [ - 'slotId' => $slot->getId(), - 'typePieceId' => $slot->getTypePiece()?->getId(), - 'typePiece' => $this->normalizeModelType($slot->getTypePiece()), - 'quantity' => $slot->getQuantity(), - 'selectedPieceId' => $selectedPiece?->getId(), - ]; - if ($selectedPiece) { - $pieceData['resolvedPiece'] = $this->normalizePiece($selectedPiece); - } - $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, - ]; - } - - /** - * Returns the Piece if its underlying row still exists in DB, otherwise null. - * getId() on a Doctrine proxy does NOT trigger __load() (the id is the key used - * to build the proxy), so we force initialization via initializeObject() to - * surface a stale FK here instead of crashing on the first real getter. - */ - private function ensurePieceExists(?Piece $piece): ?Piece - { - if (null === $piece) { - return null; - } + $payload = is_array($payload) ? $payload : []; try { - $this->entityManager->initializeObject($piece); - - return $piece; - } catch (EntityNotFoundException) { - return null; - } - } - - /** - * Returns the CustomField if its underlying row still exists, otherwise null. - * getId() on a Doctrine proxy does NOT trigger __load() — the id is the key used - * to build the proxy. We force initialization explicitly so a stale FK to a - * deleted CustomField surfaces here instead of crashing on getName() later. - */ - private function ensureCustomFieldExists(?CustomField $cf): ?CustomField - { - if (null === $cf) { - return null; + $newMachine = $this->cloner->clone($source, $payload); + } catch (MachineStructureException $e) { + return $this->json(['success' => false, 'error' => $e->getMessage()], $e->getStatusCode()); } - try { - $this->entityManager->initializeObject($cf); - - return $cf; - } catch (EntityNotFoundException) { - return null; - } - } - - 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->normalizeConstructeurLinks($piece->getConstructeurLinks()), - 'documents' => [], - '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->normalizeConstructeurLinks($product->getConstructeurLinks()), - 'documents' => [], - '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 normalizeConstructeurLinks(Collection $constructeurLinks): array - { - $items = []; - foreach ($constructeurLinks as $link) { - $items[] = [ - 'id' => $link->getId(), - 'constructeur' => [ - 'id' => $link->getConstructeur()->getId(), - 'name' => $link->getConstructeur()->getName(), - 'email' => $link->getConstructeur()->getEmail(), - 'phone' => $this->constructeurPhone($link->getConstructeur()), - ], - 'supplierReference' => $link->getSupplierReference(), - ]; - } - - return $items; - } - - private function constructeurPhone(Constructeur $constructeur): ?string - { - $first = $constructeur->getTelephones()->first(); - - return false !== $first ? $first->getNumero() : null; - } - - 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(), - 'machineContextOnly' => $cf->isMachineContextOnly(), - ]; - } - - 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 = $this->ensureCustomFieldExists($cfv->getCustomField()); - if (null === $cf) { - continue; - } - $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(), - 'machineContextOnly' => $cf->isMachineContextOnly(), - ], - ]; - } - - return $items; - } - - private function normalizeContextCustomFieldDefinitions(Collection $customFields): array - { - $items = []; - foreach ($customFields as $cf) { - if (!$cf instanceof CustomField || !$cf->isMachineContextOnly()) { - continue; - } - $items[] = [ - 'id' => $cf->getId(), - 'name' => $cf->getName(), - 'type' => $cf->getType(), - 'required' => $cf->isRequired(), - 'options' => $cf->getOptions(), - 'defaultValue' => $cf->getDefaultValue(), - 'orderIndex' => $cf->getOrderIndex(), - 'machineContextOnly' => true, - ]; - } - - usort($items, static fn (array $a, array $b) => $a['orderIndex'] <=> $b['orderIndex']); - - 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 applyOverrides(object $link, mixed $overrides): void - { - if (!is_array($overrides)) { - return; - } - - if (array_key_exists('name', $overrides) && method_exists($link, 'setNameOverride')) { - $link->setNameOverride($this->stringOrNull($overrides['name'])); - } - if (array_key_exists('reference', $overrides) && method_exists($link, 'setReferenceOverride')) { - $link->setReferenceOverride($this->stringOrNull($overrides['reference'])); - } - if (array_key_exists('prix', $overrides) && method_exists($link, 'setPrixOverride')) { - $link->setPrixOverride($this->stringOrNull($overrides['prix'])); - } - } - - private function stringOrNull(mixed $value): ?string - { - if (null === $value) { - return null; - } - $string = trim((string) $value); - - return '' === $string ? null : $string; - } - - private function resolveIdentifier(array $entry, array $keys): ?string - { - foreach ($keys as $key) { - if (!array_key_exists($key, $entry)) { - continue; - } - $value = $entry[$key]; - if (null === $value || '' === $value) { - continue; - } - - return (string) $value; - } - - return null; - } - - /** - * @param array $links - * - * @return array - */ - private function indexLinksById(array $links): array - { - $indexed = []; - foreach ($links as $link) { - if (method_exists($link, 'getId') && $link->getId()) { - $indexed[$link->getId()] = $link; - } - } - - return $indexed; - } - - private function indexNormalizedLinks(array $links): array - { - $indexed = []; - foreach ($links as $link) { - if (is_array($link) && isset($link['id'])) { - $indexed[$link['id']] = $link; - } - } - - return $indexed; - } - - private function removeMissingLinks(array $existing, array $keepIds): void - { - $keep = array_flip($keepIds); - foreach ($existing as $link) { - if (!method_exists($link, 'getId')) { - continue; - } - $id = $link->getId(); - if ($id && !isset($keep[$id])) { - $this->entityManager->remove($link); - } - } - } - - private function generateCuid(): string - { - return 'cl'.bin2hex(random_bytes(12)); + return $this->json($this->normalizer->normalize($newMachine), 201); } } diff --git a/src/Service/MachineStructure/MachineCloner.php b/src/Service/MachineStructure/MachineCloner.php new file mode 100644 index 0000000..1812064 --- /dev/null +++ b/src/Service/MachineStructure/MachineCloner.php @@ -0,0 +1,279 @@ +entityManager->getRepository(Site::class)->find($payload['siteId']); + if (!$site) { + throw new MachineStructureException('Site introuvable.', 404); + } + + // Clone mode: 'full' copies concrete components/pieces/products; 'structure' + // only keeps the slots' categories (modelType) with empty concrete entities. + $mode = $payload['mode'] ?? 'full'; + if (!in_array($mode, ['full', 'structure'], true)) { + throw new MachineStructureException('mode invalide (valeurs autorisées : full, structure).', 400); + } + $structureOnly = 'structure' === $mode; + + // Create new machine + $newMachine = new Machine(); + $newMachine->setName($payload['name']); + $newMachine->setSite($site); + if (!empty($payload['reference'])) { + $newMachine->setReference($payload['reference']); + } + $newMachine->setPrix($source->getPrix()); + + // Copy constructeur links + foreach ($source->getConstructeurLinks() as $link) { + $newLink = new MachineConstructeurLink(); + $newLink->setMachine($newMachine); + $newLink->setConstructeur($link->getConstructeur()); + $newLink->setSupplierReference($link->getSupplierReference()); + $this->entityManager->persist($newLink); + } + + $this->entityManager->persist($newMachine); + + // Copy custom fields and values + $this->cloneCustomFields($source, $newMachine); + + // Copy component links (preserving hierarchy) + $componentLinkMap = $this->cloneComponentLinks($source, $newMachine, $structureOnly); + + // Copy piece links + $pieceLinkMap = $this->clonePieceLinks($source, $newMachine, $componentLinkMap, $structureOnly); + + // Copy product links + $this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap, $structureOnly); + + $this->entityManager->flush(); + + return $newMachine; + } + + private function cloneCustomFields(Machine $source, Machine $target): void + { + $cfMap = []; + + 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->setMachineContextOnly($cf->isMachineContextOnly()); + $newCf->setMachine($target); + $this->entityManager->persist($newCf); + + $cfMap[$cf->getId()] = $newCf; + } + + foreach ($source->getCustomFieldValues() as $cfv) { + $originalCf = $cfv->getCustomField(); + $newCf = $cfMap[$originalCf->getId()] ?? null; + if (!$newCf) { + continue; + } + + $newValue = new CustomFieldValue(); + $newValue->setMachine($target); + $newValue->setCustomField($newCf); + $newValue->setValue($cfv->getValue()); + $this->entityManager->persist($newValue); + } + } + + /** + * @return array Map of old link ID → new link + */ + private function cloneComponentLinks(Machine $source, Machine $target, bool $structureOnly = false): array + { + $sourceLinks = $this->machineComponentLinkRepository->findBy(['machine' => $source], ['createdAt' => 'ASC']); + $linkMap = []; + + // First pass: create all links without parent relationships + foreach ($sourceLinks as $link) { + $newLink = new MachineComponentLink(); + $newLink->setMachine($target); + + if ($structureOnly) { + // Keep only the slot category; leave the concrete component empty. + $newLink->setModelType($link->getModelType() ?? $link->getComposant()?->getTypeComposant()); + $this->entityManager->persist($newLink); + $linkMap[$link->getId()] = $newLink; + + continue; + } + + $newLink->setComposant($link->getComposant()); + $newLink->setNameOverride($link->getNameOverride()); + $newLink->setReferenceOverride($link->getReferenceOverride()); + $newLink->setPrixOverride($link->getPrixOverride()); + $this->entityManager->persist($newLink); + + foreach ($link->getContextFieldValues() as $cfv) { + $newValue = new CustomFieldValue(); + $newValue->setCustomField($cfv->getCustomField()); + $newValue->setValue($cfv->getValue()); + $newValue->setMachineComponentLink($newLink); + $newValue->setComposant($newLink->getComposant()); + $this->entityManager->persist($newValue); + $newLink->getContextFieldValues()->add($newValue); + } + + $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 → new link + */ + private function clonePieceLinks(Machine $source, Machine $target, array $componentLinkMap, bool $structureOnly = false): array + { + $sourceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $source], ['createdAt' => 'ASC']); + $linkMap = []; + + foreach ($sourceLinks as $link) { + $newLink = new MachinePieceLink(); + $newLink->setMachine($target); + + $parent = $link->getParentLink(); + if ($parent && isset($componentLinkMap[$parent->getId()])) { + $newLink->setParentLink($componentLinkMap[$parent->getId()]); + } + + if ($structureOnly) { + // Keep only the slot category; leave the concrete piece empty. + $newLink->setModelType($link->getModelType() ?? $link->getPiece()?->getTypePiece()); + $this->entityManager->persist($newLink); + $linkMap[$link->getId()] = $newLink; + + continue; + } + + $newLink->setPiece($link->getPiece()); + $newLink->setNameOverride($link->getNameOverride()); + $newLink->setReferenceOverride($link->getReferenceOverride()); + $newLink->setPrixOverride($link->getPrixOverride()); + $newLink->setQuantity($link->getQuantity()); + + $this->entityManager->persist($newLink); + + foreach ($link->getContextFieldValues() as $cfv) { + $newValue = new CustomFieldValue(); + $newValue->setCustomField($cfv->getCustomField()); + $newValue->setValue($cfv->getValue()); + $newValue->setMachinePieceLink($newLink); + $newValue->setPiece($newLink->getPiece()); + $this->entityManager->persist($newValue); + $newLink->getContextFieldValues()->add($newValue); + } + + $linkMap[$link->getId()] = $newLink; + } + + return $linkMap; + } + + /** + * @param array $componentLinkMap + * @param array $pieceLinkMap + */ + private function cloneProductLinks( + Machine $source, + Machine $target, + array $componentLinkMap, + array $pieceLinkMap, + bool $structureOnly = false, + ): void { + $sourceLinks = $this->machineProductLinkRepository->findBy(['machine' => $source], ['createdAt' => 'ASC']); + $linkMap = []; + + // First pass: create all links + foreach ($sourceLinks as $link) { + $newLink = new MachineProductLink(); + $newLink->setMachine($target); + + if ($structureOnly) { + // Keep only the slot category; leave the concrete product empty. + $newLink->setModelType($link->getModelType() ?? $link->getProduct()?->getTypeProduct()); + } else { + $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/Service/MachineStructure/MachineStructureException.php b/src/Service/MachineStructure/MachineStructureException.php new file mode 100644 index 0000000..dbb4cbc --- /dev/null +++ b/src/Service/MachineStructure/MachineStructureException.php @@ -0,0 +1,27 @@ +statusCode; + } +} diff --git a/src/Service/MachineStructure/MachineStructureNormalizer.php b/src/Service/MachineStructure/MachineStructureNormalizer.php new file mode 100644 index 0000000..f2290ea --- /dev/null +++ b/src/Service/MachineStructure/MachineStructureNormalizer.php @@ -0,0 +1,559 @@ +machineComponentLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']); + $pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']); + $productLinks = $this->machineProductLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']); + + return $this->normalizeStructureResponse($machine, $componentLinks, $pieceLinks, $productLinks); + } + + public function normalizeStructureResponse( + Machine $machine, + array $componentLinks, + array $pieceLinks, + array $productLinks, + ): array { + $normalizedComponentLinks = $this->normalizeComponentLinks($componentLinks); + $componentIndex = $this->indexNormalizedLinks($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'] ?? $child['linkId'] ?? null; + if ($childId) { + foreach ($pieceLinks as $pieceLink) { + $parentId = $pieceLink['parentComponentLinkId'] ?? null; + if ($parentId === $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->normalizeConstructeurLinks($machine->getConstructeurLinks()), + 'customFields' => $this->normalizeCustomFields($machine->getCustomFields()), + 'documents' => null, + 'customFieldValues' => $this->normalizeCustomFieldValues($machine->getCustomFieldValues()), + ]; + } + + 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(), + 'machineContextOnly' => $customField->isMachineContextOnly(), + ]; + } + + return $items; + } + + private function normalizeComponentLinks(array $links): array + { + return array_map(function (MachineComponentLink $link): array { + $composant = $link->getComposant(); + $modelType = $link->getModelType(); + $parentLink = $link->getParentLink(); + $type = $composant?->getTypeComposant(); + + return [ + 'id' => $link->getId(), + 'linkId' => $link->getId(), + 'machineId' => $link->getMachine()->getId(), + 'composantId' => $composant?->getId(), + 'composant' => $composant ? $this->normalizeComposant($composant) : null, + 'modelTypeId' => $modelType?->getId(), + 'modelType' => $modelType ? $this->normalizeModelType($modelType) : null, + 'pendingEntity' => null === $composant, + 'parentLinkId' => $parentLink?->getId(), + 'parentComponentLinkId' => $parentLink?->getId(), + 'parentComponentId' => $parentLink?->getComposant()?->getId(), + 'overrides' => $this->normalizeOverrides($link), + 'childLinks' => [], + 'pieceLinks' => [], + 'contextCustomFields' => $type ? $this->normalizeContextCustomFieldDefinitions($type->getComponentCustomFields()) : [], + 'contextCustomFieldValues' => $this->normalizeCustomFieldValues($link->getContextFieldValues()), + ]; + }, $links); + } + + private function normalizePieceLinks(array $links): array + { + return array_map(function (MachinePieceLink $link): array { + $piece = $this->ensurePieceExists($link->getPiece()); + $modelType = $link->getModelType(); + $parentLink = $link->getParentLink(); + $type = $piece?->getTypePiece(); + + return [ + 'id' => $link->getId(), + 'linkId' => $link->getId(), + 'machineId' => $link->getMachine()->getId(), + 'pieceId' => $piece?->getId(), + 'piece' => $piece ? $this->normalizePiece($piece) : null, + 'modelTypeId' => $modelType?->getId(), + 'modelType' => $modelType ? $this->normalizeModelType($modelType) : null, + 'pendingEntity' => null === $piece, + 'parentLinkId' => $parentLink?->getId(), + 'parentComponentLinkId' => $parentLink?->getId(), + 'parentComponentId' => $parentLink?->getComposant()?->getId(), + 'overrides' => $this->normalizeOverrides($link), + 'quantity' => $piece ? $this->resolvePieceQuantity($link) : 1, + 'contextCustomFields' => $type ? $this->normalizeContextCustomFieldDefinitions($type->getPieceCustomFields()) : [], + 'contextCustomFieldValues' => $this->normalizeCustomFieldValues($link->getContextFieldValues()), + ]; + }, $links); + } + + private function resolvePieceQuantity(MachinePieceLink $link): int + { + $parentLink = $link->getParentLink(); + $piece = $this->ensurePieceExists($link->getPiece()); + + if (!$parentLink || !$piece) { + return $link->getQuantity(); + } + + $composant = $parentLink->getComposant(); + if (!$composant) { + return $link->getQuantity(); + } + + foreach ($composant->getPieceSlots() as $slot) { + $selected = $this->ensurePieceExists($slot->getSelectedPiece()); + if ($selected?->getId() === $piece->getId()) { + return $slot->getQuantity(); + } + } + + return $link->getQuantity(); + } + + private function normalizeProductLinks(array $links): array + { + return array_map(function (MachineProductLink $link): array { + $product = $link->getProduct(); + $modelType = $link->getModelType(); + + return [ + 'id' => $link->getId(), + 'linkId' => $link->getId(), + 'machineId' => $link->getMachine()->getId(), + 'productId' => $product?->getId(), + 'product' => $product ? $this->normalizeProduct($product) : null, + 'modelTypeId' => $modelType?->getId(), + 'modelType' => $modelType ? $this->normalizeModelType($modelType) : null, + 'pendingEntity' => null === $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->normalizeConstructeurLinks($composant->getConstructeurLinks()), + 'documents' => [], + 'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getComponentCustomFields()) : [], + 'customFieldValues' => $this->normalizeCustomFieldValues($composant->getCustomFieldValues()), + ]; + } + + private function buildStructureFromSlots(Composant $composant): array + { + $pieces = []; + foreach ($composant->getPieceSlots() as $slot) { + $selectedPiece = $this->ensurePieceExists($slot->getSelectedPiece()); + $pieceData = [ + 'slotId' => $slot->getId(), + 'typePieceId' => $slot->getTypePiece()?->getId(), + 'typePiece' => $this->normalizeModelType($slot->getTypePiece()), + 'quantity' => $slot->getQuantity(), + 'selectedPieceId' => $selectedPiece?->getId(), + ]; + if ($selectedPiece) { + $pieceData['resolvedPiece'] = $this->normalizePiece($selectedPiece); + } + $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, + ]; + } + + /** + * Returns the Piece if its underlying row still exists in DB, otherwise null. + * getId() on a Doctrine proxy does NOT trigger __load() (the id is the key used + * to build the proxy), so we force initialization via initializeObject() to + * surface a stale FK here instead of crashing on the first real getter. + */ + private function ensurePieceExists(?Piece $piece): ?Piece + { + if (null === $piece) { + return null; + } + + try { + $this->entityManager->initializeObject($piece); + + return $piece; + } catch (EntityNotFoundException) { + return null; + } + } + + /** + * Returns the CustomField if its underlying row still exists, otherwise null. + * getId() on a Doctrine proxy does NOT trigger __load() — the id is the key used + * to build the proxy. We force initialization explicitly so a stale FK to a + * deleted CustomField surfaces here instead of crashing on getName() later. + */ + private function ensureCustomFieldExists(?CustomField $cf): ?CustomField + { + if (null === $cf) { + return null; + } + + try { + $this->entityManager->initializeObject($cf); + + return $cf; + } catch (EntityNotFoundException) { + return null; + } + } + + 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->normalizeConstructeurLinks($piece->getConstructeurLinks()), + 'documents' => [], + '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->normalizeConstructeurLinks($product->getConstructeurLinks()), + 'documents' => [], + '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 normalizeConstructeurLinks(Collection $constructeurLinks): array + { + $items = []; + foreach ($constructeurLinks as $link) { + $items[] = [ + 'id' => $link->getId(), + 'constructeur' => [ + 'id' => $link->getConstructeur()->getId(), + 'name' => $link->getConstructeur()->getName(), + 'email' => $link->getConstructeur()->getEmail(), + 'phone' => $this->constructeurPhone($link->getConstructeur()), + ], + 'supplierReference' => $link->getSupplierReference(), + ]; + } + + return $items; + } + + private function constructeurPhone(Constructeur $constructeur): ?string + { + $first = $constructeur->getTelephones()->first(); + + return false !== $first ? $first->getNumero() : null; + } + + 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(), + 'machineContextOnly' => $cf->isMachineContextOnly(), + ]; + } + + 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 = $this->ensureCustomFieldExists($cfv->getCustomField()); + if (null === $cf) { + continue; + } + $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(), + 'machineContextOnly' => $cf->isMachineContextOnly(), + ], + ]; + } + + return $items; + } + + private function normalizeContextCustomFieldDefinitions(Collection $customFields): array + { + $items = []; + foreach ($customFields as $cf) { + if (!$cf instanceof CustomField || !$cf->isMachineContextOnly()) { + continue; + } + $items[] = [ + 'id' => $cf->getId(), + 'name' => $cf->getName(), + 'type' => $cf->getType(), + 'required' => $cf->isRequired(), + 'options' => $cf->getOptions(), + 'defaultValue' => $cf->getDefaultValue(), + 'orderIndex' => $cf->getOrderIndex(), + 'machineContextOnly' => true, + ]; + } + + usort($items, static fn (array $a, array $b) => $a['orderIndex'] <=> $b['orderIndex']); + + 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 indexNormalizedLinks(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/Service/MachineStructure/MachineStructureUpdater.php b/src/Service/MachineStructure/MachineStructureUpdater.php new file mode 100644 index 0000000..fe1e200 --- /dev/null +++ b/src/Service/MachineStructure/MachineStructureUpdater.php @@ -0,0 +1,337 @@ +, pieceLinks: list, productLinks: list} + * + * @throws MachineStructureException on invalid payload / missing entity + */ + public function apply(Machine $machine, array $payload): array + { + $componentLinksPayload = $this->normalizePayloadList($payload['componentLinks'] ?? []); + $pieceLinksPayload = $this->normalizePayloadList($payload['pieceLinks'] ?? []); + $productLinksPayload = $this->normalizePayloadList($payload['productLinks'] ?? []); + + $componentLinks = $this->applyComponentLinks($machine, $componentLinksPayload); + $pieceLinks = $this->applyPieceLinks($machine, $pieceLinksPayload, $componentLinks); + $productLinks = $this->applyProductLinks($machine, $productLinksPayload, $componentLinks, $pieceLinks); + + $this->entityManager->flush(); + + return [ + 'componentLinks' => $componentLinks, + 'pieceLinks' => $pieceLinks, + 'productLinks' => $productLinks, + ]; + } + + private function normalizePayloadList(mixed $value): array + { + if (!is_array($value)) { + return []; + } + + return array_values(array_filter($value, static fn ($item) => is_array($item))); + } + + private function applyComponentLinks(Machine $machine, array $payload): array + { + $existing = $this->indexLinksById($this->machineComponentLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC'])); + $keepIds = []; + $pendingParents = []; + $links = []; + + foreach ($payload as $entry) { + $linkId = $this->resolveIdentifier($entry, ['id', 'linkId']); + $link = $linkId && isset($existing[$linkId]) ? $existing[$linkId] : new MachineComponentLink(); + if (!$linkId) { + $linkId = $this->generateCuid(); + } + if (!$link->getId()) { + $link->setId($linkId); + } + + $composantId = $this->resolveIdentifier($entry, ['composantId', 'componentId', 'idComposant']); + if (!$composantId) { + throw new MachineStructureException('Composant requis.', 400); + } + $composant = $this->composantRepository->find($composantId); + if (!$composant instanceof Composant) { + throw new MachineStructureException('Composant introuvable.', 404); + } + + $link->setMachine($machine); + $link->setComposant($composant); + + $this->applyOverrides($link, $entry['overrides'] ?? null); + + $pendingParents[$linkId] = $this->resolveIdentifier($entry, [ + 'parentComponentLinkId', + 'parentLinkId', + 'parentMachineComponentLinkId', + ]); + + $this->entityManager->persist($link); + $links[$linkId] = $link; + $keepIds[] = $linkId; + } + + foreach ($pendingParents as $linkId => $parentId) { + if (!$parentId || !isset($links[$linkId])) { + continue; + } + $parent = $links[$parentId] ?? $existing[$parentId] ?? null; + if ($parent instanceof MachineComponentLink) { + $links[$linkId]->setParentLink($parent); + } + } + + $this->removeMissingLinks($existing, $keepIds); + + return array_values($links); + } + + private function applyPieceLinks(Machine $machine, array $payload, array $componentLinks): array + { + $existing = $this->indexLinksById($this->machinePieceLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC'])); + $componentIndex = $this->indexLinksById($componentLinks); + $keepIds = []; + $pendingParents = []; + $links = []; + + foreach ($payload as $entry) { + $linkId = $this->resolveIdentifier($entry, ['id', 'linkId']); + $link = $linkId && isset($existing[$linkId]) ? $existing[$linkId] : new MachinePieceLink(); + if (!$linkId) { + $linkId = $this->generateCuid(); + } + if (!$link->getId()) { + $link->setId($linkId); + } + + $pieceId = $this->resolveIdentifier($entry, ['pieceId']); + if (!$pieceId) { + throw new MachineStructureException('Pièce requise.', 400); + } + $piece = $this->pieceRepository->find($pieceId); + if (!$piece instanceof Piece) { + throw new MachineStructureException('Pièce introuvable.', 404); + } + + $link->setMachine($machine); + $link->setPiece($piece); + + $this->applyOverrides($link, $entry['overrides'] ?? null); + + if (!isset($entry['parentComponentLinkId']) && !isset($entry['parentLinkId'])) { + $quantity = isset($entry['quantity']) ? (int) $entry['quantity'] : $link->getQuantity(); + $link->setQuantity(max(1, $quantity)); + } + + $pendingParents[$linkId] = $this->resolveIdentifier($entry, [ + 'parentComponentLinkId', + 'parentLinkId', + 'parentMachineComponentLinkId', + ]); + + $this->entityManager->persist($link); + $links[$linkId] = $link; + $keepIds[] = $linkId; + } + + foreach ($pendingParents as $linkId => $parentId) { + if (!$parentId || !isset($links[$linkId])) { + continue; + } + $parent = $componentIndex[$parentId] ?? null; + if ($parent instanceof MachineComponentLink) { + $links[$linkId]->setParentLink($parent); + } + } + + $this->removeMissingLinks($existing, $keepIds); + + return array_values($links); + } + + private function applyProductLinks( + Machine $machine, + array $payload, + array $componentLinks, + array $pieceLinks, + ): array { + $existing = $this->indexLinksById($this->machineProductLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC'])); + $componentIndex = $this->indexLinksById($componentLinks); + $pieceIndex = $this->indexLinksById($pieceLinks); + $keepIds = []; + $pendingParents = []; + $links = []; + + foreach ($payload as $entry) { + $linkId = $this->resolveIdentifier($entry, ['id', 'linkId']); + $link = $linkId && isset($existing[$linkId]) ? $existing[$linkId] : new MachineProductLink(); + if (!$linkId) { + $linkId = $this->generateCuid(); + } + if (!$link->getId()) { + $link->setId($linkId); + } + + $productId = $this->resolveIdentifier($entry, ['productId']); + if (!$productId) { + throw new MachineStructureException('Produit requis.', 400); + } + $product = $this->productRepository->find($productId); + if (!$product instanceof Product) { + throw new MachineStructureException('Produit introuvable.', 404); + } + + $link->setMachine($machine); + $link->setProduct($product); + + $pendingParents[$linkId] = [ + 'parentComponentLinkId' => $this->resolveIdentifier($entry, ['parentComponentLinkId']), + 'parentPieceLinkId' => $this->resolveIdentifier($entry, ['parentPieceLinkId']), + 'parentLinkId' => $this->resolveIdentifier($entry, ['parentLinkId']), + ]; + + $this->entityManager->persist($link); + $links[$linkId] = $link; + $keepIds[] = $linkId; + } + + foreach ($pendingParents as $linkId => $parentIds) { + if (!isset($links[$linkId])) { + continue; + } + if (!empty($parentIds['parentComponentLinkId']) && isset($componentIndex[$parentIds['parentComponentLinkId']])) { + $links[$linkId]->setParentComponentLink($componentIndex[$parentIds['parentComponentLinkId']]); + } + if (!empty($parentIds['parentPieceLinkId']) && isset($pieceIndex[$parentIds['parentPieceLinkId']])) { + $links[$linkId]->setParentPieceLink($pieceIndex[$parentIds['parentPieceLinkId']]); + } + if (!empty($parentIds['parentLinkId']) && isset($links[$parentIds['parentLinkId']])) { + $links[$linkId]->setParentLink($links[$parentIds['parentLinkId']]); + } + } + + $this->removeMissingLinks($existing, $keepIds); + + return array_values($links); + } + + private function applyOverrides(object $link, mixed $overrides): void + { + if (!is_array($overrides)) { + return; + } + + if (array_key_exists('name', $overrides) && method_exists($link, 'setNameOverride')) { + $link->setNameOverride($this->stringOrNull($overrides['name'])); + } + if (array_key_exists('reference', $overrides) && method_exists($link, 'setReferenceOverride')) { + $link->setReferenceOverride($this->stringOrNull($overrides['reference'])); + } + if (array_key_exists('prix', $overrides) && method_exists($link, 'setPrixOverride')) { + $link->setPrixOverride($this->stringOrNull($overrides['prix'])); + } + } + + private function stringOrNull(mixed $value): ?string + { + if (null === $value) { + return null; + } + $string = trim((string) $value); + + return '' === $string ? null : $string; + } + + private function resolveIdentifier(array $entry, array $keys): ?string + { + foreach ($keys as $key) { + if (!array_key_exists($key, $entry)) { + continue; + } + $value = $entry[$key]; + if (null === $value || '' === $value) { + continue; + } + + return (string) $value; + } + + return null; + } + + /** + * @param array $links + * + * @return array + */ + private function indexLinksById(array $links): array + { + $indexed = []; + foreach ($links as $link) { + if (method_exists($link, 'getId') && $link->getId()) { + $indexed[$link->getId()] = $link; + } + } + + return $indexed; + } + + private function removeMissingLinks(array $existing, array $keepIds): void + { + $keep = array_flip($keepIds); + foreach ($existing as $link) { + if (!method_exists($link, 'getId')) { + continue; + } + $id = $link->getId(); + if ($id && !isset($keep[$id])) { + $this->entityManager->remove($link); + } + } + } + + private function generateCuid(): string + { + return 'cl'.bin2hex(random_bytes(12)); + } +}