971 lines
39 KiB
PHP
971 lines
39 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 Symfony\Component\HttpFoundation\RequestStack;
|
|
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 RequestStack $requestStack,
|
|
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->resolveActorProfileId(),
|
|
$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(),
|
|
];
|
|
}
|
|
$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();
|
|
$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'] = [];
|
|
foreach ($entity->getProductSlots() as $slot) {
|
|
$snapshot['productSlots'][] = [
|
|
'id' => $slot->getId(), 'typeProductId' => $slot->getTypeProduct()?->getId(),
|
|
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
|
'familyCode' => $slot->getFamilyCode(), 'position' => $slot->getPosition(),
|
|
];
|
|
}
|
|
}
|
|
|
|
if ('piece' === $entityType) {
|
|
$snapshot['productSlots'] = [];
|
|
foreach ($entity->getProductSlots() as $slot) {
|
|
$snapshot['productSlots'][] = [
|
|
'id' => $slot->getId(), 'typeProductId' => $slot->getTypeProduct()?->getId(),
|
|
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
|
'familyCode' => $slot->getFamilyCode(), 'position' => $slot->getPosition(),
|
|
];
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
/**
|
|
* Resolve the current actor profile ID from the session.
|
|
* Mirrors AbstractAuditSubscriber::resolveActorProfileId().
|
|
*/
|
|
private function resolveActorProfileId(): ?string
|
|
{
|
|
try {
|
|
$session = $this->requestStack->getSession();
|
|
$profileId = $session->get('profileId');
|
|
if ($profileId) {
|
|
return (string) $profileId;
|
|
}
|
|
} catch (Throwable) {
|
|
// No session available (CLI context, etc.)
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|