diff --git a/config/services.yaml b/config/services.yaml index 9647068..d5e698e 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -45,3 +45,18 @@ when@test: autowire: true autoconfigure: true public: true + + App\Service\Sync\ComposantSyncStrategy: + autowire: true + autoconfigure: true + public: true + + App\Service\Sync\PieceSyncStrategy: + autowire: true + autoconfigure: true + public: true + + # App\Service\ModelTypeSyncService: + # autowire: true + # autoconfigure: true + # public: true diff --git a/src/Service/Sync/PieceSyncStrategy.php b/src/Service/Sync/PieceSyncStrategy.php new file mode 100644 index 0000000..32618eb --- /dev/null +++ b/src/Service/Sync/PieceSyncStrategy.php @@ -0,0 +1,238 @@ +getCategory(); + } + + public function preview(ModelType $modelType, array $newStructure): SyncPreviewResult + { + $pieces = $this->em->getRepository(Piece::class)->findBy(['typePiece' => $modelType]); + + $proposedProducts = $newStructure['products'] ?? []; + $proposedCustomFields = $newStructure['customFields'] ?? []; + + $addedProductSlots = 0; + $deletedProductSlots = 0; + $addedCfValues = 0; + $deletedCfValues = 0; + + // Map proposed products by (typeProductId, position) keys + $proposedProductKeys = []; + foreach ($proposedProducts as $pp) { + $proposedProductKeys[$pp['typeProductId'].'|'.$pp['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( + ['typePiece' => $modelType], + ['orderIndex' => 'ASC'] + ); + $existingCfByOrder = []; + foreach ($existingFields as $field) { + $existingCfByOrder[$field->getOrderIndex()] = $field; + } + + // Count custom field additions/deletions (definition-level, affects all pieces) + $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 ($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; + } + } + + // Custom field values + $addedCfValues += $cfAdded; + $deletedCfValues += $cfDeleted; + } + + $itemCount = count($pieces); + + return new SyncPreviewResult( + modelTypeId: $modelType->getId(), + category: 'piece', + itemCount: $itemCount, + additions: [ + 'productSlots' => $addedProductSlots, + 'customFieldValues' => $addedCfValues, + ], + deletions: [ + 'productSlots' => $deletedProductSlots, + 'customFieldValues' => $deletedCfValues, + ], + ); + } + + public function execute(ModelType $modelType, SyncConfirmation $confirmation): SyncExecutionResult + { + $pieces = $this->em->getRepository(Piece::class)->findBy(['typePiece' => $modelType]); + + // Load skeleton requirements + $productReqs = $this->em->getRepository(SkeletonProductRequirement::class)->findBy(['modelType' => $modelType]); + $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; + $deletedCfValues = 0; + $itemsUpdated = 0; + + foreach ($pieces as $piece) { + $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; + } + + // Add missing product slots + foreach ($productReqKeys as $key => $req) { + if (!isset($existingProductSlots[$key])) { + $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; + } + } + } + + // --- Custom field values --- + $existingValues = $this->em->getRepository(CustomFieldValue::class)->findBy([ + 'piece' => $piece, + ]); + + $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->setPiece($piece); + $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) { + $piece->incrementVersion(); + ++$itemsUpdated; + } + } + + $this->em->flush(); + + return new SyncExecutionResult( + itemsUpdated: $itemsUpdated, + additions: [ + 'productSlots' => $addedProductSlots, + 'customFieldValues' => $addedCfValues, + ], + deletions: [ + 'productSlots' => $deletedProductSlots, + 'customFieldValues' => $deletedCfValues, + ], + ); + } +} diff --git a/tests/Api/Service/PieceSyncStrategyTest.php b/tests/Api/Service/PieceSyncStrategyTest.php new file mode 100644 index 0000000..22ae5ee --- /dev/null +++ b/tests/Api/Service/PieceSyncStrategyTest.php @@ -0,0 +1,103 @@ +strategy = static::getContainer()->get(PieceSyncStrategy::class); + } + + public function testSupportsPieceCategory(): void + { + $mt = $this->createModelType('Piece Cat', 'PC-001', ModelCategory::PIECE); + $this->assertTrue($this->strategy->supports($mt)); + } + + public function testPreviewDetectsNewProductSlot(): void + { + $mt = $this->createModelType('Piece Cat', 'PC-001', ModelCategory::PIECE); + $productType = $this->createModelType('Product Type', 'PT-001', ModelCategory::PRODUCT); + $this->createPiece('P1', 'P1-REF', $mt); + + $result = $this->strategy->preview($mt, [ + 'products' => [['typeProductId' => $productType->getId(), 'position' => 0]], + 'customFields' => [], + ]); + + $this->assertSame(1, $result->itemCount); + $this->assertSame(1, $result->additions['productSlots']); + } + + public function testExecuteAddsProductSlots(): void + { + $mt = $this->createModelType('Piece Cat', 'PC-001', ModelCategory::PIECE); + $productType = $this->createModelType('Product Type', 'PT-001', ModelCategory::PRODUCT); + $piece = $this->createPiece('P1', 'P1-REF', $mt); + + $em = $this->getEntityManager(); + $req = new SkeletonProductRequirement(); + $req->setModelType($mt); + $req->setTypeProduct($productType); + $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['productSlots']); + + $em->refresh($piece); + $this->assertSame(2, $piece->getVersion()); + $this->assertCount(1, $piece->getProductSlots()); + } + + public function testExecuteDeletesWithConfirmation(): void + { + $mt = $this->createModelType('Piece Cat', 'PC-001', ModelCategory::PIECE); + $productType = $this->createModelType('Product Type', 'PT-001', ModelCategory::PRODUCT); + $piece = $this->createPiece('P1', 'P1-REF', $mt); + $this->createPieceProductSlot($piece, $productType, null, null, 0); + + $result = $this->strategy->execute($mt, new SyncConfirmation(confirmDeletions: true)); + $this->assertSame(1, $result->deletions['productSlots']); + } + + public function testExecuteIsIdempotent(): void + { + $mt = $this->createModelType('Piece Cat', 'PC-001', ModelCategory::PIECE); + $productType = $this->createModelType('Product Type', 'PT-001', ModelCategory::PRODUCT); + $piece = $this->createPiece('P1', 'P1-REF', $mt); + + $em = $this->getEntityManager(); + $req = new SkeletonProductRequirement(); + $req->setModelType($mt); + $req->setTypeProduct($productType); + $req->setPosition(0); + $em->persist($req); + $em->flush(); + + $result1 = $this->strategy->execute($mt, new SyncConfirmation()); + $this->assertSame(1, $result1->additions['productSlots']); + + $em->refresh($piece); + $result2 = $this->strategy->execute($mt, new SyncConfirmation()); + $this->assertSame(0, $result2->itemsUpdated); + } +}