From b2aff0e414c83cf6ecaddccc15410efc3c7ff350 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 13 Mar 2026 16:40:44 +0100 Subject: [PATCH] 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 --- CLAUDE.md | 38 +- Inventory_frontend | 2 +- config/reference.php | 14 + ...2026-03-12-json-to-tables-normalization.md | 1067 +++++++++++++++++ .../ComposantPieceSlotController.php | 17 +- .../ComposantProductSlotController.php | 54 + .../ComposantSubcomponentSlotController.php | 54 + src/Service/SkeletonStructureService.php | 115 ++ src/Service/Sync/ComposantSyncStrategy.php | 24 +- src/Service/Sync/PieceSyncStrategy.php | 14 +- src/Service/Sync/ProductSyncStrategy.php | 9 +- 11 files changed, 1380 insertions(+), 28 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-12-json-to-tables-normalization.md create mode 100644 src/Controller/ComposantProductSlotController.php create mode 100644 src/Controller/ComposantSubcomponentSlotController.php diff --git a/CLAUDE.md b/CLAUDE.md index 4bbd648..43e9294 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,14 @@ npm run build # Build production npm run lint:fix # ESLint fix npx nuxi typecheck # TypeScript check (0 errors attendu) +# Database / Fixtures +make db-reset # Reset database (drop + recreate schema) +make fixtures-dump # Dump la DB vers fixtures/data.sql +make fixtures-load # Charger les fixtures SQL (désactive FK) +make fixtures-reset # Reset DB + recharger fixtures +make import-data # Importer les dumps SQL normalisés +make cache-clear # Clear cache Symfony + # Release ./scripts/release.sh patch # Bump patch version (ou minor/major) ``` @@ -101,6 +109,11 @@ Le frontend est un submodule git. Lors d'un commit frontend : ### Entités Principales `Machine`, `Piece`, `Composant`, `Product`, `Constructeur`, `Site`, `ModelType`, `CustomField`, `CustomFieldValue`, `Document`, `AuditLog`, `Comment`, `Profile`, `MachineComponentLink`, `MachinePieceLink`, `MachineProductLink` +#### Entités de normalisation (slots & skeleton requirements) +Remplacent les anciennes colonnes JSON `structure` et `productIds` par des tables relationnelles : +- **Slots** (données réelles d'un composant) : `ComposantPieceSlot`, `ComposantSubcomponentSlot`, `ComposantProductSlot` +- **Skeleton Requirements** (définitions du ModelType) : `SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement` + ### Patterns - **IDs** : CUID-like strings (`'cl' + bin2hex(random_bytes(12))`), pas d'auto-increment - **ORM** : Attributs PHP 8 (`#[ORM\Column(...)]`, `#[Groups([...])]`) @@ -110,15 +123,32 @@ Le frontend est un submodule git. Lors d'un commit frontend : - **Migrations** : Raw SQL PostgreSQL avec `IF NOT EXISTS`/`IF EXISTS` pour idempotence ### Custom Controllers (pas API Platform) -- `MachineStructureController` — `/api/machines/{id}/structure` (GET/PATCH) : hiérarchie complète machine avec normalisation JSON manuelle (pas Symfony Serializer). Source principale de données pour la page détail machine. +- `MachineStructureController` — `/api/machines/{id}/structure` (GET/PATCH), `/api/machines/{id}/clone` (POST) : hiérarchie complète machine avec normalisation JSON manuelle. Source principale de données pour la page détail machine. - `MachineCustomFieldsController` — `/api/machines/{id}/add-custom-fields` (POST) : initialise les CustomFieldValue manquants pour une machine. - `CustomFieldValueController` — `/api/custom-fields/values/*` : CRUD + upsert pour les valeurs de champs perso. +- `ComposantPieceSlotController` — `/api/composant-piece-slots/{id}` (PATCH) : mise à jour des slots pièce d'un composant. +- `SessionProfileController` — `/api/session/profile` (GET/POST/DELETE) : auth session (login/logout/current user). +- `SessionProfilesController` — `/api/session/profiles` (GET) : liste des profils disponibles pour la session. +- `AdminProfileController` — `/api/admin/profiles` : CRUD profils, gestion rôles et mots de passe (ROLE_ADMIN). +- `CommentController` — `/api/comments` : création, résolution, compteur non-résolus. +- `ActivityLogController` — `/api/activity-logs` (GET) : journal d'activité global. +- `EntityHistoryController` — `/api/{entity}/{id}/history` (GET) : historique audit par entité (machines, pièces, composants, produits). +- `DocumentQueryController` — `/api/documents/{entity}/{id}` (GET) : documents par site/machine/composant/pièce/produit. +- `DocumentServeController` — `/api/documents/{id}/file|download` (GET) : servir/télécharger fichiers. +- `ModelTypeConversionController` — `/api/model_types/{id}/conversion-check|convert` : vérification et conversion de ModelType. +- `HealthCheckController` — `/api/health` (GET) : health check. ### Custom Fields — Architecture -- **Composants/Pièces/Produits** : définitions dans le JSON `structure` du ModelType +- **Composants/Pièces/Produits** : définitions dans les entités `SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement` du ModelType (anciennement JSON `structure`, normalisé en tables relationnelles). Les custom fields de ces entités sont définis dans `customFields` JSON sur chaque Skeleton*Requirement. - **Machines** : définitions = entités `CustomField` liées directement via `machineId` FK (pas de ModelType) - Les deux partagent la même entité `CustomFieldValue` pour stocker les valeurs +### Normalisation JSON → Tables (architecture slots) +Les anciennes colonnes JSON `structure` et `productIds` des Composants ont été remplacées par des tables relationnelles : +- **ModelType** définit le squelette via `SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement` +- **Composant** stocke les données réelles via `ComposantPieceSlot`, `ComposantProductSlot`, `ComposantSubcomponentSlot` +- Chaque slot référence son skeleton requirement (`skeletonRequirement` FK) + l'entité sélectionnée + position + ### Rôles (hiérarchie) ``` ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER @@ -193,8 +223,8 @@ make test-setup # Créer/mettre à jour le schéma test ### Pattern de test - Hériter de `AbstractApiTestCase` (helpers auth + factories) - Ne PAS faire de TRUNCATE/cleanup dans tearDown — DAMA s'en occupe par rollback -- Factories : `createProfile()`, `createMachine()`, `createSite()`, `createComposant()`, `createPiece()`, `createProduct()`, `createCustomField()`, `createCustomFieldValue()`, `createModelType()`, `createMachineComponentLink()`, `createMachinePieceLink()`, `createMachineProductLink()` -- Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()` +- Factories : `createProfile()`, `createMachine()`, `createSite()`, `createComposant()`, `createPiece()`, `createProduct()`, `createConstructeur()`, `createCustomField()`, `createCustomFieldValue()`, `createModelType()`, `createMachineComponentLink()`, `createMachinePieceLink()`, `createMachineProductLink()`, `createComposantPieceSlot()`, `createComposantSubcomponentSlot()`, `createComposantProductSlot()` +- Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`, `createUnauthenticatedClient()` ## URLs Locales - API Symfony : `http://localhost:8081/api` diff --git a/Inventory_frontend b/Inventory_frontend index 5912216..271844e 160000 --- a/Inventory_frontend +++ b/Inventory_frontend @@ -1 +1 @@ -Subproject commit 5912216a89390d79cffdc92dee7c49033dc32cbc +Subproject commit 271844efb104bcb18232e223521a53b22fcd7e3a diff --git a/config/reference.php b/config/reference.php index 200e125..42493cb 100644 --- a/config/reference.php +++ b/config/reference.php @@ -1626,6 +1626,19 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * nelmio_cors?: NelmioCorsConfig, * api_platform?: ApiPlatformConfig, * lexik_jwt_authentication?: LexikJwtAuthenticationConfig, + * "when@dev"?: array{ + * imports?: ImportsConfig, + * parameters?: ParametersConfig, + * services?: ServicesConfig, + * framework?: FrameworkConfig, + * twig?: TwigConfig, + * security?: SecurityConfig, + * doctrine?: DoctrineConfig, + * doctrine_migrations?: DoctrineMigrationsConfig, + * nelmio_cors?: NelmioCorsConfig, + * api_platform?: ApiPlatformConfig, + * lexik_jwt_authentication?: LexikJwtAuthenticationConfig, + * }, * "when@prod"?: array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -1732,6 +1745,7 @@ namespace Symfony\Component\Routing\Loader\Configurator; * deprecated?: array{package:string, version:string, message?:string}, * } * @psalm-type RoutesConfig = array{ + * "when@dev"?: array, * "when@prod"?: array, * "when@test"?: array, * ... diff --git a/docs/superpowers/plans/2026-03-12-json-to-tables-normalization.md b/docs/superpowers/plans/2026-03-12-json-to-tables-normalization.md new file mode 100644 index 0000000..47f703b --- /dev/null +++ b/docs/superpowers/plans/2026-03-12-json-to-tables-normalization.md @@ -0,0 +1,1067 @@ +# JSON to Relational Tables Normalization — Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace all structural JSON columns (ModelType skeletons, Composant.structure, Piece.productIds) with proper relational tables, enabling referential integrity, queryability, and consistent data display. + +**Architecture:** Three-phase migration following a dual-write/dual-read strategy to avoid breaking changes. Each phase creates new tables + entities, migrates existing data via a Doctrine migration, updates backend reads then writes, updates frontend, and finally drops the JSON column. + +**Tech Stack:** Symfony 8, Doctrine ORM (PHP 8 attributes), PostgreSQL 16, API Platform, Nuxt 4 frontend + +--- + +## Scope — What Migrates vs. What Stays + +| JSON Column | Action | Reason | +|---|---|---| +| `ModelType.componentSkeleton` | **MIGRATE** → `skeleton_piece_requirements`, `skeleton_product_requirements`, `skeleton_subcomponent_requirements` | Template bill-of-materials needs FK integrity | +| `ModelType.pieceSkeleton` | **MIGRATE** → reuse `skeleton_product_requirements` table (piece types can require product types too) | Contains `customFields` (already in `custom_fields` table) AND `products` array (product type requirements for piece types) | +| `ModelType.productSkeleton` | **MIGRATE** → verify no structural data beyond `customFields`, then DROP | May contain `products` or other structural data — verify before dropping | +| `Composant.structure` | **MIGRATE** → `composant_piece_slots`, `composant_subcomponent_slots`, `composant_product_slots` | Instance-level selections need FK integrity | +| `Piece.productIds` | **MIGRATE** → `piece_products` join table | Simple M2M stored as JSON array | +| `CustomField.options` | **KEEP JSON** | Truly polymorphic per-field-type config (selectOptions, min/max, step) | +| `AuditLog.diff/snapshot` | **KEEP JSON** | Schemaless by nature — stores arbitrary entity change diffs | +| `Profile.roles` | **KEEP JSON** | Standard Symfony UserInterface pattern | + +--- + +## New Tables Design + +### Layer 1 — Skeleton Requirements (what a ModelType needs) + +> **Note:** `skeleton_product_requirements` is shared by BOTH component and piece ModelTypes. +> A component type can require piece types + product types + subcomponent types. +> A piece type can require product types (e.g., lubricant for a bearing). +> Denormalized fields like `typePieceLabel` are intentionally omitted — resolved at read-time via FK. + +```sql +-- Piece types required by a component ModelType +CREATE TABLE skeleton_piece_requirements ( + id VARCHAR(36) PRIMARY KEY, + model_type_id VARCHAR(36) NOT NULL REFERENCES model_types(id) ON DELETE CASCADE, + type_piece_id VARCHAR(36) NOT NULL REFERENCES model_types(id) ON DELETE CASCADE, + position INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_skel_piece_req_model ON skeleton_piece_requirements(model_type_id); + +-- Product types required by a component OR piece ModelType +CREATE TABLE skeleton_product_requirements ( + id VARCHAR(36) PRIMARY KEY, + model_type_id VARCHAR(36) NOT NULL REFERENCES model_types(id) ON DELETE CASCADE, + type_product_id VARCHAR(36) NOT NULL REFERENCES model_types(id) ON DELETE CASCADE, + family_code VARCHAR(255), + position INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_skel_prod_req_model ON skeleton_product_requirements(model_type_id); + +-- Subcomponent types required by a component ModelType +CREATE TABLE skeleton_subcomponent_requirements ( + id VARCHAR(36) PRIMARY KEY, + model_type_id VARCHAR(36) NOT NULL REFERENCES model_types(id) ON DELETE CASCADE, + alias VARCHAR(255) NOT NULL, + family_code VARCHAR(255) NOT NULL, + type_composant_id VARCHAR(36) REFERENCES model_types(id) ON DELETE SET NULL, + position INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_skel_sub_req_model ON skeleton_subcomponent_requirements(model_type_id); +``` + +> **Design decision — no `quantity` on skeleton requirements:** The skeleton defines WHICH types are needed, not how many. Quantity is an instance-level concern stored on `MachinePieceLink.quantity` (for machine context) or `composant_piece_slots.quantity` (for composant context). The current skeleton JSON has no `quantity` field on piece entries. + +### Layer 2 — Composant Slots (instance-level selections) + +> **Design decision — `quantity` on piece slots:** The current `Composant.structure.pieces[].definition` JSON does NOT contain a `quantity` field. The actual quantity for machine pieces comes from `MachinePieceLink.quantity`. However, we add `quantity` to `composant_piece_slots` as the natural place to store "how many of this piece type does this composant need" — it will default to 1 from migration but can be set going forward. + +```sql +-- Actual piece selections for a composant instance +CREATE TABLE composant_piece_slots ( + id VARCHAR(36) PRIMARY KEY, + composant_id VARCHAR(36) NOT NULL REFERENCES composants(id) ON DELETE CASCADE, + type_piece_id VARCHAR(36) REFERENCES model_types(id) ON DELETE SET NULL, + selected_piece_id VARCHAR(36) REFERENCES pieces(id) ON DELETE SET NULL, + quantity INT NOT NULL DEFAULT 1, + position INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_comp_piece_slot_composant ON composant_piece_slots(composant_id); +CREATE INDEX idx_comp_piece_slot_piece ON composant_piece_slots(selected_piece_id); + +-- Actual subcomponent selections for a composant instance +CREATE TABLE composant_subcomponent_slots ( + id VARCHAR(36) PRIMARY KEY, + composant_id VARCHAR(36) NOT NULL REFERENCES composants(id) ON DELETE CASCADE, + alias VARCHAR(255), + family_code VARCHAR(255), + type_composant_id VARCHAR(36) REFERENCES model_types(id) ON DELETE SET NULL, + selected_composant_id VARCHAR(36) REFERENCES composants(id) ON DELETE SET NULL, + position INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_comp_sub_slot_composant ON composant_subcomponent_slots(composant_id); + +-- Actual product selections for a composant instance +CREATE TABLE composant_product_slots ( + id VARCHAR(36) PRIMARY KEY, + composant_id VARCHAR(36) NOT NULL REFERENCES composants(id) ON DELETE CASCADE, + type_product_id VARCHAR(36) REFERENCES model_types(id) ON DELETE SET NULL, + selected_product_id VARCHAR(36) REFERENCES products(id) ON DELETE SET NULL, + family_code VARCHAR(255), + position INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_comp_prod_slot_composant ON composant_product_slots(composant_id); +``` + +### Layer 3 — Piece-Product Join Table + +```sql +CREATE TABLE piece_products ( + piece_id VARCHAR(36) NOT NULL REFERENCES pieces(id) ON DELETE CASCADE, + product_id VARCHAR(36) NOT NULL REFERENCES products(id) ON DELETE CASCADE, + PRIMARY KEY (piece_id, product_id) +); +``` + +--- + +## Chunk 1: Phase 1 — Skeleton Requirements (ModelType) + +### Task 1.1: Create Skeleton Entities + +**Files:** +- Create: `src/Entity/SkeletonPieceRequirement.php` +- Create: `src/Entity/SkeletonProductRequirement.php` +- Create: `src/Entity/SkeletonSubcomponentRequirement.php` +- Modify: `src/Entity/ModelType.php` + +- [ ] **Step 1: Create `SkeletonPieceRequirement` entity** + +```php + 0])] + private int $position = 0; + + #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] + private \DateTimeImmutable $createdAt; + + #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] + private \DateTimeImmutable $updatedAt; + + public function __construct() + { + $this->id = 'cl' . bin2hex(random_bytes(12)); + } + + // Getters & setters (id, modelType, typePiece, position, createdAt, updatedAt) + + #[ORM\PrePersist] + public function onPrePersist(): void + { + $this->createdAt = new \DateTimeImmutable(); + $this->updatedAt = new \DateTimeImmutable(); + } + + #[ORM\PreUpdate] + public function onPreUpdate(): void + { + $this->updatedAt = new \DateTimeImmutable(); + } + + // ... all standard getters/setters +} +``` + +- [ ] **Step 2: Create `SkeletonProductRequirement` entity** (same pattern with `VARCHAR(36)` IDs, `typeProduct` FK, `familyCode` varchar column; shared by component AND piece ModelTypes) + +- [ ] **Step 3: Create `SkeletonSubcomponentRequirement` entity** (same pattern, with `alias`, `familyCode`, `typeComposant` nullable FK) + +- [ ] **Step 4: Add OneToMany collections to `ModelType`** + +```php +// In ModelType.php — add these properties + constructor init + getters + +#[ORM\OneToMany(targetEntity: SkeletonPieceRequirement::class, mappedBy: 'modelType', cascade: ['persist', 'remove'], orphanRemoval: true)] +#[ORM\OrderBy(['position' => 'ASC'])] +private Collection $skeletonPieceRequirements; + +#[ORM\OneToMany(targetEntity: SkeletonProductRequirement::class, mappedBy: 'modelType', cascade: ['persist', 'remove'], orphanRemoval: true)] +#[ORM\OrderBy(['position' => 'ASC'])] +private Collection $skeletonProductRequirements; + +#[ORM\OneToMany(targetEntity: SkeletonSubcomponentRequirement::class, mappedBy: 'modelType', cascade: ['persist', 'remove'], orphanRemoval: true)] +#[ORM\OrderBy(['position' => 'ASC'])] +private Collection $skeletonSubcomponentRequirements; +``` + +- [ ] **Step 5: Run `make php-cs-fixer-allow-risky`** + +- [ ] **Step 6: Commit** +```bash +git add src/Entity/SkeletonPieceRequirement.php src/Entity/SkeletonProductRequirement.php src/Entity/SkeletonSubcomponentRequirement.php src/Entity/ModelType.php +git commit -m "feat(skeleton) : add skeleton requirement entities for ModelType" +``` + +--- + +### Task 1.2: Create Doctrine Migration for Skeleton Tables + Data Migration + +**Files:** +- Create: `migrations/VersionYYYYMMDDHHMMSS.php` + +- [ ] **Step 1: Generate migration** +```bash +docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:diff +``` + +- [ ] **Step 2: Add data migration SQL to the `up()` method** + +After the CREATE TABLE statements, add SQL to migrate existing JSON data: + +```sql +-- Migrate componentSkeleton.pieces → skeleton_piece_requirements +INSERT INTO skeleton_piece_requirements (id, model_type_id, type_piece_id, position, created_at, updated_at) +SELECT + 'cl' || encode(gen_random_bytes(12), 'hex'), + mt.id, + (piece->>'typePieceId'), + ordinality - 1, + NOW(), NOW() +FROM model_types mt, +LATERAL jsonb_array_elements(mt.componentskeleton->'pieces') WITH ORDINALITY AS t(piece, ordinality) +WHERE mt.category = 'component' + AND mt.componentskeleton IS NOT NULL + AND mt.componentskeleton->'pieces' IS NOT NULL + AND jsonb_array_length(mt.componentskeleton->'pieces') > 0; + +-- Migrate componentSkeleton.products → skeleton_product_requirements (component types) +INSERT INTO skeleton_product_requirements (id, model_type_id, type_product_id, family_code, position, created_at, updated_at) +SELECT + 'cl' || encode(gen_random_bytes(12), 'hex'), + mt.id, + (product->>'typeProductId'), + (product->>'familyCode'), + ordinality - 1, + NOW(), NOW() +FROM model_types mt, +LATERAL jsonb_array_elements(mt.componentskeleton->'products') WITH ORDINALITY AS t(product, ordinality) +WHERE mt.category = 'component' + AND mt.componentskeleton IS NOT NULL + AND mt.componentskeleton->'products' IS NOT NULL + AND jsonb_array_length(mt.componentskeleton->'products') > 0; + +-- Migrate pieceSkeleton.products → skeleton_product_requirements (piece types) +INSERT INTO skeleton_product_requirements (id, model_type_id, type_product_id, family_code, position, created_at, updated_at) +SELECT + 'cl' || encode(gen_random_bytes(12), 'hex'), + mt.id, + (product->>'typeProductId'), + (product->>'familyCode'), + ordinality - 1, + NOW(), NOW() +FROM model_types mt, +LATERAL jsonb_array_elements(mt.pieceskeleton->'products') WITH ORDINALITY AS t(product, ordinality) +WHERE mt.category = 'piece' + AND mt.pieceskeleton IS NOT NULL + AND mt.pieceskeleton->'products' IS NOT NULL + AND jsonb_array_length(mt.pieceskeleton->'products') > 0; + +-- Migrate componentSkeleton.subcomponents → skeleton_subcomponent_requirements +INSERT INTO skeleton_subcomponent_requirements (id, model_type_id, alias, family_code, type_composant_id, position, created_at, updated_at) +SELECT + 'cl' || encode(gen_random_bytes(12), 'hex'), + mt.id, + COALESCE(sub->>'alias', ''), + COALESCE(sub->>'familyCode', ''), + NULLIF(sub->>'typeComposantId', ''), + ordinality - 1, + NOW(), NOW() +FROM model_types mt, +LATERAL jsonb_array_elements(mt.componentskeleton->'subcomponents') WITH ORDINALITY AS t(sub, ordinality) +WHERE mt.category = 'component' + AND mt.componentskeleton IS NOT NULL + AND mt.componentskeleton->'subcomponents' IS NOT NULL + AND jsonb_array_length(mt.componentskeleton->'subcomponents') > 0; +``` + +- [ ] **Step 3: Run migration** +```bash +docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction +``` + +- [ ] **Step 4: Verify data migrated correctly** +```bash +docker exec -u www-data php-inventory-apache php bin/console dbal:run-sql "SELECT COUNT(*) FROM skeleton_piece_requirements" +docker exec -u www-data php-inventory-apache php bin/console dbal:run-sql "SELECT COUNT(*) FROM skeleton_product_requirements" +docker exec -u www-data php-inventory-apache php bin/console dbal:run-sql "SELECT COUNT(*) FROM skeleton_subcomponent_requirements" +``` + +- [ ] **Step 5: Commit** +```bash +git add migrations/ +git commit -m "feat(skeleton) : create skeleton requirement tables and migrate data from JSON" +``` + +--- + +### Task 1.3: Update ModelType to Expose Relations via API + +**Files:** +- Modify: `src/Entity/ModelType.php` + +- [ ] **Step 1: Add serialization groups to new collections** + +```php +#[Groups(['modelType:read', 'modelType:structure'])] +#[ORM\OneToMany(...)] +private Collection $skeletonPieceRequirements; +// Same for product and subcomponent requirements +``` + +- [ ] **Step 2: Add a `getStructureFromRelations()` method** that returns the same shape as `getStructure()` but built from the new relations (for dual-read during transition) + +```php +public function getStructureFromRelations(): array +{ + $structure = ['customFields' => [], 'pieces' => [], 'products' => [], 'subcomponents' => []]; + + foreach ($this->skeletonPieceRequirements as $req) { + $structure['pieces'][] = [ + 'typePieceId' => $req->getTypePiece()->getId(), + ]; + } + foreach ($this->skeletonProductRequirements as $req) { + $structure['products'][] = [ + 'typeProductId' => $req->getTypeProduct()->getId(), + 'familyCode' => $req->getFamilyCode(), + ]; + } + foreach ($this->skeletonSubcomponentRequirements as $req) { + $structure['subcomponents'][] = [ + 'alias' => $req->getAlias(), + 'familyCode' => $req->getFamilyCode(), + 'typeComposantId' => $req->getTypeComposant()?->getId(), + ]; + } + + return $structure; +} +``` + +- [ ] **Step 3: Update `getStructure()` to read from relations for ALL categories** + +> **IMPORTANT:** Use `ModelCategory` enum, not string comparison. Piece types also have product requirements. + +```php +use App\Enum\ModelCategory; + +public function getStructure(): ?array +{ + return match ($this->category) { + ModelCategory::COMPONENT => $this->getStructureFromRelations(), + ModelCategory::PIECE => [ + 'customFields' => [], + 'products' => array_map(fn($req) => [ + 'typeProductId' => $req->getTypeProduct()->getId(), + 'familyCode' => $req->getFamilyCode(), + ], $this->skeletonProductRequirements->toArray()), + ], + ModelCategory::PRODUCT => [ + 'customFields' => [], + ], + }; +} +``` + +- [ ] **Step 4: Create a `SkeletonStructureService` to handle writing skeleton requirements** + +> The entity cannot resolve FKs (needs EntityManager). Create a service that handles `setStructure()` logic. + +```php +// src/Service/SkeletonStructureService.php +class SkeletonStructureService +{ + public function __construct(private EntityManagerInterface $em) {} + + public function updateSkeletonRequirements(ModelType $modelType, array $structure): void + { + // Clear existing + $modelType->getSkeletonPieceRequirements()->clear(); + $modelType->getSkeletonProductRequirements()->clear(); + $modelType->getSkeletonSubcomponentRequirements()->clear(); + + // Create piece requirements + foreach (($structure['pieces'] ?? []) as $i => $pieceData) { + $req = new SkeletonPieceRequirement(); + $req->setModelType($modelType); + $req->setTypePiece($this->em->getReference(ModelType::class, $pieceData['typePieceId'])); + $req->setPosition($i); + $modelType->getSkeletonPieceRequirements()->add($req); + } + + // Create product requirements (shared by component + piece types) + foreach (($structure['products'] ?? []) as $i => $prodData) { + $req = new SkeletonProductRequirement(); + $req->setModelType($modelType); + $req->setTypeProduct($this->em->getReference(ModelType::class, $prodData['typeProductId'])); + $req->setFamilyCode($prodData['familyCode'] ?? null); + $req->setPosition($i); + $modelType->getSkeletonProductRequirements()->add($req); + } + + // Create subcomponent requirements (component types only) + foreach (($structure['subcomponents'] ?? []) as $i => $subData) { + $req = new SkeletonSubcomponentRequirement(); + $req->setModelType($modelType); + $req->setAlias($subData['alias'] ?? ''); + $req->setFamilyCode($subData['familyCode'] ?? ''); + if (!empty($subData['typeComposantId'])) { + $req->setTypeComposant($this->em->getReference(ModelType::class, $subData['typeComposantId'])); + } + $req->setPosition($i); + $modelType->getSkeletonSubcomponentRequirements()->add($req); + } + } +} +``` + +- [ ] **Step 5: Run `make php-cs-fixer-allow-risky` + `make test`** + +- [ ] **Step 6: Commit** +```bash +git add src/Entity/ModelType.php +git commit -m "feat(skeleton) : expose skeleton relations via API and update getStructure()" +``` + +--- + +### Task 1.4: Update Controllers That Read/Write Skeletons + +**Files:** +- Modify: `src/Controller/MachineStructureController.php` (reads skeleton for piece quantity resolution) +- Modify: any controller that writes to ModelType skeleton + +- [ ] **Step 1: Read `MachineStructureController.php` — identify all skeleton read points** + +- [ ] **Step 2: Do NOT change `resolvePieceQuantity()` yet** — it reads `Composant.structure` JSON which is migrated in Phase 2. Keep the existing JSON-based implementation until Phase 2 is complete. Only update skeleton-related reads (e.g., ModelType `getStructure()` calls) in this phase. + +- [ ] **Step 3: Update any API endpoint that WRITES to ModelType skeleton to use the new relation entities** + +- [ ] **Step 4: Run `make test`** + +- [ ] **Step 5: Commit** + +--- + +### Task 1.5: Verify Skeleton Custom Fields Are Fully in CustomField Entities + +**Files:** +- Check: `fixtures/data.sql` — compare skeleton `customFields` JSON entries with `custom_fields` table entries + +- [ ] **Step 1: Write a verification SQL query** + +```sql +-- Find skeleton customFields that DON'T have a corresponding CustomField entity +-- Handles both JSON formats: {key: "Name", value: {type: "text"}} and {name: "Name", type: "text"} +SELECT mt.id, mt.name, mt.category, + COALESCE(cf_json->>'key', cf_json->>'name') as field_name, + COALESCE(cf_json->'value'->>'type', cf_json->>'type') as field_type +FROM model_types mt, +LATERAL jsonb_array_elements( + CASE mt.category + WHEN 'component' THEN mt.componentskeleton->'customFields' + WHEN 'piece' THEN mt.pieceskeleton->'customFields' + WHEN 'product' THEN mt.productskeleton->'customFields' + END +) AS cf_json +WHERE NOT EXISTS ( + SELECT 1 FROM custom_fields cf + WHERE (cf.typecomposantid = mt.id OR cf.typepieceid = mt.id OR cf.typeproductid = mt.id) + AND cf.name = COALESCE(cf_json->>'key', cf_json->>'name') +); +``` + +- [ ] **Step 2: If any missing → write a migration to create the missing CustomField entities from skeleton JSON** + +- [ ] **Step 3: Commit if changes needed** + +--- + +### Task 1.6: Drop Skeleton JSON Columns + +**Files:** +- Create: `migrations/VersionYYYYMMDDHHMMSS.php` +- Modify: `src/Entity/ModelType.php` + +- [ ] **Step 1: Remove `componentSkeleton`, `pieceSkeleton`, `productSkeleton` properties from ModelType entity** + +- [ ] **Step 2: Generate migration to drop the columns** + +```sql +ALTER TABLE model_types DROP COLUMN IF EXISTS componentskeleton; +ALTER TABLE model_types DROP COLUMN IF EXISTS pieceskeleton; +ALTER TABLE model_types DROP COLUMN IF EXISTS productskeleton; +``` + +- [ ] **Step 3: Remove `getStructureFromRelations()` (merge logic into `getStructure()`)** + +- [ ] **Step 4: Run `make test` + `make php-cs-fixer-allow-risky`** + +- [ ] **Step 5: Commit** + +--- + +## Chunk 2: Phase 2 — Composant Structure Slots + +### Task 2.1: Create Composant Slot Entities + +**Files:** +- Create: `src/Entity/ComposantPieceSlot.php` +- Create: `src/Entity/ComposantSubcomponentSlot.php` +- Create: `src/Entity/ComposantProductSlot.php` +- Modify: `src/Entity/Composant.php` + +- [ ] **Step 1: Create `ComposantPieceSlot` entity** + +```php + 1])] + private int $quantity = 1; + + #[ORM\Column(type: Types::INTEGER, options: ['default' => 0])] + private int $position = 0; + + #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] + private \DateTimeImmutable $createdAt; + + #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] + private \DateTimeImmutable $updatedAt; + + public function __construct() + { + $this->id = 'cl' . bin2hex(random_bytes(12)); + } + + // ... lifecycle callbacks + getters/setters +} +``` + +- [ ] **Step 2: Create `ComposantSubcomponentSlot` entity** (`VARCHAR(36)` IDs, alias, familyCode, typeComposant nullable FK, selectedComposant nullable FK) + +- [ ] **Step 3: Create `ComposantProductSlot` entity** (`VARCHAR(36)` IDs, typeProduct nullable FK, selectedProduct nullable FK, `familyCode` varchar column — preserves `definition.familyCode` from JSON) + +- [ ] **Step 4: Add OneToMany collections to `Composant` entity** + +```php +#[ORM\OneToMany(targetEntity: ComposantPieceSlot::class, mappedBy: 'composant', cascade: ['persist', 'remove'], orphanRemoval: true)] +#[ORM\OrderBy(['position' => 'ASC'])] +private Collection $pieceSlots; + +#[ORM\OneToMany(targetEntity: ComposantSubcomponentSlot::class, mappedBy: 'composant', cascade: ['persist', 'remove'], orphanRemoval: true)] +#[ORM\OrderBy(['position' => 'ASC'])] +private Collection $subcomponentSlots; + +#[ORM\OneToMany(targetEntity: ComposantProductSlot::class, mappedBy: 'composant', cascade: ['persist', 'remove'], orphanRemoval: true)] +#[ORM\OrderBy(['position' => 'ASC'])] +private Collection $productSlots; +``` + +- [ ] **Step 5: Run `make php-cs-fixer-allow-risky`** + +- [ ] **Step 6: Commit** + +--- + +### Task 2.2: Create Migration + Migrate Composant Structure Data + +**Files:** +- Create: `migrations/VersionYYYYMMDDHHMMSS.php` + +- [ ] **Step 1: Generate migration** + +- [ ] **Step 2: Add data migration SQL** + +```sql +-- Migrate composant.structure.pieces → composant_piece_slots +-- Note: quantity defaults to 1 — the current JSON definition does NOT contain a quantity field. +-- The actual quantity comes from MachinePieceLink.quantity. The slot quantity column exists +-- for future use as the canonical place to store "how many of this piece type does this composant need". +INSERT INTO composant_piece_slots (id, composant_id, type_piece_id, selected_piece_id, quantity, position, created_at, updated_at) +SELECT + 'cl' || encode(gen_random_bytes(12), 'hex'), + c.id, + NULLIF(piece->'definition'->>'typePieceId', ''), + NULLIF(piece->>'selectedPieceId', ''), + 1, + ordinality - 1, + NOW(), NOW() +FROM composants c, +LATERAL jsonb_array_elements(c.structure->'pieces') WITH ORDINALITY AS t(piece, ordinality) +WHERE c.structure IS NOT NULL + AND c.structure->'pieces' IS NOT NULL + AND jsonb_array_length(c.structure->'pieces') > 0; + +-- Migrate composant.structure.subcomponents → composant_subcomponent_slots +INSERT INTO composant_subcomponent_slots (id, composant_id, alias, family_code, type_composant_id, selected_composant_id, position, created_at, updated_at) +SELECT + 'cl' || encode(gen_random_bytes(12), 'hex'), + c.id, + COALESCE(sub->'definition'->>'alias', ''), + COALESCE(sub->'definition'->>'familyCode', ''), + NULLIF(sub->'definition'->>'typeComposantId', ''), + NULLIF(sub->>'selectedComponentId', ''), + ordinality - 1, + NOW(), NOW() +FROM composants c, +LATERAL jsonb_array_elements(c.structure->'subcomponents') WITH ORDINALITY AS t(sub, ordinality) +WHERE c.structure IS NOT NULL + AND c.structure->'subcomponents' IS NOT NULL + AND jsonb_array_length(c.structure->'subcomponents') > 0; + +-- Migrate composant.structure.products → composant_product_slots (including familyCode) +INSERT INTO composant_product_slots (id, composant_id, type_product_id, selected_product_id, family_code, position, created_at, updated_at) +SELECT + 'cl' || encode(gen_random_bytes(12), 'hex'), + c.id, + NULLIF(prod->'definition'->>'typeProductId', ''), + NULLIF(prod->>'selectedProductId', ''), + prod->'definition'->>'familyCode', + ordinality - 1, + NOW(), NOW() +FROM composants c, +LATERAL jsonb_array_elements(c.structure->'products') WITH ORDINALITY AS t(prod, ordinality) +WHERE c.structure IS NOT NULL + AND c.structure->'products' IS NOT NULL + AND jsonb_array_length(c.structure->'products') > 0; +``` + +- [ ] **Step 3: Run migration + verify counts** + +- [ ] **Step 4: Commit** + +--- + +### Task 2.3: Update MachineStructureController to Read from Slot Tables + +**Files:** +- Modify: `src/Controller/MachineStructureController.php` + +This is the critical task — the controller currently reads `Composant.structure` JSON to: +1. Enrich structure with resolved piece data (`enrichStructureWithPieceData()`) +2. Resolve piece quantities (`resolvePieceQuantity()`) +3. Normalize composant data (`normalizeComposant()`) + +- [ ] **Step 1: Read the full `MachineStructureController.php`** + +- [ ] **Step 2: Update `normalizeComposant()` to build the `structure` response from slot relations** + +```php +private function normalizeComposant(Composant $composant): array +{ + $pieces = []; + foreach ($composant->getPieceSlots() as $slot) { + $pieceData = [ + 'typePieceId' => $slot->getTypePiece()?->getId(), + 'quantity' => $slot->getQuantity(), + 'selectedPieceId' => $slot->getSelectedPiece()?->getId(), + ]; + if ($slot->getSelectedPiece()) { + $pieceData['resolvedPiece'] = $this->normalizePiece($slot->getSelectedPiece()); + } + $pieces[] = $pieceData; + } + + $subcomponents = []; + foreach ($composant->getSubcomponentSlots() as $slot) { + $subcomponents[] = [ + 'alias' => $slot->getAlias(), + 'familyCode' => $slot->getFamilyCode(), + 'typeComposantId' => $slot->getTypeComposant()?->getId(), + 'selectedComponentId' => $slot->getSelectedComposant()?->getId(), + ]; + } + + $products = []; + foreach ($composant->getProductSlots() as $slot) { + $products[] = [ + 'typeProductId' => $slot->getTypeProduct()?->getId(), + 'familyCode' => $slot->getFamilyCode(), + 'selectedProductId' => $slot->getSelectedProduct()?->getId(), + ]; + } + + return [ + // ... existing fields (id, name, reference, prix, etc.) + 'structure' => [ + 'pieces' => $pieces, + 'subcomponents' => $subcomponents, + 'products' => $products, + ], + // ... customFields, customFieldValues + ]; +} +``` + +- [ ] **Step 3: Update `resolvePieceQuantity()` to use slot relations** + +```php +private function resolvePieceQuantity(MachinePieceLink $pieceLink): int +{ + $parentLink = $pieceLink->getParentLink(); + if (!$parentLink) { + return $pieceLink->getQuantity(); + } + + // Match on the specific selected piece — avoids ambiguity when + // multiple slots share the same typePiece + $composant = $parentLink->getComposant(); + $piece = $pieceLink->getPiece(); + + foreach ($composant->getPieceSlots() as $slot) { + if ($slot->getSelectedPiece()?->getId() === $piece->getId()) { + return $slot->getQuantity(); + } + } + + return $pieceLink->getQuantity(); +} +``` + +- [ ] **Step 4: Remove `enrichStructureWithPieceData()` (no longer needed — piece data resolved inline in `normalizeComposant()`)** + +- [ ] **Step 5: Run `make test`** + +- [ ] **Step 6: Commit** + +--- + +### Task 2.4: Update PATCH Structure Endpoint to Write to Slot Tables + +**Files:** +- Modify: `src/Controller/MachineStructureController.php` + +- [ ] **Step 1: Identify where `Composant.structure` is written/updated in the controller** + +- [ ] **Step 2: Update any code that writes to `composant->setStructure(...)` to instead create/update slot entities** + +- [ ] **Step 3: Run `make test`** + +- [ ] **Step 4: Commit** + +--- + +### Task 2.5: Update Frontend to Handle New Structure Format + +**Files:** +- Modify: `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 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: `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** + +```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. diff --git a/src/Controller/ComposantPieceSlotController.php b/src/Controller/ComposantPieceSlotController.php index 87dd12b..522a6f6 100644 --- a/src/Controller/ComposantPieceSlotController.php +++ b/src/Controller/ComposantPieceSlotController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Controller; use App\Entity\ComposantPieceSlot; +use App\Entity\Piece; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; @@ -37,12 +38,22 @@ class ComposantPieceSlotController extends AbstractController $slot->setQuantity(max(1, (int) $payload['quantity'])); } + if (array_key_exists('selectedPieceId', $payload)) { + if (null === $payload['selectedPieceId']) { + $slot->setSelectedPiece(null); + } else { + $piece = $this->entityManager->find(Piece::class, $payload['selectedPieceId']); + $slot->setSelectedPiece($piece); + } + } + $this->entityManager->flush(); return $this->json([ - 'success' => true, - 'id' => $slot->getId(), - 'quantity' => $slot->getQuantity(), + 'success' => true, + 'id' => $slot->getId(), + 'quantity' => $slot->getQuantity(), + 'selectedPieceId' => $slot->getSelectedPiece()?->getId(), ]); } } diff --git a/src/Controller/ComposantProductSlotController.php b/src/Controller/ComposantProductSlotController.php new file mode 100644 index 0000000..9cc19d3 --- /dev/null +++ b/src/Controller/ComposantProductSlotController.php @@ -0,0 +1,54 @@ +denyAccessUnlessGranted('ROLE_GESTIONNAIRE'); + + $slot = $this->entityManager->find(ComposantProductSlot::class, $id); + if (!$slot) { + return $this->json(['success' => false, 'error' => 'Slot not found.'], 404); + } + + $payload = json_decode($request->getContent(), true); + if (!is_array($payload)) { + return $this->json(['success' => false, 'error' => 'Invalid JSON payload.'], 400); + } + + if (array_key_exists('selectedProductId', $payload)) { + if (null === $payload['selectedProductId']) { + $slot->setSelectedProduct(null); + } else { + $product = $this->entityManager->find(Product::class, $payload['selectedProductId']); + $slot->setSelectedProduct($product); + } + } + + $this->entityManager->flush(); + + return $this->json([ + 'success' => true, + 'id' => $slot->getId(), + 'selectedProductId' => $slot->getSelectedProduct()?->getId(), + ]); + } +} diff --git a/src/Controller/ComposantSubcomponentSlotController.php b/src/Controller/ComposantSubcomponentSlotController.php new file mode 100644 index 0000000..378e786 --- /dev/null +++ b/src/Controller/ComposantSubcomponentSlotController.php @@ -0,0 +1,54 @@ +denyAccessUnlessGranted('ROLE_GESTIONNAIRE'); + + $slot = $this->entityManager->find(ComposantSubcomponentSlot::class, $id); + if (!$slot) { + return $this->json(['success' => false, 'error' => 'Slot not found.'], 404); + } + + $payload = json_decode($request->getContent(), true); + if (!is_array($payload)) { + return $this->json(['success' => false, 'error' => 'Invalid JSON payload.'], 400); + } + + if (array_key_exists('selectedComposantId', $payload)) { + if (null === $payload['selectedComposantId']) { + $slot->setSelectedComposant(null); + } else { + $composant = $this->entityManager->find(Composant::class, $payload['selectedComposantId']); + $slot->setSelectedComposant($composant); + } + } + + $this->entityManager->flush(); + + return $this->json([ + 'success' => true, + 'id' => $slot->getId(), + 'selectedComposantId' => $slot->getSelectedComposant()?->getId(), + ]); + } +} diff --git a/src/Service/SkeletonStructureService.php b/src/Service/SkeletonStructureService.php index 8213abe..0256800 100644 --- a/src/Service/SkeletonStructureService.php +++ b/src/Service/SkeletonStructureService.php @@ -4,10 +4,12 @@ declare(strict_types=1); namespace App\Service; +use App\Entity\CustomField; use App\Entity\ModelType; use App\Entity\SkeletonPieceRequirement; use App\Entity\SkeletonProductRequirement; use App\Entity\SkeletonSubcomponentRequirement; +use App\Enum\ModelCategory; use Doctrine\ORM\EntityManagerInterface; class SkeletonStructureService @@ -60,5 +62,118 @@ class SkeletonStructureService $req->setPosition($i); $modelType->addSkeletonSubcomponentRequirement($req); } + + // Update custom field definitions + $this->updateCustomFields($modelType, $structure['customFields'] ?? []); + } + + /** + * Sync CustomField entities for this ModelType. + * Handles two frontend formats: + * - COMPONENT: {key, value: {type, required, options?, defaultValue?}, id?, customFieldId?} + * - PIECE/PRODUCT: {name, type, required, options?, orderIndex?, defaultValue?}. + */ + private function updateCustomFields(ModelType $modelType, array $proposedFields): void + { + // Determine which FK to use based on category + $category = $modelType->getCategory(); + $fkField = match ($category) { + ModelCategory::COMPONENT => 'typeComposant', + ModelCategory::PIECE => 'typePiece', + ModelCategory::PRODUCT => 'typeProduct', + }; + + // Load existing custom fields + $existingFields = $this->em->getRepository(CustomField::class)->findBy( + [$fkField => $modelType], + ['orderIndex' => 'ASC'] + ); + + // Index existing by ID for matching + $existingById = []; + foreach ($existingFields as $cf) { + $existingById[$cf->getId()] = $cf; + } + + $processedIds = []; + + foreach ($proposedFields as $i => $fieldData) { + // Normalize both formats to a common shape + $normalized = $this->normalizeCustomFieldData($fieldData, $i); + + // Try to match an existing field by ID + $existingField = null; + $fieldId = $fieldData['customFieldId'] ?? $fieldData['id'] ?? null; + if ($fieldId && isset($existingById[$fieldId])) { + $existingField = $existingById[$fieldId]; + } + + if ($existingField) { + // Update existing field + $existingField->setName($normalized['name']); + $existingField->setType($normalized['type']); + $existingField->setRequired($normalized['required']); + $existingField->setOptions($normalized['options']); + $existingField->setDefaultValue($normalized['defaultValue']); + $existingField->setOrderIndex($normalized['orderIndex']); + $processedIds[$existingField->getId()] = true; + } else { + // Create new field + $cf = new CustomField(); + $cf->setName($normalized['name']); + $cf->setType($normalized['type']); + $cf->setRequired($normalized['required']); + $cf->setOptions($normalized['options']); + $cf->setDefaultValue($normalized['defaultValue']); + $cf->setOrderIndex($normalized['orderIndex']); + + match ($category) { + ModelCategory::COMPONENT => $cf->setTypeComposant($modelType), + ModelCategory::PIECE => $cf->setTypePiece($modelType), + ModelCategory::PRODUCT => $cf->setTypeProduct($modelType), + }; + + $this->em->persist($cf); + } + } + + // Remove orphaned fields (exist in DB but not in proposed) + foreach ($existingFields as $cf) { + if (!isset($processedIds[$cf->getId()])) { + $this->em->remove($cf); + } + } + } + + /** + * Normalize frontend custom field data to a common shape. + * + * @return array{name: string, type: string, required: bool, options: ?array, defaultValue: ?string, orderIndex: int} + */ + private function normalizeCustomFieldData(array $fieldData, int $index): array + { + // COMPONENT format: {key: "name", value: {type, required, options?, defaultValue?}} + if (isset($fieldData['key'], $fieldData['value'])) { + $value = $fieldData['value']; + + return [ + 'name' => $fieldData['key'], + 'type' => $value['type'] ?? 'text', + 'required' => (bool) ($value['required'] ?? false), + 'options' => $value['options'] ?? null, + 'defaultValue' => $value['defaultValue'] ?? null, + 'orderIndex' => $index, + ]; + } + + // PIECE/PRODUCT format: {name, type, required, options?, orderIndex?, defaultValue?} + return [ + 'name' => $fieldData['name'] ?? '', + 'type' => $fieldData['type'] ?? 'text', + 'required' => (bool) ($fieldData['required'] ?? false), + 'options' => $fieldData['options'] ?? null, + 'defaultValue' => $fieldData['defaultValue'] ?? null, + 'orderIndex' => $fieldData['orderIndex'] ?? $index, + ]; } } diff --git a/src/Service/Sync/ComposantSyncStrategy.php b/src/Service/Sync/ComposantSyncStrategy.php index 4837588..f53dc4c 100644 --- a/src/Service/Sync/ComposantSyncStrategy.php +++ b/src/Service/Sync/ComposantSyncStrategy.php @@ -51,26 +51,30 @@ class ComposantSyncStrategy implements SyncStrategyInterface $addedCfValues = 0; $deletedCfValues = 0; - // Map proposed by (typeId, position) keys + // Map proposed by (typeId, position) keys — position defaults to array index $proposedPieceKeys = []; - foreach ($proposedPieces as $pp) { - $proposedPieceKeys[$pp['typePieceId'].'|'.$pp['position']] = true; + foreach ($proposedPieces as $i => $pp) { + $pos = $pp['position'] ?? $i; + $proposedPieceKeys[$pp['typePieceId'].'|'.$pos] = true; } $proposedProductKeys = []; - foreach ($proposedProducts as $pp) { - $proposedProductKeys[$pp['typeProductId'].'|'.$pp['position']] = true; + foreach ($proposedProducts as $i => $pp) { + $pos = $pp['position'] ?? $i; + $proposedProductKeys[$pp['typeProductId'].'|'.$pos] = true; } $proposedSubKeys = []; - foreach ($proposedSubcomponents as $ps) { - $proposedSubKeys[$ps['typeComposantId'].'|'.$ps['position']] = true; + foreach ($proposedSubcomponents as $i => $ps) { + $pos = $ps['position'] ?? $i; + $proposedSubKeys[$ps['typeComposantId'].'|'.$pos] = true; } - // Map proposed custom fields by orderIndex + // Map proposed custom fields by orderIndex (falls back to array index) $proposedCfByOrder = []; - foreach ($proposedCustomFields as $pcf) { - $proposedCfByOrder[$pcf['orderIndex']] = $pcf; + foreach ($proposedCustomFields as $i => $pcf) { + $order = $pcf['orderIndex'] ?? $i; + $proposedCfByOrder[$order] = $pcf; } // Get existing custom fields for this model type diff --git a/src/Service/Sync/PieceSyncStrategy.php b/src/Service/Sync/PieceSyncStrategy.php index 32618eb..4fb7c36 100644 --- a/src/Service/Sync/PieceSyncStrategy.php +++ b/src/Service/Sync/PieceSyncStrategy.php @@ -41,16 +41,18 @@ class PieceSyncStrategy implements SyncStrategyInterface $addedCfValues = 0; $deletedCfValues = 0; - // Map proposed products by (typeProductId, position) keys + // Map proposed products by (typeProductId, position) keys — position defaults to array index $proposedProductKeys = []; - foreach ($proposedProducts as $pp) { - $proposedProductKeys[$pp['typeProductId'].'|'.$pp['position']] = true; + foreach ($proposedProducts as $i => $pp) { + $pos = $pp['position'] ?? $i; + $proposedProductKeys[$pp['typeProductId'].'|'.$pos] = true; } - // Map proposed custom fields by orderIndex + // Map proposed custom fields by orderIndex (falls back to array index) $proposedCfByOrder = []; - foreach ($proposedCustomFields as $pcf) { - $proposedCfByOrder[$pcf['orderIndex']] = $pcf; + foreach ($proposedCustomFields as $i => $pcf) { + $order = $pcf['orderIndex'] ?? $i; + $proposedCfByOrder[$order] = $pcf; } // Get existing custom fields for this model type diff --git a/src/Service/Sync/ProductSyncStrategy.php b/src/Service/Sync/ProductSyncStrategy.php index 6b75b1c..675f7da 100644 --- a/src/Service/Sync/ProductSyncStrategy.php +++ b/src/Service/Sync/ProductSyncStrategy.php @@ -43,10 +43,11 @@ class ProductSyncStrategy implements SyncStrategyInterface $existingByOrder[$field->getOrderIndex()] = $field; } - // Map proposed fields by orderIndex + // Map proposed fields by orderIndex (falls back to array index) $proposedByOrder = []; - foreach ($proposedFields as $pf) { - $proposedByOrder[$pf['orderIndex']] = $pf; + foreach ($proposedFields as $i => $pf) { + $order = $pf['orderIndex'] ?? $i; + $proposedByOrder[$order] = $pf; } $addedFields = 0; @@ -57,7 +58,7 @@ class ProductSyncStrategy implements SyncStrategyInterface foreach ($proposedByOrder as $orderIndex => $pf) { if (!isset($existingByOrder[$orderIndex])) { ++$addedFields; - } elseif ($existingByOrder[$orderIndex]->getType() !== $pf['type']) { + } elseif ($existingByOrder[$orderIndex]->getType() !== ($pf['type'] ?? $pf['value']['type'] ?? null)) { ++$modifiedFields; } }