# 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<int, CustomFieldValue>
 */
#[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<int, CustomFieldValue>
 */
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<int, CustomFieldValue>
 */
#[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<int, CustomFieldValue>
 */
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<string, MachineComponentLink> $componentLinkMap
 * @param array<string, MachinePieceLink>     $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
<?php

declare(strict_types=1);

namespace App\Tests\Api\Entity;

use App\Enum\ModelCategory;
use App\Tests\AbstractApiTestCase;

class MachineContextCustomFieldTest extends AbstractApiTestCase
{
    public function testStructureReturnsContextFieldsOnComponentLink(): void
    {
        $client = $this->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
              <div class="flex items-center gap-2 text-xs">
                <input v-model="field.machineContextOnly" type="checkbox" class="checkbox checkbox-xs">
                Contexte machine uniquement
              </div>
```

- [ ] **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
                <div class="flex items-center gap-2 text-xs">
                  <input v-model="field.machineContextOnly" type="checkbox" class="checkbox checkbox-xs">
                  Contexte machine uniquement
                </div>
```

- [ ] **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
        <!-- Context custom fields (machine-specific) -->
        <div v-if="mergedContextFields.length" class="mt-4">
          <h4 class="text-xs font-semibold text-base-content/70 mb-2">
            Champs contextuels
          </h4>
          <CustomFieldDisplay
            :fields="mergedContextFields"
            :is-edit-mode="isEditMode"
            :columns="2"
            @field-blur="updateContextCustomField"
          />
        </div>
```

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
        <!-- Context custom fields (machine-specific) -->
        <div v-if="mergedContextFields.length" class="mt-4">
          <h4 class="text-xs font-semibold text-base-content/70 mb-2">
            Champs contextuels
          </h4>
          <CustomFieldDisplay
            :fields="mergedContextFields"
            :is-edit-mode="isEditMode"
            :columns="2"
            @field-blur="updateContextCustomField"
          />
        </div>
```

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"
```
