1075 lines
33 KiB
Markdown
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
|