- 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>
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 |
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_requirementsis 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 liketypePieceLabelare 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
quantityon skeleton requirements: The skeleton defines WHICH types are needed, not how many. Quantity is an instance-level concern stored onMachinePieceLink.quantity(for machine context) orcomposant_piece_slots.quantity(for composant context). The current skeleton JSON has noquantityfield on piece entries.
Layer 2 — Composant Slots (instance-level selections)
Design decision —
quantityon piece slots: The currentComposant.structure.pieces[].definitionJSON does NOT contain aquantityfield. The actual quantity for machine pieces comes fromMachinePieceLink.quantity. However, we addquantitytocomposant_piece_slotsas 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
SkeletonPieceRequiremententity
<?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
SkeletonProductRequiremententity (same pattern withVARCHAR(36)IDs,typeProductFK,familyCodevarchar column; shared by component AND piece ModelTypes) -
Step 3: Create
SkeletonSubcomponentRequiremententity (same pattern, withalias,familyCode,typeComposantnullable 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 asgetStructure()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
ModelCategoryenum, 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
SkeletonStructureServiceto 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 readsComposant.structureJSON which is migrated in Phase 2. Keep the existing JSON-based implementation until Phase 2 is complete. Only update skeleton-related reads (e.g., ModelTypegetStructure()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 skeletoncustomFieldsJSON entries withcustom_fieldstable 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,productSkeletonproperties 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 intogetStructure()) -
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
ComposantPieceSlotentity
<?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
ComposantSubcomponentSlotentity (VARCHAR(36)IDs, alias, familyCode, typeComposant nullable FK, selectedComposant nullable FK) -
Step 3: Create
ComposantProductSlotentity (VARCHAR(36)IDs, typeProduct nullable FK, selectedProduct nullable FK,familyCodevarchar column — preservesdefinition.familyCodefrom JSON) -
Step 4: Add OneToMany collections to
Composantentity
#[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:
- Enrich structure with resolved piece data (
enrichStructureWithPieceData()) - Resolve piece quantities (
resolvePieceQuantity()) - Normalize composant data (
normalizeComposant())
-
Step 1: Read the full
MachineStructureController.php -
Step 2: Update
normalizeComposant()to build thestructureresponse 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 innormalizeComposant()) -
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.structureis 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.ts—buildMachineHierarchyFromLinks() - 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 referencespathfields -
Step 3: Update
structureDisplayUtils.tsif 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
structureproperty 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
Pieceentity 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
Productentity
#[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 withaddProduct()/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/setProductIdsin controllers -
Step 2: Update each to use the new relation
-
Step 3: Remove
productIdsJSON 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
- No
quantityon 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. quantityon 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 onMachinePieceLink.quantity.skeleton_product_requirementsshared across categories: Both component and piece ModelTypes can require product types. Single table withmodel_type_idFK handles both.- Denormalized labels dropped:
typePieceLabel,typeProductLabeletc. from JSON are not migrated — they're resolved at read-time via FK to the ModelType'snamefield. SkeletonStructureServicefor writes: Entity can't resolve FKs (no access to EntityManager). A dedicated service handles creating requirement entities from array input.