getCategory(); } public function preview(ModelType $modelType, array $newStructure): SyncPreviewResult { $composants = $this->em->getRepository(Composant::class)->findBy(['typeComposant' => $modelType]); $proposedPieces = $newStructure['pieces'] ?? []; $proposedProducts = $newStructure['products'] ?? []; $proposedSubcomponents = $newStructure['subcomponents'] ?? []; $proposedCustomFields = $newStructure['customFields'] ?? []; $addedPieceSlots = 0; $deletedPieceSlots = 0; $addedProductSlots = 0; $deletedProductSlots = 0; $addedSubSlots = 0; $deletedSubSlots = 0; $addedCfValues = 0; $deletedCfValues = 0; // Build proposed typeId lists (one entry per requirement, order = position) $proposedPieceTypeIds = []; foreach ($proposedPieces as $pp) { $proposedPieceTypeIds[] = $pp['typePieceId']; } $proposedProductTypeIds = []; foreach ($proposedProducts as $pp) { $proposedProductTypeIds[] = $pp['typeProductId']; } $proposedSubTypeIds = []; foreach ($proposedSubcomponents as $ps) { $proposedSubTypeIds[] = $ps['typeComposantId']; } // Map proposed custom fields by orderIndex (falls back to array index) $proposedCfByOrder = []; foreach ($proposedCustomFields as $i => $pcf) { $order = $pcf['orderIndex'] ?? $i; $proposedCfByOrder[$order] = $pcf; } // Get existing custom fields for this model type $existingFields = $this->em->getRepository(CustomField::class)->findBy( ['typeComposant' => $modelType], ['orderIndex' => 'ASC'] ); $existingCfByOrder = []; foreach ($existingFields as $field) { $existingCfByOrder[$field->getOrderIndex()] = $field; } // Count custom field additions/deletions (definition-level, affects all composants) $cfAdded = 0; $cfDeleted = 0; foreach ($proposedCfByOrder as $orderIndex => $pcf) { if (!isset($existingCfByOrder[$orderIndex])) { ++$cfAdded; } } foreach ($existingCfByOrder as $orderIndex => $ef) { if (!isset($proposedCfByOrder[$orderIndex])) { ++$cfDeleted; } } foreach ($composants as $composant) { // 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]); $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]); $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; $deletedCfValues += $cfDeleted; } $itemCount = count($composants); return new SyncPreviewResult( modelTypeId: $modelType->getId(), category: 'component', itemCount: $itemCount, additions: [ 'pieceSlots' => $addedPieceSlots, 'productSlots' => $addedProductSlots, 'subcomponentSlots' => $addedSubSlots, 'customFieldValues' => $addedCfValues, ], deletions: [ 'pieceSlots' => $deletedPieceSlots, 'productSlots' => $deletedProductSlots, 'subcomponentSlots' => $deletedSubSlots, 'customFieldValues' => $deletedCfValues, ], ); } public function execute(ModelType $modelType, SyncConfirmation $confirmation): SyncExecutionResult { $composants = $this->em->getRepository(Composant::class)->findBy(['typeComposant' => $modelType]); // Load skeleton requirements $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'] ); $addedPieceSlots = 0; $deletedPieceSlots = 0; $addedProductSlots = 0; $deletedProductSlots = 0; $addedSubSlots = 0; $deletedSubSlots = 0; $addedCfValues = 0; $deletedCfValues = 0; $itemsUpdated = 0; foreach ($composants as $composant) { $changed = false; // --- 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); // 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()); $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 ($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]); $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); // Update matched slots foreach ($matchResult['matched'] as [$slotIdx, $reqIdx]) { $slot = $productSlotEntities[$slotIdx]; $req = $productReqs[$reqIdx]; if ($slot->getPosition() !== $req->getPosition()) { $slot->setPosition($req->getPosition()); $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 ($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 ($matchResult['orphanedSlots'] as $slotIdx) { $slot = $subSlotEntities[$slotIdx]; $composant->removeSubcomponentSlot($slot); $this->em->remove($slot); ++$deletedSubSlots; $changed = true; } } // --- Custom field values --- $existingValues = $this->em->getRepository(CustomFieldValue::class)->findBy([ 'composant' => $composant, ]); $existingByFieldId = []; foreach ($existingValues as $cfv) { $existingByFieldId[$cfv->getCustomField()->getId()] = $cfv; } // Add missing custom field values foreach ($customFields as $cf) { if (!isset($existingByFieldId[$cf->getId()])) { $cfv = new CustomFieldValue(); $cfv->setCustomField($cf); $cfv->setComposant($composant); $cfv->setValue(''); $this->em->persist($cfv); ++$addedCfValues; $changed = true; } } // Delete orphaned custom field values if ($confirmation->confirmDeletions) { $fieldIds = array_map(fn (CustomField $cf) => $cf->getId(), $customFields); foreach ($existingValues as $cfv) { if (!in_array($cfv->getCustomField()->getId(), $fieldIds, true)) { $this->em->remove($cfv); ++$deletedCfValues; $changed = true; } } } if ($changed) { $composant->incrementVersion(); ++$itemsUpdated; } } $this->em->flush(); return new SyncExecutionResult( itemsUpdated: $itemsUpdated, additions: [ 'pieceSlots' => $addedPieceSlots, 'productSlots' => $addedProductSlots, 'subcomponentSlots' => $addedSubSlots, 'customFieldValues' => $addedCfValues, ], deletions: [ 'pieceSlots' => $deletedPieceSlots, 'productSlots' => $deletedProductSlots, 'subcomponentSlots' => $deletedSubSlots, 'customFieldValues' => $deletedCfValues, ], ); } /** * 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']), ]; } }