diff --git a/docs/superpowers/plans/2026-03-25-entity-versioning.md b/docs/superpowers/plans/2026-03-25-entity-versioning.md new file mode 100644 index 0000000..9f25749 --- /dev/null +++ b/docs/superpowers/plans/2026-03-25-entity-versioning.md @@ -0,0 +1,2101 @@ +# 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 +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** + +```bash +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: + +```php +#[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: + +```php +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()** + +```php +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** + +```bash +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)** + +```php +#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])] +private int $version = 1; +``` + +- [ ] **Step 2: Add getter and incrementVersion methods after getCustomFieldValues()** + +```php +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** + +```bash +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): + +```php +/** + * 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): + +```php +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): + +```php +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** + +```bash +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`: + +```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`: + +```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`: + +```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`: + +```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** + +```bash +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()`: + +```php +/** + * @return list + */ +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** + +```bash +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 + 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, 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, 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} + */ + 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** + +```bash +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 +, 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** + +```bash +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 +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** + +```bash +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** + +```typescript +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 | null +} + +export interface RestorePreview { + version: number + restoreMode: 'full' | 'partial' + diff: Record + warnings: Array<{ + field: string + message: string + missingEntityId: string | null + missingEntityName: string | null + }> + snapshot: Record +} + +export interface RestoreResult { + success: boolean + newVersion: number + restoredFromVersion: number + restoreMode: 'full' | 'partial' + warnings: RestorePreview['warnings'] +} + +const ENTITY_ENDPOINTS: Record = { + machine: '/machines', + composant: '/composants', + piece: '/pieces', + product: '/products', +} + +interface Deps { + entityType: MaybeRef + entityId: MaybeRef +} + +export function useEntityVersions(deps: Deps) { + const { get, post } = useApi() + + const versions = ref([]) + const loading = ref(false) + const error = ref(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 => { + const result = await get(`${getPath()}/versions/${version}/preview`) + if (!result.success || !result.data) { + return null + } + return result.data + } + + const restore = async (version: number): Promise => { + const result = await post(`${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** + +```bash +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** + +```vue + + + +``` + +- [ ] **Step 2: Run lint** + +Run (in `Inventory_frontend/`): `npm run lint:fix` + +- [ ] **Step 3: Commit** + +```bash +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** + +```vue + + + +``` + +- [ ] **Step 2: Run lint** + +Run (in `Inventory_frontend/`): `npm run lint:fix` + +- [ ] **Step 3: Commit** + +```bash +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: + +```vue + + +``` + +Add the import at the top of `