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:
Matthieu
2026-03-16 11:32:14 +01:00
parent 0181f18778
commit 43bec07bb8
4 changed files with 481 additions and 254 deletions

View File

@@ -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']),
];
}
}