feat(sync) : implement PieceSyncStrategy with tests
This commit is contained in:
@@ -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
|
||||
|
||||
238
src/Service/Sync/PieceSyncStrategy.php
Normal file
238
src/Service/Sync/PieceSyncStrategy.php
Normal file
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Sync;
|
||||
|
||||
use App\DTO\SyncConfirmation;
|
||||
use App\DTO\SyncExecutionResult;
|
||||
use App\DTO\SyncPreviewResult;
|
||||
use App\Entity\CustomField;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\ModelType;
|
||||
use App\Entity\Piece;
|
||||
use App\Entity\PieceProductSlot;
|
||||
use App\Entity\SkeletonProductRequirement;
|
||||
use App\Enum\ModelCategory;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
|
||||
|
||||
#[AutoconfigureTag('app.sync_strategy')]
|
||||
class PieceSyncStrategy implements SyncStrategyInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function supports(ModelType $modelType): bool
|
||||
{
|
||||
return ModelCategory::PIECE === $modelType->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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
103
tests/Api/Service/PieceSyncStrategyTest.php
Normal file
103
tests/Api/Service/PieceSyncStrategyTest.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Api\Service;
|
||||
|
||||
use App\DTO\SyncConfirmation;
|
||||
use App\Entity\SkeletonProductRequirement;
|
||||
use App\Enum\ModelCategory;
|
||||
use App\Service\Sync\PieceSyncStrategy;
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class PieceSyncStrategyTest extends AbstractApiTestCase
|
||||
{
|
||||
private PieceSyncStrategy $strategy;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user