Files
Inventory/docs/superpowers/plans/2026-03-25-entity-versioning.md
2026-03-25 23:04:30 +01:00

70 KiB

Entity Versioning Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add numbered versioning with restore capability to Machine, Composant, Piece, and Product entities, leveraging the existing AuditLog system.

Architecture: Extend AuditLog with a version column. Enrich snapshots to include slots/custom fields. Add EntityVersionController with list/preview/restore endpoints. New EntityVersionService centralizes restore logic with skeleton and integrity checks. Frontend gets a EntityVersionList component and VersionRestoreModal.

Tech Stack: Symfony 8 / PHP 8.4 / API Platform / PostgreSQL 16 / Nuxt 4 / Vue 3 / DaisyUI 5


File Structure

Backend — New Files

  • src/Service/EntityVersionService.php — Core restore logic (skeleton check, integrity check, apply restore)
  • src/Controller/EntityVersionController.php — REST endpoints (versions list, preview, restore)
  • migrations/VersionXXXX.php — Add version column to audit_logs and machines
  • tests/Api/Controller/EntityVersionTest.php — Full test coverage

Backend — Modified Files

  • src/Entity/AuditLog.php — Add version column + getter/setter
  • src/Entity/Machine.php — Add version property + getVersion()/incrementVersion()
  • src/EventSubscriber/AbstractAuditSubscriber.php — Auto-increment version + write to AuditLog
  • src/EventSubscriber/ComposantAuditSubscriber.php — Enrich snapshot with slots + custom fields + version
  • src/EventSubscriber/PieceAuditSubscriber.php — Enrich snapshot with productSlots + custom fields + version
  • src/EventSubscriber/ProductAuditSubscriber.php — Enrich snapshot with custom fields + version
  • src/EventSubscriber/MachineAuditSubscriber.php — Enrich snapshot with custom fields + version
  • src/Repository/AuditLogRepository.php — Add findVersionHistory() method

Frontend — New Files

  • Inventory_frontend/app/composables/useEntityVersions.ts — API calls for versions/preview/restore
  • Inventory_frontend/app/components/common/EntityVersionList.vue — Version list with restore button
  • Inventory_frontend/app/components/common/VersionRestoreModal.vue — Preview + confirm modal

Frontend — Modified Files

  • Inventory_frontend/app/pages/machine/[id].vue — Add Versions section
  • Inventory_frontend/app/pages/component/[id]/edit.vue — Add Versions section
  • Inventory_frontend/app/pages/piece/[id].vue — Add Versions section
  • Inventory_frontend/app/pages/product/[id]/edit.vue — Add Versions section

Task 1: Migration — version columns on audit_logs and machines

Files:

  • Create: migrations/Version20260325160000.php

  • Step 1: Create the migration file

<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260325160000 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');
    }
}
  • Step 2: Run the migration

Run: docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction Expected: Migration applied successfully.

  • Step 3: Update test schema

Run: make test-setup Expected: Schema updated for test environment.

  • Step 4: Commit
git add migrations/Version20260325160000.php
git commit -m "feat(versioning) : add version column to audit_logs and machines"

Task 2: AuditLog entity — add version property

Files:

  • Modify: src/Entity/AuditLog.php

  • Step 1: Add the version column property after actorProfileId (line 39)

Add after the $actorProfileId property:

#[ORM\Column(type: Types::INTEGER, nullable: true)]
private ?int $version = null;
  • Step 2: Add version to constructor parameters

Update the constructor signature to accept version:

public function __construct(
    string $entityType,
    string $entityId,
    string $action,
    ?array $diff = null,
    ?array $snapshot = null,
    ?string $actorProfileId = null,
    ?int $version = null,
) {
    $this->entityType     = $entityType;
    $this->entityId       = $entityId;
    $this->action         = $action;
    $this->diff           = $diff;
    $this->snapshot       = $snapshot;
    $this->actorProfileId = $actorProfileId;
    $this->version        = $version;
}
  • Step 3: Add getter and setter methods after getCreatedAt()
public function getVersion(): ?int
{
    return $this->version;
}

public function setVersion(?int $version): static
{
    $this->version = $version;

    return $this;
}
  • Step 4: Run php-cs-fixer

Run: make php-cs-fixer-allow-risky

  • Step 5: Run tests to verify no regressions

Run: make test Expected: All tests pass.

  • Step 6: Commit
git add src/Entity/AuditLog.php
git commit -m "feat(versioning) : add version property to AuditLog entity"

Task 3: Machine entity — add version property

Files:

  • Modify: src/Entity/Machine.php

  • Step 1: Add version property after updatedAt (line 112)

#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
private int $version = 1;
  • Step 2: Add getter and incrementVersion methods after getCustomFieldValues()
public function getVersion(): int
{
    return $this->version;
}

public function incrementVersion(): static
{
    ++$this->version;

    return $this;
}
  • Step 3: Run php-cs-fixer

Run: make php-cs-fixer-allow-risky

  • Step 4: Run tests

Run: make test Expected: All tests pass (Machine tests now see version column).

  • Step 5: Commit
git add src/Entity/Machine.php
git commit -m "feat(versioning) : add version property to Machine entity"

Task 4: AbstractAuditSubscriber — auto-increment version + write to AuditLog

Files:

  • Modify: src/EventSubscriber/AbstractAuditSubscriber.php

  • Step 1: Add a helper method to extract and increment version

Add after the resolveActorProfileId() method (after line 252):

/**
 * 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;
    }

    $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();
}
  • Step 2: Modify onFlushSimple — add version to AuditLog on create and update

Replace onFlushSimple() (lines 254-291):

private function onFlushSimple(EntityManagerInterface $em, UnitOfWork $uow, ?string $actorProfileId, string $entityType): void
{
    foreach ($uow->getScheduledEntityInsertions() as $entity) {
        if (!$this->supports($entity)) {
            continue;
        }

        $diff     = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
        $snapshot = $this->snapshotEntity($entity);
        $version  = $this->getEntityVersion($entity);
        $this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId, $version));
    }

    foreach ($uow->getScheduledEntityUpdates() as $entity) {
        if (!$this->supports($entity)) {
            continue;
        }

        $id = (string) $entity->getId();
        if ('' === $id) {
            continue;
        }

        $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, $version));
        }
    }

    foreach ($uow->getScheduledEntityDeletions() as $entity) {
        if (!$this->supports($entity)) {
            continue;
        }

        $snapshot = $this->snapshotEntity($entity);
        $this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
    }
}
  • Step 3: Modify onFlushComplex — add version to AuditLog on create and final persist

Replace onFlushComplex() (lines 293-358):

private function onFlushComplex(EntityManagerInterface $em, UnitOfWork $uow, ?string $actorProfileId, string $entityType): void
{
    $pendingUpdates   = [];
    $pendingSnapshots = [];
    $pendingEntities  = [];

    foreach ($uow->getScheduledEntityInsertions() as $entity) {
        if (!$this->supports($entity)) {
            continue;
        }

        $diff     = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
        $snapshot = $this->snapshotEntity($entity);
        $version  = $this->getEntityVersion($entity);
        $this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId, $version));
    }

    foreach ($uow->getScheduledEntityUpdates() as $entity) {
        if (!$this->supports($entity)) {
            continue;
        }

        $entityId = (string) $entity->getId();
        if ('' === $entityId) {
            continue;
        }

        $diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
        if ([] !== $diff) {
            $pendingUpdates[$entityId]   = $this->mergeDiffs($pendingUpdates[$entityId] ?? [], $diff);
            $pendingSnapshots[$entityId] = $this->snapshotEntity($entity);
            $pendingEntities[$entityId]  = $entity;
        }
    }

    foreach ($uow->getScheduledEntityDeletions() as $entity) {
        if (!$this->supports($entity)) {
            continue;
        }

        $snapshot = $this->snapshotEntity($entity);
        $this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
    }

    foreach ($uow->getScheduledCollectionUpdates() as $collection) {
        $this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingEntities);
    }
    foreach ($uow->getScheduledCollectionDeletions() as $collection) {
        $this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingEntities);
    }

    $this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingEntities);

    foreach ($pendingUpdates as $entityId => $diff) {
        if ([] === $diff) {
            continue;
        }

        $entity = $pendingEntities[$entityId] ?? null;
        if (null === $entity || !$this->supports($entity)) {
            continue;
        }

        $version  = $this->incrementEntityVersion($entity, $em, $uow);
        $snapshot = $pendingSnapshots[$entityId] ?? $this->snapshotEntity($entity);
        $this->persistAuditLog($em, new AuditLog($entityType, $entityId, 'update', $diff, $snapshot, $actorProfileId, $version));
    }
}
  • Step 4: Run php-cs-fixer

Run: make php-cs-fixer-allow-risky

  • Step 5: Run tests

Run: make test Expected: All tests pass. Version auto-increments on update.

  • Step 6: Commit
git add src/EventSubscriber/AbstractAuditSubscriber.php
git commit -m "feat(versioning) : auto-increment version on entity updates in audit subscribers"

Task 5: Enrich audit snapshots with slots + custom fields

Files:

  • Modify: src/EventSubscriber/ComposantAuditSubscriber.php

  • Modify: src/EventSubscriber/PieceAuditSubscriber.php

  • Modify: src/EventSubscriber/ProductAuditSubscriber.php

  • Modify: src/EventSubscriber/MachineAuditSubscriber.php

  • Step 1: Enrich ComposantAuditSubscriber snapshotEntity()

Replace snapshotEntity() in ComposantAuditSubscriber.php:

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'),
        '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'),
    ];
}
  • Step 2: Enrich PieceAuditSubscriber snapshotEntity()

Replace snapshotEntity() in PieceAuditSubscriber.php:

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'),
        'description'      => $this->safeGet($entity, 'getDescription'),
        '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()),
        'productSlots'     => $productSlots,
        'customFieldValues'=> $customFieldValues,
        'version'          => $this->safeGet($entity, 'getVersion'),
    ];
}
  • Step 3: Enrich ProductAuditSubscriber snapshotEntity()

Replace snapshotEntity() in ProductAuditSubscriber.php:

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()),
        'customFieldValues'=> $customFieldValues,
        'version'          => $this->safeGet($entity, 'getVersion'),
    ];
}
  • Step 4: Enrich MachineAuditSubscriber snapshotEntity()

Replace snapshotEntity() in MachineAuditSubscriber.php:

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()),
        'customFieldValues'=> $customFieldValues,
        'version'          => $this->safeGet($entity, 'getVersion'),
    ];
}
  • Step 5: Run php-cs-fixer

Run: make php-cs-fixer-allow-risky

  • Step 6: Run tests

Run: make test Expected: All tests pass. Snapshots now include enriched data.

  • Step 7: Commit
git add src/EventSubscriber/ComposantAuditSubscriber.php src/EventSubscriber/PieceAuditSubscriber.php src/EventSubscriber/ProductAuditSubscriber.php src/EventSubscriber/MachineAuditSubscriber.php
git commit -m "feat(versioning) : enrich audit snapshots with slots, custom fields and version"

Task 6: AuditLogRepository — add findVersionHistory()

Files:

  • Modify: src/Repository/AuditLogRepository.php

  • Step 1: Add findVersionHistory method

Add after findEntityHistory():

/**
 * @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()
    ;
}
  • Step 2: Run php-cs-fixer

Run: make php-cs-fixer-allow-risky

  • Step 3: Commit
git add src/Repository/AuditLogRepository.php
git commit -m "feat(versioning) : add findVersionHistory and findByVersion to AuditLogRepository"

Task 7: EntityVersionService — core restore logic

Files:

  • Create: src/Service/EntityVersionService.php

  • Step 1: Create the service

<?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;

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

        $this->applyRestore($entityType, $entity, $snapshot, $restoreMode);

        $this->em->flush();

        $newVersion = method_exists($entity, 'getVersion') ? $entity->getVersion() : null;

        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
        if (!empty($snapshot['constructeurIds'])) {
            foreach ($snapshot['constructeurIds'] as $entry) {
                $id = is_array($entry) ? ($entry['id'] ?? null) : $entry;
                $name = is_array($entry) ? ($entry['name'] ?? $id) : $id;
                if ($id && null === $this->constructeurs->find($id)) {
                    $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
        $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']] ?? [];
            foreach ($slots as $i => $slot) {
                $refId = $slot[$check['refKey']] ?? null;
                if (null !== $refId && null === $check['repo']->find($refId)) {
                    $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]))];
                }
            }
        }

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

        // 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);
        }

        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;
        }

        foreach ($snapshotCfvs as $cfvData) {
            $cfvId = $cfvData['id'] ?? null;
            if (!$cfvId) {
                continue;
            }

            $cfv = $this->customFieldValues->find($cfvId);
            if (null !== $cfv) {
                $cfv->setValue($cfvData['value'] ?? null);
            }
        }
    }
}
  • Step 2: Run php-cs-fixer

Run: make php-cs-fixer-allow-risky

  • Step 3: Commit
git add src/Service/EntityVersionService.php
git commit -m "feat(versioning) : add EntityVersionService with restore logic"

Task 8: EntityVersionController — REST endpoints

Files:

  • Create: src/Controller/EntityVersionController.php

  • Step 1: Create the controller

<?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 Doctrine\ORM\EntityRepository;
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: EntityRepository<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);
        }
    }
}
  • Step 2: Run php-cs-fixer

Run: make php-cs-fixer-allow-risky

  • Step 3: Commit
git add src/Controller/EntityVersionController.php
git commit -m "feat(versioning) : add EntityVersionController with list, preview and restore endpoints"

Task 9: Backend tests

Files:

  • Create: tests/Api/Controller/EntityVersionTest.php

  • Step 1: Create the test file

<?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);
    }
}
  • Step 2: Run tests

Run: make test FILES=tests/Api/Controller/EntityVersionTest.php Expected: All tests pass.

  • Step 3: Run full test suite

Run: make test Expected: All tests pass including existing tests.

  • Step 4: Commit
git add tests/Api/Controller/EntityVersionTest.php
git commit -m "test(versioning) : add EntityVersionTest for list, preview and restore"

Task 10: Frontend — useEntityVersions composable

Files:

  • Create: Inventory_frontend/app/composables/useEntityVersions.ts

  • Step 1: Create the composable

import { ref } from 'vue'
import { useApi } from '~/composables/useApi'
import type { MaybeRef } from 'vue'

export interface VersionEntry {
  version: number
  action: 'create' | 'update' | 'restore' | string
  createdAt: string
  actor: { id: string; label: string } | null
  diff: Record<string, { from: unknown; to: unknown }> | null
}

export interface RestorePreview {
  version: number
  restoreMode: 'full' | 'partial'
  diff: Record<string, { current: unknown; restored: unknown }>
  warnings: Array<{
    field: string
    message: string
    missingEntityId: string | null
    missingEntityName: string | null
  }>
  snapshot: Record<string, unknown>
}

export interface RestoreResult {
  success: boolean
  newVersion: number
  restoredFromVersion: number
  restoreMode: 'full' | 'partial'
  warnings: RestorePreview['warnings']
}

const ENTITY_ENDPOINTS: Record<string, string> = {
  machine: '/machines',
  composant: '/composants',
  piece: '/pieces',
  product: '/products',
}

interface Deps {
  entityType: MaybeRef<string>
  entityId: MaybeRef<string>
}

export function useEntityVersions(deps: Deps) {
  const { get, post } = useApi()

  const versions = ref<VersionEntry[]>([])
  const loading = ref(false)
  const error = ref<string | null>(null)

  const getPath = () => {
    const type = typeof deps.entityType === 'string' ? deps.entityType : deps.entityType.value
    const id = typeof deps.entityId === 'string' ? deps.entityId : deps.entityId.value
    const base = ENTITY_ENDPOINTS[type]
    return `${base}/${id}`
  }

  const fetchVersions = async () => {
    loading.value = true
    error.value = null
    try {
      const result = await get(`${getPath()}/versions`)
      if (!result.success) {
        error.value = result.error ?? 'Impossible de charger les versions.'
        versions.value = []
        return
      }
      versions.value = result.data?.items ?? []
    } catch (err: any) {
      error.value = err?.message ?? 'Erreur inconnue'
      versions.value = []
    } finally {
      loading.value = false
    }
  }

  const fetchPreview = async (version: number): Promise<RestorePreview | null> => {
    const result = await get<RestorePreview>(`${getPath()}/versions/${version}/preview`)
    if (!result.success || !result.data) {
      return null
    }
    return result.data
  }

  const restore = async (version: number): Promise<RestoreResult | null> => {
    const result = await post<RestoreResult>(`${getPath()}/versions/${version}/restore`)
    if (!result.success || !result.data) {
      return null
    }
    return result.data
  }

  return { versions, loading, error, fetchVersions, fetchPreview, restore }
}
  • Step 2: Run lint

Run (in Inventory_frontend/): npm run lint:fix

  • Step 3: Run typecheck

Run (in Inventory_frontend/): npx nuxi typecheck

  • Step 4: Commit in frontend repo
cd Inventory_frontend
git add app/composables/useEntityVersions.ts
git commit -m "feat(versioning) : add useEntityVersions composable"
cd ..

Task 11: Frontend — VersionRestoreModal component

Files:

  • Create: Inventory_frontend/app/components/common/VersionRestoreModal.vue

  • Step 1: Create the modal component

<template>
  <dialog ref="dialogRef" class="modal" :class="{ 'modal-open': visible }">
    <div class="modal-box max-w-lg">
      <h3 class="text-lg font-bold">Restaurer la version {{ preview?.version }}</h3>

      <div v-if="!preview" class="flex justify-center py-8">
        <span class="loading loading-spinner loading-md" />
      </div>

      <template v-else>
        <div class="mt-4 space-y-4">
          <!-- Restore mode -->
          <div
            class="alert text-sm"
            :class="preview.restoreMode === 'full' ? 'alert-info' : 'alert-warning'"
          >
            <span v-if="preview.restoreMode === 'full'">
              Restauration complète  le squelette est identique.
            </span>
            <span v-else>
              Restauration partielle  le squelette a changé. Seuls les champs de base seront restaurés.
            </span>
          </div>

          <!-- Diff -->
          <div v-if="Object.keys(preview.diff).length" class="space-y-2">
            <h4 class="text-sm font-semibold">Changements à appliquer</h4>
            <ul class="space-y-1 text-sm">
              <li
                v-for="(change, field) in preview.diff"
                :key="field"
                class="flex flex-col rounded-md border border-base-200 px-3 py-2"
              >
                <span class="font-medium text-base-content">{{ fieldLabels[field] || field }}</span>
                <span class="text-xs text-error line-through">{{ formatValue(change.current) }}</span>
                <span class="text-xs text-success">{{ formatValue(change.restored) }}</span>
              </li>
            </ul>
          </div>

          <!-- Warnings -->
          <div v-if="preview.warnings.length" class="space-y-1">
            <h4 class="text-sm font-semibold text-warning">Avertissements</h4>
            <ul class="space-y-1">
              <li
                v-for="(warning, i) in preview.warnings"
                :key="i"
                class="alert alert-warning text-xs py-2"
              >
                {{ warning.message }}
              </li>
            </ul>
          </div>
        </div>

        <div class="modal-action">
          <button class="btn btn-ghost btn-sm md:btn-md" :disabled="restoring" @click="$emit('close')">
            Annuler
          </button>
          <button class="btn btn-primary btn-sm md:btn-md" :disabled="restoring" @click="$emit('confirm')">
            <span v-if="restoring" class="loading loading-spinner loading-sm mr-2" />
            Confirmer la restauration
          </button>
        </div>
      </template>
    </div>
    <form method="dialog" class="modal-backdrop" @click="$emit('close')">
      <button type="button">close</button>
    </form>
  </dialog>
</template>

<script setup lang="ts">
import type { RestorePreview } from '~/composables/useEntityVersions'

defineProps<{
  visible: boolean
  preview: RestorePreview | null
  restoring: boolean
  fieldLabels: Record<string, string>
}>()

defineEmits<{
  close: []
  confirm: []
}>()

const formatValue = (value: unknown): string => {
  if (value === null || value === undefined) return '—'
  if (Array.isArray(value)) {
    return value.map((v) => (typeof v === 'object' && v !== null ? (v as any).name || (v as any).id || JSON.stringify(v) : String(v))).join(', ') || '—'
  }
  if (typeof value === 'object') return JSON.stringify(value)
  return String(value)
}
</script>
  • Step 2: Run lint

Run (in Inventory_frontend/): npm run lint:fix

  • Step 3: Commit
cd Inventory_frontend
git add app/components/common/VersionRestoreModal.vue
git commit -m "feat(versioning) : add VersionRestoreModal component"
cd ..

Task 12: Frontend — EntityVersionList component

Files:

  • Create: Inventory_frontend/app/components/common/EntityVersionList.vue

  • Step 1: Create the version list component

<template>
  <section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
    <header class="flex items-center justify-between gap-3">
      <div>
        <h2 class="font-semibold text-base-content">Versions</h2>
        <p class="text-xs text-base-content/70">
          Historique des versions avec possibilité de restauration.
        </p>
      </div>
      <span v-if="versions.length" class="badge badge-outline">
        {{ versions.length }} version{{ versions.length > 1 ? 's' : '' }}
      </span>
    </header>

    <div v-if="loading" class="flex items-center gap-2 text-sm text-base-content/70">
      <span class="loading loading-spinner loading-sm" aria-hidden="true" />
      Chargement des versions
    </div>

    <div v-else-if="error" class="alert alert-warning">
      <span>{{ error }}</span>
    </div>

    <p v-else-if="versions.length === 0" class="text-xs text-base-content/70">
      Aucune version enregistrée.
    </p>

    <ul v-else class="max-h-96 space-y-2 overflow-y-auto pr-1">
      <li
        v-for="entry in versions"
        :key="entry.version"
        class="flex items-center justify-between rounded-md border border-base-200 bg-base-100 px-3 py-2"
      >
        <div class="flex-1 min-w-0">
          <div class="flex items-center gap-2">
            <span class="font-mono text-sm font-semibold">v{{ entry.version }}</span>
            <span
              v-if="entry.version === currentVersion"
              class="badge badge-primary badge-sm"
            >
              actuelle
            </span>
            <span
              v-if="entry.action === 'restore'"
              class="badge badge-warning badge-sm"
            >
              restauration
            </span>
          </div>
          <div class="flex flex-wrap items-center gap-2 mt-0.5 text-xs text-base-content/60">
            <span>{{ actionLabel(entry.action) }}</span>
            <span>·</span>
            <span>{{ formatDate(entry.createdAt) }}</span>
            <span v-if="entry.actor">· {{ entry.actor.label }}</span>
          </div>
        </div>
        <button
          v-if="canRestore && entry.version !== currentVersion"
          class="btn btn-ghost btn-xs"
          :disabled="restoring"
          @click="handleRestore(entry.version)"
        >
          Restaurer
        </button>
      </li>
    </ul>

    <VersionRestoreModal
      :visible="modalVisible"
      :preview="previewData"
      :restoring="restoring"
      :field-labels="fieldLabels"
      @close="modalVisible = false"
      @confirm="confirmRestore"
    />
  </section>
</template>

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useEntityVersions, type RestorePreview } from '~/composables/useEntityVersions'
import { usePermissions } from '~/composables/usePermissions'
import { formatHistoryDate, historyActionLabel } from '~/shared/utils/historyDisplayUtils'
import VersionRestoreModal from './VersionRestoreModal.vue'

const props = defineProps<{
  entityType: 'machine' | 'composant' | 'piece' | 'product'
  entityId: string
  fieldLabels: Record<string, string>
}>()

const emit = defineEmits<{
  restored: []
}>()

const { canEdit } = usePermissions()
const canRestore = computed(() => canEdit.value)

const { versions, loading, error, fetchVersions, fetchPreview, restore } = useEntityVersions({
  entityType: props.entityType,
  entityId: props.entityId,
})

const currentVersion = computed(() => {
  if (versions.value.length === 0) return null
  return versions.value[0]?.version ?? null
})

const modalVisible = ref(false)
const previewData = ref<RestorePreview | null>(null)
const restoring = ref(false)
const targetVersion = ref<number | null>(null)

const actionLabel = (action: string) => historyActionLabel(action)
const formatDate = (date: string) => formatHistoryDate(date)

const handleRestore = async (version: number) => {
  targetVersion.value = version
  previewData.value = null
  modalVisible.value = true
  previewData.value = await fetchPreview(version)
}

const confirmRestore = async () => {
  if (!targetVersion.value) return
  restoring.value = true
  const result = await restore(targetVersion.value)
  restoring.value = false
  modalVisible.value = false
  if (result?.success) {
    await fetchVersions()
    emit('restored')
  }
}

onMounted(() => {
  fetchVersions()
})
</script>
  • Step 2: Run lint

Run (in Inventory_frontend/): npm run lint:fix

  • Step 3: Commit
cd Inventory_frontend
git add app/components/common/EntityVersionList.vue
git commit -m "feat(versioning) : add EntityVersionList component with restore flow"
cd ..

Task 13: Frontend — Integrate EntityVersionList into detail pages

Files:

  • Modify: Inventory_frontend/app/pages/machine/[id].vue

  • Modify: Inventory_frontend/app/pages/component/[id]/edit.vue

  • Modify: Inventory_frontend/app/pages/piece/[id].vue

  • Modify: Inventory_frontend/app/pages/product/[id]/edit.vue

  • Step 1: Add EntityVersionList to machine/[id].vue

In machine/[id].vue, add after the EntityHistorySection block (after line 147) and before the Comments section:

<!-- Versions -->
<EntityVersionList
  entity-type="machine"
  :entity-id="String(machineId)"
  :field-labels="historyFieldLabels"
  @restored="d.loadMachineData()"
/>

Add the import at the top of <script setup>:

import EntityVersionList from '~/components/common/EntityVersionList.vue'
  • Step 2: Add EntityVersionList to component/[id]/edit.vue

In component/[id]/edit.vue, add after the EntityHistorySection block (after line 317) and before the save buttons:

<EntityVersionList
  entity-type="composant"
  :entity-id="String(route.params.id)"
  :field-labels="historyFieldLabels"
  @restored="window.location.reload()"
/>

Add the import:

import EntityVersionList from '~/components/common/EntityVersionList.vue'
  • Step 3: Add EntityVersionList to piece/[id].vue

In piece/[id].vue, add after the EntityHistorySection block (after line 330) and before the save buttons:

<EntityVersionList
  entity-type="piece"
  :entity-id="String(route.params.id)"
  :field-labels="historyFieldLabels"
  @restored="fetchPiece()"
/>

Add the import:

import EntityVersionList from '~/components/common/EntityVersionList.vue'
  • Step 4: Read and modify product/[id]/edit.vue

Read the file first, then add EntityVersionList following the same pattern as the other pages.

  • Step 5: Run lint

Run (in Inventory_frontend/): npm run lint:fix

  • Step 6: Run typecheck

Run (in Inventory_frontend/): npx nuxi typecheck

  • Step 7: Commit in frontend repo
cd Inventory_frontend
git add app/pages/machine/\[id\].vue app/pages/component/\[id\]/edit.vue app/pages/piece/\[id\].vue app/pages/product/\[id\]/edit.vue
git commit -m "feat(versioning) : integrate EntityVersionList into all detail pages"
cd ..
  • Step 8: Update submodule pointer in main repo
git add Inventory_frontend
git commit -m "chore(submodule) : update frontend pointer (entity versioning)"

Task 14: Final verification

  • Step 1: Run backend tests

Run: make test Expected: All tests pass.

  • Step 2: Run frontend lint + typecheck

Run (in Inventory_frontend/): npm run lint:fix && npx nuxi typecheck Expected: 0 errors.

  • Step 3: Manual smoke test
  1. Create a composant → check versions list shows v1
  2. Update composant name → versions list shows v2
  3. Click "Restaurer" on v1 → preview shows diff + "full" mode
  4. Confirm → name reverts, v3 created
  5. Repeat for piece, product, machine
  • Step 4: Update CLAUDE.md if needed

Add EntityVersionController to the Custom Controllers section and note the version auto-increment behavior.