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).
961 lines
38 KiB
PHP
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;
|
|
}
|
|
}
|