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

1075 lines
33 KiB
Markdown

# 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:
```vue
@input="$emit('update:machine-name', ($event.target as HTMLInputElement).value)"
@blur="$emit('blur-field')"
```
With:
```vue
@input="$emit('update:machine-name', ($event.target as HTMLInputElement).value)"
```
- [ ] **Step 2: Remove `$emit('blur-field')` from site select (line 31)**
Replace:
```vue
@change="$emit('update:machine-site-id', ($event.target as HTMLSelectElement).value); $emit('blur-field')"
```
With:
```vue
@change="$emit('update:machine-site-id', ($event.target as HTMLSelectElement).value)"
```
- [ ] **Step 3: Remove `@blur` from reference input (line 57)**
Replace:
```vue
@input="$emit('update:machine-reference', ($event.target as HTMLInputElement).value)"
@blur="$emit('blur-field')"
```
With:
```vue
@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:
```vue
<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:
```vue
<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):
```ts
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:
```ts
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:
```ts
defineExpose({
saveFieldDefinitions: () => fieldDefs.saveDefinitions(),
})
```
- [ ] **Step 7: Commit**
```bash
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):
```vue
<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:
```vue
<h3 class="text-sm font-semibold">
Définitions des champs personnalisés
</h3>
```
- [ ] **Step 2: Remove the `save` emit from defineEmits (line 120)**
Replace:
```ts
defineEmits<{
save: []
'add-field': []
'remove-field': [index: number]
}>()
```
With:
```ts
defineEmits<{
'add-field': []
'remove-field': [index: number]
}>()
```
- [ ] **Step 3: Commit**
```bash
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:
```ts
const handleMachineConstructeurChange = async (value: unknown) => {
machineConstructeurIds.value = uniqueConstructeurIds(value)
await updateMachineInfo()
}
```
With:
```ts
const handleMachineConstructeurChange = (value: unknown) => {
machineConstructeurIds.value = uniqueConstructeurIds(value)
}
```
- [ ] **Step 2: Commit**
```bash
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:
```ts
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:
```ts
// Methods
syncMachineCustomFields,
setMachineCustomFieldValue,
updateMachineCustomField,
updatePieceCustomField,
```
With:
```ts
// Methods
syncMachineCustomFields,
setMachineCustomFieldValue,
updateMachineCustomField,
updatePieceCustomField,
saveAllMachineCustomFields,
```
- [ ] **Step 3: Commit**
```bash
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:
```ts
const saving = ref(false)
```
- [ ] **Step 2: Destructure `saveAllMachineCustomFields` from custom fields sub-composable (line 145)**
Replace:
```ts
syncMachineCustomFields,
setMachineCustomFieldValue,
updateMachineCustomField,
updatePieceCustomField,
```
With:
```ts
syncMachineCustomFields,
setMachineCustomFieldValue,
updateMachineCustomField,
updatePieceCustomField,
saveAllMachineCustomFields,
```
- [ ] **Step 3: Add `canSubmit` computed after the `isEditMode` ref block (after line 111)**
```ts
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.
```ts
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:
```ts
// Save
saving, canSubmit, submitEdition, cancelEdition,
```
- [ ] **Step 6: Commit**
```bash
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:
```vue
<MachineInfoCard
```
With:
```vue
<MachineInfoCard
ref="machineInfoCardRef"
```
- [ ] **Step 2: Remove the blur-field and update-custom-field handlers from MachineInfoCard (lines 73-77)**
Replace:
```vue
@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:
```vue
@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):
```js
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:
```js
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):
```vue
@remove-product="d.removeProductLink"
```
```vue
@remove-product="async (id) => { await d.removeProductLink(id); refreshVersions() }"
```
MachineComponentsCard (line 115):
```vue
@remove-component="d.removeComponentLink"
```
```vue
@remove-component="async (id) => { await d.removeComponentLink(id); refreshVersions() }"
```
MachinePiecesCard (line 129):
```vue
@remove-piece="d.removePieceLink"
```
```vue
@remove-piece="async (id) => { await d.removePieceLink(id); refreshVersions() }"
```
- [ ] **Step 4: Add Save/Cancel buttons before EntityHistorySection (before line 141)**
Insert before `<!-- Historique -->`:
```vue
<!-- 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:
```js
const machineInfoCardRef = ref(null)
```
After `machineViewTitle` computed (line 265), add:
```js
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:
```js
const historyFieldLabels = {
name: 'Nom',
reference: 'Référence',
prix: 'Prix',
site: 'Site',
constructeurIds: 'Fournisseurs',
}
```
With:
```js
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**
```bash
git add frontend/app/pages/machine/[id].vue
git commit -m "feat(machine) : add single save button and wire cancel/submit"
```
---
### Task 7: Enrich machine snapshot with link data
**Files:**
- Modify: `src/EventSubscriber/MachineAuditSubscriber.php`
- [ ] **Step 1: Add link data to snapshotEntity**
Replace the `snapshotEntity` method (lines 37-59):
```php
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:
```php
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**
```bash
make php-cs-fixer-allow-risky
```
- [ ] **Step 3: Commit**
```bash
git add src/EventSubscriber/MachineAuditSubscriber.php
git commit -m "feat(versioning) : enrich machine snapshot with component/piece/product links"
```
---
### Task 8: Add link change detection to MachineAuditSubscriber
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:
```php
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
<?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**
```bash
make php-cs-fixer-allow-risky
```
- [ ] **Step 3: Run tests**
```bash
make test
```
- [ ] **Step 4: Commit**
```bash
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**
```bash
cd frontend && npm run lint:fix
```
- [ ] **Step 2: Run typecheck**
```bash
cd frontend && npx nuxi typecheck
```
Expected: 0 errors.
- [ ] **Step 3: Fix any issues found and commit**
```bash
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**
```bash
make php-cs-fixer-allow-risky
```
- [ ] **Step 2: Run tests**
```bash
make test
```
Expected: All tests pass.
- [ ] **Step 3: Fix any issues and commit**
```bash
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**
```bash
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