1068 lines
40 KiB
Markdown
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.
|