# 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<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 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<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 `$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
<?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];

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