From f09c7e4782ca88b05f6305322be688f9a31c12f1 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 13 Mar 2026 14:00:59 +0100 Subject: [PATCH] feat(sync) : implement ComposantSyncStrategy with tests --- src/Service/Sync/ComposantSyncStrategy.php | 388 ++++++++++++++++++ .../Api/Service/ComposantSyncStrategyTest.php | 178 ++++++++ 2 files changed, 566 insertions(+) create mode 100644 src/Service/Sync/ComposantSyncStrategy.php create mode 100644 tests/Api/Service/ComposantSyncStrategyTest.php diff --git a/src/Service/Sync/ComposantSyncStrategy.php b/src/Service/Sync/ComposantSyncStrategy.php new file mode 100644 index 0000000..4837588 --- /dev/null +++ b/src/Service/Sync/ComposantSyncStrategy.php @@ -0,0 +1,388 @@ +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; + + // Map proposed by (typeId, position) keys + $proposedPieceKeys = []; + foreach ($proposedPieces as $pp) { + $proposedPieceKeys[$pp['typePieceId'].'|'.$pp['position']] = true; + } + + $proposedProductKeys = []; + foreach ($proposedProducts as $pp) { + $proposedProductKeys[$pp['typeProductId'].'|'.$pp['position']] = true; + } + + $proposedSubKeys = []; + foreach ($proposedSubcomponents as $ps) { + $proposedSubKeys[$ps['typeComposantId'].'|'.$ps['position']] = true; + } + + // Map proposed custom fields by orderIndex + $proposedCfByOrder = []; + foreach ($proposedCustomFields as $pcf) { + $proposedCfByOrder[$pcf['orderIndex']] = $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 — 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; + } + } + + // 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; + } + } + + // 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; + } + } + + // 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]); + $productReqs = $this->em->getRepository(SkeletonProductRequirement::class)->findBy(['modelType' => $modelType]); + $subReqs = $this->em->getRepository(SkeletonSubcomponentRequirement::class)->findBy(['modelType' => $modelType]); + $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; + $deletedProductSlots = 0; + $addedSubSlots = 0; + $deletedSubSlots = 0; + $addedCfValues = 0; + $deletedCfValues = 0; + $itemsUpdated = 0; + + 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; + } + + // Add missing piece slots + foreach ($pieceReqKeys as $key => $req) { + if (!isset($existingPieceSlots[$key])) { + $slot = new ComposantPieceSlot(); + $slot->setComposant($composant); + $slot->setTypePiece($req->getTypePiece()); + $slot->setPosition($req->getPosition()); + // Default quantity = 1, selectedPiece = null (already defaults) + $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; + } + } + } + + // --- 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; + } + + // Add missing product slots + foreach ($productReqKeys as $key => $req) { + if (!isset($existingProductSlots[$key])) { + $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; + $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; + } + } + } + + // --- 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, + ], + ); + } +} diff --git a/tests/Api/Service/ComposantSyncStrategyTest.php b/tests/Api/Service/ComposantSyncStrategyTest.php new file mode 100644 index 0000000..681f731 --- /dev/null +++ b/tests/Api/Service/ComposantSyncStrategyTest.php @@ -0,0 +1,178 @@ +strategy = static::getContainer()->get(ComposantSyncStrategy::class); + } + + public function testSupportsComponentCategory(): void + { + $mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT); + $this->assertTrue($this->strategy->supports($mt)); + } + + public function testPreviewNoImpactWhenNoComposants(): void + { + $mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT); + $result = $this->strategy->preview($mt, ['pieces' => [], 'products' => [], 'subcomponents' => [], 'customFields' => []]); + $this->assertSame(0, $result->itemCount); + $this->assertFalse($result->hasImpact()); + } + + public function testPreviewDetectsNewPieceSlot(): void + { + $mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT); + $pieceType = $this->createModelType('Piece Type', 'PT-001', ModelCategory::PIECE); + $this->createComposant('C1', $mt); + + $result = $this->strategy->preview($mt, [ + 'pieces' => [['typePieceId' => $pieceType->getId(), 'position' => 0]], + 'products' => [], + 'subcomponents' => [], + 'customFields' => [], + ]); + + $this->assertSame(1, $result->itemCount); + $this->assertSame(1, $result->additions['pieceSlots']); + } + + public function testPreviewDetectsSlotDeletion(): void + { + $mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT); + $pieceType = $this->createModelType('Piece Type', 'PT-001', ModelCategory::PIECE); + $composant = $this->createComposant('C1', $mt); + $this->createComposantPieceSlot($composant, $pieceType, null, 1, 0); + + $result = $this->strategy->preview($mt, [ + 'pieces' => [], + 'products' => [], + 'subcomponents' => [], + 'customFields' => [], + ]); + + $this->assertSame(1, $result->deletions['pieceSlots']); + } + + public function testPreviewNoImpactWhenSlotsMatch(): void + { + $mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT); + $pieceType = $this->createModelType('Piece Type', 'PT-001', ModelCategory::PIECE); + $composant = $this->createComposant('C1', $mt); + $this->createComposantPieceSlot($composant, $pieceType, null, 1, 0); + + $result = $this->strategy->preview($mt, [ + 'pieces' => [['typePieceId' => $pieceType->getId(), 'position' => 0]], + 'products' => [], + 'subcomponents' => [], + 'customFields' => [], + ]); + + $this->assertFalse($result->hasImpact()); + } + + public function testExecuteAddsMissingSlots(): void + { + $mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT); + $pieceType = $this->createModelType('Piece Type', 'PT-001', ModelCategory::PIECE); + $composant = $this->createComposant('C1', $mt); + + $em = $this->getEntityManager(); + $req = new SkeletonPieceRequirement(); + $req->setModelType($mt); + $req->setTypePiece($pieceType); + $req->setPosition(0); + $em->persist($req); + $em->flush(); + + $result = $this->strategy->execute($mt, new SyncConfirmation()); + + $this->assertSame(1, $result->itemsUpdated); + $this->assertSame(1, $result->additions['pieceSlots']); + + $em->refresh($composant); + $this->assertSame(2, $composant->getVersion()); + } + + public function testExecutePreservesExistingSelections(): void + { + $mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT); + $pieceType = $this->createModelType('Piece Type', 'PT-001', ModelCategory::PIECE); + $composant = $this->createComposant('C1', $mt); + $piece = $this->createPiece('P1', 'P1-REF', $pieceType); + $slot = $this->createComposantPieceSlot($composant, $pieceType, $piece, 5, 0); + + $em = $this->getEntityManager(); + $req = new SkeletonPieceRequirement(); + $req->setModelType($mt); + $req->setTypePiece($pieceType); + $req->setPosition(0); + $em->persist($req); + $em->flush(); + + $result = $this->strategy->execute($mt, new SyncConfirmation()); + + // No changes — slot already matches + $this->assertSame(0, $result->itemsUpdated); + + // Selection and quantity preserved + $em->refresh($slot); + $this->assertSame($piece->getId(), $slot->getSelectedPiece()->getId()); + $this->assertSame(5, $slot->getQuantity()); + } + + public function testExecuteDeletesSlotsOnlyWithConfirmation(): void + { + $mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT); + $pieceType = $this->createModelType('Piece Type', 'PT-001', ModelCategory::PIECE); + $composant = $this->createComposant('C1', $mt); + $this->createComposantPieceSlot($composant, $pieceType, null, 1, 0); + + // No skeleton requirements -> slot should be deleted + $result = $this->strategy->execute($mt, new SyncConfirmation(confirmDeletions: false)); + $this->assertSame(0, $result->deletions['pieceSlots']); + + // With confirmation + $result = $this->strategy->execute($mt, new SyncConfirmation(confirmDeletions: true)); + $this->assertSame(1, $result->deletions['pieceSlots']); + } + + public function testExecuteIsIdempotent(): void + { + $mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT); + $pieceType = $this->createModelType('Piece Type', 'PT-001', ModelCategory::PIECE); + $composant = $this->createComposant('C1', $mt); + + $em = $this->getEntityManager(); + $req = new SkeletonPieceRequirement(); + $req->setModelType($mt); + $req->setTypePiece($pieceType); + $req->setPosition(0); + $em->persist($req); + $em->flush(); + + $result1 = $this->strategy->execute($mt, new SyncConfirmation()); + $this->assertSame(1, $result1->additions['pieceSlots']); + + $em->refresh($composant); + $result2 = $this->strategy->execute($mt, new SyncConfirmation()); + $this->assertSame(0, $result2->itemsUpdated); + } +}