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

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();