Files
Inventory/docs/superpowers/plans/2026-03-26-machine-single-save.md
Matthieu be859e57db refactor : rename Inventory_frontend to frontend in docs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:20:19 +02:00

33 KiB

Machine Single Save Button + Link Versioning — Implementation 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.

Goal: Replace the machine page's auto-save-on-blur with a single "Enregistrer les modifications" button (matching composant/pièce/produit pages), and add versioning for machine link add/remove operations.

Architecture: Frontend changes remove all @blur save triggers from MachineInfoCard, consolidate saves into a single submitEdition() method in the orchestrator composable, and add Save/Cancel buttons at the bottom of the page. MachineInfoCard exposes saveFieldDefinitions() via defineExpose so the parent can call it during submit. Backend changes extend MachineAuditSubscriber's onFlush to detect link entity insertions/deletions and create audit entries on the parent machine.

Tech Stack: Vue 3 / Nuxt 4, Symfony 8, Doctrine ORM, PHP 8.4


File Structure

Action File Responsibility
Modify frontend/app/components/machine/MachineInfoCard.vue Remove blur-triggered saves, expose saveFieldDefinitions via defineExpose
Modify frontend/app/components/machine/MachineCustomFieldDefEditor.vue Remove standalone save button
Modify frontend/app/pages/machine/[id].vue Add Save/Cancel buttons, wire submitEdition via template ref
Modify frontend/app/composables/useMachineDetailData.ts Add submitEdition, cancelEdition, saving, canSubmit
Modify frontend/app/composables/useMachineDetailUpdates.ts Remove auto-save from handleMachineConstructeurChange
Modify frontend/app/composables/useMachineDetailCustomFields.ts Add saveAllMachineCustomFields batch method
Modify src/EventSubscriber/MachineAuditSubscriber.php Enrich snapshot with links + detect link changes in onFlush

Task 1: Remove blur-triggered saves from MachineInfoCard

Files:

  • Modify: frontend/app/components/machine/MachineInfoCard.vue

  • Step 1: Remove @blur from name input (line 17)

Replace:

            @input="$emit('update:machine-name', ($event.target as HTMLInputElement).value)"
            @blur="$emit('blur-field')"

With:

            @input="$emit('update:machine-name', ($event.target as HTMLInputElement).value)"
  • Step 2: Remove $emit('blur-field') from site select (line 31)

Replace:

            @change="$emit('update:machine-site-id', ($event.target as HTMLSelectElement).value); $emit('blur-field')"

With:

            @change="$emit('update:machine-site-id', ($event.target as HTMLSelectElement).value)"
  • Step 3: Remove @blur from reference input (line 57)

Replace:

            @input="$emit('update:machine-reference', ($event.target as HTMLInputElement).value)"
            @blur="$emit('blur-field')"

With:

            @input="$emit('update:machine-reference', ($event.target as HTMLInputElement).value)"
  • Step 4: Remove @blur from all custom field inputs

For each custom field input, remove the @blur="$emit('update-custom-field', field)" line:

  • text input (line 118)

  • number input (line 127)

  • select (line 135)

  • boolean checkbox (line 152)

  • date input (line 163)

  • Step 5: Remove @save handler from MachineCustomFieldDefEditor usage (line 187)

Replace:

        <MachineCustomFieldDefEditor
          :fields="fieldDefs.fields.value"
          :saving="fieldDefs.saving.value"
          :reorder-class="fieldDefs.reorderClass"
          :on-drag-start="fieldDefs.onDragStart"
          :on-drag-enter="fieldDefs.onDragEnter"
          :on-drop="fieldDefs.onDrop"
          :on-drag-end="fieldDefs.onDragEnd"
          @save="fieldDefs.saveDefinitions()"
          @add-field="fieldDefs.addField()"
          @remove-field="fieldDefs.removeField($event)"
        />

With:

        <MachineCustomFieldDefEditor
          :fields="fieldDefs.fields.value"
          :saving="fieldDefs.saving.value"
          :reorder-class="fieldDefs.reorderClass"
          :on-drag-start="fieldDefs.onDragStart"
          :on-drag-enter="fieldDefs.onDragEnter"
          :on-drop="fieldDefs.onDrop"
          :on-drag-end="fieldDefs.onDragEnd"
          @add-field="fieldDefs.addField()"
          @remove-field="fieldDefs.removeField($event)"
        />
  • Step 6: Remove unused emit declarations and add defineExpose

Replace the defineEmits block (lines 222-231):

const emit = defineEmits<{
  'update:machine-name': [value: string]
  'update:machine-reference': [value: string]
  'update:machine-site-id': [value: string]
  'update:constructeur-ids': [ids: unknown]
  'blur-field': []
  'set-custom-field-value': [field: any, value: unknown]
  'update-custom-field': [field: any]
  'custom-fields-saved': []
}>()

With:

const emit = defineEmits<{
  'update:machine-name': [value: string]
  'update:machine-reference': [value: string]
  'update:machine-site-id': [value: string]
  'update:constructeur-ids': [ids: unknown]
  'set-custom-field-value': [field: any, value: unknown]
  'custom-fields-saved': []
}>()

Then, at the end of the <script setup> block (after the watch at line 239-241), add:

defineExpose({
  saveFieldDefinitions: () => fieldDefs.saveDefinitions(),
})
  • Step 7: Commit
git add frontend/app/components/machine/MachineInfoCard.vue
git commit -m "refactor(machine) : remove blur-triggered auto-saves from MachineInfoCard"

Task 2: Remove standalone save button from MachineCustomFieldDefEditor

Files:

  • Modify: frontend/app/components/machine/MachineCustomFieldDefEditor.vue

  • Step 1: Remove the "Enregistrer les champs" button (lines 7-15)

Replace the header div (lines 3-16):

    <div class="flex items-center justify-between">
      <h3 class="text-sm font-semibold">
        Définitions des champs personnalisés
      </h3>
      <button
        type="button"
        class="btn btn-primary btn-sm"
        :disabled="saving"
        @click="$emit('save')"
      >
        <span v-if="saving" class="loading loading-spinner loading-xs" />
        Enregistrer les champs
      </button>
    </div>

With:

    <h3 class="text-sm font-semibold">
      Définitions des champs personnalisés
    </h3>
  • Step 2: Remove the save emit from defineEmits (line 120)

Replace:

defineEmits<{
  save: []
  'add-field': []
  'remove-field': [index: number]
}>()

With:

defineEmits<{
  'add-field': []
  'remove-field': [index: number]
}>()
  • Step 3: Commit
git add frontend/app/components/machine/MachineCustomFieldDefEditor.vue
git commit -m "refactor(machine) : remove standalone save button from custom field def editor"

Task 3: Stop auto-save in handleMachineConstructeurChange

Files:

  • Modify: frontend/app/composables/useMachineDetailUpdates.ts:211-214

  • Step 1: Remove the auto-save call

Replace:

  const handleMachineConstructeurChange = async (value: unknown) => {
    machineConstructeurIds.value = uniqueConstructeurIds(value)
    await updateMachineInfo()
  }

With:

  const handleMachineConstructeurChange = (value: unknown) => {
    machineConstructeurIds.value = uniqueConstructeurIds(value)
  }
  • Step 2: Commit
git add frontend/app/composables/useMachineDetailUpdates.ts
git commit -m "refactor(machine) : stop auto-saving on constructeur change"

Task 4: Add batch custom field save method

Files:

  • Modify: frontend/app/composables/useMachineDetailCustomFields.ts

  • Step 1: Add saveAllMachineCustomFields method

After the updatePieceCustomField method (before the return block at line 379), add:

  const saveAllMachineCustomFields = async () => {
    if (!machine.value) return

    const fields = Array.isArray(machineCustomFields.value) ? machineCustomFields.value : []
    const fieldsToSave = fields.filter(
      (field) => field.value !== undefined && field.value !== null && String(field.value).trim() !== '',
    )

    for (const field of fieldsToSave) {
      const { id: customFieldId, customFieldValueId } = field

      try {
        if (customFieldValueId) {
          await updateCustomFieldValueApi(customFieldValueId as string, {
            value: field.value ?? '',
          } as any)
        } else if (customFieldId) {
          const result: any = await upsertCustomFieldValue(
            customFieldId as string,
            'machine',
            machine.value.id as string,
            field.value ?? '',
          )
          if (result.success) {
            const createdValue = result.data as AnyRecord
            if (createdValue?.id) {
              field.customFieldValueId = createdValue.id
              if (!createdValue.customField) {
                createdValue.customField = {
                  id: customFieldId,
                  name: field.name,
                  type: field.type,
                  required: field.required,
                  options: field.options,
                }
              }
              const existingValues = Array.isArray(machine.value.customFieldValues)
                ? (machine.value.customFieldValues as AnyRecord[]).filter(
                    (item) => item.id !== createdValue.id,
                  )
                : []
              machine.value.customFieldValues = [...existingValues, createdValue]
            }
          }
        }
      } catch (error) {
        console.error('Erreur lors de la sauvegarde du champ personnalisé:', error)
        throw error
      }
    }
  }
  • Step 2: Export the new method in the return block

Add saveAllMachineCustomFields to the return object:

Replace:

    // Methods
    syncMachineCustomFields,
    setMachineCustomFieldValue,
    updateMachineCustomField,
    updatePieceCustomField,

With:

    // Methods
    syncMachineCustomFields,
    setMachineCustomFieldValue,
    updateMachineCustomField,
    updatePieceCustomField,
    saveAllMachineCustomFields,
  • Step 3: Commit
git add frontend/app/composables/useMachineDetailCustomFields.ts
git commit -m "feat(machine) : add batch saveAllMachineCustomFields method"

Task 5: Add submitEdition, cancelEdition, saving, canSubmit to orchestrator

Files:

  • Modify: frontend/app/composables/useMachineDetailData.ts

  • Step 1: Add saving ref in the core state block (after line 63)

After const printAreaRef = ref<HTMLElement | null>(null), add:

  const saving = ref(false)
  • Step 2: Destructure saveAllMachineCustomFields from custom fields sub-composable (line 145)

Replace:

    syncMachineCustomFields,
    setMachineCustomFieldValue,
    updateMachineCustomField,
    updatePieceCustomField,

With:

    syncMachineCustomFields,
    setMachineCustomFieldValue,
    updateMachineCustomField,
    updatePieceCustomField,
    saveAllMachineCustomFields,
  • Step 3: Add canSubmit computed after the isEditMode ref block (after line 111)
  const canSubmit = computed(() => {
    if (!machine.value) return false
    if (saving.value) return false
    if (!machineName.value.trim()) return false
    return true
  })

computed is already imported from vue at line 8.

  • Step 4: Add submitEdition and cancelEdition methods after toggleAllPieces (after line 303)

Note: submitEdition does NOT call fieldDefs.saveDefinitions() directly — the page handles that via a template ref on MachineInfoCard, since fieldDefs is instantiated inside MachineInfoCard, not in the orchestrator.

  const submitEdition = async () => {
    if (!machine.value || saving.value) return
    saving.value = true

    try {
      // 1. Save machine info (name, reference, site, constructeurs)
      await updateMachineInfo()

      // 2. Save all custom field values
      await saveAllMachineCustomFields()

      // 3. Reload machine data to get fresh state
      await loadMachineData()

      // 4. Exit edit mode
      isEditMode.value = false
      toast.showSuccess('Machine mise à jour avec succès')
    } catch (error) {
      console.error('Erreur lors de la sauvegarde:', error)
      toast.showError('Erreur lors de la sauvegarde de la machine')
    } finally {
      saving.value = false
    }
  }

  const cancelEdition = () => {
    // Reset local state from machine.value
    initMachineFields()
    syncMachineCustomFields()
    isEditMode.value = false
  }
  • Step 5: Expose new items in the return block (line 423+)

Add to the return object:

    // Save
    saving, canSubmit, submitEdition, cancelEdition,
  • Step 6: Commit
git add frontend/app/composables/useMachineDetailData.ts
git commit -m "feat(machine) : add submitEdition, cancelEdition, saving, canSubmit"

Task 6: Wire Save/Cancel buttons in the page

Files:

  • Modify: frontend/app/pages/machine/[id].vue

  • Step 1: Add template ref on MachineInfoCard (line 56)

Replace:

      <MachineInfoCard

With:

      <MachineInfoCard
        ref="machineInfoCardRef"
  • Step 2: Remove the blur-field and update-custom-field handlers from MachineInfoCard (lines 73-77)

Replace:

        @update:constructeur-ids="d.handleMachineConstructeurChange"
        @blur-field="() => { d.updateMachineInfo(); refreshVersions() }"
        @set-custom-field-value="d.setMachineCustomFieldValue"
        @update-custom-field="d.updateMachineCustomField"
        @custom-fields-saved="() => { d.loadMachineData(); refreshVersions() }"

With:

        @update:constructeur-ids="d.handleMachineConstructeurChange"
        @set-custom-field-value="d.setMachineCustomFieldValue"
        @custom-fields-saved="() => { d.loadMachineData(); refreshVersions() }"
  • Step 3: Add version refresh to handleAddEntity and link removal handlers

Replace handleAddEntity (lines 253-261):

const handleAddEntity = async (entityId) => {
  if (addModalKind.value === 'component') {
    await d.addComponentLink(entityId)
  } else if (addModalKind.value === 'piece') {
    await d.addPieceLink(entityId)
  } else {
    await d.addProductLink(entityId)
  }
}

With:

const handleAddEntity = async (entityId) => {
  if (addModalKind.value === 'component') {
    await d.addComponentLink(entityId)
  } else if (addModalKind.value === 'piece') {
    await d.addPieceLink(entityId)
  } else {
    await d.addProductLink(entityId)
  }
  refreshVersions()
}

Replace the three remove link handlers in the template:

MachineProductsCard (line 100):

        @remove-product="d.removeProductLink"

        @remove-product="async (id) => { await d.removeProductLink(id); refreshVersions() }"

MachineComponentsCard (line 115):

        @remove-component="d.removeComponentLink"

        @remove-component="async (id) => { await d.removeComponentLink(id); refreshVersions() }"

MachinePiecesCard (line 129):

        @remove-piece="d.removePieceLink"

        @remove-piece="async (id) => { await d.removePieceLink(id); refreshVersions() }"
  • Step 4: Add Save/Cancel buttons before EntityHistorySection (before line 141)

Insert before <!-- Historique -->:

      <!-- Save / Cancel buttons -->
      <div v-if="d.isEditMode.value" class="flex flex-col gap-3 md:flex-row md:justify-end">
        <button
          type="button"
          class="btn btn-ghost"
          :class="{ 'btn-disabled': d.saving.value }"
          @click="d.cancelEdition()"
        >
          Annuler
        </button>
        <button
          type="button"
          class="btn btn-primary"
          :disabled="!d.canSubmit.value"
          @click="submitMachineEdition"
        >
          <span v-if="d.saving.value" class="loading loading-spinner loading-sm mr-2" />
          Enregistrer les modifications
        </button>
      </div>
  • Step 5: Add ref and submitMachineEdition in the script section

After const d = useMachineDetailData(machineId) (line 226), add:

const machineInfoCardRef = ref(null)

After machineViewTitle computed (line 265), add:

const submitMachineEdition = async () => {
  // Save field definitions via template ref (fieldDefs lives inside MachineInfoCard)
  if (machineInfoCardRef.value?.saveFieldDefinitions) {
    await machineInfoCardRef.value.saveFieldDefinitions()
  }
  await d.submitEdition()
  refreshVersions()
}
  • Step 6: Add link versioning labels to historyFieldLabels (lines 237-243)

Replace:

const historyFieldLabels = {
  name: 'Nom',
  reference: 'Référence',
  prix: 'Prix',
  site: 'Site',
  constructeurIds: 'Fournisseurs',
}

With:

const historyFieldLabels = {
  name: 'Nom',
  reference: 'Référence',
  prix: 'Prix',
  site: 'Site',
  constructeurIds: 'Fournisseurs',
  addedComponent: 'Composant ajouté',
  removedComponent: 'Composant supprimé',
  addedPiece: 'Pièce ajoutée',
  removedPiece: 'Pièce supprimée',
  addedProduct: 'Produit ajouté',
  removedProduct: 'Produit supprimé',
}
  • Step 7: Commit
git add frontend/app/pages/machine/[id].vue
git commit -m "feat(machine) : add single save button and wire cancel/submit"

Files:

  • Modify: src/EventSubscriber/MachineAuditSubscriber.php

  • Step 1: Add link data to snapshotEntity

Replace the snapshotEntity method (lines 37-59):

    protected function snapshotEntity(object $entity): array
    {
        $customFieldValues = [];
        foreach ($entity->getCustomFieldValues() as $cfv) {
            $customFieldValues[] = [
                'id'        => $cfv->getId(),
                'fieldName' => $cfv->getCustomField()?->getName(),
                'fieldId'   => $cfv->getCustomField()?->getId(),
                'value'     => $cfv->getValue(),
            ];
        }

        return [
            'id'                => $entity->getId(),
            'name'              => $this->safeGet($entity, 'getName'),
            'reference'         => $this->safeGet($entity, 'getReference'),
            'prix'              => $this->safeGet($entity, 'getPrix'),
            'site'              => $this->normalizeValue($this->safeGet($entity, 'getSite')),
            'constructeurIds'   => $this->normalizeCollection($entity->getConstructeurs()),
            'customFieldValues' => $customFieldValues,
            'version'           => $this->safeGet($entity, 'getVersion'),
        ];
    }

With:

    protected function snapshotEntity(object $entity): array
    {
        $customFieldValues = [];
        foreach ($entity->getCustomFieldValues() as $cfv) {
            $customFieldValues[] = [
                'id'        => $cfv->getId(),
                'fieldName' => $cfv->getCustomField()?->getName(),
                'fieldId'   => $cfv->getCustomField()?->getId(),
                'value'     => $cfv->getValue(),
            ];
        }

        $componentLinks = [];
        foreach ($entity->getComponentLinks() as $link) {
            $componentLinks[] = [
                'id'            => $link->getId(),
                'composantId'   => $link->getComposant()->getId(),
                'composantName' => $link->getComposant()->getName(),
            ];
        }

        $pieceLinks = [];
        foreach ($entity->getPieceLinks() as $link) {
            $pieceLinks[] = [
                'id'        => $link->getId(),
                'pieceId'   => $link->getPiece()->getId(),
                'pieceName' => $link->getPiece()->getName(),
                'quantity'  => $link->getQuantity(),
            ];
        }

        $productLinks = [];
        foreach ($entity->getProductLinks() as $link) {
            $productLinks[] = [
                'id'          => $link->getId(),
                'productId'   => $link->getProduct()->getId(),
                'productName' => $link->getProduct()->getName(),
            ];
        }

        return [
            'id'                => $entity->getId(),
            'name'              => $this->safeGet($entity, 'getName'),
            'reference'         => $this->safeGet($entity, 'getReference'),
            'prix'              => $this->safeGet($entity, 'getPrix'),
            'site'              => $this->normalizeValue($this->safeGet($entity, 'getSite')),
            'constructeurIds'   => $this->normalizeCollection($entity->getConstructeurs()),
            'customFieldValues' => $customFieldValues,
            'componentLinks'    => $componentLinks,
            'pieceLinks'        => $pieceLinks,
            'productLinks'      => $productLinks,
            'version'           => $this->safeGet($entity, 'getVersion'),
        ];
    }
  • Step 2: Run php-cs-fixer
make php-cs-fixer-allow-risky
  • Step 3: Commit
git add src/EventSubscriber/MachineAuditSubscriber.php
git commit -m "feat(versioning) : enrich machine snapshot with component/piece/product links"

This uses the existing onFlush pattern (same as collectCustomFieldValueChanges in AbstractAuditSubscriber) instead of a separate subscriber with postPersist/preRemove — which would cause a flush() inside flush() anti-pattern.

Files:

  • Modify: src/EventSubscriber/MachineAuditSubscriber.php

  • Step 1: Add use statements

At the top of the file, add:

use App\Entity\MachineComponentLink;
use App\Entity\MachinePieceLink;
use App\Entity\MachineProductLink;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\UnitOfWork;
  • Step 2: Override onFlush to also collect link changes

The base onFlushComplex in AbstractAuditSubscriber already builds $pendingUpdates, $pendingSnapshots, $pendingEntities for collection + custom field changes, then persists audit logs. We need to hook into this.

However, onFlushComplex is private in AbstractAuditSubscriber. So we override onFlush in MachineAuditSubscriber to add link tracking after the parent's processing.

Actually, the simplest correct approach: override the onFlush method in MachineAuditSubscriber to call parent::onFlush() first, then process link entities in a second pass.

But there's a subtlety: after parent::onFlush(), the UnitOfWork state may have changed. The cleaner approach is to add a new protected method in AbstractAuditSubscriber that subclasses can override to inject additional pending changes.

The simplest approach that works with the current architecture: Add a new Doctrine listener on MachineAuditSubscriber for postFlush. After all entities are flushed, check if any link entities were involved, and create a separate audit log.

Actually, let's take an even simpler approach: use a separate subscriber that listens to onFlush (NOT postPersist/preRemove), detects link entity insertions/deletions in the UnitOfWork, and creates audit logs for the parent machine within the same flush cycle — exactly how AbstractAuditSubscriber does it.

Replace the existing MachineAuditSubscriber.php entirely:

<?php

declare(strict_types=1);

namespace App\EventSubscriber;

use App\Entity\CustomFieldValue;
use App\Entity\Machine;
use App\Entity\MachineComponentLink;
use App\Entity\MachinePieceLink;
use App\Entity\MachineProductLink;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Events;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\UnitOfWork;

#[AsDoctrineListener(event: Events::onFlush)]
final class MachineAuditSubscriber extends AbstractAuditSubscriber
{
    protected function supports(object $entity): bool
    {
        return $entity instanceof Machine;
    }

    protected function entityType(): string
    {
        return 'machine';
    }

    protected function hasCollectionTracking(): bool
    {
        return true;
    }

    protected function getOwnerFromCustomFieldValue(CustomFieldValue $cfv): ?object
    {
        $owner = $cfv->getMachine();

        return $owner instanceof Machine ? $owner : null;
    }

    public function onFlush(OnFlushEventArgs $args): void
    {
        // Let parent handle regular Machine entity changes (fields, collections, custom fields)
        parent::onFlush($args);

        // Now handle link entity changes
        $em = $args->getObjectManager();
        if (!$em instanceof EntityManagerInterface) {
            return;
        }

        $uow            = $em->getUnitOfWork();
        $actorProfileId = $this->resolveActorProfileId();

        $this->processLinkChanges($em, $uow, $actorProfileId);
    }

    private function processLinkChanges(EntityManagerInterface $em, UnitOfWork $uow, ?string $actorProfileId): void
    {
        $machineChanges = [];

        // Detect inserted links
        foreach ($uow->getScheduledEntityInsertions() as $entity) {
            $info = $this->extractLinkInfo($entity, 'added');
            if (null === $info) {
                continue;
            }
            $machineId = (string) $info['machine']->getId();
            if ('' === $machineId) {
                continue;
            }
            $machineChanges[$machineId] ??= ['machine' => $info['machine'], 'diffs' => []];
            $machineChanges[$machineId]['diffs'][$info['diffKey']] = [
                'from' => null,
                'to'   => $info['diffValue'],
            ];
        }

        // Detect deleted links
        foreach ($uow->getScheduledEntityDeletions() as $entity) {
            $info = $this->extractLinkInfo($entity, 'removed');
            if (null === $info) {
                continue;
            }
            $machineId = (string) $info['machine']->getId();
            if ('' === $machineId) {
                continue;
            }
            $machineChanges[$machineId] ??= ['machine' => $info['machine'], 'diffs' => []];
            $machineChanges[$machineId]['diffs'][$info['diffKey']] = [
                'from' => $info['diffValue'],
                'to'   => null,
            ];
        }

        // Create audit logs for each affected machine
        foreach ($machineChanges as $machineId => $change) {
            $machine = $change['machine'];
            $diff    = $change['diffs'];

            if ([] === $diff) {
                continue;
            }

            $version  = $this->incrementEntityVersion($machine, $em, $uow);
            $snapshot = $this->snapshotEntity($machine);

            $this->persistAuditLog(
                $em,
                new \App\Entity\AuditLog('machine', $machineId, 'update', $diff, $snapshot, $actorProfileId, $version),
            );
        }
    }

    /**
     * @return array{machine: Machine, diffKey: string, diffValue: array{id: string, name: string}}|null
     */
    private function extractLinkInfo(object $entity, string $action): ?array
    {
        if ($entity instanceof MachineComponentLink) {
            return [
                'machine'   => $entity->getMachine(),
                'diffKey'   => $action . 'Component',
                'diffValue' => [
                    'id'   => $entity->getComposant()->getId(),
                    'name' => $entity->getComposant()->getName(),
                ],
            ];
        }

        if ($entity instanceof MachinePieceLink) {
            return [
                'machine'   => $entity->getMachine(),
                'diffKey'   => $action . 'Piece',
                'diffValue' => [
                    'id'   => $entity->getPiece()->getId(),
                    'name' => $entity->getPiece()->getName(),
                ],
            ];
        }

        if ($entity instanceof MachineProductLink) {
            return [
                'machine'   => $entity->getMachine(),
                'diffKey'   => $action . 'Product',
                'diffValue' => [
                    'id'   => $entity->getProduct()->getId(),
                    'name' => $entity->getProduct()->getName(),
                ],
            ];
        }

        return null;
    }

    protected function snapshotEntity(object $entity): array
    {
        $customFieldValues = [];
        foreach ($entity->getCustomFieldValues() as $cfv) {
            $customFieldValues[] = [
                'id'        => $cfv->getId(),
                'fieldName' => $cfv->getCustomField()?->getName(),
                'fieldId'   => $cfv->getCustomField()?->getId(),
                'value'     => $cfv->getValue(),
            ];
        }

        $componentLinks = [];
        foreach ($entity->getComponentLinks() as $link) {
            $componentLinks[] = [
                'id'            => $link->getId(),
                'composantId'   => $link->getComposant()->getId(),
                'composantName' => $link->getComposant()->getName(),
            ];
        }

        $pieceLinks = [];
        foreach ($entity->getPieceLinks() as $link) {
            $pieceLinks[] = [
                'id'        => $link->getId(),
                'pieceId'   => $link->getPiece()->getId(),
                'pieceName' => $link->getPiece()->getName(),
                'quantity'  => $link->getQuantity(),
            ];
        }

        $productLinks = [];
        foreach ($entity->getProductLinks() as $link) {
            $productLinks[] = [
                'id'          => $link->getId(),
                'productId'   => $link->getProduct()->getId(),
                'productName' => $link->getProduct()->getName(),
            ];
        }

        return [
            'id'                => $entity->getId(),
            'name'              => $this->safeGet($entity, 'getName'),
            'reference'         => $this->safeGet($entity, 'getReference'),
            'prix'              => $this->safeGet($entity, 'getPrix'),
            'site'              => $this->normalizeValue($this->safeGet($entity, 'getSite')),
            'constructeurIds'   => $this->normalizeCollection($entity->getConstructeurs()),
            'customFieldValues' => $customFieldValues,
            'componentLinks'    => $componentLinks,
            'pieceLinks'        => $pieceLinks,
            'productLinks'      => $productLinks,
            'version'           => $this->safeGet($entity, 'getVersion'),
        ];
    }
}

Note: This combines Task 7 (snapshot enrichment) into this file. If you already committed Task 7 separately, this replaces the full file content.

  • Step 2: Run php-cs-fixer
make php-cs-fixer-allow-risky
  • Step 3: Run tests
make test
  • Step 4: Commit
git add src/EventSubscriber/MachineAuditSubscriber.php
git commit -m "feat(versioning) : detect machine link add/remove in onFlush and create audit logs"

Task 9: Frontend lint and typecheck

Files: All modified frontend files

  • Step 1: Run ESLint fix
cd frontend && npm run lint:fix
  • Step 2: Run typecheck
cd frontend && npx nuxi typecheck

Expected: 0 errors.

  • Step 3: Fix any issues found and commit
git add -A && git commit -m "fix(machine) : fix lint and type issues from single save refactor"

Task 10: Backend lint and test

Files: All modified backend files

  • Step 1: Run php-cs-fixer
make php-cs-fixer-allow-risky
  • Step 2: Run tests
make test

Expected: All tests pass.

  • Step 3: Fix any issues and commit
git add -A && git commit -m "fix(machine) : fix cs-fixer and test issues from single save refactor"

Task 11: Manual verification

  • Step 1: Start the app
make start
cd frontend && npm run dev
  • Step 2: Test single save flow
  1. Navigate to a machine detail page
  2. Click edit
  3. Change name, reference, site, constructeurs, custom field values, custom field definitions
  4. Verify NO API calls are fired while editing (check network tab)
  5. Click "Enregistrer les modifications"
  6. Verify batch of API calls fires (machine info + custom fields + field defs)
  7. Verify page reloads in view mode with updated values
  8. Verify new version appears in version list
  • Step 3: Test cancel flow
  1. Edit a machine, make changes to all fields
  2. Click "Annuler"
  3. Verify all fields reset to original values
  4. Verify no API calls were made
  • Step 4: Test link versioning
  1. Add a composant to a machine via modal
  2. Check the versions section — should show new version with "Composant ajouté"
  3. Remove a pièce — should show "Pièce supprimée"
  4. Add/remove a produit — same pattern
  • Step 5: Test documents still work
  1. Upload a document — should save immediately
  2. Delete a document — should delete immediately
  3. No change in behavior