feat(sync) : implement ComposantSyncStrategy with tests

This commit is contained in:
Matthieu
2026-03-13 14:00:59 +01:00
parent 6a20dcce54
commit f09c7e4782
2 changed files with 566 additions and 0 deletions

View File

@@ -0,0 +1,388 @@
<?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;
// 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,
],
);
}
}

View File

@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
namespace App\Tests\Api\Service;
use App\DTO\SyncConfirmation;
use App\Entity\SkeletonPieceRequirement;
use App\Enum\ModelCategory;
use App\Service\Sync\ComposantSyncStrategy;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class ComposantSyncStrategyTest extends AbstractApiTestCase
{
private ComposantSyncStrategy $strategy;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}