From 43bec07bb8df9bcc65ecef47530b3786ab5d8775 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 16 Mar 2026 11:32:14 +0100 Subject: [PATCH] fix(sync) : preserve slot selections when modifying ModelType structure SkeletonStructureService was deleting all skeleton requirements and recreating them on every ModelType update. Combined with position-based matching in sync strategies, any reordering or insertion caused all existing slots to be orphaned and recreated empty, losing selections. - SkeletonStructureService: update requirements in-place by matching on typeId instead of delete-all/recreate-all - ComposantSyncStrategy & PieceSyncStrategy: two-pass smart matching algorithm (exact typeId+position first, then typeId-only fallback) to preserve selectedPiece/selectedComposant/selectedProduct on reorder/insertion - Frontend: check patch result.success before updating local state Co-Authored-By: Claude Opus 4.6 (1M context) --- Inventory_frontend | 2 +- src/Service/SkeletonStructureService.php | 187 ++++++++--- src/Service/Sync/ComposantSyncStrategy.php | 371 ++++++++++++--------- src/Service/Sync/PieceSyncStrategy.php | 175 +++++++--- 4 files changed, 481 insertions(+), 254 deletions(-) diff --git a/Inventory_frontend b/Inventory_frontend index f8403dd..d4fc0f1 160000 --- a/Inventory_frontend +++ b/Inventory_frontend @@ -1 +1 @@ -Subproject commit f8403ddfbc47ec85661f16744f507fa5c2f8abb4 +Subproject commit d4fc0f1fee370e1a073c0685b141303d5fce48e9 diff --git a/src/Service/SkeletonStructureService.php b/src/Service/SkeletonStructureService.php index 0256800..c2970d4 100644 --- a/src/Service/SkeletonStructureService.php +++ b/src/Service/SkeletonStructureService.php @@ -18,55 +18,158 @@ class SkeletonStructureService public function updateSkeletonRequirements(ModelType $modelType, array $structure): void { - // Clear existing requirements - foreach ($modelType->getSkeletonPieceRequirements() as $req) { - $modelType->removeSkeletonPieceRequirement($req); - } + // Update piece requirements in-place (match by typeId, then update position) + $this->syncPieceRequirements($modelType, $structure['pieces'] ?? []); - foreach ($modelType->getSkeletonProductRequirements() as $req) { - $modelType->removeSkeletonProductRequirement($req); - } + // Update product requirements in-place + $this->syncProductRequirements($modelType, $structure['products'] ?? []); - foreach ($modelType->getSkeletonSubcomponentRequirements() as $req) { - $modelType->removeSkeletonSubcomponentRequirement($req); - } - - // Create piece requirements - foreach (($structure['pieces'] ?? []) as $i => $pieceData) { - $req = new SkeletonPieceRequirement(); - $req->setModelType($modelType); - $req->setTypePiece($this->em->getReference(ModelType::class, $pieceData['typePieceId'])); - $req->setPosition($i); - $modelType->addSkeletonPieceRequirement($req); - } - - // Create product requirements (shared by component + piece types) - foreach (($structure['products'] ?? []) as $i => $prodData) { - $req = new SkeletonProductRequirement(); - $req->setModelType($modelType); - $req->setTypeProduct($this->em->getReference(ModelType::class, $prodData['typeProductId'])); - $req->setFamilyCode($prodData['familyCode'] ?? null); - $req->setPosition($i); - $modelType->addSkeletonProductRequirement($req); - } - - // Create subcomponent requirements (component types only) - foreach (($structure['subcomponents'] ?? []) as $i => $subData) { - $req = new SkeletonSubcomponentRequirement(); - $req->setModelType($modelType); - $req->setAlias($subData['alias'] ?? ''); - $req->setFamilyCode($subData['familyCode'] ?? ''); - if (!empty($subData['typeComposantId'])) { - $req->setTypeComposant($this->em->getReference(ModelType::class, $subData['typeComposantId'])); - } - $req->setPosition($i); - $modelType->addSkeletonSubcomponentRequirement($req); - } + // Update subcomponent requirements in-place + $this->syncSubcomponentRequirements($modelType, $structure['subcomponents'] ?? []); // Update custom field definitions $this->updateCustomFields($modelType, $structure['customFields'] ?? []); } + /** + * @param array $proposedPieces + */ + private function syncPieceRequirements(ModelType $modelType, array $proposedPieces): void + { + $existing = $modelType->getSkeletonPieceRequirements()->toArray(); + + // Index existing by typeId for matching + $existingByTypeId = []; + foreach ($existing as $req) { + $existingByTypeId[$req->getTypePiece()->getId()][] = $req; + } + + $matched = []; + $toCreate = []; + + foreach ($proposedPieces as $i => $pieceData) { + $typeId = $pieceData['typePieceId']; + if (!empty($existingByTypeId[$typeId])) { + // Reuse existing requirement, update position + $req = array_shift($existingByTypeId[$typeId]); + $req->setPosition($i); + $matched[spl_object_id($req)] = true; + } else { + $toCreate[] = ['data' => $pieceData, 'position' => $i]; + } + } + + // Remove unmatched existing requirements + foreach ($existing as $req) { + if (!isset($matched[spl_object_id($req)])) { + $modelType->removeSkeletonPieceRequirement($req); + } + } + + // Create new requirements + foreach ($toCreate as $item) { + $req = new SkeletonPieceRequirement(); + $req->setModelType($modelType); + $req->setTypePiece($this->em->getReference(ModelType::class, $item['data']['typePieceId'])); + $req->setPosition($item['position']); + $modelType->addSkeletonPieceRequirement($req); + } + } + + /** + * @param array $proposedProducts + */ + private function syncProductRequirements(ModelType $modelType, array $proposedProducts): void + { + $existing = $modelType->getSkeletonProductRequirements()->toArray(); + + $existingByTypeId = []; + foreach ($existing as $req) { + $existingByTypeId[$req->getTypeProduct()->getId()][] = $req; + } + + $matched = []; + $toCreate = []; + + foreach ($proposedProducts as $i => $prodData) { + $typeId = $prodData['typeProductId']; + if (!empty($existingByTypeId[$typeId])) { + $req = array_shift($existingByTypeId[$typeId]); + $req->setFamilyCode($prodData['familyCode'] ?? null); + $req->setPosition($i); + $matched[spl_object_id($req)] = true; + } else { + $toCreate[] = ['data' => $prodData, 'position' => $i]; + } + } + + foreach ($existing as $req) { + if (!isset($matched[spl_object_id($req)])) { + $modelType->removeSkeletonProductRequirement($req); + } + } + + foreach ($toCreate as $item) { + $req = new SkeletonProductRequirement(); + $req->setModelType($modelType); + $req->setTypeProduct($this->em->getReference(ModelType::class, $item['data']['typeProductId'])); + $req->setFamilyCode($item['data']['familyCode'] ?? null); + $req->setPosition($item['position']); + $modelType->addSkeletonProductRequirement($req); + } + } + + /** + * @param array $proposedSubs + */ + private function syncSubcomponentRequirements(ModelType $modelType, array $proposedSubs): void + { + $existing = $modelType->getSkeletonSubcomponentRequirements()->toArray(); + + $existingByTypeId = []; + foreach ($existing as $req) { + $key = $req->getTypeComposant()?->getId() ?? ''; + $existingByTypeId[$key][] = $req; + } + + $matched = []; + $toCreate = []; + + foreach ($proposedSubs as $i => $subData) { + $typeId = $subData['typeComposantId'] ?? ''; + if (!empty($existingByTypeId[$typeId])) { + $req = array_shift($existingByTypeId[$typeId]); + $req->setAlias($subData['alias'] ?? ''); + $req->setFamilyCode($subData['familyCode'] ?? ''); + if (!empty($subData['typeComposantId'])) { + $req->setTypeComposant($this->em->getReference(ModelType::class, $subData['typeComposantId'])); + } + $req->setPosition($i); + $matched[spl_object_id($req)] = true; + } else { + $toCreate[] = ['data' => $subData, 'position' => $i]; + } + } + + foreach ($existing as $req) { + if (!isset($matched[spl_object_id($req)])) { + $modelType->removeSkeletonSubcomponentRequirement($req); + } + } + + foreach ($toCreate as $item) { + $req = new SkeletonSubcomponentRequirement(); + $req->setModelType($modelType); + $req->setAlias($item['data']['alias'] ?? ''); + $req->setFamilyCode($item['data']['familyCode'] ?? ''); + if (!empty($item['data']['typeComposantId'])) { + $req->setTypeComposant($this->em->getReference(ModelType::class, $item['data']['typeComposantId'])); + } + $req->setPosition($item['position']); + $modelType->addSkeletonSubcomponentRequirement($req); + } + } + /** * Sync CustomField entities for this ModelType. * Handles two frontend formats: diff --git a/src/Service/Sync/ComposantSyncStrategy.php b/src/Service/Sync/ComposantSyncStrategy.php index f53dc4c..44701c8 100644 --- a/src/Service/Sync/ComposantSyncStrategy.php +++ b/src/Service/Sync/ComposantSyncStrategy.php @@ -51,23 +51,20 @@ class ComposantSyncStrategy implements SyncStrategyInterface $addedCfValues = 0; $deletedCfValues = 0; - // Map proposed by (typeId, position) keys — position defaults to array index - $proposedPieceKeys = []; - foreach ($proposedPieces as $i => $pp) { - $pos = $pp['position'] ?? $i; - $proposedPieceKeys[$pp['typePieceId'].'|'.$pos] = true; + // Build proposed typeId lists (one entry per requirement, order = position) + $proposedPieceTypeIds = []; + foreach ($proposedPieces as $pp) { + $proposedPieceTypeIds[] = $pp['typePieceId']; } - $proposedProductKeys = []; - foreach ($proposedProducts as $i => $pp) { - $pos = $pp['position'] ?? $i; - $proposedProductKeys[$pp['typeProductId'].'|'.$pos] = true; + $proposedProductTypeIds = []; + foreach ($proposedProducts as $pp) { + $proposedProductTypeIds[] = $pp['typeProductId']; } - $proposedSubKeys = []; - foreach ($proposedSubcomponents as $i => $ps) { - $pos = $ps['position'] ?? $i; - $proposedSubKeys[$ps['typeComposantId'].'|'.$pos] = true; + $proposedSubTypeIds = []; + foreach ($proposedSubcomponents as $ps) { + $proposedSubTypeIds[] = $ps['typeComposantId']; } // Map proposed custom fields by orderIndex (falls back to array index) @@ -102,59 +99,26 @@ class ComposantSyncStrategy implements SyncStrategyInterface } foreach ($composants as $composant) { - // Piece slots — query from repository to avoid stale collection - $pieceSlots = $this->em->getRepository(ComposantPieceSlot::class)->findBy(['composant' => $composant]); - $existingPieceKeys = []; - foreach ($pieceSlots as $slot) { - $key = ($slot->getTypePiece()?->getId() ?? '').'|'.$slot->getPosition(); - $existingPieceKeys[$key] = true; - } - foreach ($proposedPieceKeys as $key => $_) { - if (!isset($existingPieceKeys[$key])) { - ++$addedPieceSlots; - } - } - foreach ($existingPieceKeys as $key => $_) { - if (!isset($proposedPieceKeys[$key])) { - ++$deletedPieceSlots; - } - } + // Piece slots + $pieceSlots = $this->em->getRepository(ComposantPieceSlot::class)->findBy(['composant' => $composant]); + $existingPieceTypes = array_map(fn (ComposantPieceSlot $s) => $s->getTypePiece()?->getId() ?? '', $pieceSlots); + $result = $this->smartMatchPreview($existingPieceTypes, $proposedPieceTypeIds); + $addedPieceSlots += $result['added']; + $deletedPieceSlots += $result['deleted']; // Product slots - $productSlots = $this->em->getRepository(ComposantProductSlot::class)->findBy(['composant' => $composant]); - $existingProductKeys = []; - foreach ($productSlots as $slot) { - $key = ($slot->getTypeProduct()?->getId() ?? '').'|'.$slot->getPosition(); - $existingProductKeys[$key] = true; - } - foreach ($proposedProductKeys as $key => $_) { - if (!isset($existingProductKeys[$key])) { - ++$addedProductSlots; - } - } - foreach ($existingProductKeys as $key => $_) { - if (!isset($proposedProductKeys[$key])) { - ++$deletedProductSlots; - } - } + $productSlots = $this->em->getRepository(ComposantProductSlot::class)->findBy(['composant' => $composant]); + $existingProductTypes = array_map(fn (ComposantProductSlot $s) => $s->getTypeProduct()?->getId() ?? '', $productSlots); + $result = $this->smartMatchPreview($existingProductTypes, $proposedProductTypeIds); + $addedProductSlots += $result['added']; + $deletedProductSlots += $result['deleted']; // Subcomponent slots - $subSlots = $this->em->getRepository(ComposantSubcomponentSlot::class)->findBy(['composant' => $composant]); - $existingSubKeys = []; - foreach ($subSlots as $slot) { - $key = ($slot->getTypeComposant()?->getId() ?? '').'|'.$slot->getPosition(); - $existingSubKeys[$key] = true; - } - foreach ($proposedSubKeys as $key => $_) { - if (!isset($existingSubKeys[$key])) { - ++$addedSubSlots; - } - } - foreach ($existingSubKeys as $key => $_) { - if (!isset($proposedSubKeys[$key])) { - ++$deletedSubSlots; - } - } + $subSlots = $this->em->getRepository(ComposantSubcomponentSlot::class)->findBy(['composant' => $composant]); + $existingSubTypes = array_map(fn (ComposantSubcomponentSlot $s) => $s->getTypeComposant()?->getId() ?? '', $subSlots); + $result = $this->smartMatchPreview($existingSubTypes, $proposedSubTypeIds); + $addedSubSlots += $result['added']; + $deletedSubSlots += $result['deleted']; // Custom field values $addedCfValues += $cfAdded; @@ -187,31 +151,14 @@ class ComposantSyncStrategy implements SyncStrategyInterface $composants = $this->em->getRepository(Composant::class)->findBy(['typeComposant' => $modelType]); // Load skeleton requirements - $pieceReqs = $this->em->getRepository(SkeletonPieceRequirement::class)->findBy(['modelType' => $modelType]); - $productReqs = $this->em->getRepository(SkeletonProductRequirement::class)->findBy(['modelType' => $modelType]); - $subReqs = $this->em->getRepository(SkeletonSubcomponentRequirement::class)->findBy(['modelType' => $modelType]); + $pieceReqs = $this->em->getRepository(SkeletonPieceRequirement::class)->findBy(['modelType' => $modelType], ['position' => 'ASC']); + $productReqs = $this->em->getRepository(SkeletonProductRequirement::class)->findBy(['modelType' => $modelType], ['position' => 'ASC']); + $subReqs = $this->em->getRepository(SkeletonSubcomponentRequirement::class)->findBy(['modelType' => $modelType], ['position' => 'ASC']); $customFields = $this->em->getRepository(CustomField::class)->findBy( ['typeComposant' => $modelType], ['orderIndex' => 'ASC'] ); - // Map requirements by (typeId, position) - $pieceReqKeys = []; - foreach ($pieceReqs as $req) { - $pieceReqKeys[$req->getTypePiece()->getId().'|'.$req->getPosition()] = $req; - } - - $productReqKeys = []; - foreach ($productReqs as $req) { - $productReqKeys[$req->getTypeProduct()->getId().'|'.$req->getPosition()] = $req; - } - - $subReqKeys = []; - foreach ($subReqs as $req) { - $key = ($req->getTypeComposant()?->getId() ?? '').'|'.$req->getPosition(); - $subReqKeys[$key] = $req; - } - $addedPieceSlots = 0; $deletedPieceSlots = 0; $addedProductSlots = 0; @@ -225,108 +172,137 @@ class ComposantSyncStrategy implements SyncStrategyInterface foreach ($composants as $composant) { $changed = false; - // --- Piece slots — query from repository to avoid stale collection --- - $pieceSlotEntities = $this->em->getRepository(ComposantPieceSlot::class)->findBy(['composant' => $composant]); - $existingPieceSlots = []; - foreach ($pieceSlotEntities as $slot) { - $key = ($slot->getTypePiece()?->getId() ?? '').'|'.$slot->getPosition(); - $existingPieceSlots[$key] = $slot; - } + // --- Piece slots --- + $pieceSlotEntities = $this->em->getRepository(ComposantPieceSlot::class)->findBy(['composant' => $composant]); + $existingPieceTypeIds = array_map(fn (ComposantPieceSlot $s) => $s->getTypePiece()?->getId() ?? '', $pieceSlotEntities); + $reqPieceTypeIds = array_map(fn (SkeletonPieceRequirement $r) => $r->getTypePiece()->getId(), $pieceReqs); + $matchResult = $this->smartMatch($existingPieceTypeIds, $reqPieceTypeIds); - // Add missing piece slots - foreach ($pieceReqKeys as $key => $req) { - if (!isset($existingPieceSlots[$key])) { - $slot = new ComposantPieceSlot(); - $slot->setComposant($composant); - $slot->setTypePiece($req->getTypePiece()); + // Update matched slots (position may have changed) + foreach ($matchResult['matched'] as [$slotIdx, $reqIdx]) { + $slot = $pieceSlotEntities[$slotIdx]; + $req = $pieceReqs[$reqIdx]; + if ($slot->getPosition() !== $req->getPosition()) { $slot->setPosition($req->getPosition()); - // Default quantity = 1, selectedPiece = null (already defaults) - $this->em->persist($slot); - ++$addedPieceSlots; $changed = true; } } + // Add new piece slots for unmatched requirements + foreach ($matchResult['unmatchedReqs'] as $reqIdx) { + $req = $pieceReqs[$reqIdx]; + $slot = new ComposantPieceSlot(); + $slot->setComposant($composant); + $slot->setTypePiece($req->getTypePiece()); + $slot->setPosition($req->getPosition()); + $this->em->persist($slot); + ++$addedPieceSlots; + $changed = true; + } + // Delete orphaned piece slots if ($confirmation->confirmDeletions) { - foreach ($existingPieceSlots as $key => $slot) { - if (!isset($pieceReqKeys[$key])) { - $composant->removePieceSlot($slot); - $this->em->remove($slot); - ++$deletedPieceSlots; - $changed = true; - } + foreach ($matchResult['orphanedSlots'] as $slotIdx) { + $slot = $pieceSlotEntities[$slotIdx]; + $composant->removePieceSlot($slot); + $this->em->remove($slot); + ++$deletedPieceSlots; + $changed = true; } } // --- Product slots --- - $productSlotEntities = $this->em->getRepository(ComposantProductSlot::class)->findBy(['composant' => $composant]); - $existingProductSlots = []; - foreach ($productSlotEntities as $slot) { - $key = ($slot->getTypeProduct()?->getId() ?? '').'|'.$slot->getPosition(); - $existingProductSlots[$key] = $slot; - } + $productSlotEntities = $this->em->getRepository(ComposantProductSlot::class)->findBy(['composant' => $composant]); + $existingProductTypeIds = array_map(fn (ComposantProductSlot $s) => $s->getTypeProduct()?->getId() ?? '', $productSlotEntities); + $reqProductTypeIds = array_map(fn (SkeletonProductRequirement $r) => $r->getTypeProduct()->getId(), $productReqs); + $matchResult = $this->smartMatch($existingProductTypeIds, $reqProductTypeIds); - // Add missing product slots - foreach ($productReqKeys as $key => $req) { - if (!isset($existingProductSlots[$key])) { - $slot = new ComposantProductSlot(); - $slot->setComposant($composant); - $slot->setTypeProduct($req->getTypeProduct()); + // Update matched slots + foreach ($matchResult['matched'] as [$slotIdx, $reqIdx]) { + $slot = $productSlotEntities[$slotIdx]; + $req = $productReqs[$reqIdx]; + if ($slot->getPosition() !== $req->getPosition()) { $slot->setPosition($req->getPosition()); - if (null !== $req->getFamilyCode()) { - $slot->setFamilyCode($req->getFamilyCode()); - } - $this->em->persist($slot); - ++$addedProductSlots; $changed = true; } + if ($slot->getFamilyCode() !== $req->getFamilyCode()) { + $slot->setFamilyCode($req->getFamilyCode()); + $changed = true; + } + } + + // Add new product slots + foreach ($matchResult['unmatchedReqs'] as $reqIdx) { + $req = $productReqs[$reqIdx]; + $slot = new ComposantProductSlot(); + $slot->setComposant($composant); + $slot->setTypeProduct($req->getTypeProduct()); + $slot->setPosition($req->getPosition()); + if (null !== $req->getFamilyCode()) { + $slot->setFamilyCode($req->getFamilyCode()); + } + $this->em->persist($slot); + ++$addedProductSlots; + $changed = true; } // Delete orphaned product slots if ($confirmation->confirmDeletions) { - foreach ($existingProductSlots as $key => $slot) { - if (!isset($productReqKeys[$key])) { - $composant->removeProductSlot($slot); - $this->em->remove($slot); - ++$deletedProductSlots; - $changed = true; - } - } - } - - // --- Subcomponent slots --- - $subSlotEntities = $this->em->getRepository(ComposantSubcomponentSlot::class)->findBy(['composant' => $composant]); - $existingSubSlots = []; - foreach ($subSlotEntities as $slot) { - $key = ($slot->getTypeComposant()?->getId() ?? '').'|'.$slot->getPosition(); - $existingSubSlots[$key] = $slot; - } - - // Add missing subcomponent slots - foreach ($subReqKeys as $key => $req) { - if (!isset($existingSubSlots[$key])) { - $slot = new ComposantSubcomponentSlot(); - $slot->setComposant($composant); - $slot->setTypeComposant($req->getTypeComposant()); - $slot->setPosition($req->getPosition()); - $slot->setAlias($req->getAlias()); - $slot->setFamilyCode($req->getFamilyCode()); - $this->em->persist($slot); - ++$addedSubSlots; + foreach ($matchResult['orphanedSlots'] as $slotIdx) { + $slot = $productSlotEntities[$slotIdx]; + $composant->removeProductSlot($slot); + $this->em->remove($slot); + ++$deletedProductSlots; $changed = true; } } + // --- Subcomponent slots --- + $subSlotEntities = $this->em->getRepository(ComposantSubcomponentSlot::class)->findBy(['composant' => $composant]); + $existingSubTypeIds = array_map(fn (ComposantSubcomponentSlot $s) => $s->getTypeComposant()?->getId() ?? '', $subSlotEntities); + $reqSubTypeIds = array_map(fn (SkeletonSubcomponentRequirement $r) => $r->getTypeComposant()?->getId() ?? '', $subReqs); + $matchResult = $this->smartMatch($existingSubTypeIds, $reqSubTypeIds); + + // Update matched slots + foreach ($matchResult['matched'] as [$slotIdx, $reqIdx]) { + $slot = $subSlotEntities[$slotIdx]; + $req = $subReqs[$reqIdx]; + if ($slot->getPosition() !== $req->getPosition()) { + $slot->setPosition($req->getPosition()); + $changed = true; + } + if ($slot->getAlias() !== $req->getAlias()) { + $slot->setAlias($req->getAlias()); + $changed = true; + } + if ($slot->getFamilyCode() !== $req->getFamilyCode()) { + $slot->setFamilyCode($req->getFamilyCode()); + $changed = true; + } + } + + // Add new subcomponent slots + foreach ($matchResult['unmatchedReqs'] as $reqIdx) { + $req = $subReqs[$reqIdx]; + $slot = new ComposantSubcomponentSlot(); + $slot->setComposant($composant); + $slot->setTypeComposant($req->getTypeComposant()); + $slot->setPosition($req->getPosition()); + $slot->setAlias($req->getAlias()); + $slot->setFamilyCode($req->getFamilyCode()); + $this->em->persist($slot); + ++$addedSubSlots; + $changed = true; + } + // Delete orphaned subcomponent slots if ($confirmation->confirmDeletions) { - foreach ($existingSubSlots as $key => $slot) { - if (!isset($subReqKeys[$key])) { - $composant->removeSubcomponentSlot($slot); - $this->em->remove($slot); - ++$deletedSubSlots; - $changed = true; - } + foreach ($matchResult['orphanedSlots'] as $slotIdx) { + $slot = $subSlotEntities[$slotIdx]; + $composant->removeSubcomponentSlot($slot); + $this->em->remove($slot); + ++$deletedSubSlots; + $changed = true; } } @@ -389,4 +365,83 @@ class ComposantSyncStrategy implements SyncStrategyInterface ], ); } + + /** + * Smart-match existing slots to proposed requirements by typeId. + * + * Pass 1: exact match by typeId + position index. + * Pass 2: match remaining by typeId only (handles reordering/insertion). + * + * @param string[] $existingTypeIds typeIds of existing slots (index = slot index) + * @param string[] $proposedTypeIds typeIds of proposed requirements (index = req index) + * + * @return array{matched: list, orphanedSlots: int[], unmatchedReqs: int[]} + */ + private function smartMatch(array $existingTypeIds, array $proposedTypeIds): array + { + $matchedSlots = []; + $matchedReqs = []; + $matched = []; + + // Pass 1: exact match where typeId AND position index are identical + foreach ($proposedTypeIds as $reqIdx => $reqTypeId) { + if (isset($existingTypeIds[$reqIdx]) && $existingTypeIds[$reqIdx] === $reqTypeId && !isset($matchedSlots[$reqIdx])) { + $matched[] = [$reqIdx, $reqIdx]; + $matchedSlots[$reqIdx] = true; + $matchedReqs[$reqIdx] = true; + } + } + + // Pass 2: match remaining by typeId only (preserves selections on reorder) + $remainingSlotsByType = []; + foreach ($existingTypeIds as $slotIdx => $typeId) { + if (!isset($matchedSlots[$slotIdx])) { + $remainingSlotsByType[$typeId][] = $slotIdx; + } + } + + foreach ($proposedTypeIds as $reqIdx => $reqTypeId) { + if (!isset($matchedReqs[$reqIdx]) && !empty($remainingSlotsByType[$reqTypeId])) { + $slotIdx = array_shift($remainingSlotsByType[$reqTypeId]); + $matched[] = [$slotIdx, $reqIdx]; + $matchedSlots[$slotIdx] = true; + $matchedReqs[$reqIdx] = true; + } + } + + // Collect unmatched + $orphanedSlots = []; + foreach ($existingTypeIds as $slotIdx => $_) { + if (!isset($matchedSlots[$slotIdx])) { + $orphanedSlots[] = $slotIdx; + } + } + + $unmatchedReqs = []; + foreach ($proposedTypeIds as $reqIdx => $_) { + if (!isset($matchedReqs[$reqIdx])) { + $unmatchedReqs[] = $reqIdx; + } + } + + return ['matched' => $matched, 'orphanedSlots' => $orphanedSlots, 'unmatchedReqs' => $unmatchedReqs]; + } + + /** + * Preview version of smart matching — counts additions and deletions. + * + * @param string[] $existingTypeIds + * @param string[] $proposedTypeIds + * + * @return array{added: int, deleted: int} + */ + private function smartMatchPreview(array $existingTypeIds, array $proposedTypeIds): array + { + $result = $this->smartMatch($existingTypeIds, $proposedTypeIds); + + return [ + 'added' => count($result['unmatchedReqs']), + 'deleted' => count($result['orphanedSlots']), + ]; + } } diff --git a/src/Service/Sync/PieceSyncStrategy.php b/src/Service/Sync/PieceSyncStrategy.php index 4fb7c36..78c2146 100644 --- a/src/Service/Sync/PieceSyncStrategy.php +++ b/src/Service/Sync/PieceSyncStrategy.php @@ -41,11 +41,10 @@ class PieceSyncStrategy implements SyncStrategyInterface $addedCfValues = 0; $deletedCfValues = 0; - // Map proposed products by (typeProductId, position) keys — position defaults to array index - $proposedProductKeys = []; - foreach ($proposedProducts as $i => $pp) { - $pos = $pp['position'] ?? $i; - $proposedProductKeys[$pp['typeProductId'].'|'.$pos] = true; + // Build proposed typeId list + $proposedProductTypeIds = []; + foreach ($proposedProducts as $pp) { + $proposedProductTypeIds[] = $pp['typeProductId']; } // Map proposed custom fields by orderIndex (falls back to array index) @@ -80,23 +79,12 @@ class PieceSyncStrategy implements SyncStrategyInterface } foreach ($pieces as $piece) { - // Product slots - $productSlots = $this->em->getRepository(PieceProductSlot::class)->findBy(['piece' => $piece]); - $existingProductKeys = []; - foreach ($productSlots as $slot) { - $key = ($slot->getTypeProduct()?->getId() ?? '').'|'.$slot->getPosition(); - $existingProductKeys[$key] = true; - } - foreach ($proposedProductKeys as $key => $_) { - if (!isset($existingProductKeys[$key])) { - ++$addedProductSlots; - } - } - foreach ($existingProductKeys as $key => $_) { - if (!isset($proposedProductKeys[$key])) { - ++$deletedProductSlots; - } - } + // Product slots — smart matching by typeId + $productSlots = $this->em->getRepository(PieceProductSlot::class)->findBy(['piece' => $piece]); + $existingProductTypes = array_map(fn (PieceProductSlot $s) => $s->getTypeProduct()?->getId() ?? '', $productSlots); + $result = $this->smartMatchPreview($existingProductTypes, $proposedProductTypeIds); + $addedProductSlots += $result['added']; + $deletedProductSlots += $result['deleted']; // Custom field values $addedCfValues += $cfAdded; @@ -125,18 +113,12 @@ class PieceSyncStrategy implements SyncStrategyInterface $pieces = $this->em->getRepository(Piece::class)->findBy(['typePiece' => $modelType]); // Load skeleton requirements - $productReqs = $this->em->getRepository(SkeletonProductRequirement::class)->findBy(['modelType' => $modelType]); + $productReqs = $this->em->getRepository(SkeletonProductRequirement::class)->findBy(['modelType' => $modelType], ['position' => 'ASC']); $customFields = $this->em->getRepository(CustomField::class)->findBy( ['typePiece' => $modelType], ['orderIndex' => 'ASC'] ); - // Map requirements by (typeProductId, position) - $productReqKeys = []; - foreach ($productReqs as $req) { - $productReqKeys[$req->getTypeProduct()->getId().'|'.$req->getPosition()] = $req; - } - $addedProductSlots = 0; $deletedProductSlots = 0; $addedCfValues = 0; @@ -147,38 +129,48 @@ class PieceSyncStrategy implements SyncStrategyInterface $changed = false; // --- Product slots --- - $productSlotEntities = $this->em->getRepository(PieceProductSlot::class)->findBy(['piece' => $piece]); - $existingProductSlots = []; - foreach ($productSlotEntities as $slot) { - $key = ($slot->getTypeProduct()?->getId() ?? '').'|'.$slot->getPosition(); - $existingProductSlots[$key] = $slot; - } + $productSlotEntities = $this->em->getRepository(PieceProductSlot::class)->findBy(['piece' => $piece]); + $existingProductTypeIds = array_map(fn (PieceProductSlot $s) => $s->getTypeProduct()?->getId() ?? '', $productSlotEntities); + $reqProductTypeIds = array_map(fn (SkeletonProductRequirement $r) => $r->getTypeProduct()->getId(), $productReqs); + $matchResult = $this->smartMatch($existingProductTypeIds, $reqProductTypeIds); - // Add missing product slots - foreach ($productReqKeys as $key => $req) { - if (!isset($existingProductSlots[$key])) { - $slot = new PieceProductSlot(); - $slot->setPiece($piece); - $slot->setTypeProduct($req->getTypeProduct()); + // Update matched slots (position/familyCode may have changed) + foreach ($matchResult['matched'] as [$slotIdx, $reqIdx]) { + $slot = $productSlotEntities[$slotIdx]; + $req = $productReqs[$reqIdx]; + if ($slot->getPosition() !== $req->getPosition()) { $slot->setPosition($req->getPosition()); - if (null !== $req->getFamilyCode()) { - $slot->setFamilyCode($req->getFamilyCode()); - } - $this->em->persist($slot); - ++$addedProductSlots; + $changed = true; + } + if ($slot->getFamilyCode() !== $req->getFamilyCode()) { + $slot->setFamilyCode($req->getFamilyCode()); $changed = true; } } + // Add new product slots + foreach ($matchResult['unmatchedReqs'] as $reqIdx) { + $req = $productReqs[$reqIdx]; + $slot = new PieceProductSlot(); + $slot->setPiece($piece); + $slot->setTypeProduct($req->getTypeProduct()); + $slot->setPosition($req->getPosition()); + if (null !== $req->getFamilyCode()) { + $slot->setFamilyCode($req->getFamilyCode()); + } + $this->em->persist($slot); + ++$addedProductSlots; + $changed = true; + } + // Delete orphaned product slots if ($confirmation->confirmDeletions) { - foreach ($existingProductSlots as $key => $slot) { - if (!isset($productReqKeys[$key])) { - $piece->removeProductSlot($slot); - $this->em->remove($slot); - ++$deletedProductSlots; - $changed = true; - } + foreach ($matchResult['orphanedSlots'] as $slotIdx) { + $slot = $productSlotEntities[$slotIdx]; + $piece->removeProductSlot($slot); + $this->em->remove($slot); + ++$deletedProductSlots; + $changed = true; } } @@ -237,4 +229,81 @@ class PieceSyncStrategy implements SyncStrategyInterface ], ); } + + /** + * Smart-match existing slots to proposed requirements by typeId. + * + * Pass 1: exact match by typeId + position index. + * Pass 2: match remaining by typeId only (handles reordering/insertion). + * + * @param string[] $existingTypeIds typeIds of existing slots (index = slot index) + * @param string[] $proposedTypeIds typeIds of proposed requirements (index = req index) + * + * @return array{matched: list, orphanedSlots: int[], unmatchedReqs: int[]} + */ + private function smartMatch(array $existingTypeIds, array $proposedTypeIds): array + { + $matchedSlots = []; + $matchedReqs = []; + $matched = []; + + // Pass 1: exact match where typeId AND position index are identical + foreach ($proposedTypeIds as $reqIdx => $reqTypeId) { + if (isset($existingTypeIds[$reqIdx]) && $existingTypeIds[$reqIdx] === $reqTypeId && !isset($matchedSlots[$reqIdx])) { + $matched[] = [$reqIdx, $reqIdx]; + $matchedSlots[$reqIdx] = true; + $matchedReqs[$reqIdx] = true; + } + } + + // Pass 2: match remaining by typeId only (preserves selections on reorder) + $remainingSlotsByType = []; + foreach ($existingTypeIds as $slotIdx => $typeId) { + if (!isset($matchedSlots[$slotIdx])) { + $remainingSlotsByType[$typeId][] = $slotIdx; + } + } + + foreach ($proposedTypeIds as $reqIdx => $reqTypeId) { + if (!isset($matchedReqs[$reqIdx]) && !empty($remainingSlotsByType[$reqTypeId])) { + $slotIdx = array_shift($remainingSlotsByType[$reqTypeId]); + $matched[] = [$slotIdx, $reqIdx]; + $matchedSlots[$slotIdx] = true; + $matchedReqs[$reqIdx] = true; + } + } + + // Collect unmatched + $orphanedSlots = []; + foreach ($existingTypeIds as $slotIdx => $_) { + if (!isset($matchedSlots[$slotIdx])) { + $orphanedSlots[] = $slotIdx; + } + } + + $unmatchedReqs = []; + foreach ($proposedTypeIds as $reqIdx => $_) { + if (!isset($matchedReqs[$reqIdx])) { + $unmatchedReqs[] = $reqIdx; + } + } + + return ['matched' => $matched, 'orphanedSlots' => $orphanedSlots, 'unmatchedReqs' => $unmatchedReqs]; + } + + /** + * @param string[] $existingTypeIds + * @param string[] $proposedTypeIds + * + * @return array{added: int, deleted: int} + */ + private function smartMatchPreview(array $existingTypeIds, array $proposedTypeIds): array + { + $result = $this->smartMatch($existingTypeIds, $proposedTypeIds); + + return [ + 'added' => count($result['unmatchedReqs']), + 'deleted' => count($result['orphanedSlots']), + ]; + } }