Files
Inventory/src/Service/EntityVersionService.php
Matthieu 5c55441e6c fix(audit) : visibilité protected pour ActorProfileResolver
AbstractAuditSubscriber déclarait $actorProfileResolver en private readonly
via promoted property. MachineAuditSubscriber surcharge onFlush() et accède
à $this->actorProfileResolver, mais private n'est pas hérité — PHP voyait
null et levait "Call to a member function resolve() on null" sur chaque
flush Doctrine touchant des link entities.

Le passage à protected suit la convention déjà en place dans la classe
(safeGet, normalizeValue, persistAuditLog, etc. sont protected). readonly
préserve l'immutabilité de la dépendance DI.

Ajoute aussi deux tests de régression pour le clone des contextFieldValues
(symétrique au test composant existant) et nettoie deux lignes vides
cosmétiques laissées par le refactor précédent.

- testCloneMachineCopiesPieceContextFieldValues : vérifie que les CFV
  context d'un MachinePieceLink sont bien rattachées au nouveau lien
  après clone.
- testCloneMachineLeavesSourceContextFieldValuesIntact : vérifie que la
  machine source garde ses CFV context après clone (invariant implicite).
2026-05-06 15:30:59 +02:00

961 lines
38 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\AuditLog;
use App\Entity\Composant;
use App\Entity\ComposantConstructeurLink;
use App\Entity\ComposantPieceSlot;
use App\Entity\ComposantProductSlot;
use App\Entity\ComposantSubcomponentSlot;
use App\Entity\Machine;
use App\Entity\MachineComponentLink;
use App\Entity\MachineConstructeurLink;
use App\Entity\MachinePieceLink;
use App\Entity\MachineProductLink;
use App\Entity\Piece;
use App\Entity\PieceConstructeurLink;
use App\Entity\PieceProductSlot;
use App\Entity\Product;
use App\Entity\ProductConstructeurLink;
use App\Repository\AuditLogRepository;
use App\Repository\ComposantRepository;
use App\Repository\ConstructeurRepository;
use App\Repository\CustomFieldValueRepository;
use App\Repository\MachineRepository;
use App\Repository\ModelTypeRepository;
use App\Repository\PieceRepository;
use App\Repository\ProductRepository;
use App\Repository\ProfileRepository;
use App\Repository\SiteRepository;
use DateTimeInterface;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use LogicException;
use Throwable;
final class EntityVersionService
{
private const ENTITY_MAP = [
'machine' => Machine::class,
'composant' => Composant::class,
'piece' => Piece::class,
'product' => Product::class,
];
private const BASE_FIELDS = [
'machine' => ['name', 'reference', 'prix'],
'composant' => ['name', 'reference', 'description', 'prix'],
'piece' => ['name', 'reference', 'description', 'prix'],
'product' => ['name', 'reference', 'supplierPrice'],
];
public function __construct(
private readonly AuditLogRepository $auditLogs,
private readonly EntityManagerInterface $em,
private readonly ActorProfileResolver $actorProfileResolver,
private readonly MachineRepository $machines,
private readonly ComposantRepository $composants,
private readonly PieceRepository $pieces,
private readonly ProductRepository $products,
private readonly ConstructeurRepository $constructeurs,
private readonly SiteRepository $sites,
private readonly ModelTypeRepository $modelTypes,
private readonly CustomFieldValueRepository $customFieldValues,
private readonly ProfileRepository $profiles,
) {}
/**
* @return array{items: list<array>, total: int}
*/
public function getVersions(string $entityType, string $entityId): array
{
$logs = $this->auditLogs->findVersionHistory($entityType, $entityId);
$actorIds = array_values(array_unique(array_filter(array_map(
static fn (AuditLog $log) => $log->getActorProfileId(),
$logs,
))));
$actorMap = [];
if ([] !== $actorIds) {
$profileEntities = $this->profiles->findBy(['id' => $actorIds]);
foreach ($profileEntities as $profile) {
$label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
if ('' === $label) {
$label = $profile->getEmail() ?? $profile->getId();
}
$actorMap[$profile->getId()] = $label;
}
}
$items = array_map(
static function (AuditLog $log) use ($actorMap) {
$actorId = $log->getActorProfileId();
return [
'version' => $log->getVersion(),
'action' => $log->getAction(),
'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
'actor' => $actorId
? ['id' => $actorId, 'label' => $actorMap[$actorId] ?? $actorId]
: null,
'diff' => $log->getDiff(),
];
},
$logs,
);
return ['items' => array_values($items), 'total' => count($items)];
}
/**
* @return array{version: int, restoreMode: string, diff: array, warnings: list<array>, snapshot: array}
*/
public function getRestorePreview(string $entityType, string $entityId, int $version): array
{
$entity = $this->findEntity($entityType, $entityId);
if (null === $entity) {
throw new InvalidArgumentException('Entité introuvable.');
}
$auditLog = $this->auditLogs->findByVersion($entityType, $entityId, $version);
if (null === $auditLog) {
throw new InvalidArgumentException('Version introuvable.');
}
$snapshot = $auditLog->getSnapshot() ?? [];
$restoreMode = $this->checkSkeletonCompatibility($entityType, $entity, $snapshot);
$warnings = $this->checkIntegrity($entityType, $snapshot, $restoreMode);
$diff = $this->buildRestoreDiff($entityType, $entity, $snapshot, $restoreMode);
return [
'version' => $version,
'restoreMode' => $restoreMode,
'diff' => $diff,
'warnings' => $warnings,
'snapshot' => $snapshot,
];
}
/**
* @return array{success: true, newVersion: int, restoredFromVersion: int, restoreMode: string, warnings: list<array>}
*/
public function restore(string $entityType, string $entityId, int $version): array
{
$entity = $this->findEntity($entityType, $entityId);
if (null === $entity) {
throw new InvalidArgumentException('Entité introuvable.');
}
$auditLog = $this->auditLogs->findByVersion($entityType, $entityId, $version);
if (null === $auditLog) {
throw new InvalidArgumentException('Version introuvable.');
}
$snapshot = $auditLog->getSnapshot() ?? [];
$restoreMode = $this->checkSkeletonCompatibility($entityType, $entity, $snapshot);
$warnings = $this->checkIntegrity($entityType, $snapshot, $restoreMode);
$connection = $this->em->getConnection();
$connection->beginTransaction();
try {
// Mark entity to skip audit subscriber (we create the AuditLog manually)
if (method_exists($entity, 'setSkipAudit')) {
$entity->setSkipAudit(true);
}
$this->applyRestore($entityType, $entity, $snapshot, $restoreMode);
// Increment version manually (since subscriber is skipped)
if (method_exists($entity, 'incrementVersion')) {
$entity->incrementVersion();
}
$this->em->flush();
$newVersion = method_exists($entity, 'getVersion') ? $entity->getVersion() : null;
// Create the restore AuditLog manually with action = "restore"
$restoreAuditLog = new AuditLog(
$entityType,
$entityId,
'restore',
['restoredFromVersion' => $version, 'restoreMode' => $restoreMode],
$this->buildCurrentSnapshot($entityType, $entity),
$this->actorProfileResolver->resolve(),
$newVersion,
);
$this->em->persist($restoreAuditLog);
$this->em->flush();
$connection->commit();
} catch (Throwable $e) {
$connection->rollBack();
throw $e;
} finally {
// Clear skip flag
if (method_exists($entity, 'setSkipAudit')) {
$entity->setSkipAudit(false);
}
}
return [
'success' => true,
'newVersion' => $newVersion,
'restoredFromVersion' => $version,
'restoreMode' => $restoreMode,
'warnings' => $warnings,
];
}
private function findEntity(string $entityType, string $entityId): ?object
{
return match ($entityType) {
'machine' => $this->machines->find($entityId),
'composant' => $this->composants->find($entityId),
'piece' => $this->pieces->find($entityId),
'product' => $this->products->find($entityId),
default => null,
};
}
private function checkSkeletonCompatibility(string $entityType, object $entity, array $snapshot): string
{
if ('machine' === $entityType) {
return 'full';
}
$currentTypeId = match ($entityType) {
'composant' => $entity->getTypeComposant()?->getId(),
'piece' => $entity->getTypePiece()?->getId(),
'product' => $entity->getTypeProduct()?->getId(),
default => null,
};
$typeKey = match ($entityType) {
'composant' => 'typeComposant',
'piece' => 'typePiece',
'product' => 'typeProduct',
default => null,
};
$snapshotTypeId = null;
if ($typeKey && isset($snapshot[$typeKey])) {
$snapshotTypeId = is_array($snapshot[$typeKey]) ? ($snapshot[$typeKey]['id'] ?? null) : $snapshot[$typeKey];
}
return $currentTypeId === $snapshotTypeId ? 'full' : 'partial';
}
private function checkIntegrity(string $entityType, array $snapshot, string $restoreMode): array
{
$warnings = [];
// Check constructeurs (batch query)
if (!empty($snapshot['constructeurIds'])) {
$constructeurEntries = [];
foreach ($snapshot['constructeurIds'] as $entry) {
$id = is_array($entry) ? ($entry['id'] ?? null) : $entry;
$name = is_array($entry) ? ($entry['name'] ?? $id) : $id;
if ($id) {
$constructeurEntries[$id] = $name;
}
}
if ([] !== $constructeurEntries) {
$foundIds = array_map(
fn ($c) => $c->getId(),
$this->constructeurs->findBy(['id' => array_keys($constructeurEntries)]),
);
foreach ($constructeurEntries as $id => $name) {
if (!in_array($id, $foundIds, true)) {
$warnings[] = [
'field' => 'constructeurIds',
'message' => sprintf("Le fournisseur '%s' n'existe plus. Il ne sera pas restauré.", $name),
'missingEntityId' => $id,
'missingEntityName' => $name,
];
}
}
}
}
// Machine: check site
if ('machine' === $entityType && !empty($snapshot['site'])) {
$siteId = is_array($snapshot['site']) ? ($snapshot['site']['id'] ?? null) : $snapshot['site'];
if ($siteId && null === $this->sites->find($siteId)) {
$siteName = is_array($snapshot['site']) ? ($snapshot['site']['name'] ?? $siteId) : $siteId;
$warnings[] = [
'field' => 'site',
'message' => sprintf("Le site '%s' n'existe plus. Le site actuel sera conservé.", $siteName),
'missingEntityId' => $siteId,
'missingEntityName' => $siteName,
];
}
}
if ('partial' === $restoreMode) {
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' => [
['key' => 'pieceSlots', 'refKey' => 'selectedPieceId', 'label' => 'pièce', 'repo' => $this->pieces],
['key' => 'subcomponentSlots', 'refKey' => 'selectedComposantId', 'label' => 'sous-composant', 'repo' => $this->composants],
['key' => 'productSlots', 'refKey' => 'selectedProductId', 'label' => 'produit', 'repo' => $this->products],
],
'piece' => [
['key' => 'productSlots', 'refKey' => 'selectedProductId', 'label' => 'produit', 'repo' => $this->products],
],
default => [],
};
foreach ($slotChecks as $check) {
$slots = $snapshot[$check['key']] ?? [];
// Collect all referenced IDs for batch lookup
$refIds = [];
foreach ($slots as $i => $slot) {
$refId = $slot[$check['refKey']] ?? null;
if (null !== $refId) {
$refIds[$i] = $refId;
}
}
if ([] === $refIds) {
continue;
}
$foundEntities = $check['repo']->findBy(['id' => array_values(array_unique($refIds))]);
$foundIds = array_map(fn ($e) => $e->getId(), $foundEntities);
foreach ($refIds as $i => $refId) {
if (!in_array($refId, $foundIds, true)) {
$warnings[] = [
'field' => sprintf('%s[%d].%s', $check['key'], $i, $check['refKey']),
'message' => sprintf("Le %s sélectionné dans le slot n'existe plus. Le slot sera restauré vide.", $check['label']),
'missingEntityId' => $refId,
'missingEntityName' => null,
];
}
}
}
return $warnings;
}
private function buildRestoreDiff(string $entityType, object $entity, array $snapshot, string $restoreMode): array
{
$diff = [];
$baseFields = self::BASE_FIELDS[$entityType] ?? [];
foreach ($baseFields as $field) {
$getter = 'get'.ucfirst($field);
if (!method_exists($entity, $getter)) {
continue;
}
$current = $entity->{$getter}();
$restored = $snapshot[$field] ?? null;
if ((string) ($current ?? '') !== (string) ($restored ?? '')) {
$diff[$field] = ['current' => $current, 'restored' => $restored];
}
}
// Constructeurs
$currentConstructeurIds = [];
if (method_exists($entity, 'getConstructeurLinks')) {
foreach ($entity->getConstructeurLinks() as $link) {
$currentConstructeurIds[] = $link->getConstructeur()->getId();
}
}
$snapshotConstructeurIds = [];
foreach ($snapshot['constructeurIds'] ?? [] as $entry) {
$snapshotConstructeurIds[] = is_array($entry) ? ($entry['id'] ?? null) : $entry;
}
sort($currentConstructeurIds);
sort($snapshotConstructeurIds);
if ($currentConstructeurIds !== $snapshotConstructeurIds) {
$diff['constructeurIds'] = ['current' => $currentConstructeurIds, 'restored' => $snapshotConstructeurIds];
}
if ('full' === $restoreMode) {
// We signal slot restore as a single diff entry
$slotKeys = match ($entityType) {
'composant' => ['pieceSlots', 'subcomponentSlots', 'productSlots'],
'piece' => ['productSlots'],
default => [],
};
foreach ($slotKeys as $key) {
if (!empty($snapshot[$key])) {
$diff[$key] = ['current' => '(structure actuelle)', 'restored' => sprintf('%d slot(s)', count($snapshot[$key]))];
}
}
// 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')) {
$currentCfvsByFieldId = [];
foreach ($entity->getCustomFieldValues() as $cfv) {
$fieldId = $cfv->getCustomField()?->getId();
if (null !== $fieldId) {
$currentCfvsByFieldId[$fieldId] = [
'fieldName' => $cfv->getCustomField()->getName(),
'value' => $cfv->getValue(),
];
}
}
foreach ($snapshotCfvs as $cfvData) {
$fieldId = $cfvData['fieldId'] ?? null;
$fieldName = $cfvData['fieldName'] ?? $fieldId;
if (!$fieldId) {
continue;
}
$currentValue = $currentCfvsByFieldId[$fieldId]['value'] ?? null;
$restoredValue = $cfvData['value'] ?? null;
if ((string) ($currentValue ?? '') !== (string) ($restoredValue ?? '')) {
$diff['customField:'.$fieldName] = ['current' => $currentValue, 'restored' => $restoredValue];
}
}
}
}
return $diff;
}
private function applyRestore(string $entityType, object $entity, array $snapshot, string $restoreMode): void
{
// Restore base fields
$baseFields = self::BASE_FIELDS[$entityType] ?? [];
foreach ($baseFields as $field) {
$setter = 'set'.ucfirst($field);
if (method_exists($entity, $setter) && array_key_exists($field, $snapshot)) {
$entity->{$setter}($snapshot[$field]);
}
}
// Restore constructeurs
$this->restoreConstructeurs($entity, $snapshot);
// Machine: restore site
if ('machine' === $entityType && isset($snapshot['site'])) {
$siteId = is_array($snapshot['site']) ? ($snapshot['site']['id'] ?? null) : $snapshot['site'];
if ($siteId) {
$site = $this->sites->find($siteId);
if (null !== $site) {
$entity->setSite($site);
}
}
}
if ('partial' === $restoreMode) {
return;
}
// Full mode: restore slots
match ($entityType) {
'composant' => $this->restoreComposantSlots($entity, $snapshot),
'piece' => $this->restorePieceSlots($entity, $snapshot),
default => null,
};
// Machine: restore links
if ('machine' === $entityType) {
$this->restoreMachineLinks($entity, $snapshot);
}
// Full mode: restore custom field values
$this->restoreCustomFieldValues($entityType, $entity, $snapshot);
}
private function restoreConstructeurs(object $entity, array $snapshot): void
{
if (!method_exists($entity, 'getConstructeurLinks')) {
return;
}
$targetIds = [];
$targetRefs = [];
foreach ($snapshot['constructeurIds'] ?? [] as $entry) {
$id = is_array($entry) ? ($entry['id'] ?? null) : $entry;
if ($id) {
$targetIds[] = $id;
$targetRefs[$id] = is_array($entry) ? ($entry['supplierReference'] ?? null) : null;
}
}
// Remove current links not in snapshot
foreach ($entity->getConstructeurLinks()->toArray() as $link) {
$cId = $link->getConstructeur()->getId();
if (!in_array($cId, $targetIds, true)) {
$this->em->remove($link);
} else {
// Update supplierReference if present in snapshot
$link->setSupplierReference($targetRefs[$cId] ?? null);
}
}
// Add missing constructeur links from snapshot
$currentIds = array_map(
fn ($link) => $link->getConstructeur()->getId(),
$entity->getConstructeurLinks()->toArray(),
);
foreach ($targetIds as $id) {
if (!in_array($id, $currentIds, true)) {
$constructeur = $this->constructeurs->find($id);
if (null !== $constructeur) {
$link = $this->createConstructeurLink($entity);
$link->setConstructeur($constructeur);
$link->setSupplierReference($targetRefs[$id] ?? null);
$this->em->persist($link);
}
}
}
}
private function createConstructeurLink(object $entity): object
{
if ($entity instanceof Machine) {
$link = new MachineConstructeurLink();
$link->setMachine($entity);
return $link;
}
if ($entity instanceof Piece) {
$link = new PieceConstructeurLink();
$link->setPiece($entity);
return $link;
}
if ($entity instanceof Composant) {
$link = new ComposantConstructeurLink();
$link->setComposant($entity);
return $link;
}
if ($entity instanceof Product) {
$link = new ProductConstructeurLink();
$link->setProduct($entity);
return $link;
}
throw new LogicException('Unsupported entity type for constructeur link');
}
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
foreach ($entity->getPieceSlots()->toArray() as $slot) {
$this->em->remove($slot);
}
foreach ($entity->getSubcomponentSlots()->toArray() as $slot) {
$this->em->remove($slot);
}
foreach ($entity->getProductSlots()->toArray() as $slot) {
$this->em->remove($slot);
}
// Flush removals first to avoid FK constraint conflicts with new slots
$this->em->flush();
// Recreate piece slots
foreach ($snapshot['pieceSlots'] ?? [] as $slotData) {
$slot = new ComposantPieceSlot();
$slot->setComposant($entity);
$slot->setQuantity($slotData['quantity'] ?? 1);
$slot->setPosition($slotData['position'] ?? 0);
if (!empty($slotData['typePieceId'])) {
$slot->setTypePiece($this->modelTypes->find($slotData['typePieceId']));
}
if (!empty($slotData['selectedPieceId'])) {
$piece = $this->pieces->find($slotData['selectedPieceId']);
if (null !== $piece) {
$slot->setSelectedPiece($piece);
}
}
$this->em->persist($slot);
}
// Recreate subcomponent slots
foreach ($snapshot['subcomponentSlots'] ?? [] as $slotData) {
$slot = new ComposantSubcomponentSlot();
$slot->setComposant($entity);
$slot->setAlias($slotData['alias'] ?? null);
$slot->setFamilyCode($slotData['familyCode'] ?? null);
$slot->setPosition($slotData['position'] ?? 0);
if (!empty($slotData['typeComposantId'])) {
$slot->setTypeComposant($this->modelTypes->find($slotData['typeComposantId']));
}
if (!empty($slotData['selectedComposantId'])) {
$composant = $this->composants->find($slotData['selectedComposantId']);
if (null !== $composant) {
$slot->setSelectedComposant($composant);
}
}
$this->em->persist($slot);
}
// Recreate product slots
foreach ($snapshot['productSlots'] ?? [] as $slotData) {
$slot = new ComposantProductSlot();
$slot->setComposant($entity);
$slot->setFamilyCode($slotData['familyCode'] ?? null);
$slot->setPosition($slotData['position'] ?? 0);
if (!empty($slotData['typeProductId'])) {
$slot->setTypeProduct($this->modelTypes->find($slotData['typeProductId']));
}
if (!empty($slotData['selectedProductId'])) {
$product = $this->products->find($slotData['selectedProductId']);
if (null !== $product) {
$slot->setSelectedProduct($product);
}
}
$this->em->persist($slot);
}
}
private function restorePieceSlots(Piece $entity, array $snapshot): void
{
foreach ($entity->getProductSlots()->toArray() as $slot) {
$this->em->remove($slot);
}
// Flush removals first to avoid FK constraint conflicts
$this->em->flush();
foreach ($snapshot['productSlots'] ?? [] as $slotData) {
$slot = new PieceProductSlot();
$slot->setPiece($entity);
$slot->setFamilyCode($slotData['familyCode'] ?? null);
$slot->setPosition($slotData['position'] ?? 0);
if (!empty($slotData['typeProductId'])) {
$slot->setTypeProduct($this->modelTypes->find($slotData['typeProductId']));
}
if (!empty($slotData['selectedProductId'])) {
$product = $this->products->find($slotData['selectedProductId']);
if (null !== $product) {
$slot->setSelectedProduct($product);
}
}
$this->em->persist($slot);
}
}
private function restoreCustomFieldValues(string $entityType, object $entity, array $snapshot): void
{
$snapshotCfvs = $snapshot['customFieldValues'] ?? [];
if ([] === $snapshotCfvs) {
return;
}
// Build a map of current CFVs by fieldId for lookup
$currentCfvsByFieldId = [];
if (method_exists($entity, 'getCustomFieldValues')) {
foreach ($entity->getCustomFieldValues() as $cfv) {
$fieldId = $cfv->getCustomField()?->getId();
if (null !== $fieldId) {
$currentCfvsByFieldId[$fieldId] = $cfv;
}
}
}
foreach ($snapshotCfvs as $cfvData) {
$fieldId = $cfvData['fieldId'] ?? null;
if (!$fieldId) {
continue;
}
// Try to find the current CFV by fieldId (resilient to ID changes after sync)
$cfv = $currentCfvsByFieldId[$fieldId] ?? null;
// Fallback: try by original ID if fieldId lookup failed
if (null === $cfv && !empty($cfvData['id'])) {
$cfv = $this->customFieldValues->find($cfvData['id']);
}
if (null !== $cfv) {
$cfv->setValue($cfvData['value'] ?? null);
}
}
}
/**
* Build a complete snapshot of the entity in its current state (after restore).
* Must be consistent with the snapshots built by the audit subscribers,
* so that a future restore from a "restore" version works correctly.
*/
private function buildCurrentSnapshot(string $entityType, object $entity): array
{
$snapshot = ['id' => $entity->getId()];
// Base fields
$baseFields = self::BASE_FIELDS[$entityType] ?? [];
foreach ($baseFields as $field) {
$getter = 'get'.ucfirst($field);
if (method_exists($entity, $getter)) {
$snapshot[$field] = $entity->{$getter}();
}
}
// Version
if (method_exists($entity, 'getVersion')) {
$snapshot['version'] = $entity->getVersion();
}
// Constructeur links
if (method_exists($entity, 'getConstructeurLinks')) {
$snapshot['constructeurLinks'] = [];
foreach ($entity->getConstructeurLinks() as $link) {
$snapshot['constructeurLinks'][] = [
'id' => $link->getId(),
'constructeurId' => $link->getConstructeur()->getId(),
'constructeurName' => $link->getConstructeur()->getName(),
'supplierReference' => $link->getSupplierReference(),
];
}
}
// Type (ModelType reference)
$typeGetters = ['composant' => 'getTypeComposant', 'piece' => 'getTypePiece', 'product' => 'getTypeProduct'];
$typeKeys = ['composant' => 'typeComposant', 'piece' => 'typePiece', 'product' => 'typeProduct'];
if (isset($typeGetters[$entityType]) && method_exists($entity, $typeGetters[$entityType])) {
$type = $entity->{$typeGetters[$entityType]}();
$snapshot[$typeKeys[$entityType]] = $type ? ['id' => $type->getId(), 'name' => $type->getName(), 'code' => $type->getCode()] : null;
}
// Machine: site
if ('machine' === $entityType && method_exists($entity, 'getSite')) {
$site = $entity->getSite();
$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(),
'modelTypeId' => $link->getModelType()?->getId(),
];
}
$snapshot['pieceLinks'] = [];
foreach ($entity->getPieceLinks() as $link) {
$snapshot['pieceLinks'][] = [
'id' => $link->getId(), 'pieceId' => $link->getPiece()?->getId(),
'pieceName' => $link->getPiece()?->getName(), 'quantity' => $link->getQuantity(),
'modelTypeId' => $link->getModelType()?->getId(),
];
}
$snapshot['productLinks'] = [];
foreach ($entity->getProductLinks() as $link) {
$snapshot['productLinks'][] = [
'id' => $link->getId(), 'productId' => $link->getProduct()?->getId(),
'productName' => $link->getProduct()?->getName(),
'modelTypeId' => $link->getModelType()?->getId(),
];
}
}
// Composant/Piece: product
if (in_array($entityType, ['composant', 'piece'], true) && method_exists($entity, 'getProduct')) {
$product = $entity->getProduct();
$snapshot['product'] = $product ? ['id' => $product->getId(), 'name' => $product->getName()] : null;
}
// Slots
if ('composant' === $entityType) {
$snapshot['pieceSlots'] = [];
foreach ($entity->getPieceSlots() as $slot) {
$snapshot['pieceSlots'][] = [
'id' => $slot->getId(), 'typePieceId' => $slot->getTypePiece()?->getId(),
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
'quantity' => $slot->getQuantity(), 'position' => $slot->getPosition(),
];
}
$snapshot['subcomponentSlots'] = [];
foreach ($entity->getSubcomponentSlots() as $slot) {
$snapshot['subcomponentSlots'][] = [
'id' => $slot->getId(), 'alias' => $slot->getAlias(), 'familyCode' => $slot->getFamilyCode(),
'typeComposantId' => $slot->getTypeComposant()?->getId(),
'selectedComposantId' => $slot->getSelectedComposant()?->getId(),
'position' => $slot->getPosition(),
];
}
$snapshot['productSlots'] = $this->serializeProductSlots($entity->getProductSlots());
}
if ('piece' === $entityType) {
$snapshot['productSlots'] = $this->serializeProductSlots($entity->getProductSlots());
}
// Custom field values
if (method_exists($entity, 'getCustomFieldValues')) {
$snapshot['customFieldValues'] = [];
foreach ($entity->getCustomFieldValues() as $cfv) {
$snapshot['customFieldValues'][] = [
'id' => $cfv->getId(), 'fieldName' => $cfv->getCustomField()?->getName(),
'fieldId' => $cfv->getCustomField()?->getId(), 'value' => $cfv->getValue(),
];
}
}
return $snapshot;
}
/**
* @param iterable<ComposantProductSlot|PieceProductSlot> $slots
*
* @return list<array<string, mixed>>
*/
private function serializeProductSlots(iterable $slots): array
{
$serialized = [];
foreach ($slots as $slot) {
$serialized[] = [
'id' => $slot->getId(),
'typeProductId' => $slot->getTypeProduct()?->getId(),
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
'familyCode' => $slot->getFamilyCode(),
'position' => $slot->getPosition(),
];
}
return $serialized;
}
}