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>
448 lines
18 KiB
PHP
448 lines
18 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Service\Sync;
|
|
|
|
use App\DTO\SyncConfirmation;
|
|
use App\DTO\SyncExecutionResult;
|
|
use App\DTO\SyncPreviewResult;
|
|
use App\Entity\Composant;
|
|
use App\Entity\ComposantPieceSlot;
|
|
use App\Entity\ComposantProductSlot;
|
|
use App\Entity\ComposantSubcomponentSlot;
|
|
use App\Entity\CustomField;
|
|
use App\Entity\CustomFieldValue;
|
|
use App\Entity\ModelType;
|
|
use App\Entity\SkeletonPieceRequirement;
|
|
use App\Entity\SkeletonProductRequirement;
|
|
use App\Entity\SkeletonSubcomponentRequirement;
|
|
use App\Enum\ModelCategory;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
|
|
|
|
#[AutoconfigureTag('app.sync_strategy')]
|
|
class ComposantSyncStrategy implements SyncStrategyInterface
|
|
{
|
|
public function __construct(
|
|
private readonly EntityManagerInterface $em,
|
|
) {}
|
|
|
|
public function supports(ModelType $modelType): bool
|
|
{
|
|
return ModelCategory::COMPONENT === $modelType->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<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']),
|
|
];
|
|
}
|
|
}
|