Files
Inventory/docs/superpowers/plans/2026-03-12-json-to-tables-normalization.md
Matthieu b2aff0e414 feat(sync) : add slot selection controllers, custom field sync, and position fallbacks
- Add selectedPieceId support to ComposantPieceSlotController
- Create ComposantProductSlotController and ComposantSubcomponentSlotController
- Add updateCustomFields() to SkeletonStructureService for managing CustomField entities
- Fix position/orderIndex fallback to array index in all 3 sync strategies
- Fix type comparison in ProductSyncStrategy for dual format support
- Update CLAUDE.md with new entities, controllers, and fixtures documentation
- Update frontend submodule with interactive slot selectors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:40:44 +01:00

40 KiB

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 MIGRATEskeleton_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 MIGRATEcomposant_piece_slots, composant_subcomponent_slots, composant_product_slots Instance-level selections need FK integrity
Piece.productIds MIGRATEpiece_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.

-- 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.

-- 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

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

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

// 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

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

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:

-- 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
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction
  • Step 4: Verify data migrated correctly
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
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

#[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)
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.

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.

// 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

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

-- 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

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

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

#[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

-- 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

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
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: Inventory_frontend/app/composables/useMachineHierarchy.tsbuildMachineHierarchyFromLinks()
  • Modify: Inventory_frontend/app/shared/utils/structureDisplayUtils.ts
  • Modify: Inventory_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.tscollectStructureSelections() 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

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.

#[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
#[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

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

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

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: Inventory_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

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
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
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

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.