From 3f07162b94135cc4e62e9b300aa6df19e1ad98ca Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 13 Mar 2026 12:16:19 +0100 Subject: [PATCH] docs(sync) : add implementation plan for ModelType sync Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-03-13-modeltype-sync.md | 1582 +++++++++++++++++ 1 file changed, 1582 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-13-modeltype-sync.md diff --git a/docs/superpowers/plans/2026-03-13-modeltype-sync.md b/docs/superpowers/plans/2026-03-13-modeltype-sync.md new file mode 100644 index 0000000..c82ff95 --- /dev/null +++ b/docs/superpowers/plans/2026-03-13-modeltype-sync.md @@ -0,0 +1,1582 @@ +# 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 + 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 + */ +#[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 +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 + $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 +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 +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 +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 + */ + 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 +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 +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: `Inventory_frontend/app/composables/useCategoryEditGuard.ts` +- Delete: `Inventory_frontend/tests/composables/useCategoryEditGuard.test.ts` + +- [ ] **Step 1: Delete files + commit** + +```bash +cd Inventory_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: `Inventory_frontend/app/components/StructureNodeEditor.vue` — remove `restrictedMode` prop, `v-if="!restrictedMode"` guards +- Modify: `Inventory_frontend/app/components/PieceModelStructureEditor.vue` — same +- Modify: `Inventory_frontend/app/components/ComponentModelStructureEditor.vue` — remove prop forwarding +- Modify: `Inventory_frontend/app/composables/useStructureNodeCrud.ts` — remove `restrictedMode` from props, remove `isExisting*` guards, remove `initial*Indices` +- Modify: `Inventory_frontend/app/composables/useStructureNodeLogic.ts` — remove from props, computed, and crud call +- Modify: `Inventory_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 Inventory_frontend && npm run lint:fix && npx nuxi typecheck +``` + +- [ ] **Step 3: Commit** + +```bash +cd Inventory_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: `Inventory_frontend/app/components/model-types/ModelTypeForm.vue` — remove `restrictedMode`, `disableSubmit`, `disableSubmitMessage`, `restrictedModeMessage` props and warning banner +- Modify: `Inventory_frontend/app/pages/component-category/[id]/edit.vue` — remove `useCategoryEditGuard` import/usage, guard props from `` +- Modify: `Inventory_frontend/app/pages/piece-category/[id]/edit.vue` — same +- Modify: `Inventory_frontend/app/pages/product-category/[id]/edit.vue` — same +- Modify: `Inventory_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 Inventory_frontend && npm run lint:fix && npx nuxi typecheck +``` + +- [ ] **Step 3: Commit** + +```bash +cd Inventory_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: `Inventory_frontend/app/services/modelTypes.ts` + +- [ ] **Step 1: Add `syncPreview` and `syncExecute`** + +Add to the end of `Inventory_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 + deletions: Record + modifications: Record + }>(`${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 + deletions: Record + modifications: Record + }>(`${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 Inventory_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: `Inventory_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 `` 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 Inventory_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: `Inventory_frontend/app/pages/component-category/[id]/edit.vue` +- Modify: `Inventory_frontend/app/pages/piece-category/[id]/edit.vue` +- Modify: `Inventory_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 `` 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 Inventory_frontend && npm run lint:fix && npx nuxi typecheck && npm run build +``` + +- [ ] **Step 5: Commit** + +```bash +cd Inventory_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 Inventory_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 Inventory_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" +```