feat(versioning) : add entity versioning with numbered versions and restore

Backend:
- Migration: version column on audit_logs and machines
- AuditLog, Machine, Composant, Piece, Product: version + skipAudit properties
- AbstractAuditSubscriber: auto-increment version, skip on restore, fix decimal diff
- Enriched snapshots with slots, custom fields and version number
- AuditLogRepository: findVersionHistory, findByVersion
- EntityVersionService: list, preview, restore with skeleton/integrity checks
- EntityVersionController: REST endpoints for all 4 entity types
- 11 tests covering list, preview, restore, auth

Frontend: update submodule pointer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-03-26 15:01:56 +01:00
parent 162c6ece71
commit 9299a46c8b
16 changed files with 1425 additions and 35 deletions

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260326100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add version column to audit_logs and machines tables';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS version INT DEFAULT NULL');
$this->addSql('ALTER TABLE machines ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1');
$this->addSql('CREATE INDEX IF NOT EXISTS idx_audit_entity_version ON audit_logs (entitytype, entityid, version)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX IF EXISTS idx_audit_entity_version');
$this->addSql('ALTER TABLE audit_logs DROP COLUMN IF EXISTS version');
$this->addSql('ALTER TABLE machines DROP COLUMN IF EXISTS version');
}
}

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\ComposantRepository;
use App\Repository\MachineRepository;
use App\Repository\PieceRepository;
use App\Repository\ProductRepository;
use App\Service\EntityVersionService;
use InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class EntityVersionController extends AbstractController
{
/** @var array<string, array{repo: object, label: string}> */
private readonly array $entityConfig;
public function __construct(
MachineRepository $machines,
PieceRepository $pieces,
ComposantRepository $composants,
ProductRepository $products,
private readonly EntityVersionService $versionService,
) {
$this->entityConfig = [
'machine' => ['repo' => $machines, 'label' => 'Machine introuvable.'],
'piece' => ['repo' => $pieces, 'label' => 'Pièce introuvable.'],
'composant' => ['repo' => $composants, 'label' => 'Composant introuvable.'],
'product' => ['repo' => $products, 'label' => 'Produit introuvable.'],
];
}
// ── Versions list ───────────────────────────────────────────────
#[Route('/api/machines/{id}/versions', name: 'api_machine_versions', methods: ['GET'])]
public function machineVersions(string $id): JsonResponse
{
return $this->listVersions('machine', $id);
}
#[Route('/api/composants/{id}/versions', name: 'api_composant_versions', methods: ['GET'])]
public function composantVersions(string $id): JsonResponse
{
return $this->listVersions('composant', $id);
}
#[Route('/api/pieces/{id}/versions', name: 'api_piece_versions', methods: ['GET'])]
public function pieceVersions(string $id): JsonResponse
{
return $this->listVersions('piece', $id);
}
#[Route('/api/products/{id}/versions', name: 'api_product_versions', methods: ['GET'])]
public function productVersions(string $id): JsonResponse
{
return $this->listVersions('product', $id);
}
// ── Preview ─────────────────────────────────────────────────────
#[Route('/api/machines/{id}/versions/{version}/preview', name: 'api_machine_version_preview', methods: ['GET'])]
public function machineVersionPreview(string $id, int $version): JsonResponse
{
return $this->preview('machine', $id, $version);
}
#[Route('/api/composants/{id}/versions/{version}/preview', name: 'api_composant_version_preview', methods: ['GET'])]
public function composantVersionPreview(string $id, int $version): JsonResponse
{
return $this->preview('composant', $id, $version);
}
#[Route('/api/pieces/{id}/versions/{version}/preview', name: 'api_piece_version_preview', methods: ['GET'])]
public function pieceVersionPreview(string $id, int $version): JsonResponse
{
return $this->preview('piece', $id, $version);
}
#[Route('/api/products/{id}/versions/{version}/preview', name: 'api_product_version_preview', methods: ['GET'])]
public function productVersionPreview(string $id, int $version): JsonResponse
{
return $this->preview('product', $id, $version);
}
// ── Restore ─────────────────────────────────────────────────────
#[Route('/api/machines/{id}/versions/{version}/restore', name: 'api_machine_version_restore', methods: ['POST'])]
public function machineVersionRestore(string $id, int $version): JsonResponse
{
return $this->restoreVersion('machine', $id, $version);
}
#[Route('/api/composants/{id}/versions/{version}/restore', name: 'api_composant_version_restore', methods: ['POST'])]
public function composantVersionRestore(string $id, int $version): JsonResponse
{
return $this->restoreVersion('composant', $id, $version);
}
#[Route('/api/pieces/{id}/versions/{version}/restore', name: 'api_piece_version_restore', methods: ['POST'])]
public function pieceVersionRestore(string $id, int $version): JsonResponse
{
return $this->restoreVersion('piece', $id, $version);
}
#[Route('/api/products/{id}/versions/{version}/restore', name: 'api_product_version_restore', methods: ['POST'])]
public function productVersionRestore(string $id, int $version): JsonResponse
{
return $this->restoreVersion('product', $id, $version);
}
// ── Private helpers ─────────────────────────────────────────────
private function listVersions(string $entityType, string $entityId): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$config = $this->entityConfig[$entityType];
if (!$config['repo']->find($entityId)) {
return new JsonResponse(['message' => $config['label']], Response::HTTP_NOT_FOUND);
}
return new JsonResponse($this->versionService->getVersions($entityType, $entityId));
}
private function preview(string $entityType, string $entityId, int $version): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
try {
$result = $this->versionService->getRestorePreview($entityType, $entityId, $version);
return new JsonResponse($result);
} catch (InvalidArgumentException $e) {
return new JsonResponse(['message' => $e->getMessage()], Response::HTTP_NOT_FOUND);
}
}
private function restoreVersion(string $entityType, string $entityId, int $version): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
try {
$result = $this->versionService->restore($entityType, $entityId, $version);
return new JsonResponse($result);
} catch (InvalidArgumentException $e) {
return new JsonResponse(['message' => $e->getMessage()], Response::HTTP_NOT_FOUND);
}
}
}

View File

@@ -38,6 +38,9 @@ class AuditLog
#[ORM\Column(type: Types::STRING, length: 36, nullable: true)]
private ?string $actorProfileId = null;
#[ORM\Column(type: Types::INTEGER, nullable: true)]
private ?int $version = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private DateTimeImmutable $createdAt;
@@ -48,6 +51,7 @@ class AuditLog
?array $diff = null,
?array $snapshot = null,
?string $actorProfileId = null,
?int $version = null,
) {
$this->entityType = $entityType;
$this->entityId = $entityId;
@@ -55,6 +59,7 @@ class AuditLog
$this->diff = $diff;
$this->snapshot = $snapshot;
$this->actorProfileId = $actorProfileId;
$this->version = $version;
}
#[ORM\PrePersist]
@@ -109,6 +114,18 @@ class AuditLog
return $this->createdAt;
}
public function getVersion(): ?int
{
return $this->version;
}
public function setVersion(?int $version): static
{
$this->version = $version;
return $this;
}
private function generateCuid(): string
{
// Keep the same lightweight CUID-like strategy used across the project.

View File

@@ -145,6 +145,8 @@ class Composant
#[Groups(['composant:read'])]
private int $version = 1;
private bool $skipAudit = false;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
#[Groups(['composant:read'])]
private DateTimeImmutable $createdAt;
@@ -454,4 +456,16 @@ class Composant
return $this;
}
public function getSkipAudit(): bool
{
return $this->skipAudit;
}
public function setSkipAudit(bool $skipAudit): static
{
$this->skipAudit = $skipAudit;
return $this;
}
}

View File

@@ -108,6 +108,15 @@ class Machine
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
private int $version = 1;
/**
* Transient flag — when true, audit subscribers skip this entity.
* Used by EntityVersionService::restore() to avoid duplicate AuditLogs.
*/
private bool $skipAudit = false;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private DateTimeImmutable $updatedAt;
@@ -265,4 +274,28 @@ class Machine
{
return $this->customFieldValues;
}
public function getVersion(): int
{
return $this->version;
}
public function incrementVersion(): static
{
++$this->version;
return $this;
}
public function getSkipAudit(): bool
{
return $this->skipAudit;
}
public function setSkipAudit(bool $skipAudit): static
{
$this->skipAudit = $skipAudit;
return $this;
}
}

View File

@@ -133,6 +133,8 @@ class Piece
#[Groups(['piece:read'])]
private int $version = 1;
private bool $skipAudit = false;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
#[Groups(['piece:read'])]
private DateTimeImmutable $createdAt;
@@ -354,4 +356,16 @@ class Piece
return $this;
}
public function getSkipAudit(): bool
{
return $this->skipAudit;
}
public function setSkipAudit(bool $skipAudit): static
{
$this->skipAudit = $skipAudit;
return $this;
}
}

View File

@@ -124,6 +124,8 @@ class Product
#[Groups(['product:read'])]
private int $version = 1;
private bool $skipAudit = false;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
#[Groups(['product:read'])]
private DateTimeImmutable $createdAt;
@@ -268,4 +270,16 @@ class Product
return $this;
}
public function getSkipAudit(): bool
{
return $this->skipAudit;
}
public function setSkipAudit(bool $skipAudit): static
{
$this->skipAudit = $skipAudit;
return $this;
}
}

View File

@@ -52,7 +52,16 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
return;
}
$uow = $em->getUnitOfWork();
$uow = $em->getUnitOfWork();
// If any tracked entity has skipAudit=true, skip the entire subscriber.
// This is set by EntityVersionService::restore() to avoid duplicate audit logs.
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if ($this->supports($entity) && method_exists($entity, 'getSkipAudit') && $entity->getSkipAudit()) {
return;
}
}
$actorProfileId = $this->resolveActorProfileId();
$entityType = $this->entityType();
@@ -106,7 +115,7 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
{
$diff = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) {
if ('updatedAt' === $field || 'createdAt' === $field) {
if ('updatedAt' === $field || 'createdAt' === $field || 'version' === $field) {
continue;
}
@@ -117,6 +126,11 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
continue;
}
// Skip decimal formatting differences (e.g. "33.00" vs "33")
if (is_numeric($normalizedOld) && is_numeric($normalizedNew) && (float) $normalizedOld === (float) $normalizedNew) {
continue;
}
$diff[$field] = [
'from' => $normalizedOld,
'to' => $normalizedNew,
@@ -229,6 +243,43 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
return $base;
}
/**
* If the entity has a version, increment it and return the new value.
* Recomputes the changeset so Doctrine picks up the version bump.
*/
protected function incrementEntityVersion(object $entity, EntityManagerInterface $em, UnitOfWork $uow): ?int
{
if (!method_exists($entity, 'incrementVersion') || !method_exists($entity, 'getVersion')) {
return null;
}
// If the version was already changed (e.g. by a sync strategy), don't double-increment
$changeSet = $uow->getEntityChangeSet($entity);
if (isset($changeSet['version'])) {
return $entity->getVersion();
}
$entity->incrementVersion();
$uow->recomputeSingleEntityChangeSet(
$em->getClassMetadata($entity::class),
$entity,
);
return $entity->getVersion();
}
/**
* Get the current version without incrementing (for create actions).
*/
protected function getEntityVersion(object $entity): ?int
{
if (!method_exists($entity, 'getVersion')) {
return null;
}
return $entity->getVersion();
}
protected function resolveActorProfileId(): ?string
{
try {
@@ -260,7 +311,8 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
$snapshot = $this->snapshotEntity($entity);
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
$version = $this->getEntityVersion($entity);
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId, $version));
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
@@ -275,8 +327,9 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ([] !== $diff) {
$version = $this->incrementEntityVersion($entity, $em, $uow);
$snapshot = $this->snapshotEntity($entity);
$this->persistAuditLog($em, new AuditLog($entityType, $id, 'update', $diff, $snapshot, $actorProfileId));
$this->persistAuditLog($em, new AuditLog($entityType, $id, 'update', $diff, $snapshot, $actorProfileId, $version));
}
}
@@ -303,7 +356,8 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
$snapshot = $this->snapshotEntity($entity);
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
$version = $this->getEntityVersion($entity);
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId, $version));
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
@@ -352,8 +406,10 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
continue;
}
$snapshot = $pendingSnapshots[$entityId] ?? $this->snapshotEntity($entity);
$this->persistAuditLog($em, new AuditLog($entityType, $entityId, 'update', $diff, $snapshot, $actorProfileId));
$version = $this->incrementEntityVersion($entity, $em, $uow);
// Re-take snapshot after version increment so it captures the new version number
$snapshot = $this->snapshotEntity($entity);
$this->persistAuditLog($em, new AuditLog($entityType, $entityId, 'update', $diff, $snapshot, $actorProfileId, $version));
}
}

View File

@@ -34,14 +34,64 @@ final class ComposantAuditSubscriber extends AbstractAuditSubscriber
protected function snapshotEntity(object $entity): array
{
$pieceSlots = [];
foreach ($entity->getPieceSlots() as $slot) {
$pieceSlots[] = [
'id' => $slot->getId(),
'typePieceId' => $slot->getTypePiece()?->getId(),
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
'quantity' => $slot->getQuantity(),
'position' => $slot->getPosition(),
];
}
$subcomponentSlots = [];
foreach ($entity->getSubcomponentSlots() as $slot) {
$subcomponentSlots[] = [
'id' => $slot->getId(),
'alias' => $slot->getAlias(),
'familyCode' => $slot->getFamilyCode(),
'typeComposantId' => $slot->getTypeComposant()?->getId(),
'selectedComposantId' => $slot->getSelectedComposant()?->getId(),
'position' => $slot->getPosition(),
];
}
$productSlots = [];
foreach ($entity->getProductSlots() as $slot) {
$productSlots[] = [
'id' => $slot->getId(),
'typeProductId' => $slot->getTypeProduct()?->getId(),
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
'familyCode' => $slot->getFamilyCode(),
'position' => $slot->getPosition(),
];
}
$customFieldValues = [];
foreach ($entity->getCustomFieldValues() as $cfv) {
$customFieldValues[] = [
'id' => $cfv->getId(),
'fieldName' => $cfv->getCustomField()?->getName(),
'fieldId' => $cfv->getCustomField()?->getId(),
'value' => $cfv->getValue(),
];
}
return [
'id' => $entity->getId(),
'name' => $this->safeGet($entity, 'getName'),
'reference' => $this->safeGet($entity, 'getReference'),
'prix' => $this->safeGet($entity, 'getPrix'),
'typeComposant' => $this->normalizeValue($this->safeGet($entity, 'getTypeComposant')),
'product' => $this->normalizeValue($this->safeGet($entity, 'getProduct')),
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
'id' => $entity->getId(),
'name' => $this->safeGet($entity, 'getName'),
'reference' => $this->safeGet($entity, 'getReference'),
'description' => $this->safeGet($entity, 'getDescription'),
'prix' => $this->safeGet($entity, 'getPrix'),
'typeComposant' => $this->normalizeValue($this->safeGet($entity, 'getTypeComposant')),
'product' => $this->normalizeValue($this->safeGet($entity, 'getProduct')),
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
'pieceSlots' => $pieceSlots,
'subcomponentSlots' => $subcomponentSlots,
'productSlots' => $productSlots,
'customFieldValues' => $customFieldValues,
'version' => $this->safeGet($entity, 'getVersion'),
];
}
}

View File

@@ -36,13 +36,25 @@ final class MachineAuditSubscriber extends AbstractAuditSubscriber
protected function snapshotEntity(object $entity): array
{
$customFieldValues = [];
foreach ($entity->getCustomFieldValues() as $cfv) {
$customFieldValues[] = [
'id' => $cfv->getId(),
'fieldName' => $cfv->getCustomField()?->getName(),
'fieldId' => $cfv->getCustomField()?->getId(),
'value' => $cfv->getValue(),
];
}
return [
'id' => $entity->getId(),
'name' => $this->safeGet($entity, 'getName'),
'reference' => $this->safeGet($entity, 'getReference'),
'prix' => $this->safeGet($entity, 'getPrix'),
'site' => $this->normalizeValue($this->safeGet($entity, 'getSite')),
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
'id' => $entity->getId(),
'name' => $this->safeGet($entity, 'getName'),
'reference' => $this->safeGet($entity, 'getReference'),
'prix' => $this->safeGet($entity, 'getPrix'),
'site' => $this->normalizeValue($this->safeGet($entity, 'getSite')),
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
'customFieldValues' => $customFieldValues,
'version' => $this->safeGet($entity, 'getVersion'),
];
}
}

View File

@@ -34,15 +34,39 @@ final class PieceAuditSubscriber extends AbstractAuditSubscriber
protected function snapshotEntity(object $entity): array
{
$productSlots = [];
foreach ($entity->getProductSlots() as $slot) {
$productSlots[] = [
'id' => $slot->getId(),
'typeProductId' => $slot->getTypeProduct()?->getId(),
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
'familyCode' => $slot->getFamilyCode(),
'position' => $slot->getPosition(),
];
}
$customFieldValues = [];
foreach ($entity->getCustomFieldValues() as $cfv) {
$customFieldValues[] = [
'id' => $cfv->getId(),
'fieldName' => $cfv->getCustomField()?->getName(),
'fieldId' => $cfv->getCustomField()?->getId(),
'value' => $cfv->getValue(),
];
}
return [
'id' => $entity->getId(),
'name' => $this->safeGet($entity, 'getName'),
'reference' => $this->safeGet($entity, 'getReference'),
'prix' => $this->safeGet($entity, 'getPrix'),
'typePiece' => $this->normalizeValue($this->safeGet($entity, 'getTypePiece')),
'product' => $this->normalizeValue($this->safeGet($entity, 'getProduct')),
'productIds' => $this->safeGet($entity, 'getProductIds') ?? [],
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
'id' => $entity->getId(),
'name' => $this->safeGet($entity, 'getName'),
'reference' => $this->safeGet($entity, 'getReference'),
'description' => $this->safeGet($entity, 'getDescription'),
'prix' => $this->safeGet($entity, 'getPrix'),
'typePiece' => $this->normalizeValue($this->safeGet($entity, 'getTypePiece')),
'product' => $this->normalizeValue($this->safeGet($entity, 'getProduct')),
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
'productSlots' => $productSlots,
'customFieldValues' => $customFieldValues,
'version' => $this->safeGet($entity, 'getVersion'),
];
}
}

View File

@@ -34,13 +34,25 @@ final class ProductAuditSubscriber extends AbstractAuditSubscriber
protected function snapshotEntity(object $entity): array
{
$customFieldValues = [];
foreach ($entity->getCustomFieldValues() as $cfv) {
$customFieldValues[] = [
'id' => $cfv->getId(),
'fieldName' => $cfv->getCustomField()?->getName(),
'fieldId' => $cfv->getCustomField()?->getId(),
'value' => $cfv->getValue(),
];
}
return [
'id' => $entity->getId(),
'name' => $this->safeGet($entity, 'getName'),
'reference' => $this->safeGet($entity, 'getReference'),
'supplierPrice' => $this->safeGet($entity, 'getSupplierPrice'),
'typeProduct' => $this->normalizeValue($this->safeGet($entity, 'getTypeProduct')),
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
'id' => $entity->getId(),
'name' => $this->safeGet($entity, 'getName'),
'reference' => $this->safeGet($entity, 'getReference'),
'supplierPrice' => $this->safeGet($entity, 'getSupplierPrice'),
'typeProduct' => $this->normalizeValue($this->safeGet($entity, 'getTypeProduct')),
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
'customFieldValues' => $customFieldValues,
'version' => $this->safeGet($entity, 'getVersion'),
];
}
}

View File

@@ -35,6 +35,38 @@ final class AuditLogRepository extends ServiceEntityRepository
;
}
/**
* @return list<AuditLog>
*/
public function findVersionHistory(string $entityType, string $entityId): array
{
return $this->createQueryBuilder('a')
->andWhere('a.entityType = :entityType')
->andWhere('a.entityId = :entityId')
->andWhere('a.version IS NOT NULL')
->setParameter('entityType', $entityType)
->setParameter('entityId', $entityId)
->orderBy('a.version', 'DESC')
->getQuery()
->getResult()
;
}
public function findByVersion(string $entityType, string $entityId, int $version): ?AuditLog
{
return $this->createQueryBuilder('a')
->andWhere('a.entityType = :entityType')
->andWhere('a.entityId = :entityId')
->andWhere('a.version = :version')
->setParameter('entityType', $entityType)
->setParameter('entityId', $entityId)
->setParameter('version', $version)
->setMaxResults(1)
->getQuery()
->getOneOrNullResult()
;
}
/**
* @param array{entityType?: string, action?: string} $filters
*

View File

@@ -0,0 +1,753 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\AuditLog;
use App\Entity\Composant;
use App\Entity\ComposantPieceSlot;
use App\Entity\ComposantProductSlot;
use App\Entity\ComposantSubcomponentSlot;
use App\Entity\Machine;
use App\Entity\Piece;
use App\Entity\PieceProductSlot;
use App\Entity\Product;
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 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;
}
// 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, 'getConstructeurs')) {
foreach ($entity->getConstructeurs() as $c) {
$currentConstructeurIds[] = $c->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]))];
}
}
// 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,
};
// Full mode: restore custom field values
$this->restoreCustomFieldValues($entityType, $entity, $snapshot);
}
private function restoreConstructeurs(object $entity, array $snapshot): void
{
if (!method_exists($entity, 'getConstructeurs') || !method_exists($entity, 'addConstructeur') || !method_exists($entity, 'removeConstructeur')) {
return;
}
$targetIds = [];
foreach ($snapshot['constructeurIds'] ?? [] as $entry) {
$id = is_array($entry) ? ($entry['id'] ?? null) : $entry;
if ($id) {
$targetIds[] = $id;
}
}
// Remove current constructeurs not in snapshot
foreach ($entity->getConstructeurs()->toArray() as $c) {
if (!in_array($c->getId(), $targetIds, true)) {
$entity->removeConstructeur($c);
}
}
// Add missing constructeurs from snapshot
$currentIds = array_map(fn ($c) => $c->getId(), $entity->getConstructeurs()->toArray());
foreach ($targetIds as $id) {
if (!in_array($id, $currentIds, true)) {
$constructeur = $this->constructeurs->find($id);
if (null !== $constructeur) {
$entity->addConstructeur($constructeur);
}
}
}
}
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();
}
// Constructeurs
if (method_exists($entity, 'getConstructeurs')) {
$snapshot['constructeurIds'] = [];
foreach ($entity->getConstructeurs() as $c) {
$snapshot['constructeurIds'][] = ['id' => $c->getId(), 'name' => $c->getName()];
}
}
// 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;
}
// 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;
}
}

View File

@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace App\Tests\Api\Controller;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class EntityVersionTest extends AbstractApiTestCase
{
// ── Versions list ───────────────────────────────────────────────
public function testMachineVersionsAfterCreateAndUpdate(): void
{
$machine = $this->createMachine('Machine V');
$gClient = $this->createGestionnaireClient();
$gClient->request('PATCH', self::iri('machines', $machine->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['name' => 'Machine V Updated'],
]);
$this->assertResponseIsSuccessful();
$vClient = $this->createViewerClient();
$vClient->request('GET', sprintf('/api/machines/%s/versions', $machine->getId()));
$this->assertResponseIsSuccessful();
$data = $vClient->getResponse()->toArray();
$this->assertArrayHasKey('items', $data);
$this->assertArrayHasKey('total', $data);
$this->assertGreaterThanOrEqual(1, $data['total']);
$firstItem = $data['items'][0];
$this->assertArrayHasKey('version', $firstItem);
$this->assertArrayHasKey('action', $firstItem);
$this->assertArrayHasKey('createdAt', $firstItem);
}
public function testComposantVersionsList(): void
{
$composant = $this->createComposant('Composant V');
$client = $this->createViewerClient();
$client->request('GET', sprintf('/api/composants/%s/versions', $composant->getId()));
$this->assertResponseIsSuccessful();
$data = $client->getResponse()->toArray();
$this->assertArrayHasKey('items', $data);
}
public function testPieceVersionsList(): void
{
$piece = $this->createPiece('Piece V');
$client = $this->createViewerClient();
$client->request('GET', sprintf('/api/pieces/%s/versions', $piece->getId()));
$this->assertResponseIsSuccessful();
}
public function testProductVersionsList(): void
{
$product = $this->createProduct('Product V');
$client = $this->createViewerClient();
$client->request('GET', sprintf('/api/products/%s/versions', $product->getId()));
$this->assertResponseIsSuccessful();
}
public function testVersionsNotFound(): void
{
$client = $this->createViewerClient();
$client->request('GET', '/api/machines/nonexistent-id/versions');
$this->assertResponseStatusCodeSame(404);
}
public function testVersionsUnauthenticated(): void
{
$machine = $this->createMachine('Machine V');
$client = $this->createUnauthenticatedClient();
$client->request('GET', sprintf('/api/machines/%s/versions', $machine->getId()));
$this->assertResponseStatusCodeSame(401);
}
// ── Preview ─────────────────────────────────────────────────────
public function testPreviewRequiresGestionnaire(): void
{
$machine = $this->createMachine('Machine P');
$client = $this->createViewerClient();
$client->request('GET', sprintf('/api/machines/%s/versions/1/preview', $machine->getId()));
$this->assertResponseStatusCodeSame(403);
}
public function testPreviewReturnsRestoreInfo(): void
{
$composant = $this->createComposant('Composant P');
// Update to create version 2
$gClient = $this->createGestionnaireClient();
$gClient->request('PATCH', self::iri('composants', $composant->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['name' => 'Composant P Updated'],
]);
$this->assertResponseIsSuccessful();
// Preview restore to version 1
$gClient2 = $this->createGestionnaireClient();
$gClient2->request('GET', sprintf('/api/composants/%s/versions/1/preview', $composant->getId()));
$this->assertResponseIsSuccessful();
$data = $gClient2->getResponse()->toArray();
$this->assertArrayHasKey('version', $data);
$this->assertArrayHasKey('restoreMode', $data);
$this->assertArrayHasKey('diff', $data);
$this->assertArrayHasKey('warnings', $data);
$this->assertEquals(1, $data['version']);
$this->assertEquals('full', $data['restoreMode']);
}
// ── Restore ─────────────────────────────────────────────────────
public function testRestoreRequiresGestionnaire(): void
{
$machine = $this->createMachine('Machine R');
$client = $this->createViewerClient();
$client->request('POST', sprintf('/api/machines/%s/versions/1/restore', $machine->getId()));
$this->assertResponseStatusCodeSame(403);
}
public function testRestoreCreatesNewVersion(): void
{
$composant = $this->createComposant('Original Name');
// Update
$gClient = $this->createGestionnaireClient();
$gClient->request('PATCH', self::iri('composants', $composant->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['name' => 'Updated Name'],
]);
$this->assertResponseIsSuccessful();
// Restore to version 1
$gClient2 = $this->createGestionnaireClient();
$gClient2->request('POST', sprintf('/api/composants/%s/versions/1/restore', $composant->getId()));
$this->assertResponseIsSuccessful();
$data = $gClient2->getResponse()->toArray();
$this->assertTrue($data['success']);
$this->assertEquals(1, $data['restoredFromVersion']);
$this->assertGreaterThan(2, $data['newVersion']);
// Verify the name was restored
$vClient = $this->createViewerClient();
$vClient->request('GET', self::iri('composants', $composant->getId()));
$this->assertResponseIsSuccessful();
$entityData = $vClient->getResponse()->toArray();
$this->assertEquals('Original Name', $entityData['name']);
}
public function testRestoreVersionNotFound(): void
{
$composant = $this->createComposant('Composant NF');
$client = $this->createGestionnaireClient();
$client->request('POST', sprintf('/api/composants/%s/versions/999/restore', $composant->getId()));
$this->assertResponseStatusCodeSame(404);
}
}