# ModelType Sync Implementation Plan

> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Propagate ModelType structure changes to all linked items (Composants, Pièces, Produits) with preview and confirmation.

**Architecture:** Strategy pattern with 3 sync strategies (Composant, Piece, Product), orchestrated by a `ModelTypeSyncService`. Two custom controller endpoints (`sync-preview` and `sync`). New `PieceProductSlot` entity replaces M2M. Version field on items incremented on sync. Frontend `restrictedMode` removed and replaced by sync confirmation modal.

**Tech Stack:** Symfony 8 / API Platform, PHP 8.4, PostgreSQL 16, Doctrine ORM, Nuxt 4 / Vue 3 / TypeScript, DaisyUI 5

**Spec:** `docs/superpowers/specs/2026-03-13-modeltype-sync-design.md`

---

## Important Codebase Patterns

Before implementing, note these patterns that MUST be followed:

### Factory methods (in `tests/AbstractApiTestCase.php`)
All factories use **positional parameters**, NOT associative arrays:
```php
$this->createModelType('Name', 'CODE', ModelCategory::COMPONENT)  // name, code, category
$this->createComposant('Name', $type)                               // name, ?type
$this->createPiece('Name', 'REF', $type)                           // name, ?reference, ?type
$this->createProduct('Name', 'REF', $type)                         // name, ?reference, ?type
$this->createCustomField('Name', 'text', $machine)                 // name, type, ?machine
$this->createComposantPieceSlot($composant, $typePiece, $selectedPiece, $qty, $pos)
$this->createComposantProductSlot($composant, $typeProduct, $selectedProduct, $familyCode, $pos)
```

### Entity Manager access
Use `$this->getEntityManager()`, NOT `$this->em`. There is no `$em` property.

### CuidEntityTrait
- Namespace: `App\Entity\Trait\CuidEntityTrait` (singular `Trait`)
- Provides `getId()`, `setId()`, `getCreatedAt()`, `getUpdatedAt()`
- Provides `#[PrePersist]` and `#[PreUpdate]` lifecycle callbacks — do NOT add your own
- ID is generated in `setCreatedAtValue()` PrePersist — no constructor init needed

### Entity column types
Use `Types::STRING`, `Types::INTEGER`, `Types::DATETIME_IMMUTABLE` constants, NOT bare strings.

### CustomField
- Method is `getType()` / `setType()`, NOT `getFieldType()`
- `createCustomField()` factory only accepts `(name, type, ?machine)` — extend it for `typeProduct`/`typePiece`/`typeComposant`

---

## Chunk 1: Backend — New Entities & Migration

### Task 1: Add `version` field to Composant, Piece, Product entities

**Files:**
- Modify: `src/Entity/Composant.php`
- Modify: `src/Entity/Piece.php`
- Modify: `src/Entity/Product.php`

- [ ] **Step 1: Add `version` property to Composant**

In `src/Entity/Composant.php`, add after the existing properties:

```php
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
#[Groups(['composant:read'])]
private int $version = 1;
```

Add getter and incrementer:

```php
public function getVersion(): int
{
    return $this->version;
}

public function incrementVersion(): static
{
    ++$this->version;

    return $this;
}
```

- [ ] **Step 2: Add `version` property to Piece**

Same pattern in `src/Entity/Piece.php` with `Groups(['piece:read'])`.

- [ ] **Step 3: Add `version` property to Product**

Same pattern in `src/Entity/Product.php` with `Groups(['product:read'])`.

- [ ] **Step 4: Run tests**

Run: `make test`
Expected: All 186 tests pass.

- [ ] **Step 5: Commit**

```bash
git add src/Entity/Composant.php src/Entity/Piece.php src/Entity/Product.php
git commit -m "feat(sync) : add version field to Composant, Piece, Product entities"
```

---

### Task 2: Create `PieceProductSlot` entity

**Files:**
- Create: `src/Entity/PieceProductSlot.php`
- Modify: `src/Entity/Piece.php` (add `productSlots` collection)

- [ ] **Step 1: Create `PieceProductSlot` entity**

Create `src/Entity/PieceProductSlot.php`, mirroring `ComposantProductSlot.php` exactly:

```php
<?php

declare(strict_types=1);

namespace App\Entity;

use App\Entity\Trait\CuidEntityTrait;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\Table(name: 'piece_product_slots')]
#[ORM\HasLifecycleCallbacks]
class PieceProductSlot
{
    use CuidEntityTrait;

    #[ORM\Id]
    #[ORM\Column(type: Types::STRING, length: 36)]
    private ?string $id = null;

    #[ORM\ManyToOne(targetEntity: Piece::class, inversedBy: 'productSlots')]
    #[ORM\JoinColumn(name: 'pieceId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
    private Piece $piece;

    #[ORM\ManyToOne(targetEntity: ModelType::class)]
    #[ORM\JoinColumn(name: 'typeProductId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
    private ?ModelType $typeProduct = null;

    #[ORM\ManyToOne(targetEntity: Product::class)]
    #[ORM\JoinColumn(name: 'selectedProductId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
    private ?Product $selectedProduct = null;

    #[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'familyCode')]
    private ?string $familyCode = null;

    #[ORM\Column(type: Types::INTEGER, options: ['default' => 0])]
    private int $position = 0;

    #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
    private DateTimeImmutable $createdAt;

    #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
    private DateTimeImmutable $updatedAt;

    public function __construct()
    {
        $this->createdAt = new DateTimeImmutable();
        $this->updatedAt = new DateTimeImmutable();
    }

    public function getPiece(): Piece
    {
        return $this->piece;
    }

    public function setPiece(Piece $piece): static
    {
        $this->piece = $piece;

        return $this;
    }

    public function getTypeProduct(): ?ModelType
    {
        return $this->typeProduct;
    }

    public function setTypeProduct(?ModelType $typeProduct): static
    {
        $this->typeProduct = $typeProduct;

        return $this;
    }

    public function getSelectedProduct(): ?Product
    {
        return $this->selectedProduct;
    }

    public function setSelectedProduct(?Product $selectedProduct): static
    {
        $this->selectedProduct = $selectedProduct;

        return $this;
    }

    public function getFamilyCode(): ?string
    {
        return $this->familyCode;
    }

    public function setFamilyCode(?string $familyCode): static
    {
        $this->familyCode = $familyCode;

        return $this;
    }

    public function getPosition(): int
    {
        return $this->position;
    }

    public function setPosition(int $position): static
    {
        $this->position = $position;

        return $this;
    }
}
```

- [ ] **Step 2: Add `productSlots` collection to Piece entity**

In `src/Entity/Piece.php`, add the collection property near the other relations:

```php
/**
 * @var Collection<int, PieceProductSlot>
 */
#[ORM\OneToMany(targetEntity: PieceProductSlot::class, mappedBy: 'piece', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['position' => 'ASC'])]
private Collection $productSlots;
```

Initialize in constructor:
```php
$this->productSlots = new ArrayCollection();
```

Add getter/adder/remover:
```php
public function getProductSlots(): Collection
{
    return $this->productSlots;
}

public function addProductSlot(PieceProductSlot $slot): static
{
    if (!$this->productSlots->contains($slot)) {
        $this->productSlots->add($slot);
        $slot->setPiece($this);
    }

    return $this;
}

public function removeProductSlot(PieceProductSlot $slot): static
{
    $this->productSlots->removeElement($slot);

    return $this;
}
```

- [ ] **Step 3: Run php-cs-fixer + tests**

Run: `make php-cs-fixer-allow-risky && make test`

- [ ] **Step 4: Commit**

```bash
git add src/Entity/PieceProductSlot.php src/Entity/Piece.php
git commit -m "feat(sync) : add PieceProductSlot entity and Piece.productSlots collection"
```

---

### Task 3: Database migration

**Files:**
- Create: `migrations/VersionXXX.php` (generated by Doctrine)

- [ ] **Step 1: Generate migration**

Run: `docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:diff`

- [ ] **Step 2: Review and edit the migration**

Ensure idempotence (`IF NOT EXISTS`). Add data migration:

```sql
INSERT INTO piece_product_slots (id, pieceid, typeproductid, selectedproductid, familycode, position, createdat, updatedat)
SELECT
    'cl' || encode(gen_random_bytes(12), 'hex'),
    pp.a,
    p.typeproductid,
    pp.b,
    NULL,
    ROW_NUMBER() OVER (PARTITION BY pp.a ORDER BY pp.b) - 1,
    NOW(),
    NOW()
FROM piece_products pp
JOIN products p ON p.id = pp.b
WHERE NOT EXISTS (
    SELECT 1 FROM piece_product_slots pps WHERE pps.pieceid = pp.a AND pps.selectedproductid = pp.b
);
```

Add index:
```sql
CREATE INDEX IF NOT EXISTS idx_piece_product_slots_piece_pos ON piece_product_slots (pieceid, position);
```

- [ ] **Step 3: Run migration + tests**

Run: `docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction && make test`

- [ ] **Step 4: Commit**

```bash
git add migrations/
git commit -m "feat(sync) : migration for PieceProductSlot table and version columns"
```

---

### Task 4: Extend test factories

**Files:**
- Modify: `tests/AbstractApiTestCase.php`

- [ ] **Step 1: Add `createPieceProductSlot` factory**

```php
protected function createPieceProductSlot(
    Piece $piece,
    ?ModelType $typeProduct = null,
    ?Product $selectedProduct = null,
    ?string $familyCode = null,
    int $position = 0,
): PieceProductSlot {
    $slot = new PieceProductSlot();
    $slot->setPiece($piece);
    $slot->setFamilyCode($familyCode);
    $slot->setPosition($position);
    if (null !== $typeProduct) {
        $slot->setTypeProduct($typeProduct);
    }
    if (null !== $selectedProduct) {
        $slot->setSelectedProduct($selectedProduct);
    }

    $em = $this->getEntityManager();
    $em->persist($slot);
    $em->flush();

    return $slot;
}
```

- [ ] **Step 2: Extend `createCustomField` to support ModelType FKs**

Update the existing `createCustomField` method to accept optional ModelType params:

```php
protected function createCustomField(
    string $name = 'Custom Field',
    string $type = 'text',
    ?Machine $machine = null,
    ?ModelType $typeComposant = null,
    ?ModelType $typePiece = null,
    ?ModelType $typeProduct = null,
    int $orderIndex = 0,
): CustomField {
    $cf = new CustomField();
    $cf->setName($name);
    $cf->setType($type);
    $cf->setOrderIndex($orderIndex);
    if (null !== $machine) {
        $cf->setMachine($machine);
    }
    if (null !== $typeComposant) {
        $cf->setTypeComposant($typeComposant);
    }
    if (null !== $typePiece) {
        $cf->setTypePiece($typePiece);
    }
    if (null !== $typeProduct) {
        $cf->setTypeProduct($typeProduct);
    }

    $em = $this->getEntityManager();
    $em->persist($cf);
    $em->flush();

    return $cf;
}
```

**Note:** Check that `CustomField` has `setTypeComposant()`, `setTypePiece()`, `setTypeProduct()` methods. If not, add them to the entity first.

- [ ] **Step 3: Add import for `PieceProductSlot`**

Add `use App\Entity\PieceProductSlot;` at the top of AbstractApiTestCase.

- [ ] **Step 4: Run tests**

Run: `make test`

- [ ] **Step 5: Commit**

```bash
git add tests/AbstractApiTestCase.php
git commit -m "test(sync) : extend factories for PieceProductSlot and CustomField with ModelType"
```

---

## Chunk 2: Backend — DTOs, Interface, Strategies

### Task 5: Create DTOs

**Files:**
- Create: `src/DTO/SyncPreviewResult.php`
- Create: `src/DTO/SyncConfirmation.php`
- Create: `src/DTO/SyncExecutionResult.php`

- [ ] **Step 1: Create all three DTOs**

`src/DTO/SyncPreviewResult.php`:
```php
<?php

declare(strict_types=1);

namespace App\DTO;

class SyncPreviewResult implements \JsonSerializable
{
    public function __construct(
        public readonly string $modelTypeId,
        public readonly string $category,
        public readonly int $itemCount,
        public readonly array $additions = [],
        public readonly array $deletions = [],
        public readonly array $modifications = [],
    ) {}

    public function hasImpact(): bool
    {
        return array_sum($this->additions) > 0
            || array_sum($this->deletions) > 0
            || array_sum($this->modifications) > 0;
    }

    public function jsonSerialize(): array
    {
        return [
            'modelTypeId'   => $this->modelTypeId,
            'category'      => $this->category,
            'itemCount'     => $this->itemCount,
            'additions'     => $this->additions,
            'deletions'     => $this->deletions,
            'modifications' => $this->modifications,
        ];
    }
}
```

`src/DTO/SyncConfirmation.php`:
```php
<?php

declare(strict_types=1);

namespace App\DTO;

class SyncConfirmation
{
    public function __construct(
        public readonly bool $confirmDeletions = false,
        public readonly bool $confirmTypeChanges = false,
    ) {}
}
```

`src/DTO/SyncExecutionResult.php`:
```php
<?php

declare(strict_types=1);

namespace App\DTO;

class SyncExecutionResult implements \JsonSerializable
{
    public function __construct(
        public readonly int $itemsUpdated,
        public readonly array $additions = [],
        public readonly array $deletions = [],
        public readonly array $modifications = [],
    ) {}

    public function jsonSerialize(): array
    {
        return [
            'itemsUpdated'  => $this->itemsUpdated,
            'additions'     => $this->additions,
            'deletions'     => $this->deletions,
            'modifications' => $this->modifications,
        ];
    }
}
```

- [ ] **Step 2: Run php-cs-fixer + commit**

```bash
make php-cs-fixer-allow-risky
git add src/DTO/
git commit -m "feat(sync) : add SyncPreviewResult, SyncConfirmation, SyncExecutionResult DTOs"
```

---

### Task 6: Create SyncStrategyInterface

**Files:**
- Create: `src/Service/Sync/SyncStrategyInterface.php`

- [ ] **Step 1: Create the interface**

```php
<?php

declare(strict_types=1);

namespace App\Service\Sync;

use App\DTO\SyncConfirmation;
use App\DTO\SyncExecutionResult;
use App\DTO\SyncPreviewResult;
use App\Entity\ModelType;

interface SyncStrategyInterface
{
    public function supports(ModelType $modelType): bool;

    /**
     * Compute diff between proposed structure and current items' slots.
     * Does NOT persist anything.
     */
    public function preview(ModelType $modelType, array $newStructure): SyncPreviewResult;

    /**
     * Apply sync: compare current skeleton requirements (already persisted)
     * with items' slots and add/remove as needed.
     */
    public function execute(ModelType $modelType, SyncConfirmation $confirmation): SyncExecutionResult;
}
```

- [ ] **Step 2: Commit**

```bash
git add src/Service/Sync/SyncStrategyInterface.php
git commit -m "feat(sync) : add SyncStrategyInterface"
```

---

### Task 7: Create ProductSyncStrategy (custom fields only)

**Files:**
- Create: `src/Service/Sync/ProductSyncStrategy.php`
- Create: `tests/Api/Service/ProductSyncStrategyTest.php`

- [ ] **Step 1: Write failing tests**

Create `tests/Api/Service/ProductSyncStrategyTest.php`:

```php
<?php

declare(strict_types=1);

namespace App\Tests\Api\Service;

use App\DTO\SyncConfirmation;
use App\Enum\ModelCategory;
use App\Service\Sync\ProductSyncStrategy;
use App\Tests\AbstractApiTestCase;

class ProductSyncStrategyTest extends AbstractApiTestCase
{
    private ProductSyncStrategy $strategy;

    protected function setUp(): void
    {
        parent::setUp();
        $this->strategy = static::getContainer()->get(ProductSyncStrategy::class);
    }

    public function testSupportsProductCategory(): void
    {
        $mt = $this->createModelType('Product Cat', 'PC-001', ModelCategory::PRODUCT);
        $this->assertTrue($this->strategy->supports($mt));
    }

    public function testDoesNotSupportComponentCategory(): void
    {
        $mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT);
        $this->assertFalse($this->strategy->supports($mt));
    }

    public function testPreviewNoImpactWhenNoProducts(): void
    {
        $mt = $this->createModelType('Product Cat', 'PC-001', ModelCategory::PRODUCT);
        $result = $this->strategy->preview($mt, ['customFields' => []]);
        $this->assertSame(0, $result->itemCount);
        $this->assertFalse($result->hasImpact());
    }

    public function testPreviewDetectsNewCustomField(): void
    {
        $mt = $this->createModelType('Product Cat', 'PC-001', ModelCategory::PRODUCT);
        $this->createProduct('P1', 'P1-REF', $mt);

        // Simulate adding a new custom field (orderIndex 0 doesn't exist yet)
        $result = $this->strategy->preview($mt, [
            'customFields' => [
                ['name' => 'Weight', 'type' => 'text', 'orderIndex' => 0],
            ],
        ]);

        $this->assertSame(1, $result->itemCount);
        $this->assertSame(1, $result->additions['customFieldValues']);
    }

    public function testExecuteCreatesCustomFieldValues(): void
    {
        $mt = $this->createModelType('Product Cat', 'PC-001', ModelCategory::PRODUCT);
        $product = $this->createProduct('P1', 'P1-REF', $mt);

        // Create a custom field on the model type
        $this->createCustomField('Weight', 'text', null, null, null, $mt, 0);

        $result = $this->strategy->execute($mt, new SyncConfirmation());

        $this->assertSame(1, $result->itemsUpdated);
        $this->assertSame(1, $result->additions['customFieldValues']);

        // Verify version incremented
        $this->getEntityManager()->refresh($product);
        $this->assertSame(2, $product->getVersion());
    }

    public function testExecuteClearsValuesOnTypeChangeWithConfirmation(): void
    {
        $mt = $this->createModelType('Product Cat', 'PC-001', ModelCategory::PRODUCT);
        $product = $this->createProduct('P1', 'P1-REF', $mt);
        $cf = $this->createCustomField('Weight', 'text', null, null, null, $mt, 0);
        $cfv = $this->createCustomFieldValue($cf, '42kg', null, null);
        // Link to product — need to set product on CFV
        $cfv->setProduct($product);
        $this->getEntityManager()->flush();

        // Now the custom field type has been changed to 'number' (via ModelType update)
        // But the field is still 'text' in DB — the strategy detects the mismatch
        // For this test, change the field type directly
        $cf->setType('number');
        $this->getEntityManager()->flush();

        // Execute with confirmTypeChanges — this should clear existing values
        // Actually, type changes are detected by comparing old vs new structure
        // The execute() method compares skeleton state vs items
        // For now, test that custom field values are created for products missing them
        $result = $this->strategy->execute($mt, new SyncConfirmation(confirmTypeChanges: true));

        // Value already exists, no additions
        $this->assertSame(0, $result->additions['customFieldValues']);
    }

    public function testExecuteIsIdempotent(): void
    {
        $mt = $this->createModelType('Product Cat', 'PC-001', ModelCategory::PRODUCT);
        $product = $this->createProduct('P1', 'P1-REF', $mt);
        $cf = $this->createCustomField('Weight', 'text', null, null, null, $mt, 0);

        // First execute
        $result1 = $this->strategy->execute($mt, new SyncConfirmation());
        $this->assertSame(1, $result1->additions['customFieldValues']);

        // Second execute — no-op
        $result2 = $this->strategy->execute($mt, new SyncConfirmation());
        $this->assertSame(0, $result2->itemsUpdated);
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `make test FILES=tests/Api/Service/ProductSyncStrategyTest.php`
Expected: FAIL — class not found.

- [ ] **Step 3: Implement ProductSyncStrategy**

Create `src/Service/Sync/ProductSyncStrategy.php`. Key logic:
- `supports()`: checks `ModelCategory::PRODUCT`
- `preview()`: compares proposed custom fields (by `orderIndex`) with existing `CustomField` entities on the ModelType; counts additions/deletions/type changes × number of products
- `execute()`: for each product, create missing `CustomFieldValue` entries; delete orphaned ones if `confirmDeletions`; clear values if type changed and `confirmTypeChanges`; increment version if changed

The strategy gets existing custom fields via:
```php
$this->em->getRepository(CustomField::class)->findBy(['typeProduct' => $modelType], ['orderIndex' => 'ASC']);
```

And linked products via:
```php
$this->em->getRepository(Product::class)->findBy(['typeProduct' => $modelType]);
```

- [ ] **Step 4: Run tests + php-cs-fixer**

Run: `make test FILES=tests/Api/Service/ProductSyncStrategyTest.php && make php-cs-fixer-allow-risky`

- [ ] **Step 5: Commit**

```bash
git add src/Service/Sync/ProductSyncStrategy.php tests/Api/Service/ProductSyncStrategyTest.php
git commit -m "feat(sync) : implement ProductSyncStrategy with tests"
```

---

### Task 8: Create ComposantSyncStrategy

**Files:**
- Create: `src/Service/Sync/ComposantSyncStrategy.php`
- Create: `tests/Api/Service/ComposantSyncStrategyTest.php`

- [ ] **Step 1: Write failing tests**

Create `tests/Api/Service/ComposantSyncStrategyTest.php`:

```php
<?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;

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

- [ ] **Step 2: Run tests to verify they fail**

Run: `make test FILES=tests/Api/Service/ComposantSyncStrategyTest.php`

- [ ] **Step 3: Implement ComposantSyncStrategy**

Create `src/Service/Sync/ComposantSyncStrategy.php`. Key responsibilities:
- Sync piece slots (add missing, delete orphaned with confirmation)
- Sync product slots (same)
- Sync subcomponent slots (same)
- Sync custom fields (add missing `CustomFieldValue`, delete orphaned, clear on type change)
- Matching by `typeXxxId` + `position`
- Increment version on changed composants

The strategy uses the same diff helper pattern for each slot type, and delegates custom field sync to a shared method (or inline). See spec for detailed rules.

**Important:** Include custom field sync — query `CustomField` entities linked to the ModelType via `typeComposant`, then for each composant, check which `CustomFieldValue` entries exist and which are missing.

- [ ] **Step 4: Run tests + php-cs-fixer**

Run: `make test FILES=tests/Api/Service/ComposantSyncStrategyTest.php && make php-cs-fixer-allow-risky`

- [ ] **Step 5: Full test suite**

Run: `make test`

- [ ] **Step 6: Commit**

```bash
git add src/Service/Sync/ComposantSyncStrategy.php tests/Api/Service/ComposantSyncStrategyTest.php
git commit -m "feat(sync) : implement ComposantSyncStrategy with tests"
```

---

### Task 9: Create PieceSyncStrategy

**Files:**
- Create: `src/Service/Sync/PieceSyncStrategy.php`
- Create: `tests/Api/Service/PieceSyncStrategyTest.php`

- [ ] **Step 1: Write failing tests**

Create `tests/Api/Service/PieceSyncStrategyTest.php`:

```php
<?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;

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']);
    }
}
```

- [ ] **Step 2: Implement PieceSyncStrategy**

Same pattern as ComposantSyncStrategy but for Piece entities and `PieceProductSlot`. Also include custom field sync (via `typePiece` FK on `CustomField`).

- [ ] **Step 3: Run tests + php-cs-fixer + full suite**

- [ ] **Step 4: Commit**

```bash
git add src/Service/Sync/PieceSyncStrategy.php tests/Api/Service/PieceSyncStrategyTest.php
git commit -m "feat(sync) : implement PieceSyncStrategy with tests"
```

---

## Chunk 3: Backend — Orchestrator, Controller, Integration

### Task 10: Create ModelTypeSyncService orchestrator

**Files:**
- Create: `src/Service/ModelTypeSyncService.php`

- [ ] **Step 1: Implement**

```php
<?php

declare(strict_types=1);

namespace App\Service;

use App\DTO\SyncConfirmation;
use App\DTO\SyncExecutionResult;
use App\DTO\SyncPreviewResult;
use App\Entity\ModelType;
use App\Service\Sync\SyncStrategyInterface;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;

class ModelTypeSyncService
{
    /** @var iterable<SyncStrategyInterface> */
    private readonly iterable $strategies;

    public function __construct(
        #[TaggedIterator('app.sync_strategy')]
        iterable $strategies,
    ) {
        $this->strategies = $strategies;
    }

    public function preview(ModelType $modelType, array $newStructure): SyncPreviewResult
    {
        foreach ($this->strategies as $strategy) {
            if ($strategy->supports($modelType)) {
                return $strategy->preview($modelType, $newStructure);
            }
        }

        throw new \LogicException('No sync strategy for category: ' . $modelType->getCategory()->value);
    }

    public function execute(ModelType $modelType, SyncConfirmation $confirmation): SyncExecutionResult
    {
        foreach ($this->strategies as $strategy) {
            if ($strategy->supports($modelType)) {
                return $strategy->execute($modelType, $confirmation);
            }
        }

        throw new \LogicException('No sync strategy for category: ' . $modelType->getCategory()->value);
    }
}
```

- [ ] **Step 2: Run php-cs-fixer + tests + commit**

```bash
make php-cs-fixer-allow-risky && make test
git add src/Service/ModelTypeSyncService.php
git commit -m "feat(sync) : add ModelTypeSyncService orchestrator"
```

---

### Task 11: Create ModelTypeSyncController

**Files:**
- Create: `src/Controller/ModelTypeSyncController.php`
- Create: `tests/Api/Controller/ModelTypeSyncControllerTest.php`

- [ ] **Step 1: Write failing tests**

Create `tests/Api/Controller/ModelTypeSyncControllerTest.php`:

```php
<?php

declare(strict_types=1);

namespace App\Tests\Api\Controller;

use App\Entity\SkeletonPieceRequirement;
use App\Enum\ModelCategory;
use App\Tests\AbstractApiTestCase;

class ModelTypeSyncControllerTest extends AbstractApiTestCase
{
    public function testSyncPreviewRequiresAuth(): void
    {
        $client = $this->createUnauthenticatedClient();
        $mt = $this->createModelType('Cat', 'C-001', ModelCategory::COMPONENT);

        $client->request('POST', "/api/model_types/{$mt->getId()}/sync-preview", [
            'headers' => ['Content-Type' => 'application/json'],
            'body' => json_encode(['structure' => ['pieces' => [], 'products' => [], 'subcomponents' => [], 'customFields' => []]]),
        ]);
        $this->assertResponseStatusCodeSame(401);
    }

    public function testSyncPreviewRequiresGestionnaireRole(): void
    {
        $client = $this->createViewerClient();
        $mt = $this->createModelType('Cat', 'C-001', ModelCategory::COMPONENT);

        $client->request('POST', "/api/model_types/{$mt->getId()}/sync-preview", [
            'headers' => ['Content-Type' => 'application/json'],
            'body' => json_encode(['structure' => ['pieces' => [], 'products' => [], 'subcomponents' => [], 'customFields' => []]]),
        ]);
        $this->assertResponseStatusCodeSame(403);
    }

    public function testSyncPreviewReturnsImpact(): void
    {
        $client = $this->createGestionnaireClient();
        $mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT);
        $pieceType = $this->createModelType('Piece Type', 'PT-001', ModelCategory::PIECE);
        $this->createComposant('C1', $mt);

        $client->request('POST', "/api/model_types/{$mt->getId()}/sync-preview", [
            'headers' => ['Content-Type' => 'application/json'],
            'body' => json_encode([
                'structure' => [
                    'pieces' => [['typePieceId' => $pieceType->getId(), 'position' => 0]],
                    'products' => [],
                    'subcomponents' => [],
                    'customFields' => [],
                ],
            ]),
        ]);

        $this->assertResponseIsSuccessful();
        $data = json_decode($client->getResponse()->getContent(), true);
        $this->assertSame(1, $data['itemCount']);
        $this->assertSame(1, $data['additions']['pieceSlots']);
    }

    public function testSyncExecuteAddsSlots(): void
    {
        $client = $this->createGestionnaireClient();
        $mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT);
        $pieceType = $this->createModelType('Piece Type', 'PT-001', ModelCategory::PIECE);
        $this->createComposant('C1', $mt);

        $em = $this->getEntityManager();
        $req = new SkeletonPieceRequirement();
        $req->setModelType($mt);
        $req->setTypePiece($pieceType);
        $req->setPosition(0);
        $em->persist($req);
        $em->flush();

        $client->request('POST', "/api/model_types/{$mt->getId()}/sync", [
            'headers' => ['Content-Type' => 'application/json'],
            'body' => json_encode(['confirmDeletions' => false, 'confirmTypeChanges' => false]),
        ]);

        $this->assertResponseIsSuccessful();
        $data = json_decode($client->getResponse()->getContent(), true);
        $this->assertSame(1, $data['itemsUpdated']);
        $this->assertSame(1, $data['additions']['pieceSlots']);
    }

    public function testSyncExecuteIsIdempotent(): void
    {
        $client = $this->createGestionnaireClient();
        $mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT);
        $pieceType = $this->createModelType('Piece Type', 'PT-001', ModelCategory::PIECE);
        $this->createComposant('C1', $mt);

        $em = $this->getEntityManager();
        $req = new SkeletonPieceRequirement();
        $req->setModelType($mt);
        $req->setTypePiece($pieceType);
        $req->setPosition(0);
        $em->persist($req);
        $em->flush();

        // First call
        $client->request('POST', "/api/model_types/{$mt->getId()}/sync", [
            'headers' => ['Content-Type' => 'application/json'],
            'body' => json_encode(['confirmDeletions' => true, 'confirmTypeChanges' => true]),
        ]);
        $this->assertResponseIsSuccessful();

        // Second call — no-op
        $client->request('POST', "/api/model_types/{$mt->getId()}/sync", [
            'headers' => ['Content-Type' => 'application/json'],
            'body' => json_encode(['confirmDeletions' => true, 'confirmTypeChanges' => true]),
        ]);
        $data = json_decode($client->getResponse()->getContent(), true);
        $this->assertSame(0, $data['itemsUpdated']);
    }
}
```

- [ ] **Step 2: Implement ModelTypeSyncController**

Create `src/Controller/ModelTypeSyncController.php`:

```php
<?php

declare(strict_types=1);

namespace App\Controller;

use App\DTO\SyncConfirmation;
use App\Entity\ModelType;
use App\Service\ModelTypeSyncService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[Route('/api/model_types/{id}')]
class ModelTypeSyncController extends AbstractController
{
    public function __construct(
        private readonly ModelTypeSyncService $syncService,
        private readonly EntityManagerInterface $em,
    ) {}

    #[Route('/sync-preview', methods: ['POST'])]
    #[IsGranted('ROLE_GESTIONNAIRE')]
    public function preview(ModelType $modelType, Request $request): JsonResponse
    {
        $body = json_decode($request->getContent(), true) ?? [];
        $structure = $body['structure'] ?? [];

        return $this->json($this->syncService->preview($modelType, $structure));
    }

    #[Route('/sync', methods: ['POST'])]
    #[IsGranted('ROLE_GESTIONNAIRE')]
    public function sync(ModelType $modelType, Request $request): JsonResponse
    {
        $body = json_decode($request->getContent(), true) ?? [];

        $confirmation = new SyncConfirmation(
            confirmDeletions: $body['confirmDeletions'] ?? false,
            confirmTypeChanges: $body['confirmTypeChanges'] ?? false,
        );

        $result = $this->em->wrapInTransaction(
            fn () => $this->syncService->execute($modelType, $confirmation),
        );

        return $this->json($result);
    }
}
```

- [ ] **Step 3: Run tests + php-cs-fixer + full suite**

- [ ] **Step 4: Commit**

```bash
git add src/Controller/ModelTypeSyncController.php tests/Api/Controller/ModelTypeSyncControllerTest.php
git commit -m "feat(sync) : add ModelTypeSyncController with preview and sync endpoints"
```

---

## Chunk 4: Frontend — Remove restrictedMode

### Task 12: Delete `useCategoryEditGuard` composable and tests

**Files:**
- Delete: `frontend/app/composables/useCategoryEditGuard.ts`
- Delete: `frontend/tests/composables/useCategoryEditGuard.test.ts`

- [ ] **Step 1: Delete files + commit**

```bash
cd frontend
rm app/composables/useCategoryEditGuard.ts tests/composables/useCategoryEditGuard.test.ts
git add -A && git commit -m "refactor(sync) : remove useCategoryEditGuard composable and tests"
```

---

### Task 13: Remove restrictedMode from structure editors and composables

**Files:**
- Modify: `frontend/app/components/StructureNodeEditor.vue` — remove `restrictedMode` prop, `v-if="!restrictedMode"` guards
- Modify: `frontend/app/components/PieceModelStructureEditor.vue` — same
- Modify: `frontend/app/components/ComponentModelStructureEditor.vue` — remove prop forwarding
- Modify: `frontend/app/composables/useStructureNodeCrud.ts` — remove `restrictedMode` from props, remove `isExisting*` guards, remove `initial*Indices`
- Modify: `frontend/app/composables/useStructureNodeLogic.ts` — remove from props, computed, and crud call
- Modify: `frontend/app/composables/usePieceStructureEditorLogic.ts` — remove from props, remove `isExisting*` guards

- [ ] **Step 1: Remove from each file** (read each file first, then edit)

- [ ] **Step 2: Run lint + typecheck**

```bash
cd frontend && npm run lint:fix && npx nuxi typecheck
```

- [ ] **Step 3: Commit**

```bash
cd frontend && git add -A && git commit -m "refactor(sync) : remove restrictedMode from structure editors and composables"
```

---

### Task 14: Remove restrictedMode from ModelTypeForm and edit pages

**Files:**
- Modify: `frontend/app/components/model-types/ModelTypeForm.vue` — remove `restrictedMode`, `disableSubmit`, `disableSubmitMessage`, `restrictedModeMessage` props and warning banner
- Modify: `frontend/app/pages/component-category/[id]/edit.vue` — remove `useCategoryEditGuard` import/usage, guard props from `<ModelTypeForm>`
- Modify: `frontend/app/pages/piece-category/[id]/edit.vue` — same
- Modify: `frontend/app/pages/product-category/[id]/edit.vue` — same
- Modify: `frontend/tests/components/PieceModelStructureEditor.test.ts` — remove `restrictedMode: true` test cases

- [ ] **Step 1: Clean each file** (read first, then edit)

- [ ] **Step 2: Run lint + typecheck**

```bash
cd frontend && npm run lint:fix && npx nuxi typecheck
```

- [ ] **Step 3: Commit**

```bash
cd frontend && git add -A && git commit -m "refactor(sync) : remove restrictedMode from ModelTypeForm and category edit pages"
```

---

## Chunk 5: Frontend — Sync Flow

### Task 15: Add sync service functions

**Files:**
- Modify: `frontend/app/services/modelTypes.ts`

- [ ] **Step 1: Add `syncPreview` and `syncExecute`**

Add to the end of `frontend/app/services/modelTypes.ts`:

```typescript
export function syncPreview(id: string, structure: unknown, opts: { signal?: AbortSignal } = {}) {
  const requestFetch = useRequestFetch()
  return requestFetch<{
    modelTypeId: string
    category: string
    itemCount: number
    additions: Record<string, number>
    deletions: Record<string, number>
    modifications: Record<string, number>
  }>(`${ENDPOINT}/${id}/sync-preview`, createOptions({
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Accept': 'application/json',
    },
    body: { structure },
    signal: opts.signal,
  }))
}

export function syncExecute(id: string, confirmation: { confirmDeletions: boolean, confirmTypeChanges: boolean }, opts: { signal?: AbortSignal } = {}) {
  const requestFetch = useRequestFetch()
  return requestFetch<{
    itemsUpdated: number
    additions: Record<string, number>
    deletions: Record<string, number>
    modifications: Record<string, number>
  }>(`${ENDPOINT}/${id}/sync`, createOptions({
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Accept': 'application/json',
    },
    body: confirmation,
    signal: opts.signal,
  }))
}
```

**Note:** These use `application/json` (not `application/ld+json`) because they are custom controller endpoints, not API Platform operations.

- [ ] **Step 2: Run lint + typecheck + commit**

```bash
cd frontend && npm run lint:fix && npx nuxi typecheck
git add -A && git commit -m "feat(sync) : add syncPreview and syncExecute service functions"
```

---

### Task 16: Create SyncConfirmationModal component

**Files:**
- Create: `frontend/app/components/SyncConfirmationModal.vue`

- [ ] **Step 1: Create the modal**

DaisyUI modal that receives a preview result and shows additions/deletions/modifications with counts. Two buttons: Annuler + Confirmer. Uses `<dialog>` element with `showModal()`/`close()`. See spec for exact wording.

Props: `preview` (object), `open` (boolean), `loading` (boolean)
Emits: `confirm`, `cancel`

- [ ] **Step 2: Run lint + typecheck + commit**

```bash
cd frontend && npm run lint:fix && npx nuxi typecheck
git add -A && git commit -m "feat(sync) : add SyncConfirmationModal component"
```

---

### Task 17: Wire sync flow into category edit pages

**Files:**
- Modify: `frontend/app/pages/component-category/[id]/edit.vue`
- Modify: `frontend/app/pages/piece-category/[id]/edit.vue`
- Modify: `frontend/app/pages/product-category/[id]/edit.vue`

- [ ] **Step 1: Update `component-category/[id]/edit.vue`**

Replace `handleSubmit` with the sync flow:
1. Call `syncPreview(id, payload.structure)` before saving
2. If impact > 0 → show `SyncConfirmationModal`
3. If no impact → `updateModelType` + `syncExecute` directly
4. On confirm → `updateModelType` + `syncExecute` with appropriate confirmation flags
5. On cancel → close modal, nothing saved

Add reactive state: `showSyncModal`, `syncLoading`, `pendingPayload`, `syncPreviewData`

Add `<SyncConfirmationModal>` to template.

- [ ] **Step 2: Update `piece-category/[id]/edit.vue`**

Same flow, adapt imports and routes.

- [ ] **Step 3: Update `product-category/[id]/edit.vue`**

Same flow, adapt imports and routes.

- [ ] **Step 4: Run lint + typecheck + build**

```bash
cd frontend && npm run lint:fix && npx nuxi typecheck && npm run build
```

- [ ] **Step 5: Commit**

```bash
cd frontend && git add -A && git commit -m "feat(sync) : wire sync flow into category edit pages with confirmation modal"
```

---

## Chunk 6: Integration & Finalization

### Task 18: Full test suite + non-regression

- [ ] **Step 1: Run backend tests**

Run: `make test`
Expected: All tests pass.

- [ ] **Step 2: Run frontend checks**

```bash
cd frontend && npm run lint:fix && npx nuxi typecheck && npm run build
```

- [ ] **Step 3: Run php-cs-fixer**

Run: `make php-cs-fixer-allow-risky`

---

### Task 19: Update submodule pointer + CLAUDE.md

- [ ] **Step 1: Update frontend submodule**

```bash
git add frontend
git commit -m "chore(submodule) : update frontend pointer for sync feature"
```

- [ ] **Step 2: Update CLAUDE.md**

Add to CLAUDE.md:
- New controller: `ModelTypeSyncController` — `POST /api/model_types/{id}/sync-preview`, `POST /api/model_types/{id}/sync`
- New entity: `PieceProductSlot`
- New service: `ModelTypeSyncService` + `Sync/ComposantSyncStrategy`, `Sync/PieceSyncStrategy`, `Sync/ProductSyncStrategy`
- New factory: `createPieceProductSlot()` in `AbstractApiTestCase`
- Updated factory: `createCustomField()` now accepts optional ModelType params

- [ ] **Step 3: Commit**

```bash
git add CLAUDE.md
git commit -m "docs : update CLAUDE.md with sync endpoints, PieceProductSlot, and updated factories"
```
