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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<int, array{typePieceId: string}> $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<int, array{typeProductId: string, familyCode?: ?string}> $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<int, array{alias?: string, familyCode?: string, typeComposantId?: string}> $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:
|
||||
|
||||
@@ -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<array{int, int}>, 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']),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<array{int, int}>, 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']),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user