# Machine Context Custom Fields — Implementation Plan (SUPERSEDED) > **This plan has been split into two parallel plans:** > - **Backend:** `2026-04-02-machine-context-fields-backend.md` > - **Frontend:** `2026-04-02-machine-context-fields-frontend.md` > > Use those plans instead. This file is kept for reference only. **Goal:** Allow defining custom fields on ModelTypes that only appear and store values per machine-link (not globally on the piece/composant). **Architecture:** Add `machineContextOnly` boolean on `CustomField`. Add nullable FKs on `CustomFieldValue` pointing to `MachineComponentLink` / `MachinePieceLink`. The `MachineStructureController` exposes `contextCustomFields` and `contextCustomFieldValues` on each link in the response. Frontend structure editors get a toggle, machine detail components get a new "Champs contextuels" section, and standalone pages filter these fields out. **Tech Stack:** Symfony 8, Doctrine ORM, API Platform 4, PostgreSQL 16, Nuxt 4, Vue 3 Composition API, TypeScript, DaisyUI 5 **Spec:** `docs/superpowers/specs/2026-04-02-machine-context-custom-fields-design.md` --- ## File Map ### Backend — Create - `migrations/VersionXXXX_MachineContextCustomFields.php` — migration - `tests/Api/Entity/MachineContextCustomFieldTest.php` — dedicated test class ### Backend — Modify - `src/Entity/CustomField.php` — add `machineContextOnly` property - `src/Entity/CustomFieldValue.php` — add `machineComponentLink` and `machinePieceLink` FKs - `src/Entity/MachineComponentLink.php` — add `contextFieldValues` collection - `src/Entity/MachinePieceLink.php` — add `contextFieldValues` collection - `src/Controller/MachineStructureController.php` — normalize context fields in structure response + clone context values - `src/Controller/CustomFieldValueController.php` — support link-based upsert/lookup - `tests/AbstractApiTestCase.php` — add `machineContextOnly` param to `createCustomField()`, add link params to `createCustomFieldValue()` ### Frontend — Modify - `frontend/app/shared/types/inventory.ts` — add `machineContextOnly` to custom field types - `frontend/app/components/PieceModelStructureEditor.vue` — add checkbox toggle per field - `frontend/app/components/StructureNodeEditor.vue` — add checkbox toggle per field - `frontend/app/composables/usePieceStructureEditorLogic.ts` — add `machineContextOnly: false` in `createEmptyField()` - `frontend/app/composables/useStructureNodeCrud.ts` — add `machineContextOnly: false` in `addCustomField()` - `frontend/app/composables/useEntityCustomFields.ts` — filter out `machineContextOnly` fields - `frontend/app/composables/useMachineDetailCustomFields.ts` — propagate context fields + filter from normal merge - `frontend/app/components/ComponentItem.vue` — display context custom fields section - `frontend/app/components/PieceItem.vue` — display context custom fields section --- ## Task 1: Migration + Entity `CustomField` — `machineContextOnly` **Files:** - Modify: `src/Entity/CustomField.php` (add property after line 56) - [ ] **Step 1: Add `machineContextOnly` property to `CustomField` entity** In `src/Entity/CustomField.php`, add after the `$required` property (line 56): ```php #[ORM\Column(type: Types::BOOLEAN, options: ['default' => false], name: 'machinecontextonly')] #[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])] private bool $machineContextOnly = false; ``` Add getter/setter before the closing `}`: ```php public function isMachineContextOnly(): bool { return $this->machineContextOnly; } public function setMachineContextOnly(bool $machineContextOnly): static { $this->machineContextOnly = $machineContextOnly; return $this; } ``` - [ ] **Step 2: Generate and adjust migration** ```bash docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:diff ``` Edit the generated migration to use idempotent SQL: ```sql ALTER TABLE custom_fields ADD COLUMN IF NOT EXISTS machinecontextonly BOOLEAN DEFAULT false NOT NULL; ``` - [ ] **Step 3: Run migration** ```bash docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction ``` - [ ] **Step 4: Run linter** ```bash make php-cs-fixer-allow-risky ``` - [ ] **Step 5: Commit** ```bash git add src/Entity/CustomField.php migrations/ git commit -m "feat(custom-fields) : add machineContextOnly flag to CustomField entity" ``` --- ## Task 2: Entity `CustomFieldValue` — link FKs + inverse collections **Files:** - Modify: `src/Entity/CustomFieldValue.php` (add after line 67 — `$product` property) - Modify: `src/Entity/MachineComponentLink.php` (add after line 72 — `$productLinks`) - Modify: `src/Entity/MachinePieceLink.php` (add after line 61 — `$productLinks`) - [ ] **Step 1: Add FKs to `CustomFieldValue`** In `src/Entity/CustomFieldValue.php`, add after the `$product` property (line 67): ```php #[ORM\ManyToOne(targetEntity: MachineComponentLink::class, inversedBy: 'contextFieldValues')] #[ORM\JoinColumn(name: 'machinecomponentlinkid', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] private ?MachineComponentLink $machineComponentLink = null; #[ORM\ManyToOne(targetEntity: MachinePieceLink::class, inversedBy: 'contextFieldValues')] #[ORM\JoinColumn(name: 'machinepiecelinkid', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] private ?MachinePieceLink $machinePieceLink = null; ``` Add getters/setters before the closing `}`: ```php public function getMachineComponentLink(): ?MachineComponentLink { return $this->machineComponentLink; } public function setMachineComponentLink(?MachineComponentLink $machineComponentLink): static { $this->machineComponentLink = $machineComponentLink; return $this; } public function getMachinePieceLink(): ?MachinePieceLink { return $this->machinePieceLink; } public function setMachinePieceLink(?MachinePieceLink $machinePieceLink): static { $this->machinePieceLink = $machinePieceLink; return $this; } ``` - [ ] **Step 2: Add `contextFieldValues` collection to `MachineComponentLink`** In `src/Entity/MachineComponentLink.php`, add after the `$productLinks` collection (line 72): ```php /** * @var Collection */ #[ORM\OneToMany(mappedBy: 'machineComponentLink', targetEntity: CustomFieldValue::class, cascade: ['persist', 'remove'], orphanRemoval: true)] private Collection $contextFieldValues; ``` In the constructor (line 95), add: ```php $this->contextFieldValues = new ArrayCollection(); ``` Add getter before the closing `}`: ```php /** * @return Collection */ public function getContextFieldValues(): Collection { return $this->contextFieldValues; } ``` - [ ] **Step 3: Add `contextFieldValues` collection to `MachinePieceLink`** In `src/Entity/MachinePieceLink.php`, add after the `$productLinks` collection (line 61): ```php /** * @var Collection */ #[ORM\OneToMany(mappedBy: 'machinePieceLink', targetEntity: CustomFieldValue::class, cascade: ['persist', 'remove'], orphanRemoval: true)] private Collection $contextFieldValues; ``` In the constructor (line 86), add: ```php $this->contextFieldValues = new ArrayCollection(); ``` Add getter: ```php /** * @return Collection */ public function getContextFieldValues(): Collection { return $this->contextFieldValues; } ``` - [ ] **Step 4: Generate and adjust migration** ```bash docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:diff ``` Edit migration to use idempotent SQL: ```sql ALTER TABLE custom_field_values ADD COLUMN IF NOT EXISTS machinecomponentlinkid VARCHAR(36) DEFAULT NULL; ALTER TABLE custom_field_values ADD COLUMN IF NOT EXISTS machinepiecelinkid VARCHAR(36) DEFAULT NULL; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_cfv_machine_component_link') THEN ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_machine_component_link FOREIGN KEY (machinecomponentlinkid) REFERENCES machine_component_links(id) ON DELETE CASCADE; END IF; END $$; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_cfv_machine_piece_link') THEN ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_machine_piece_link FOREIGN KEY (machinepiecelinkid) REFERENCES machine_piece_links(id) ON DELETE CASCADE; END IF; END $$; CREATE INDEX IF NOT EXISTS idx_cfv_machine_component_link ON custom_field_values(machinecomponentlinkid); CREATE INDEX IF NOT EXISTS idx_cfv_machine_piece_link ON custom_field_values(machinepiecelinkid); ``` - [ ] **Step 5: Run migration + linter** ```bash docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction make php-cs-fixer-allow-risky ``` - [ ] **Step 6: Commit** ```bash git add src/Entity/CustomFieldValue.php src/Entity/MachineComponentLink.php src/Entity/MachinePieceLink.php migrations/ git commit -m "feat(custom-fields) : add link FKs to CustomFieldValue for machine context" ``` --- ## Task 3: Test factories — extend helpers **Files:** - Modify: `tests/AbstractApiTestCase.php:399-461` - [ ] **Step 1: Update `createCustomField()` factory** In `tests/AbstractApiTestCase.php`, add `bool $machineContextOnly = false` parameter at the end of `createCustomField` (line 399), and add `$cf->setMachineContextOnly($machineContextOnly);` after `$cf->setOrderIndex($orderIndex);` (line 411). - [ ] **Step 2: Update `createCustomFieldValue()` factory** Add two new nullable parameters at the end of `createCustomFieldValue` (line 432): ```php ?MachineComponentLink $machineComponentLink = null, ?MachinePieceLink $machinePieceLink = null, ``` Add the corresponding setter calls after the `$product` setter (line 453): ```php if (null !== $machineComponentLink) { $cfv->setMachineComponentLink($machineComponentLink); } if (null !== $machinePieceLink) { $cfv->setMachinePieceLink($machinePieceLink); } ``` - [ ] **Step 3: Run linter** ```bash make php-cs-fixer-allow-risky ``` - [ ] **Step 4: Commit** ```bash git add tests/AbstractApiTestCase.php git commit -m "test(custom-fields) : extend factories for machineContextOnly and link params" ``` --- ## Task 4: `MachineStructureController` — normalize context fields **Files:** - Modify: `src/Controller/MachineStructureController.php` - [ ] **Step 1: Add `machineContextOnly` to all normalization methods** In `normalizeCustomFields` (line 601), add to the output array at line 615: ```php 'machineContextOnly' => $customField->isMachineContextOnly(), ``` In `normalizeCustomFieldDefinitions` (line 838), add to the output array at line 852: ```php 'machineContextOnly' => $cf->isMachineContextOnly(), ``` In `normalizeCustomFieldValues` (line 861), add to the nested `customField` array at line 879: ```php 'machineContextOnly' => $cf->isMachineContextOnly(), ``` - [ ] **Step 2: Add `normalizeContextCustomFieldDefinitions` helper** Add a new private method after `normalizeCustomFieldValues`: ```php private function normalizeContextCustomFieldDefinitions(Collection $customFields): array { $items = []; foreach ($customFields as $cf) { if (!$cf instanceof CustomField || !$cf->isMachineContextOnly()) { continue; } $items[] = [ 'id' => $cf->getId(), 'name' => $cf->getName(), 'type' => $cf->getType(), 'required' => $cf->isRequired(), 'options' => $cf->getOptions(), 'defaultValue' => $cf->getDefaultValue(), 'orderIndex' => $cf->getOrderIndex(), 'machineContextOnly' => true, ]; } usort($items, static fn (array $a, array $b) => $a['orderIndex'] <=> $b['orderIndex']); return $items; } ``` - [ ] **Step 3: Update `normalizeComponentLinks` to include context fields** In `normalizeComponentLinks` (line 622), add `$type` variable and context field keys to the returned array: ```php private function normalizeComponentLinks(array $links): array { return array_map(function (MachineComponentLink $link): array { $composant = $link->getComposant(); $parentLink = $link->getParentLink(); $type = $composant->getTypeComposant(); return [ 'id' => $link->getId(), 'linkId' => $link->getId(), 'machineId' => $link->getMachine()->getId(), 'composantId' => $composant->getId(), 'composant' => $this->normalizeComposant($composant), 'parentLinkId' => $parentLink?->getId(), 'parentComponentLinkId' => $parentLink?->getId(), 'parentComponentId' => $parentLink?->getComposant()->getId(), 'overrides' => $this->normalizeOverrides($link), 'childLinks' => [], 'pieceLinks' => [], 'contextCustomFields' => $type ? $this->normalizeContextCustomFieldDefinitions($type->getComponentCustomFields()) : [], 'contextCustomFieldValues' => $this->normalizeCustomFieldValues($link->getContextFieldValues()), ]; }, $links); } ``` - [ ] **Step 4: Update `normalizePieceLinks` to include context fields** In `normalizePieceLinks` (line 644), same pattern: ```php private function normalizePieceLinks(array $links): array { return array_map(function (MachinePieceLink $link): array { $piece = $link->getPiece(); $parentLink = $link->getParentLink(); $type = $piece->getTypePiece(); return [ 'id' => $link->getId(), 'linkId' => $link->getId(), 'machineId' => $link->getMachine()->getId(), 'pieceId' => $piece->getId(), 'piece' => $this->normalizePiece($piece), 'parentLinkId' => $parentLink?->getId(), 'parentComponentLinkId' => $parentLink?->getId(), 'parentComponentId' => $parentLink?->getComposant()->getId(), 'overrides' => $this->normalizeOverrides($link), 'quantity' => $this->resolvePieceQuantity($link), 'contextCustomFields' => $type ? $this->normalizeContextCustomFieldDefinitions($type->getPieceCustomFields()) : [], 'contextCustomFieldValues' => $this->normalizeCustomFieldValues($link->getContextFieldValues()), ]; }, $links); } ``` - [ ] **Step 5: Run linter** ```bash make php-cs-fixer-allow-risky ``` - [ ] **Step 6: Commit** ```bash git add src/Controller/MachineStructureController.php git commit -m "feat(custom-fields) : expose context custom fields in machine structure response" ``` --- ## Task 5: `CustomFieldValueController` — support link-based upsert **Files:** - Modify: `src/Controller/CustomFieldValueController.php` - [ ] **Step 1: Inject link repositories in constructor** In the constructor (line 24), add: ```php private readonly MachineComponentLinkRepository $machineComponentLinkRepository, private readonly MachinePieceLinkRepository $machinePieceLinkRepository, ``` Add use statements at the top: ```php use App\Repository\MachineComponentLinkRepository; use App\Repository\MachinePieceLinkRepository; ``` - [ ] **Step 2: Extend `resolveTarget` to support link entities** In `resolveTarget` (line 211), the method applies `strtolower()` on entityType at line 213. The candidate loop (line 217) and `match` block (line 233) must both use lowercase. Update the candidate list in the foreach: ```php foreach (['machine', 'composant', 'piece', 'product'] as $candidate) { ``` Replace with: ```php foreach (['machine', 'composant', 'piece', 'product', 'machineComponentLink', 'machinePieceLink'] as $candidate) { ``` The `$entityType` is lowercased at line 213, so when `machineComponentLinkId` is found, `$entityType` becomes `machinecomponentlink`. Update the match block: ```php return match ($entityType) { 'machine' => $this->resolveEntity('machine', $entityId, $this->machineRepository), 'composant' => $this->resolveEntity('composant', $entityId, $this->composantRepository), 'piece' => $this->resolveEntity('piece', $entityId, $this->pieceRepository), 'product' => $this->resolveEntity('product', $entityId, $this->productRepository), 'machinecomponentlink' => $this->resolveEntity('machineComponentLink', $entityId, $this->machineComponentLinkRepository), 'machinepiecelink' => $this->resolveEntity('machinePieceLink', $entityId, $this->machinePieceLinkRepository), default => $this->json(['success' => false, 'error' => 'Unsupported entity type.'], 400), }; ``` Note: the match keys are lowercase (post-strtolower), but `resolveEntity` returns the original camelCase type for `applyTarget`. - [ ] **Step 3: Extend `applyTarget` for link entities** Add two new cases in `applyTarget` (line 252): ```php case 'machineComponentLink': $value->setMachineComponentLink($entity); $value->setComposant($entity->getComposant()); break; case 'machinePieceLink': $value->setMachinePieceLink($entity); $value->setPiece($entity->getPiece()); break; ``` - [ ] **Step 4: Run linter** ```bash make php-cs-fixer-allow-risky ``` - [ ] **Step 5: Commit** ```bash git add src/Controller/CustomFieldValueController.php git commit -m "feat(custom-fields) : support link-based upsert in CustomFieldValueController" ``` --- ## Task 6: Clone support — copy context field values **Files:** - Modify: `src/Controller/MachineStructureController.php` (clone methods, around line 163) - [ ] **Step 1: Add `cloneContextFieldValues` helper method** Add after `cloneProductLinks`: ```php /** * @param array $componentLinkMap * @param array $pieceLinkMap */ private function cloneContextFieldValues( array $componentLinkMap, array $pieceLinkMap, ): void { foreach ($componentLinkMap as $oldLinkId => $newLink) { $oldLink = $this->machineComponentLinkRepository->find($oldLinkId); if (!$oldLink) { continue; } foreach ($oldLink->getContextFieldValues() as $cfv) { $newValue = new CustomFieldValue(); $newValue->setCustomField($cfv->getCustomField()); $newValue->setValue($cfv->getValue()); $newValue->setMachineComponentLink($newLink); $newValue->setComposant($newLink->getComposant()); $this->entityManager->persist($newValue); } } foreach ($pieceLinkMap as $oldLinkId => $newLink) { $oldLink = $this->machinePieceLinkRepository->find($oldLinkId); if (!$oldLink) { continue; } foreach ($oldLink->getContextFieldValues() as $cfv) { $newValue = new CustomFieldValue(); $newValue->setCustomField($cfv->getCustomField()); $newValue->setValue($cfv->getValue()); $newValue->setMachinePieceLink($newLink); $newValue->setPiece($newLink->getPiece()); $this->entityManager->persist($newValue); } } } ``` - [ ] **Step 2: Call from `cloneMachine` method** In `cloneMachine` (line 113), after the `cloneProductLinks` call (line 163) and before `$this->entityManager->flush()` (line 165), add: ```php $this->cloneContextFieldValues($componentLinkMap, $pieceLinkMap); ``` - [ ] **Step 3: Run linter** ```bash make php-cs-fixer-allow-risky ``` - [ ] **Step 4: Commit** ```bash git add src/Controller/MachineStructureController.php git commit -m "feat(custom-fields) : clone context field values on machine clone" ``` --- ## Task 7: Backend tests **Files:** - Create: `tests/Api/Entity/MachineContextCustomFieldTest.php` - [ ] **Step 1: Write test class** ```php createGestionnaireClient(); $site = $this->createSite('Site A'); $modelType = $this->createModelType('Motor', 'MOT', ModelCategory::COMPONENT); $contextField = $this->createCustomField( name: 'Voltage', type: 'number', typeComposant: $modelType, machineContextOnly: true, ); $normalField = $this->createCustomField( name: 'Serial', type: 'text', typeComposant: $modelType, ); $machine = $this->createMachine('Machine A', $site); $composant = $this->createComposant('Motor 1', 'MOT-001', $modelType); $link = $this->createMachineComponentLink($machine, $composant); $this->createCustomFieldValue( customField: $contextField, value: '220', composant: $composant, machineComponentLink: $link, ); $response = $client->request('GET', '/api/machines/'.$machine->getId().'/structure'); $this->assertResponseIsSuccessful(); $data = $response->toArray(); $componentLink = $data['componentLinks'][0]; // Context fields on the link $this->assertArrayHasKey('contextCustomFields', $componentLink); $this->assertCount(1, $componentLink['contextCustomFields']); $this->assertSame('Voltage', $componentLink['contextCustomFields'][0]['name']); $this->assertTrue($componentLink['contextCustomFields'][0]['machineContextOnly']); // Context values on the link $this->assertArrayHasKey('contextCustomFieldValues', $componentLink); $this->assertCount(1, $componentLink['contextCustomFieldValues']); $this->assertSame('220', $componentLink['contextCustomFieldValues'][0]['value']); // Normal fields still in composant.customFields $normalFields = array_filter( $componentLink['composant']['customFields'], fn (array $f) => $f['name'] === 'Serial', ); $this->assertCount(1, $normalFields); } public function testStructureReturnsContextFieldsOnPieceLink(): void { $client = $this->createGestionnaireClient(); $site = $this->createSite('Site B'); $modelType = $this->createModelType('Bearing', 'BRG', ModelCategory::PIECE); $contextField = $this->createCustomField( name: 'Wear Level', type: 'select', typePiece: $modelType, machineContextOnly: true, ); $contextField->setOptions(['Good', 'Fair', 'Replace']); $this->getEntityManager()->flush(); $machine = $this->createMachine('Machine B', $site); $piece = $this->createPiece('Bearing 1', 'BRG-001', $modelType); $link = $this->createMachinePieceLink($machine, $piece); $this->createCustomFieldValue( customField: $contextField, value: 'Fair', piece: $piece, machinePieceLink: $link, ); $response = $client->request('GET', '/api/machines/'.$machine->getId().'/structure'); $data = $response->toArray(); $pieceLink = $data['pieceLinks'][0]; $this->assertCount(1, $pieceLink['contextCustomFields']); $this->assertSame('Wear Level', $pieceLink['contextCustomFields'][0]['name']); $this->assertCount(1, $pieceLink['contextCustomFieldValues']); $this->assertSame('Fair', $pieceLink['contextCustomFieldValues'][0]['value']); } public function testUpsertContextFieldValueViaComponentLink(): void { $client = $this->createGestionnaireClient(); $site = $this->createSite('Site C'); $modelType = $this->createModelType('Pump', 'PMP', ModelCategory::COMPONENT); $contextField = $this->createCustomField( name: 'Flow Rate', type: 'number', typeComposant: $modelType, machineContextOnly: true, ); $machine = $this->createMachine('Machine C', $site); $composant = $this->createComposant('Pump 1', 'PMP-001', $modelType); $link = $this->createMachineComponentLink($machine, $composant); $response = $client->request('POST', '/api/custom-fields/values/upsert', [ 'json' => [ 'customFieldId' => $contextField->getId(), 'machineComponentLinkId' => $link->getId(), 'value' => '380', ], ]); $this->assertResponseIsSuccessful(); $data = $response->toArray(); $this->assertSame('380', $data['value']); } public function testSameComposantDifferentValuesPerMachine(): void { $client = $this->createGestionnaireClient(); $site = $this->createSite('Site D'); $modelType = $this->createModelType('Valve', 'VLV', ModelCategory::COMPONENT); $contextField = $this->createCustomField( name: 'Pressure', type: 'number', typeComposant: $modelType, machineContextOnly: true, ); $machineA = $this->createMachine('Machine A', $site); $machineB = $this->createMachine('Machine B', $site); $composant = $this->createComposant('Valve 1', 'VLV-001', $modelType); $linkA = $this->createMachineComponentLink($machineA, $composant); $linkB = $this->createMachineComponentLink($machineB, $composant); $this->createCustomFieldValue( customField: $contextField, value: '100', composant: $composant, machineComponentLink: $linkA, ); $this->createCustomFieldValue( customField: $contextField, value: '200', composant: $composant, machineComponentLink: $linkB, ); $dataA = $client->request('GET', '/api/machines/'.$machineA->getId().'/structure')->toArray(); $this->assertSame('100', $dataA['componentLinks'][0]['contextCustomFieldValues'][0]['value']); $dataB = $client->request('GET', '/api/machines/'.$machineB->getId().'/structure')->toArray(); $this->assertSame('200', $dataB['componentLinks'][0]['contextCustomFieldValues'][0]['value']); } public function testMachineContextOnlyFieldSerialization(): void { $client = $this->createGestionnaireClient(); $site = $this->createSite('Site E'); $modelType = $this->createModelType('Sensor', 'SNS', ModelCategory::COMPONENT); $contextField = $this->createCustomField( name: 'Calibration Date', type: 'date', typeComposant: $modelType, machineContextOnly: true, ); $response = $client->request('GET', '/api/custom_fields/'.$contextField->getId()); $this->assertResponseIsSuccessful(); $data = $response->toArray(); $this->assertTrue($data['machineContextOnly']); } public function testCloneMachineCopiesContextFieldValues(): void { $client = $this->createGestionnaireClient(); $site = $this->createSite('Site F'); $modelType = $this->createModelType('Motor Clone', 'MOTC', ModelCategory::COMPONENT); $contextField = $this->createCustomField( name: 'RPM Setting', type: 'number', typeComposant: $modelType, machineContextOnly: true, ); $source = $this->createMachine('Source Machine', $site); $composant = $this->createComposant('Motor C', 'MOTC-001', $modelType); $link = $this->createMachineComponentLink($source, $composant); $this->createCustomFieldValue( customField: $contextField, value: '3000', composant: $composant, machineComponentLink: $link, ); $response = $client->request('POST', '/api/machines/'.$source->getId().'/clone', [ 'json' => [ 'name' => 'Cloned Machine', 'siteId' => $site->getId(), ], ]); $this->assertResponseStatusCodeSame(201); $data = $response->toArray(); $clonedLink = $data['componentLinks'][0] ?? null; $this->assertNotNull($clonedLink); $this->assertCount(1, $clonedLink['contextCustomFieldValues']); $this->assertSame('3000', $clonedLink['contextCustomFieldValues'][0]['value']); } } ``` - [ ] **Step 2: Run tests** ```bash make test FILES=tests/Api/Entity/MachineContextCustomFieldTest.php ``` Expected: All 6 tests pass. - [ ] **Step 3: Run linter** ```bash make php-cs-fixer-allow-risky ``` - [ ] **Step 4: Commit** ```bash git add tests/Api/Entity/MachineContextCustomFieldTest.php git commit -m "test(custom-fields) : add tests for machine context custom fields" ``` --- ## Task 8: Frontend types — add `machineContextOnly` **Files:** - Modify: `frontend/app/shared/types/inventory.ts` - [ ] **Step 1: Add `machineContextOnly` to `ComponentModelCustomField`** In the `ComponentModelCustomField` interface (around line 14), add: ```typescript machineContextOnly?: boolean ``` - [ ] **Step 2: Add `machineContextOnly` to `PieceModelCustomField`** In the `PieceModelCustomField` interface (around line 65), add: ```typescript machineContextOnly?: boolean ``` - [ ] **Step 3: Commit** ```bash cd frontend && git add app/shared/types/inventory.ts git commit -m "feat(custom-fields) : add machineContextOnly to custom field types" ``` --- ## Task 9: Structure editors — add toggle **Files:** - Modify: `frontend/app/components/PieceModelStructureEditor.vue:122-125` - Modify: `frontend/app/composables/usePieceStructureEditorLogic.ts:283-290` - Modify: `frontend/app/components/StructureNodeEditor.vue:121-125` - Modify: `frontend/app/composables/useStructureNodeCrud.ts:49-62` - [ ] **Step 1: Add toggle in `PieceModelStructureEditor.vue`** After the "Obligatoire" checkbox block (line 125), add: ```vue
Contexte machine uniquement
``` - [ ] **Step 2: Update `createEmptyField` in `usePieceStructureEditorLogic.ts`** In `createEmptyField` (line 283), add `machineContextOnly: false` to the returned object: ```typescript const createEmptyField = (orderIndex: number): EditorField => ({ uid: createUid('field'), name: '', type: 'text', required: false, optionsText: '', machineContextOnly: false, orderIndex, }) ``` - [ ] **Step 3: Add toggle in `StructureNodeEditor.vue`** After the "Obligatoire" checkbox (around line 121-125), add: ```vue
Contexte machine uniquement
``` - [ ] **Step 4: Update `addCustomField` in `useStructureNodeCrud.ts`** In `addCustomField` (line 49), add `machineContextOnly: false` to the pushed object at line 53: ```typescript fields.push({ name: '', type: 'text', required: false, optionsText: '', options: [], machineContextOnly: false, orderIndex: nextIndex, }) ``` - [ ] **Step 5: Run lint + typecheck** ```bash cd frontend && npm run lint:fix && npx nuxi typecheck ``` - [ ] **Step 6: Commit** ```bash cd frontend && git add . git commit -m "feat(custom-fields) : add machineContextOnly toggle in structure editors" ``` --- ## Task 10: Frontend — filter context fields on standalone pages + machine-detail transform **Files:** - Modify: `frontend/app/composables/useEntityCustomFields.ts:42-49` - Modify: `frontend/app/composables/useMachineDetailCustomFields.ts:96,186,141-154,241-256` - [ ] **Step 1: Filter `machineContextOnly` from `displayedCustomFields` in `useEntityCustomFields.ts`** Update the `displayedCustomFields` computed (line 42): ```typescript const displayedCustomFields = computed(() => dedupeMergedFields( mergeFieldDefinitionsWithValues( definitionSources.value, entity().customFieldValues, ), ).filter((field: any) => !field.machineContextOnly && !field.customField?.machineContextOnly), ) ``` - [ ] **Step 2: Filter `machineContextOnly` from normal customFields merge in `useMachineDetailCustomFields.ts`** In `transformCustomFields` (line 70), after the `customFields` merge at line 96, filter out context fields. Change the return object (line 141-154) to filter: Replace `customFields,` (line 143) with: ```typescript customFields: customFields.filter((f: AnyRecord) => !f.machineContextOnly && !f.customField?.machineContextOnly), contextCustomFields: piece.contextCustomFields ?? [], contextCustomFieldValues: piece.contextCustomFieldValues ?? [], ``` In `transformComponentCustomFields` (line 158), same pattern. Replace `customFields,` (line 243) with: ```typescript customFields: customFields.filter((f: AnyRecord) => !f.machineContextOnly && !f.customField?.machineContextOnly), contextCustomFields: component.contextCustomFields ?? [], contextCustomFieldValues: component.contextCustomFieldValues ?? [], ``` - [ ] **Step 3: Run lint + typecheck** ```bash cd frontend && npm run lint:fix && npx nuxi typecheck ``` - [ ] **Step 4: Commit** ```bash cd frontend && git add . git commit -m "feat(custom-fields) : filter machineContextOnly from standalone and machine-detail views" ``` --- ## Task 11: Frontend — display context fields in machine view **Files:** - Modify: `frontend/app/components/ComponentItem.vue` - Modify: `frontend/app/components/PieceItem.vue` Context fields are on the `component` / `piece` object (set by the transform in Task 10), not as separate props. - [ ] **Step 1: Add context fields section in `ComponentItem.vue`** After the existing `CustomFieldDisplay` block (line 195), add: ```vue

Champs contextuels

``` In the script section, add: ```typescript import { mergeFieldDefinitionsWithValues, dedupeMergedFields } from '~/shared/utils/entityCustomFieldLogic' const mergedContextFields = computed(() => { const definitions = props.component?.contextCustomFields ?? [] const values = props.component?.contextCustomFieldValues ?? [] if (!definitions.length && !values.length) return [] return dedupeMergedFields( mergeFieldDefinitionsWithValues(definitions, values), ) }) const updateContextCustomField = async (field: any) => { const linkId = props.component?.linkId if (!linkId || !field) return const customFieldId = field.customFieldId || field.customField?.id if (!customFieldId) return const { upsertCustomFieldValue } = useCustomFields() const result: any = await upsertCustomFieldValue( customFieldId, 'machineComponentLink', linkId, field.value ?? '', ) if (result.success) { showSuccess(`Champ contextuel "${field.name || field.customField?.name}" mis à jour`) } else { showError(`Erreur lors de la mise à jour du champ contextuel`) } } ``` Note: `useCustomFields`, `showSuccess`, and `showError` may need to be imported or may already be available in the component. Check the existing imports and add if missing: ```typescript import { useCustomFields } from '~/composables/useCustomFields' import { useToast } from '~/composables/useToast' // ... in setup: const { showSuccess, showError } = useToast() ``` - [ ] **Step 2: Add context fields section in `PieceItem.vue`** Same pattern. After the existing `CustomFieldDisplay` (line 236), add: ```vue

Champs contextuels

``` In the script: ```typescript const mergedContextFields = computed(() => { const definitions = props.piece?.contextCustomFields ?? [] const values = props.piece?.contextCustomFieldValues ?? [] if (!definitions.length && !values.length) return [] return dedupeMergedFields( mergeFieldDefinitionsWithValues(definitions, values), ) }) const updateContextCustomField = async (field: any) => { const linkId = props.piece?.linkId if (!linkId || !field) return const customFieldId = field.customFieldId || field.customField?.id if (!customFieldId) return const { upsertCustomFieldValue } = useCustomFields() const result: any = await upsertCustomFieldValue( customFieldId, 'machinePieceLink', linkId, field.value ?? '', ) if (result.success) { showSuccess(`Champ contextuel "${field.name || field.customField?.name}" mis à jour`) } else { showError(`Erreur lors de la mise à jour du champ contextuel`) } } ``` - [ ] **Step 3: Run lint + typecheck** ```bash cd frontend && npm run lint:fix && npx nuxi typecheck ``` - [ ] **Step 4: Commit** ```bash cd frontend && git add . git commit -m "feat(custom-fields) : display context custom fields in machine view" ``` --- ## Task 12: Frontend build + full backend test verification - [ ] **Step 1: Run full frontend build** ```bash cd frontend && npm run build ``` Expected: Build succeeds. - [ ] **Step 2: Run all backend tests** ```bash make test ``` Expected: All tests pass. - [ ] **Step 3: Update frontend submodule pointer** ```bash cd /home/matthieu/dev_malio/Inventory git add frontend git commit -m "chore : update frontend submodule for context custom fields" ```