Files
Inventory/docs/superpowers/plans/2026-03-12-json-to-tables-normalization.md
Matthieu be859e57db refactor : rename Inventory_frontend to frontend in docs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:20:19 +02:00

1068 lines
40 KiB
Markdown

# 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
<?php
namespace App\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'skeleton_piece_requirements')]
#[ORM\HasLifecycleCallbacks]
class SkeletonPieceRequirement
{
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private string $id;
#[ORM\ManyToOne(targetEntity: ModelType::class, inversedBy: 'skeletonPieceRequirements')]
#[ORM\JoinColumn(name: 'model_type_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ModelType $modelType;
#[ORM\ManyToOne(targetEntity: ModelType::class)]
#[ORM\JoinColumn(name: 'type_piece_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ModelType $typePiece;
#[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));
}
// 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
<?php
namespace App\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'composant_piece_slots')]
#[ORM\HasLifecycleCallbacks]
class ComposantPieceSlot
{
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private string $id;
#[ORM\ManyToOne(targetEntity: Composant::class, inversedBy: 'pieceSlots')]
#[ORM\JoinColumn(name: 'composant_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Composant $composant;
#[ORM\ManyToOne(targetEntity: ModelType::class)]
#[ORM\JoinColumn(name: 'type_piece_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?ModelType $typePiece = null;
#[ORM\ManyToOne(targetEntity: Piece::class)]
#[ORM\JoinColumn(name: 'selected_piece_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?Piece $selectedPiece = null;
#[ORM\Column(type: Types::INTEGER, options: ['default' => 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.