feat(sync) : implement PieceSyncStrategy with tests

This commit is contained in:
Matthieu
2026-03-13 14:07:04 +01:00
parent f09c7e4782
commit 089ca43404
3 changed files with 356 additions and 0 deletions

View File

@@ -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

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

View 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);
}
}