1183 lines
38 KiB
Markdown
1183 lines
38 KiB
Markdown
# 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"
|
|
```
|