feat(machine) : single save button + link versioning with restore

Backend:
- Enrich machine snapshot with componentLinks/pieceLinks/productLinks
- Detect link add/remove in MachineAuditSubscriber onFlush
- Add link diff comparison in restore preview
- Add link restoration in applyRestore for machines
- Add integrity warnings for missing linked entities

Frontend (submodule update):
- Single save button replacing auto-save-on-blur
- Link versioning display in version list and restore modal

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-03-26 16:51:58 +01:00
parent 9299a46c8b
commit d568961eb3
6 changed files with 1540 additions and 1 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,117 @@
# Machine : Bouton Save Unique + Versioning des Liens
**Date :** 2026-03-26
**Statut :** Approuvé
## Contexte
La page machine utilise actuellement un auto-save au blur pour chaque champ (info, custom fields, constructeurs). Les pages composant/pièce/produit utilisent un bouton unique "Enregistrer les modifications" en bas du formulaire. L'objectif est d'aligner la page machine sur ce pattern.
De plus, les ajouts/suppressions de liens composant/pièce/produit sur une machine ne sont pas tracés dans le versioning. Ils doivent l'être.
## Volet 1 : Bouton Save Unique
### Comportement cible
- En mode édition, tous les champs (info machine, custom field values, custom field definitions, constructeurs) sont modifiés localement sans appel API.
- Un bouton "Enregistrer les modifications" en bas du formulaire sauvegarde tout d'un coup.
- Un bouton "Annuler" réinitialise l'état local et sort du mode édition.
- Les documents restent en upload/suppression immédiate (inchangé).
- Les ajouts/suppressions de liens composant/pièce/produit restent immédiats via modales (inchangé).
### Changements frontend
#### MachineInfoCard.vue
- Supprimer les `@blur``$emit('blur-field')` sur les inputs (nom, référence)
- Supprimer le `@change` qui émet `blur-field` sur le select site
- Supprimer les `@blur``$emit('update-custom-field', field)` sur tous les champs custom
- Conserver `@input` / `@update:*` / `set-custom-field-value` pour la mise à jour de l'état local
- Le `MachineCustomFieldDefEditor` perd son bouton save propre : l'état est collecté au submit global
#### machine/[id].vue
- Supprimer le handler `@blur-field`
- Supprimer le handler `@update-custom-field`
- `@update:constructeur-ids` met à jour l'état local sans save
- Ajouter le bloc boutons en bas (pattern identique à component/[id]/index.vue) :
- "Annuler" (btn-ghost) → `cancelEdition()` : réinitialise depuis `machine.value` + sort du mode édition
- "Enregistrer les modifications" (btn-primary, disabled si `!canSubmit`) → `submitEdition()`
#### useMachineDetailData.ts
- Exposer `saving` ref
- Exposer `submitEdition()` :
1. `updateMachineInfo()` — PATCH machine (nom, ref, site, constructeurs)
2. Batch save custom field values (tous les `visibleMachineCustomFields` avec valeur)
3. Save custom field definitions si modifiées (`fieldDefs.saveDefinitions()`)
4. `loadMachineData()` pour recharger
5. Sortie du mode édition + toast succès
- Exposer `cancelEdition()` :
1. `initMachineFields()` — réinitialise nom, ref, site, constructeurs depuis `machine.value`
2. `syncMachineCustomFields()` — réinitialise les custom fields
3. Sort du mode édition
#### useMachineDetailUpdates.ts
- `handleMachineConstructeurChange` ne déclenche plus `updateMachineInfo()`, met juste à jour le ref local
#### useMachineDetailCustomFields.ts
- `updateMachineCustomField` n'est plus appelé au blur — sera appelé en batch par `submitEdition()`
- Ajouter méthode `saveAllMachineCustomFields()` qui itère sur les champs visibles et sauvegarde ceux avec valeur
### Validation (`canSubmit`)
- Machine existe
- Nom non vide
- Pas en cours de sauvegarde (`!saving.value`)
- `canEdit` est true
## Volet 2 : Versioning des Liens Machine
### Comportement cible
Quand un composant, pièce ou produit est ajouté ou supprimé d'une machine, cela doit :
1. Incrémenter la `version` de la Machine
2. Créer une entrée `AuditLog` avec diff et snapshot
### Changements backend
#### MachineAuditSubscriber — enrichir le snapshot
Ajouter au snapshot machine les liens :
```php
'componentLinks' => array_map(fn($link) => [
'id' => $link->getId(),
'composantId' => $link->getComposant()->getId(),
'composantName' => $link->getComposant()->getName(),
], $entity->getComponentLinks()->toArray()),
'pieceLinks' => [...],
'productLinks' => [...],
```
#### Nouveau subscriber ou service : MachineLinkAuditService
Écouter les événements Doctrine `postPersist` et `postRemove` sur les 3 entités link.
Quand un lien est créé/supprimé :
1. Récupérer la Machine parente
2. Incrémenter `$machine->incrementVersion()`
3. Créer un `AuditLog` :
- `entityType: 'machine'`
- `entityId: $machine->getId()`
- `action: 'update'`
- `diff: { addedComponent: {id, name} }` ou `{ removedPiece: {id, name} }`
- `snapshot:` snapshot complet de la machine (avec liens mis à jour)
- `version:` nouvelle version
### Labels pour le diff (frontend)
Ajouter au `historyFieldLabels` de la page machine :
```js
addedComponent: 'Composant ajouté',
removedComponent: 'Composant supprimé',
addedPiece: 'Pièce ajoutée',
removedPiece: 'Pièce supprimée',
addedProduct: 'Produit ajouté',
removedProduct: 'Produit supprimé',
```
## Ce qui ne change PAS
- Upload/suppression de documents (immédiat)
- Pattern read/edit toggle dans le header
- L'affichage des sections composants/pièces/produits
- Les modales d'ajout/suppression de liens (restent immédiates)
- Le versioning des autres entités (composant, pièce, produit)

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260326120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add referenceFormula and requiredFieldsForReference to model_types, referenceAuto to pieces';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE model_types ADD COLUMN IF NOT EXISTS referenceformula TEXT DEFAULT NULL');
$this->addSql('ALTER TABLE model_types ADD COLUMN IF NOT EXISTS requiredfieldsforreference JSON DEFAULT NULL');
$this->addSql('ALTER TABLE pieces ADD COLUMN IF NOT EXISTS referenceauto VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE pieces DROP COLUMN IF EXISTS referenceauto');
$this->addSql('ALTER TABLE model_types DROP COLUMN IF EXISTS requiredfieldsforreference');
$this->addSql('ALTER TABLE model_types DROP COLUMN IF EXISTS referenceformula');
}
}

View File

@@ -4,14 +4,38 @@ declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\AuditLog;
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\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Doctrine\ORM\UnitOfWork;
#[AsDoctrineListener(event: Events::onFlush)]
final class MachineAuditSubscriber extends AbstractAuditSubscriber
{
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);
}
protected function supports(object $entity): bool
{
return $entity instanceof Machine;
@@ -46,6 +70,34 @@ final class MachineAuditSubscriber extends AbstractAuditSubscriber
];
}
$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'),
@@ -54,7 +106,108 @@ final class MachineAuditSubscriber extends AbstractAuditSubscriber
'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'),
];
}
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 AuditLog('machine', $machineId, 'update', $diff, $snapshot, $actorProfileId, $version),
);
}
}
/**
* @return null|array{machine: Machine, diffKey: string, diffValue: array{id: string, name: string}}
*/
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;
}
}

View File

@@ -10,6 +10,9 @@ use App\Entity\ComposantPieceSlot;
use App\Entity\ComposantProductSlot;
use App\Entity\ComposantSubcomponentSlot;
use App\Entity\Machine;
use App\Entity\MachineComponentLink;
use App\Entity\MachinePieceLink;
use App\Entity\MachineProductLink;
use App\Entity\Piece;
use App\Entity\PieceProductSlot;
use App\Entity\Product;
@@ -295,6 +298,40 @@ final class EntityVersionService
return $warnings;
}
// Machine: check link references
if ('machine' === $entityType) {
$linkChecks = [
['key' => 'componentLinks', 'refKey' => 'composantId', 'nameKey' => 'composantName', 'label' => 'composant', 'repo' => $this->composants],
['key' => 'pieceLinks', 'refKey' => 'pieceId', 'nameKey' => 'pieceName', 'label' => 'pièce', 'repo' => $this->pieces],
['key' => 'productLinks', 'refKey' => 'productId', 'nameKey' => 'productName', 'label' => 'produit', 'repo' => $this->products],
];
foreach ($linkChecks as $check) {
$links = $snapshot[$check['key']] ?? [];
$refIds = [];
foreach ($links as $link) {
$refId = $link[$check['refKey']] ?? null;
if (null !== $refId) {
$refIds[$refId] = $link[$check['nameKey']] ?? $refId;
}
}
if ([] === $refIds) {
continue;
}
$foundIds = array_map(fn ($e) => $e->getId(), $check['repo']->findBy(['id' => array_keys($refIds)]));
foreach ($refIds as $id => $name) {
if (!in_array($id, $foundIds, true)) {
$warnings[] = [
'field' => $check['key'],
'message' => sprintf("Le %s '%s' n'existe plus. Le lien ne sera pas restauré.", $check['label'], $name),
'missingEntityId' => $id,
'missingEntityName' => $name,
];
}
}
}
}
// Full mode: check slot references (batch queries per slot type)
$slotChecks = match ($entityType) {
'composant' => [
@@ -385,6 +422,48 @@ final class EntityVersionService
}
}
// Machine: link diffs
if ('machine' === $entityType) {
$linkTypes = [
'componentLinks' => ['idKey' => 'composantId', 'nameKey' => 'composantName', 'getter' => 'getComponentLinks', 'entityGetter' => 'getComposant'],
'pieceLinks' => ['idKey' => 'pieceId', 'nameKey' => 'pieceName', 'getter' => 'getPieceLinks', 'entityGetter' => 'getPiece'],
'productLinks' => ['idKey' => 'productId', 'nameKey' => 'productName', 'getter' => 'getProductLinks', 'entityGetter' => 'getProduct'],
];
foreach ($linkTypes as $key => $config) {
$currentIds = [];
$currentNames = [];
if (method_exists($entity, $config['getter'])) {
foreach ($entity->{$config['getter']}() as $link) {
$linked = $link->{$config['entityGetter']}();
$currentIds[] = $linked->getId();
$currentNames[$linked->getId()] = $linked->getName();
}
}
$snapshotIds = [];
$snapshotNames = [];
foreach ($snapshot[$key] ?? [] as $entry) {
$id = $entry[$config['idKey']] ?? null;
if ($id) {
$snapshotIds[] = $id;
$snapshotNames[$id] = $entry[$config['nameKey']] ?? $id;
}
}
sort($currentIds);
sort($snapshotIds);
if ($currentIds !== $snapshotIds) {
$currentDisplay = array_map(fn ($id) => $currentNames[$id] ?? $id, $currentIds);
$restoredDisplay = array_map(fn ($id) => $snapshotNames[$id] ?? $id, $snapshotIds);
$diff[$key] = [
'current' => [] !== $currentDisplay ? implode(', ', $currentDisplay) : '(aucun)',
'restored' => [] !== $restoredDisplay ? implode(', ', $restoredDisplay) : '(aucun)',
];
}
}
}
// Custom field values diff
$snapshotCfvs = $snapshot['customFieldValues'] ?? [];
if ([] !== $snapshotCfvs && method_exists($entity, 'getCustomFieldValues')) {
@@ -453,6 +532,11 @@ final class EntityVersionService
default => null,
};
// Machine: restore links
if ('machine' === $entityType) {
$this->restoreMachineLinks($entity, $snapshot);
}
// Full mode: restore custom field values
$this->restoreCustomFieldValues($entityType, $entity, $snapshot);
}
@@ -490,6 +574,62 @@ final class EntityVersionService
}
}
private function restoreMachineLinks(Machine $machine, array $snapshot): void
{
// Remove all existing links
foreach ($machine->getProductLinks()->toArray() as $link) {
$this->em->remove($link);
}
foreach ($machine->getPieceLinks()->toArray() as $link) {
$this->em->remove($link);
}
foreach ($machine->getComponentLinks()->toArray() as $link) {
$this->em->remove($link);
}
// Flush removals to avoid FK conflicts
$this->em->flush();
// Recreate component links
foreach ($snapshot['componentLinks'] ?? [] as $data) {
$composant = !empty($data['composantId']) ? $this->composants->find($data['composantId']) : null;
if (null === $composant) {
continue;
}
$link = new MachineComponentLink();
$link->setMachine($machine);
$link->setComposant($composant);
$this->em->persist($link);
}
// Recreate piece links
foreach ($snapshot['pieceLinks'] ?? [] as $data) {
$piece = !empty($data['pieceId']) ? $this->pieces->find($data['pieceId']) : null;
if (null === $piece) {
continue;
}
$link = new MachinePieceLink();
$link->setMachine($machine);
$link->setPiece($piece);
if (isset($data['quantity']) && $data['quantity'] > 0) {
$link->setQuantity((int) $data['quantity']);
}
$this->em->persist($link);
}
// Recreate product links
foreach ($snapshot['productLinks'] ?? [] as $data) {
$product = !empty($data['productId']) ? $this->products->find($data['productId']) : null;
if (null === $product) {
continue;
}
$link = new MachineProductLink();
$link->setMachine($machine);
$link->setProduct($product);
$this->em->persist($link);
}
}
private function restoreComposantSlots(Composant $entity, array $snapshot): void
{
// Clear existing slots
@@ -672,6 +812,31 @@ final class EntityVersionService
$snapshot['site'] = $site ? ['id' => $site->getId(), 'name' => $site->getName()] : null;
}
// Machine: links
if ('machine' === $entityType) {
$snapshot['componentLinks'] = [];
foreach ($entity->getComponentLinks() as $link) {
$snapshot['componentLinks'][] = [
'id' => $link->getId(), 'composantId' => $link->getComposant()->getId(),
'composantName' => $link->getComposant()->getName(),
];
}
$snapshot['pieceLinks'] = [];
foreach ($entity->getPieceLinks() as $link) {
$snapshot['pieceLinks'][] = [
'id' => $link->getId(), 'pieceId' => $link->getPiece()->getId(),
'pieceName' => $link->getPiece()->getName(), 'quantity' => $link->getQuantity(),
];
}
$snapshot['productLinks'] = [];
foreach ($entity->getProductLinks() as $link) {
$snapshot['productLinks'][] = [
'id' => $link->getId(), 'productId' => $link->getProduct()->getId(),
'productName' => $link->getProduct()->getName(),
];
}
}
// Composant/Piece: product
if (in_array($entityType, ['composant', 'piece'], true) && method_exists($entity, 'getProduct')) {
$product = $entity->getProduct();