# JSON to Relational Tables Normalization — 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:** Replace all structural JSON columns (ModelType skeletons, Composant.structure, Piece.productIds) with proper relational tables, enabling referential integrity, queryability, and consistent data display. **Architecture:** Three-phase migration following a dual-write/dual-read strategy to avoid breaking changes. Each phase creates new tables + entities, migrates existing data via a Doctrine migration, updates backend reads then writes, updates frontend, and finally drops the JSON column. **Tech Stack:** Symfony 8, Doctrine ORM (PHP 8 attributes), PostgreSQL 16, API Platform, Nuxt 4 frontend --- ## Scope — What Migrates vs. What Stays | JSON Column | Action | Reason | |---|---|---| | `ModelType.componentSkeleton` | **MIGRATE** → `skeleton_piece_requirements`, `skeleton_product_requirements`, `skeleton_subcomponent_requirements` | Template bill-of-materials needs FK integrity | | `ModelType.pieceSkeleton` | **MIGRATE** → reuse `skeleton_product_requirements` table (piece types can require product types too) | Contains `customFields` (already in `custom_fields` table) AND `products` array (product type requirements for piece types) | | `ModelType.productSkeleton` | **MIGRATE** → verify no structural data beyond `customFields`, then DROP | May contain `products` or other structural data — verify before dropping | | `Composant.structure` | **MIGRATE** → `composant_piece_slots`, `composant_subcomponent_slots`, `composant_product_slots` | Instance-level selections need FK integrity | | `Piece.productIds` | **MIGRATE** → `piece_products` join table | Simple M2M stored as JSON array | | `CustomField.options` | **KEEP JSON** | Truly polymorphic per-field-type config (selectOptions, min/max, step) | | `AuditLog.diff/snapshot` | **KEEP JSON** | Schemaless by nature — stores arbitrary entity change diffs | | `Profile.roles` | **KEEP JSON** | Standard Symfony UserInterface pattern | --- ## New Tables Design ### Layer 1 — Skeleton Requirements (what a ModelType needs) > **Note:** `skeleton_product_requirements` is shared by BOTH component and piece ModelTypes. > A component type can require piece types + product types + subcomponent types. > A piece type can require product types (e.g., lubricant for a bearing). > Denormalized fields like `typePieceLabel` are intentionally omitted — resolved at read-time via FK. ```sql -- Piece types required by a component ModelType CREATE TABLE skeleton_piece_requirements ( id VARCHAR(36) PRIMARY KEY, model_type_id VARCHAR(36) NOT NULL REFERENCES model_types(id) ON DELETE CASCADE, type_piece_id VARCHAR(36) NOT NULL REFERENCES model_types(id) ON DELETE CASCADE, position INT NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW() ); CREATE INDEX idx_skel_piece_req_model ON skeleton_piece_requirements(model_type_id); -- Product types required by a component OR piece ModelType CREATE TABLE skeleton_product_requirements ( id VARCHAR(36) PRIMARY KEY, model_type_id VARCHAR(36) NOT NULL REFERENCES model_types(id) ON DELETE CASCADE, type_product_id VARCHAR(36) NOT NULL REFERENCES model_types(id) ON DELETE CASCADE, family_code VARCHAR(255), position INT NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW() ); CREATE INDEX idx_skel_prod_req_model ON skeleton_product_requirements(model_type_id); -- Subcomponent types required by a component ModelType CREATE TABLE skeleton_subcomponent_requirements ( id VARCHAR(36) PRIMARY KEY, model_type_id VARCHAR(36) NOT NULL REFERENCES model_types(id) ON DELETE CASCADE, alias VARCHAR(255) NOT NULL, family_code VARCHAR(255) NOT NULL, type_composant_id VARCHAR(36) REFERENCES model_types(id) ON DELETE SET NULL, position INT NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW() ); CREATE INDEX idx_skel_sub_req_model ON skeleton_subcomponent_requirements(model_type_id); ``` > **Design decision — no `quantity` on skeleton requirements:** The skeleton defines WHICH types are needed, not how many. Quantity is an instance-level concern stored on `MachinePieceLink.quantity` (for machine context) or `composant_piece_slots.quantity` (for composant context). The current skeleton JSON has no `quantity` field on piece entries. ### Layer 2 — Composant Slots (instance-level selections) > **Design decision — `quantity` on piece slots:** The current `Composant.structure.pieces[].definition` JSON does NOT contain a `quantity` field. The actual quantity for machine pieces comes from `MachinePieceLink.quantity`. However, we add `quantity` to `composant_piece_slots` as the natural place to store "how many of this piece type does this composant need" — it will default to 1 from migration but can be set going forward. ```sql -- Actual piece selections for a composant instance CREATE TABLE composant_piece_slots ( id VARCHAR(36) PRIMARY KEY, composant_id VARCHAR(36) NOT NULL REFERENCES composants(id) ON DELETE CASCADE, type_piece_id VARCHAR(36) REFERENCES model_types(id) ON DELETE SET NULL, selected_piece_id VARCHAR(36) REFERENCES pieces(id) ON DELETE SET NULL, quantity INT NOT NULL DEFAULT 1, position INT NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW() ); CREATE INDEX idx_comp_piece_slot_composant ON composant_piece_slots(composant_id); CREATE INDEX idx_comp_piece_slot_piece ON composant_piece_slots(selected_piece_id); -- Actual subcomponent selections for a composant instance CREATE TABLE composant_subcomponent_slots ( id VARCHAR(36) PRIMARY KEY, composant_id VARCHAR(36) NOT NULL REFERENCES composants(id) ON DELETE CASCADE, alias VARCHAR(255), family_code VARCHAR(255), type_composant_id VARCHAR(36) REFERENCES model_types(id) ON DELETE SET NULL, selected_composant_id VARCHAR(36) REFERENCES composants(id) ON DELETE SET NULL, position INT NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW() ); CREATE INDEX idx_comp_sub_slot_composant ON composant_subcomponent_slots(composant_id); -- Actual product selections for a composant instance CREATE TABLE composant_product_slots ( id VARCHAR(36) PRIMARY KEY, composant_id VARCHAR(36) NOT NULL REFERENCES composants(id) ON DELETE CASCADE, type_product_id VARCHAR(36) REFERENCES model_types(id) ON DELETE SET NULL, selected_product_id VARCHAR(36) REFERENCES products(id) ON DELETE SET NULL, family_code VARCHAR(255), position INT NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW() ); CREATE INDEX idx_comp_prod_slot_composant ON composant_product_slots(composant_id); ``` ### Layer 3 — Piece-Product Join Table ```sql CREATE TABLE piece_products ( piece_id VARCHAR(36) NOT NULL REFERENCES pieces(id) ON DELETE CASCADE, product_id VARCHAR(36) NOT NULL REFERENCES products(id) ON DELETE CASCADE, PRIMARY KEY (piece_id, product_id) ); ``` --- ## Chunk 1: Phase 1 — Skeleton Requirements (ModelType) ### Task 1.1: Create Skeleton Entities **Files:** - Create: `src/Entity/SkeletonPieceRequirement.php` - Create: `src/Entity/SkeletonProductRequirement.php` - Create: `src/Entity/SkeletonSubcomponentRequirement.php` - Modify: `src/Entity/ModelType.php` - [ ] **Step 1: Create `SkeletonPieceRequirement` entity** ```php 0])] private int $position = 0; #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] private \DateTimeImmutable $createdAt; #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] private \DateTimeImmutable $updatedAt; public function __construct() { $this->id = 'cl' . bin2hex(random_bytes(12)); } // Getters & setters (id, modelType, typePiece, position, createdAt, updatedAt) #[ORM\PrePersist] public function onPrePersist(): void { $this->createdAt = new \DateTimeImmutable(); $this->updatedAt = new \DateTimeImmutable(); } #[ORM\PreUpdate] public function onPreUpdate(): void { $this->updatedAt = new \DateTimeImmutable(); } // ... all standard getters/setters } ``` - [ ] **Step 2: Create `SkeletonProductRequirement` entity** (same pattern with `VARCHAR(36)` IDs, `typeProduct` FK, `familyCode` varchar column; shared by component AND piece ModelTypes) - [ ] **Step 3: Create `SkeletonSubcomponentRequirement` entity** (same pattern, with `alias`, `familyCode`, `typeComposant` nullable FK) - [ ] **Step 4: Add OneToMany collections to `ModelType`** ```php // In ModelType.php — add these properties + constructor init + getters #[ORM\OneToMany(targetEntity: SkeletonPieceRequirement::class, mappedBy: 'modelType', cascade: ['persist', 'remove'], orphanRemoval: true)] #[ORM\OrderBy(['position' => 'ASC'])] private Collection $skeletonPieceRequirements; #[ORM\OneToMany(targetEntity: SkeletonProductRequirement::class, mappedBy: 'modelType', cascade: ['persist', 'remove'], orphanRemoval: true)] #[ORM\OrderBy(['position' => 'ASC'])] private Collection $skeletonProductRequirements; #[ORM\OneToMany(targetEntity: SkeletonSubcomponentRequirement::class, mappedBy: 'modelType', cascade: ['persist', 'remove'], orphanRemoval: true)] #[ORM\OrderBy(['position' => 'ASC'])] private Collection $skeletonSubcomponentRequirements; ``` - [ ] **Step 5: Run `make php-cs-fixer-allow-risky`** - [ ] **Step 6: Commit** ```bash git add src/Entity/SkeletonPieceRequirement.php src/Entity/SkeletonProductRequirement.php src/Entity/SkeletonSubcomponentRequirement.php src/Entity/ModelType.php git commit -m "feat(skeleton) : add skeleton requirement entities for ModelType" ``` --- ### Task 1.2: Create Doctrine Migration for Skeleton Tables + Data Migration **Files:** - Create: `migrations/VersionYYYYMMDDHHMMSS.php` - [ ] **Step 1: Generate migration** ```bash docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:diff ``` - [ ] **Step 2: Add data migration SQL to the `up()` method** After the CREATE TABLE statements, add SQL to migrate existing JSON data: ```sql -- Migrate componentSkeleton.pieces → skeleton_piece_requirements INSERT INTO skeleton_piece_requirements (id, model_type_id, type_piece_id, position, created_at, updated_at) SELECT 'cl' || encode(gen_random_bytes(12), 'hex'), mt.id, (piece->>'typePieceId'), ordinality - 1, NOW(), NOW() FROM model_types mt, LATERAL jsonb_array_elements(mt.componentskeleton->'pieces') WITH ORDINALITY AS t(piece, ordinality) WHERE mt.category = 'component' AND mt.componentskeleton IS NOT NULL AND mt.componentskeleton->'pieces' IS NOT NULL AND jsonb_array_length(mt.componentskeleton->'pieces') > 0; -- Migrate componentSkeleton.products → skeleton_product_requirements (component types) INSERT INTO skeleton_product_requirements (id, model_type_id, type_product_id, family_code, position, created_at, updated_at) SELECT 'cl' || encode(gen_random_bytes(12), 'hex'), mt.id, (product->>'typeProductId'), (product->>'familyCode'), ordinality - 1, NOW(), NOW() FROM model_types mt, LATERAL jsonb_array_elements(mt.componentskeleton->'products') WITH ORDINALITY AS t(product, ordinality) WHERE mt.category = 'component' AND mt.componentskeleton IS NOT NULL AND mt.componentskeleton->'products' IS NOT NULL AND jsonb_array_length(mt.componentskeleton->'products') > 0; -- Migrate pieceSkeleton.products → skeleton_product_requirements (piece types) INSERT INTO skeleton_product_requirements (id, model_type_id, type_product_id, family_code, position, created_at, updated_at) SELECT 'cl' || encode(gen_random_bytes(12), 'hex'), mt.id, (product->>'typeProductId'), (product->>'familyCode'), ordinality - 1, NOW(), NOW() FROM model_types mt, LATERAL jsonb_array_elements(mt.pieceskeleton->'products') WITH ORDINALITY AS t(product, ordinality) WHERE mt.category = 'piece' AND mt.pieceskeleton IS NOT NULL AND mt.pieceskeleton->'products' IS NOT NULL AND jsonb_array_length(mt.pieceskeleton->'products') > 0; -- Migrate componentSkeleton.subcomponents → skeleton_subcomponent_requirements INSERT INTO skeleton_subcomponent_requirements (id, model_type_id, alias, family_code, type_composant_id, position, created_at, updated_at) SELECT 'cl' || encode(gen_random_bytes(12), 'hex'), mt.id, COALESCE(sub->>'alias', ''), COALESCE(sub->>'familyCode', ''), NULLIF(sub->>'typeComposantId', ''), ordinality - 1, NOW(), NOW() FROM model_types mt, LATERAL jsonb_array_elements(mt.componentskeleton->'subcomponents') WITH ORDINALITY AS t(sub, ordinality) WHERE mt.category = 'component' AND mt.componentskeleton IS NOT NULL AND mt.componentskeleton->'subcomponents' IS NOT NULL AND jsonb_array_length(mt.componentskeleton->'subcomponents') > 0; ``` - [ ] **Step 3: Run migration** ```bash docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction ``` - [ ] **Step 4: Verify data migrated correctly** ```bash docker exec -u www-data php-inventory-apache php bin/console dbal:run-sql "SELECT COUNT(*) FROM skeleton_piece_requirements" docker exec -u www-data php-inventory-apache php bin/console dbal:run-sql "SELECT COUNT(*) FROM skeleton_product_requirements" docker exec -u www-data php-inventory-apache php bin/console dbal:run-sql "SELECT COUNT(*) FROM skeleton_subcomponent_requirements" ``` - [ ] **Step 5: Commit** ```bash git add migrations/ git commit -m "feat(skeleton) : create skeleton requirement tables and migrate data from JSON" ``` --- ### Task 1.3: Update ModelType to Expose Relations via API **Files:** - Modify: `src/Entity/ModelType.php` - [ ] **Step 1: Add serialization groups to new collections** ```php #[Groups(['modelType:read', 'modelType:structure'])] #[ORM\OneToMany(...)] private Collection $skeletonPieceRequirements; // Same for product and subcomponent requirements ``` - [ ] **Step 2: Add a `getStructureFromRelations()` method** that returns the same shape as `getStructure()` but built from the new relations (for dual-read during transition) ```php public function getStructureFromRelations(): array { $structure = ['customFields' => [], 'pieces' => [], 'products' => [], 'subcomponents' => []]; foreach ($this->skeletonPieceRequirements as $req) { $structure['pieces'][] = [ 'typePieceId' => $req->getTypePiece()->getId(), ]; } foreach ($this->skeletonProductRequirements as $req) { $structure['products'][] = [ 'typeProductId' => $req->getTypeProduct()->getId(), 'familyCode' => $req->getFamilyCode(), ]; } foreach ($this->skeletonSubcomponentRequirements as $req) { $structure['subcomponents'][] = [ 'alias' => $req->getAlias(), 'familyCode' => $req->getFamilyCode(), 'typeComposantId' => $req->getTypeComposant()?->getId(), ]; } return $structure; } ``` - [ ] **Step 3: Update `getStructure()` to read from relations for ALL categories** > **IMPORTANT:** Use `ModelCategory` enum, not string comparison. Piece types also have product requirements. ```php use App\Enum\ModelCategory; public function getStructure(): ?array { return match ($this->category) { ModelCategory::COMPONENT => $this->getStructureFromRelations(), ModelCategory::PIECE => [ 'customFields' => [], 'products' => array_map(fn($req) => [ 'typeProductId' => $req->getTypeProduct()->getId(), 'familyCode' => $req->getFamilyCode(), ], $this->skeletonProductRequirements->toArray()), ], ModelCategory::PRODUCT => [ 'customFields' => [], ], }; } ``` - [ ] **Step 4: Create a `SkeletonStructureService` to handle writing skeleton requirements** > The entity cannot resolve FKs (needs EntityManager). Create a service that handles `setStructure()` logic. ```php // src/Service/SkeletonStructureService.php class SkeletonStructureService { public function __construct(private EntityManagerInterface $em) {} public function updateSkeletonRequirements(ModelType $modelType, array $structure): void { // Clear existing $modelType->getSkeletonPieceRequirements()->clear(); $modelType->getSkeletonProductRequirements()->clear(); $modelType->getSkeletonSubcomponentRequirements()->clear(); // Create piece requirements foreach (($structure['pieces'] ?? []) as $i => $pieceData) { $req = new SkeletonPieceRequirement(); $req->setModelType($modelType); $req->setTypePiece($this->em->getReference(ModelType::class, $pieceData['typePieceId'])); $req->setPosition($i); $modelType->getSkeletonPieceRequirements()->add($req); } // Create product requirements (shared by component + piece types) foreach (($structure['products'] ?? []) as $i => $prodData) { $req = new SkeletonProductRequirement(); $req->setModelType($modelType); $req->setTypeProduct($this->em->getReference(ModelType::class, $prodData['typeProductId'])); $req->setFamilyCode($prodData['familyCode'] ?? null); $req->setPosition($i); $modelType->getSkeletonProductRequirements()->add($req); } // Create subcomponent requirements (component types only) foreach (($structure['subcomponents'] ?? []) as $i => $subData) { $req = new SkeletonSubcomponentRequirement(); $req->setModelType($modelType); $req->setAlias($subData['alias'] ?? ''); $req->setFamilyCode($subData['familyCode'] ?? ''); if (!empty($subData['typeComposantId'])) { $req->setTypeComposant($this->em->getReference(ModelType::class, $subData['typeComposantId'])); } $req->setPosition($i); $modelType->getSkeletonSubcomponentRequirements()->add($req); } } } ``` - [ ] **Step 5: Run `make php-cs-fixer-allow-risky` + `make test`** - [ ] **Step 6: Commit** ```bash git add src/Entity/ModelType.php git commit -m "feat(skeleton) : expose skeleton relations via API and update getStructure()" ``` --- ### Task 1.4: Update Controllers That Read/Write Skeletons **Files:** - Modify: `src/Controller/MachineStructureController.php` (reads skeleton for piece quantity resolution) - Modify: any controller that writes to ModelType skeleton - [ ] **Step 1: Read `MachineStructureController.php` — identify all skeleton read points** - [ ] **Step 2: Do NOT change `resolvePieceQuantity()` yet** — it reads `Composant.structure` JSON which is migrated in Phase 2. Keep the existing JSON-based implementation until Phase 2 is complete. Only update skeleton-related reads (e.g., ModelType `getStructure()` calls) in this phase. - [ ] **Step 3: Update any API endpoint that WRITES to ModelType skeleton to use the new relation entities** - [ ] **Step 4: Run `make test`** - [ ] **Step 5: Commit** --- ### Task 1.5: Verify Skeleton Custom Fields Are Fully in CustomField Entities **Files:** - Check: `fixtures/data.sql` — compare skeleton `customFields` JSON entries with `custom_fields` table entries - [ ] **Step 1: Write a verification SQL query** ```sql -- Find skeleton customFields that DON'T have a corresponding CustomField entity -- Handles both JSON formats: {key: "Name", value: {type: "text"}} and {name: "Name", type: "text"} SELECT mt.id, mt.name, mt.category, COALESCE(cf_json->>'key', cf_json->>'name') as field_name, COALESCE(cf_json->'value'->>'type', cf_json->>'type') as field_type FROM model_types mt, LATERAL jsonb_array_elements( CASE mt.category WHEN 'component' THEN mt.componentskeleton->'customFields' WHEN 'piece' THEN mt.pieceskeleton->'customFields' WHEN 'product' THEN mt.productskeleton->'customFields' END ) AS cf_json WHERE NOT EXISTS ( SELECT 1 FROM custom_fields cf WHERE (cf.typecomposantid = mt.id OR cf.typepieceid = mt.id OR cf.typeproductid = mt.id) AND cf.name = COALESCE(cf_json->>'key', cf_json->>'name') ); ``` - [ ] **Step 2: If any missing → write a migration to create the missing CustomField entities from skeleton JSON** - [ ] **Step 3: Commit if changes needed** --- ### Task 1.6: Drop Skeleton JSON Columns **Files:** - Create: `migrations/VersionYYYYMMDDHHMMSS.php` - Modify: `src/Entity/ModelType.php` - [ ] **Step 1: Remove `componentSkeleton`, `pieceSkeleton`, `productSkeleton` properties from ModelType entity** - [ ] **Step 2: Generate migration to drop the columns** ```sql ALTER TABLE model_types DROP COLUMN IF EXISTS componentskeleton; ALTER TABLE model_types DROP COLUMN IF EXISTS pieceskeleton; ALTER TABLE model_types DROP COLUMN IF EXISTS productskeleton; ``` - [ ] **Step 3: Remove `getStructureFromRelations()` (merge logic into `getStructure()`)** - [ ] **Step 4: Run `make test` + `make php-cs-fixer-allow-risky`** - [ ] **Step 5: Commit** --- ## Chunk 2: Phase 2 — Composant Structure Slots ### Task 2.1: Create Composant Slot Entities **Files:** - Create: `src/Entity/ComposantPieceSlot.php` - Create: `src/Entity/ComposantSubcomponentSlot.php` - Create: `src/Entity/ComposantProductSlot.php` - Modify: `src/Entity/Composant.php` - [ ] **Step 1: Create `ComposantPieceSlot` entity** ```php 1])] private int $quantity = 1; #[ORM\Column(type: Types::INTEGER, options: ['default' => 0])] private int $position = 0; #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] private \DateTimeImmutable $createdAt; #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] private \DateTimeImmutable $updatedAt; public function __construct() { $this->id = 'cl' . bin2hex(random_bytes(12)); } // ... lifecycle callbacks + getters/setters } ``` - [ ] **Step 2: Create `ComposantSubcomponentSlot` entity** (`VARCHAR(36)` IDs, alias, familyCode, typeComposant nullable FK, selectedComposant nullable FK) - [ ] **Step 3: Create `ComposantProductSlot` entity** (`VARCHAR(36)` IDs, typeProduct nullable FK, selectedProduct nullable FK, `familyCode` varchar column — preserves `definition.familyCode` from JSON) - [ ] **Step 4: Add OneToMany collections to `Composant` entity** ```php #[ORM\OneToMany(targetEntity: ComposantPieceSlot::class, mappedBy: 'composant', cascade: ['persist', 'remove'], orphanRemoval: true)] #[ORM\OrderBy(['position' => 'ASC'])] private Collection $pieceSlots; #[ORM\OneToMany(targetEntity: ComposantSubcomponentSlot::class, mappedBy: 'composant', cascade: ['persist', 'remove'], orphanRemoval: true)] #[ORM\OrderBy(['position' => 'ASC'])] private Collection $subcomponentSlots; #[ORM\OneToMany(targetEntity: ComposantProductSlot::class, mappedBy: 'composant', cascade: ['persist', 'remove'], orphanRemoval: true)] #[ORM\OrderBy(['position' => 'ASC'])] private Collection $productSlots; ``` - [ ] **Step 5: Run `make php-cs-fixer-allow-risky`** - [ ] **Step 6: Commit** --- ### Task 2.2: Create Migration + Migrate Composant Structure Data **Files:** - Create: `migrations/VersionYYYYMMDDHHMMSS.php` - [ ] **Step 1: Generate migration** - [ ] **Step 2: Add data migration SQL** ```sql -- Migrate composant.structure.pieces → composant_piece_slots -- Note: quantity defaults to 1 — the current JSON definition does NOT contain a quantity field. -- The actual quantity comes from MachinePieceLink.quantity. The slot quantity column exists -- for future use as the canonical place to store "how many of this piece type does this composant need". INSERT INTO composant_piece_slots (id, composant_id, type_piece_id, selected_piece_id, quantity, position, created_at, updated_at) SELECT 'cl' || encode(gen_random_bytes(12), 'hex'), c.id, NULLIF(piece->'definition'->>'typePieceId', ''), NULLIF(piece->>'selectedPieceId', ''), 1, ordinality - 1, NOW(), NOW() FROM composants c, LATERAL jsonb_array_elements(c.structure->'pieces') WITH ORDINALITY AS t(piece, ordinality) WHERE c.structure IS NOT NULL AND c.structure->'pieces' IS NOT NULL AND jsonb_array_length(c.structure->'pieces') > 0; -- Migrate composant.structure.subcomponents → composant_subcomponent_slots INSERT INTO composant_subcomponent_slots (id, composant_id, alias, family_code, type_composant_id, selected_composant_id, position, created_at, updated_at) SELECT 'cl' || encode(gen_random_bytes(12), 'hex'), c.id, COALESCE(sub->'definition'->>'alias', ''), COALESCE(sub->'definition'->>'familyCode', ''), NULLIF(sub->'definition'->>'typeComposantId', ''), NULLIF(sub->>'selectedComponentId', ''), ordinality - 1, NOW(), NOW() FROM composants c, LATERAL jsonb_array_elements(c.structure->'subcomponents') WITH ORDINALITY AS t(sub, ordinality) WHERE c.structure IS NOT NULL AND c.structure->'subcomponents' IS NOT NULL AND jsonb_array_length(c.structure->'subcomponents') > 0; -- Migrate composant.structure.products → composant_product_slots (including familyCode) INSERT INTO composant_product_slots (id, composant_id, type_product_id, selected_product_id, family_code, position, created_at, updated_at) SELECT 'cl' || encode(gen_random_bytes(12), 'hex'), c.id, NULLIF(prod->'definition'->>'typeProductId', ''), NULLIF(prod->>'selectedProductId', ''), prod->'definition'->>'familyCode', ordinality - 1, NOW(), NOW() FROM composants c, LATERAL jsonb_array_elements(c.structure->'products') WITH ORDINALITY AS t(prod, ordinality) WHERE c.structure IS NOT NULL AND c.structure->'products' IS NOT NULL AND jsonb_array_length(c.structure->'products') > 0; ``` - [ ] **Step 3: Run migration + verify counts** - [ ] **Step 4: Commit** --- ### Task 2.3: Update MachineStructureController to Read from Slot Tables **Files:** - Modify: `src/Controller/MachineStructureController.php` This is the critical task — the controller currently reads `Composant.structure` JSON to: 1. Enrich structure with resolved piece data (`enrichStructureWithPieceData()`) 2. Resolve piece quantities (`resolvePieceQuantity()`) 3. Normalize composant data (`normalizeComposant()`) - [ ] **Step 1: Read the full `MachineStructureController.php`** - [ ] **Step 2: Update `normalizeComposant()` to build the `structure` response from slot relations** ```php private function normalizeComposant(Composant $composant): array { $pieces = []; foreach ($composant->getPieceSlots() as $slot) { $pieceData = [ 'typePieceId' => $slot->getTypePiece()?->getId(), 'quantity' => $slot->getQuantity(), 'selectedPieceId' => $slot->getSelectedPiece()?->getId(), ]; if ($slot->getSelectedPiece()) { $pieceData['resolvedPiece'] = $this->normalizePiece($slot->getSelectedPiece()); } $pieces[] = $pieceData; } $subcomponents = []; foreach ($composant->getSubcomponentSlots() as $slot) { $subcomponents[] = [ 'alias' => $slot->getAlias(), 'familyCode' => $slot->getFamilyCode(), 'typeComposantId' => $slot->getTypeComposant()?->getId(), 'selectedComponentId' => $slot->getSelectedComposant()?->getId(), ]; } $products = []; foreach ($composant->getProductSlots() as $slot) { $products[] = [ 'typeProductId' => $slot->getTypeProduct()?->getId(), 'familyCode' => $slot->getFamilyCode(), 'selectedProductId' => $slot->getSelectedProduct()?->getId(), ]; } return [ // ... existing fields (id, name, reference, prix, etc.) 'structure' => [ 'pieces' => $pieces, 'subcomponents' => $subcomponents, 'products' => $products, ], // ... customFields, customFieldValues ]; } ``` - [ ] **Step 3: Update `resolvePieceQuantity()` to use slot relations** ```php private function resolvePieceQuantity(MachinePieceLink $pieceLink): int { $parentLink = $pieceLink->getParentLink(); if (!$parentLink) { return $pieceLink->getQuantity(); } // Match on the specific selected piece — avoids ambiguity when // multiple slots share the same typePiece $composant = $parentLink->getComposant(); $piece = $pieceLink->getPiece(); foreach ($composant->getPieceSlots() as $slot) { if ($slot->getSelectedPiece()?->getId() === $piece->getId()) { return $slot->getQuantity(); } } return $pieceLink->getQuantity(); } ``` - [ ] **Step 4: Remove `enrichStructureWithPieceData()` (no longer needed — piece data resolved inline in `normalizeComposant()`)** - [ ] **Step 5: Run `make test`** - [ ] **Step 6: Commit** --- ### Task 2.4: Update PATCH Structure Endpoint to Write to Slot Tables **Files:** - Modify: `src/Controller/MachineStructureController.php` - [ ] **Step 1: Identify where `Composant.structure` is written/updated in the controller** - [ ] **Step 2: Update any code that writes to `composant->setStructure(...)` to instead create/update slot entities** - [ ] **Step 3: Run `make test`** - [ ] **Step 4: Commit** --- ### Task 2.5: Update Frontend to Handle New Structure Format **Files:** - Modify: `frontend/app/composables/useMachineHierarchy.ts` — `buildMachineHierarchyFromLinks()` - Modify: `frontend/app/shared/utils/structureDisplayUtils.ts` - Modify: `frontend/app/shared/utils/structureSelectionUtils.ts` **Note:** The API response shape for `structure` stays the same (pieces/subcomponents/products arrays), so frontend changes should be minimal. The main change is that `path` fields are removed and `resolvedPiece` is now always populated inline. - [ ] **Step 1: Read the frontend files that consume structure data** - [ ] **Step 2: Update `buildMachineHierarchyFromLinks()` if it references `path` fields** - [ ] **Step 3: Update `structureDisplayUtils.ts` if it depends on old JSON shape** - [ ] **Step 4: Update `structureSelectionUtils.ts` — `collectStructureSelections()` to use new format** - [ ] **Step 5: Run `npm run lint:fix` + `npx nuxi typecheck`** - [ ] **Step 6: Commit in frontend submodule, then update pointer in main repo** --- ### Task 2.6: Drop Composant.structure JSON Column **Files:** - Modify: `src/Entity/Composant.php` - Create: `migrations/VersionYYYYMMDDHHMMSS.php` - [ ] **Step 1: Remove `structure` property from Composant entity** - [ ] **Step 2: Generate and run migration** ```sql ALTER TABLE composants DROP COLUMN IF EXISTS structure; ``` - [ ] **Step 3: Run `make test`** - [ ] **Step 4: Commit** --- ## Chunk 3: Phase 3 — Piece.productIds → Join Table ### Task 3.1: Create Join Table Entity + Migration **Files:** - Modify: `src/Entity/Piece.php` - Modify: `src/Entity/Product.php` - Create: `migrations/VersionYYYYMMDDHHMMSS.php` - [ ] **Step 1: Add ManyToMany relation to `Piece` entity with explicit JoinColumn annotations** > Must use explicit column names — Doctrine defaults would NOT match the migration SQL. ```php #[ORM\ManyToMany(targetEntity: Product::class, inversedBy: 'pieces')] #[ORM\JoinTable(name: 'piece_products')] #[ORM\JoinColumn(name: 'piece_id', referencedColumnName: 'id', onDelete: 'CASCADE')] #[ORM\InverseJoinColumn(name: 'product_id', referencedColumnName: 'id', onDelete: 'CASCADE')] private Collection $products; ``` - [ ] **Step 2: Add inverse side to `Product` entity** ```php #[ORM\ManyToMany(targetEntity: Piece::class, mappedBy: 'products')] private Collection $pieces; ``` - [ ] **Step 3: Generate migration** - [ ] **Step 4: Add data migration SQL to populate join table from JSON** ```sql INSERT INTO piece_products (piece_id, product_id) SELECT DISTINCT p.id, jsonb_array_elements_text(p.productids) FROM pieces p WHERE p.productids IS NOT NULL AND jsonb_array_length(p.productids) > 0; ``` - [ ] **Step 5: Run migration + verify** - [ ] **Step 6: Commit** --- ### Task 3.2: Update Piece Entity to Use Relation **Files:** - Modify: `src/Entity/Piece.php` - [ ] **Step 1: Update `getProductIds()` to return IDs from the relation** ```php public function getProductIds(): array { return $this->products->map(fn(Product $p) => $p->getId())->toArray(); } ``` - [ ] **Step 2: Remove `setProductIds()` — replace with `addProduct()`/`removeProduct()` methods** - [ ] **Step 3: Update `setProduct()` to also add to the collection** - [ ] **Step 4: Run `make test`** - [ ] **Step 5: Commit** --- ### Task 3.3: Update Controllers + Drop JSON Column **Files:** - Modify: any controller that reads/writes `Piece.productIds` - Create: `migrations/VersionYYYYMMDDHHMMSS.php` - [ ] **Step 1: Search for all usages of `productIds` / `getProductIds` / `setProductIds` in controllers** - [ ] **Step 2: Update each to use the new relation** - [ ] **Step 3: Remove `productIds` JSON column from entity** - [ ] **Step 4: Generate migration to drop column** ```sql ALTER TABLE pieces DROP COLUMN IF EXISTS productids; ``` - [ ] **Step 5: Run `make test`** - [ ] **Step 6: Commit** --- ## Chunk 4: Phase 4 — Frontend Consolidation + Test Suite ### Task 4.1: Update Frontend Types **Files:** - Modify: `frontend/app/shared/types/` (if type definitions reference structure/skeleton JSON shapes) - [ ] **Step 1: Search frontend for all references to `structure.pieces`, `structure.subcomponents`, `structure.products`, `skeleton`, `productIds`** - [ ] **Step 2: Update types to reflect the new API response shapes (should be minimal since we preserved response format)** - [ ] **Step 3: Run `npm run lint:fix` + `npx nuxi typecheck` + `npm run build`** - [ ] **Step 4: Commit in submodule + update pointer** --- ### Task 4.2: Update and Expand Backend Tests **Files:** - Modify: `tests/Api/Entity/ModelTypeTest.php` (or create if missing) - Modify: `tests/Api/Entity/ComposantTest.php` - Modify: `tests/Api/Entity/PieceTest.php` - Modify: `tests/Api/Entity/MachineStructureTest.php` (or create) - [ ] **Step 1: Write test for skeleton requirements CRUD via ModelType API** ```php public function testCreateModelTypeWithSkeletonRequirements(): void { $client = $this->createAdminClient(); // Create a piece ModelType first // Create a component ModelType with skeleton requirements // GET the ModelType and verify structure is returned from relations } ``` - [ ] **Step 2: Write test for composant slot creation and retrieval** ```php public function testComposantStructureFromSlots(): void { // Create composant with slots // GET machine structure // Verify pieces/subcomponents/products are returned correctly } ``` - [ ] **Step 3: Write test for piece-product relation** ```php public function testPieceProductRelation(): void { // Create piece with products // Verify getProductIds() returns correct IDs // Verify products appear in piece API response } ``` - [ ] **Step 4: Run full test suite: `make test`** - [ ] **Step 5: Commit** --- ### Task 4.3: Update Fixtures **Files:** - Modify: `fixtures/data.sql` - [ ] **Step 1: Add INSERT statements for the new tables matching existing JSON data** - [ ] **Step 2: Remove JSON values from the fixture SQL for dropped columns** - [ ] **Step 3: Verify fixtures load cleanly** ```bash docker exec -u www-data php-inventory-apache php bin/console doctrine:fixtures:load --no-interaction ``` - [ ] **Step 4: Commit** --- ## Summary — Execution Order | Phase | What | New Tables | JSON Columns Dropped | |---|---|---|---| | 1 | Skeleton Requirements | `skeleton_piece_requirements`, `skeleton_product_requirements`, `skeleton_subcomponent_requirements` | `componentSkeleton`, `pieceSkeleton`, `productSkeleton` | | 2 | Composant Structure | `composant_piece_slots`, `composant_subcomponent_slots`, `composant_product_slots` | `structure` | | 3 | Piece Products | `piece_products` | `productIds` | | 4 | Frontend + Tests + Fixtures | — | — | **Total new tables:** 7 **Total JSON columns removed:** 5 **JSON columns kept:** `AuditLog.diff`, `AuditLog.snapshot`, `CustomField.options`, `Profile.roles` (all legitimate JSON use cases) --- ## Risks & Mitigations | Risk | Mitigation | |---|---| | Data loss during migration | SQL migrations use `INSERT ... SELECT` — original JSON untouched until explicit DROP. JSON columns only dropped AFTER all reads/writes are migrated and verified. | | Frontend breaks during transition | API response shape preserved — `structure` key still returns same format, built from relations | | Performance regression (N+1 queries) | Use `fetch: 'EAGER'` or explicit JOINs in DQL for slot collections | | Existing tests fail | Run `make test` after each task — fix before proceeding | | Composant.structure has edge cases (empty arrays, null entries) | Migration SQL uses `COALESCE`/`NULLIF` + `WHERE` guards | | Phase ordering dependency | Phase 1 (skeletons) must NOT reference Phase 2 tables. `resolvePieceQuantity()` stays JSON-based until Phase 2 slot tables exist. | | Denormalized labels lost (typePieceLabel, typeProductLabel) | Intentional — these are resolved at read-time via FK joins. No data loss, just a read pattern change. | --- ## Important Design Decisions 1. **No `quantity` on skeleton requirements:** The skeleton defines WHICH types are needed, not how many. Current JSON data confirms no quantity field exists in skeleton entries. Quantity is instance-level only. 2. **`quantity` on composant_piece_slots:** Will be 1 for all migrated data (current JSON has no quantity either). Exists as the future canonical location for "how many of this piece does this composant need." Real machine-level quantities remain on `MachinePieceLink.quantity`. 3. **`skeleton_product_requirements` shared across categories:** Both component and piece ModelTypes can require product types. Single table with `model_type_id` FK handles both. 4. **Denormalized labels dropped:** `typePieceLabel`, `typeProductLabel` etc. from JSON are not migrated — they're resolved at read-time via FK to the ModelType's `name` field. 5. **`SkeletonStructureService` for writes:** Entity can't resolve FKs (no access to EntityManager). A dedicated service handles creating requirement entities from array input.