diff --git a/docs/superpowers/plans/2026-04-02-machine-context-custom-fields.md b/docs/superpowers/plans/2026-04-02-machine-context-custom-fields.md new file mode 100644 index 0000000..2b03cdf --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-machine-context-custom-fields.md @@ -0,0 +1,1182 @@ +# 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" +``` diff --git a/docs/superpowers/plans/2026-04-02-machine-context-fields-backend.md b/docs/superpowers/plans/2026-04-02-machine-context-fields-backend.md new file mode 100644 index 0000000..b0314f3 --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-machine-context-fields-backend.md @@ -0,0 +1,841 @@ +# Machine Context Custom Fields — Backend Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **Parallel plan:** This is the backend half. The frontend plan is at `2026-04-02-machine-context-fields-frontend.md`. Both can run in parallel on separate worktrees — they share no files. + +**Goal:** Add `machineContextOnly` flag on `CustomField`, link FKs on `CustomFieldValue`, structure controller normalization, upsert support, clone support, and tests. + +**Architecture:** `CustomField.machineContextOnly` flags definitions. `CustomFieldValue` gets nullable FKs to `MachineComponentLink`/`MachinePieceLink`. The structure response returns `contextCustomFields` and `contextCustomFieldValues` per link. Upsert and clone are extended. + +**Tech Stack:** Symfony 8, Doctrine ORM, API Platform 4, PostgreSQL 16, PHPUnit 12 + +**Spec:** `docs/superpowers/specs/2026-04-02-machine-context-custom-fields-design.md` + +--- + +## File Map + +### Create +- `migrations/VersionXXXX_MachineContextCustomFields.php` — migration +- `tests/Api/Entity/MachineContextCustomFieldTest.php` — test class + +### 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 + clone +- `src/Controller/CustomFieldValueController.php` — link-based upsert +- `tests/AbstractApiTestCase.php` — extend factories + +--- + +## 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 closing `}` of the `if (null !== $product)` block** (after line 454, NOT inside it): + +```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): + +```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 and match block must handle this. + +Update the candidate list in the foreach (line 217): +```php +foreach (['machine', 'composant', 'piece', 'product', 'machineComponentLink', 'machinePieceLink'] as $candidate) { +``` + +**IMPORTANT:** The candidate loop assigns `$entityType` in camelCase, but `strtolower()` (line 213) only applies when `entityType` comes from the payload directly. Add a normalization after the loop closes (after the existing `break;` at line 226), before the empty-check at line 229: + +```php +$entityType = strtolower($entityType); +``` + +This ensures both code paths (direct payload `entityType` and candidate loop) deliver lowercase to the match block. + +Update the match block (line 233) — all keys lowercase, but `resolveEntity` returns camelCase type for `applyTarget`: + +```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), +}; +``` + +- [ ] **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` (after `cloneProductLinks`, before `flush` in `cloneMachine`) + +- [ ] **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 `$this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap);` (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]; + + $this->assertArrayHasKey('contextCustomFields', $componentLink); + $this->assertCount(1, $componentLink['contextCustomFields']); + $this->assertSame('Voltage', $componentLink['contextCustomFields'][0]['name']); + $this->assertTrue($componentLink['contextCustomFields'][0]['machineContextOnly']); + + $this->assertArrayHasKey('contextCustomFieldValues', $componentLink); + $this->assertCount(1, $componentLink['contextCustomFieldValues']); + $this->assertSame('220', $componentLink['contextCustomFieldValues'][0]['value']); + + $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: Run full test suite** + +```bash +make test +``` + +Expected: All existing tests still pass. + +- [ ] **Step 5: Commit** + +```bash +git add tests/Api/Entity/MachineContextCustomFieldTest.php +git commit -m "test(custom-fields) : add tests for machine context custom fields" +``` diff --git a/docs/superpowers/plans/2026-04-02-machine-context-fields-frontend.md b/docs/superpowers/plans/2026-04-02-machine-context-fields-frontend.md new file mode 100644 index 0000000..9b41504 --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-machine-context-fields-frontend.md @@ -0,0 +1,404 @@ +# Machine Context Custom Fields — Frontend Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **Parallel plan:** This is the frontend half. The backend plan is at `2026-04-02-machine-context-fields-backend.md`. Both can run in parallel on separate worktrees — they share no files. Frontend tests requiring the API will need the backend done first. + +**Goal:** Add `machineContextOnly` toggle in structure editors, filter context fields from standalone pages, and display/edit them in the machine detail view. + +**Architecture:** Structure editors get a checkbox per field. The machine-detail transform propagates `contextCustomFields`/`contextCustomFieldValues` from the API link response onto the component/piece objects. Standalone entity views filter these out. Machine view displays them in a separate "Champs contextuels" section using the existing `CustomFieldDisplay` component, saving via upsert with the link ID. + +**Tech Stack:** Nuxt 4, Vue 3 Composition API, TypeScript, DaisyUI 5 + +**Spec:** `docs/superpowers/specs/2026-04-02-machine-context-custom-fields-design.md` + +--- + +## File Map + +### Modify +- `frontend/app/shared/types/inventory.ts` — add `machineContextOnly` to custom field types +- `frontend/app/components/PieceModelStructureEditor.vue` — add checkbox toggle +- `frontend/app/composables/usePieceStructureEditorLogic.ts` — add default in `createEmptyField()` +- `frontend/app/components/StructureNodeEditor.vue` — add checkbox toggle +- `frontend/app/composables/useStructureNodeCrud.ts` — add default 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: 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 2: 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, after `` closing the required checkbox), add: + +```vue +
+ + Contexte machine uniquement +
+``` + +- [ ] **Step 2: Update `usePieceStructureEditorLogic.ts` — 3 functions** + +**a) `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, +}) +``` + +**b) `toEditorField`** (line 78-91) — add `machineContextOnly` to the returned object, after the `orderIndex` line (line 90): + +```typescript +machineContextOnly: Boolean(input?.machineContextOnly), +``` + +**c) `buildPayload`** (line 160-165) — add `machineContextOnly` to the `payload` object after `orderIndex` (line 164): + +```typescript +machineContextOnly: Boolean(field.machineContextOnly), +``` + +- [ ] **Step 3: Add toggle in `StructureNodeEditor.vue`** + +After the "Obligatoire" checkbox closing `` (line 123) and **before** the `