Files
Inventory/docs/superpowers/plans/2026-03-25-entity-versioning.md
Matthieu be859e57db refactor : rename Inventory_frontend to frontend in docs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:20:19 +02:00

82 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

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

Frontend — Modified Files

  • frontend/app/pages/machine/[id].vue — Add Versions section
  • frontend/app/pages/component/[id]/edit.vue — Add Versions section
  • frontend/app/pages/piece/[id].vue — Add Versions section
  • 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);
        // 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));
    }
}
  • 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')),
        '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;
use Symfony\Component\HttpFoundation\RequestStack;

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

        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;
    }
}
  • 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 7b: skipAudit flag on entities + subscriber check

The restore() method creates its own AuditLog with action = "restore". The audit subscribers must skip entities flagged with skipAudit = true to avoid a duplicate update AuditLog.

Files:

  • Modify: src/Entity/Machine.php, src/Entity/Composant.php, src/Entity/Piece.php, src/Entity/Product.php

  • Modify: src/EventSubscriber/AbstractAuditSubscriber.php

  • Step 1: Add skipAudit flag to each entity

Add to Machine, Composant, Piece, Product (transient property, NOT mapped to DB):

/**
 * Transient flag — when true, audit subscribers skip this entity.
 * Used by EntityVersionService::restore() to avoid duplicate AuditLogs.
 */
private bool $skipAudit = false;

public function getSkipAudit(): bool
{
    return $this->skipAudit;
}

public function setSkipAudit(bool $skipAudit): static
{
    $this->skipAudit = $skipAudit;

    return $this;
}
  • Step 2: Add skipAudit check in AbstractAuditSubscriber

In onFlush() method, add an early return that scans all scheduled entities for the skipAudit flag. This covers ALL paths (simple, complex, collections, CFV changes) and avoids any duplicate AuditLogs:

public function onFlush(OnFlushEventArgs $args): void
{
    $em = $args->getObjectManager();
    if (!$em instanceof EntityManagerInterface) {
        return;
    }

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

    if ($this->hasCollectionTracking()) {
        $this->onFlushComplex($em, $uow, $actorProfileId, $entityType);
    } else {
        $this->onFlushSimple($em, $uow, $actorProfileId, $entityType);
    }
}

This replaces the existing onFlush() method. The check is at the top level so it covers entity updates, collection changes, and custom field value changes — all paths that onFlushComplex processes.

  • Step 3: Run php-cs-fixer

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

  • Step 4: Run tests

Run: make test Expected: All tests pass.

  • Step 5: Commit
git add src/Entity/Machine.php src/Entity/Composant.php src/Entity/Piece.php src/Entity/Product.php src/EventSubscriber/AbstractAuditSubscriber.php
git commit -m "feat(versioning) : add skipAudit flag for restore-originated flushes"

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 9b: Frontend — add restore action label to historyDisplayUtils

Files:

  • Modify: frontend/app/shared/utils/historyDisplayUtils.ts

  • Step 1: Add restore case to historyActionLabel

In historyDisplayUtils.ts, update historyActionLabel:

export const historyActionLabel = (action: string): string => {
  if (action === 'create') return 'Création'
  if (action === 'delete') return 'Suppression'
  if (action === 'restore') return 'Restauration'
  return 'Modification'
}
  • Step 2: Commit in frontend repo
cd frontend
git add app/shared/utils/historyDisplayUtils.ts
git commit -m "feat(versioning) : add restore action label to historyDisplayUtils"
cd ..

Task 10: Frontend — useEntityVersions composable

Files:

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

  • Step 1: Create the composable

import { ref, toValue } 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 = toValue(deps.entityType)
    const id = toValue(deps.entityId)
    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 frontend/): npm run lint:fix

  • Step 3: Run typecheck

Run (in frontend/): npx nuxi typecheck

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

Task 11: Frontend — VersionRestoreModal component

Files:

  • Create: 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 frontend/): npm run lint:fix

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

Task 12: Frontend — EntityVersionList component

Files:

  • Create: 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
  if (result?.success) {
    modalVisible.value = false
    await fetchVersions()
    emit('restored')
  } else {
    error.value = 'La restauration a échoué.'
    modalVisible.value = false
  }
}

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

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

  • Step 3: Commit
cd 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: frontend/app/pages/machine/[id].vue

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

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

  • Modify: 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="fetchComponent()"
/>

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: Add EntityVersionList to product/[id]/edit.vue

In product/[id]/edit.vue, add after the EntityHistorySection block and before the save buttons:

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

Where loadProduct is the existing function that calls getProduct(id) and populates the form. If no such function is exposed, extract the onMounted data-loading logic into a named function that can be called from @restored.

Add the import:

import EntityVersionList from '~/components/common/EntityVersionList.vue'
  • Step 5: Run lint

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

  • Step 6: Run typecheck

Run (in frontend/): npx nuxi typecheck

  • Step 7: Commit in frontend repo
cd 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 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 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.