Files
Inventory/src/Service/Sync/ComposantSyncStrategy.php
Matthieu 43bec07bb8 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>
2026-03-16 11:32:14 +01:00

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