Files
Inventory/docs/superpowers/plans/2026-04-02-machine-context-custom-fields.md
2026-04-03 09:25:07 +02:00

38 KiB

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 CustomFieldmachineContextOnly

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):

#[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 }:

public function isMachineContextOnly(): bool
{
    return $this->machineContextOnly;
}

public function setMachineContextOnly(bool $machineContextOnly): static
{
    $this->machineContextOnly = $machineContextOnly;

    return $this;
}
  • Step 2: Generate and adjust migration
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:diff

Edit the generated migration to use idempotent SQL:

ALTER TABLE custom_fields ADD COLUMN IF NOT EXISTS machinecontextonly BOOLEAN DEFAULT false NOT NULL;
  • Step 3: Run migration
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction
  • Step 4: Run linter
make php-cs-fixer-allow-risky
  • Step 5: Commit
git add src/Entity/CustomField.php migrations/
git commit -m "feat(custom-fields) : add machineContextOnly flag to CustomField entity"

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):

#[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 }:

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):

/**
 * @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:

$this->contextFieldValues = new ArrayCollection();

Add getter before the closing }:

/**
 * @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):

/**
 * @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:

$this->contextFieldValues = new ArrayCollection();

Add getter:

/**
 * @return Collection<int, CustomFieldValue>
 */
public function getContextFieldValues(): Collection
{
    return $this->contextFieldValues;
}
  • Step 4: Generate and adjust migration
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:diff

Edit migration to use idempotent 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
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
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):

?MachineComponentLink $machineComponentLink = null,
?MachinePieceLink $machinePieceLink = null,

Add the corresponding setter calls after the $product setter (line 453):

if (null !== $machineComponentLink) {
    $cfv->setMachineComponentLink($machineComponentLink);
}
if (null !== $machinePieceLink) {
    $cfv->setMachinePieceLink($machinePieceLink);
}
  • Step 3: Run linter
make php-cs-fixer-allow-risky
  • Step 4: Commit
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:

'machineContextOnly' => $customField->isMachineContextOnly(),

In normalizeCustomFieldDefinitions (line 838), add to the output array at line 852:

'machineContextOnly' => $cf->isMachineContextOnly(),

In normalizeCustomFieldValues (line 861), add to the nested customField array at line 879:

'machineContextOnly' => $cf->isMachineContextOnly(),
  • Step 2: Add normalizeContextCustomFieldDefinitions helper

Add a new private method after normalizeCustomFieldValues:

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:

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:

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
make php-cs-fixer-allow-risky
  • Step 6: Commit
git add src/Controller/MachineStructureController.php
git commit -m "feat(custom-fields) : expose context custom fields in machine structure response"

Files:

  • Modify: src/Controller/CustomFieldValueController.php

  • Step 1: Inject link repositories in constructor

In the constructor (line 24), add:

private readonly MachineComponentLinkRepository $machineComponentLinkRepository,
private readonly MachinePieceLinkRepository $machinePieceLinkRepository,

Add use statements at the top:

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:

foreach (['machine', 'composant', 'piece', 'product'] as $candidate) {

Replace with:

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:

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):

case 'machineComponentLink':
    $value->setMachineComponentLink($entity);
    $value->setComposant($entity->getComposant());

    break;

case 'machinePieceLink':
    $value->setMachinePieceLink($entity);
    $value->setPiece($entity->getPiece());

    break;
  • Step 4: Run linter
make php-cs-fixer-allow-risky
  • Step 5: Commit
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:

/**
 * @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:

$this->cloneContextFieldValues($componentLinkMap, $pieceLinkMap);
  • Step 3: Run linter
make php-cs-fixer-allow-risky
  • Step 4: Commit
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

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
make test FILES=tests/Api/Entity/MachineContextCustomFieldTest.php

Expected: All 6 tests pass.

  • Step 3: Run linter
make php-cs-fixer-allow-risky
  • Step 4: Commit
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:

machineContextOnly?: boolean
  • Step 2: Add machineContextOnly to PieceModelCustomField

In the PieceModelCustomField interface (around line 65), add:

machineContextOnly?: boolean
  • Step 3: Commit
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:

              <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:

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:

                <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:

fields.push({
    name: '',
    type: 'text',
    required: false,
    optionsText: '',
    options: [],
    machineContextOnly: false,
    orderIndex: nextIndex,
})
  • Step 5: Run lint + typecheck
cd frontend && npm run lint:fix && npx nuxi typecheck
  • Step 6: Commit
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):

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:

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:

customFields: customFields.filter((f: AnyRecord) => !f.machineContextOnly && !f.customField?.machineContextOnly),
contextCustomFields: component.contextCustomFields ?? [],
contextCustomFieldValues: component.contextCustomFieldValues ?? [],
  • Step 3: Run lint + typecheck
cd frontend && npm run lint:fix && npx nuxi typecheck
  • Step 4: Commit
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:

        <!-- 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:

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:

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:

        <!-- 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:

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
cd frontend && npm run lint:fix && npx nuxi typecheck
  • Step 4: Commit
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
cd frontend && npm run build

Expected: Build succeeds.

  • Step 2: Run all backend tests
make test

Expected: All tests pass.

  • Step 3: Update frontend submodule pointer
cd /home/matthieu/dev_malio/Inventory
git add frontend
git commit -m "chore : update frontend submodule for context custom fields"