diff --git a/Inventory_frontend b/Inventory_frontend index 232436b..767c9a7 160000 --- a/Inventory_frontend +++ b/Inventory_frontend @@ -1 +1 @@ -Subproject commit 232436be213567d3c3e180a88c2305a9a94beaae +Subproject commit 767c9a7424cdc4f66c6d77fa1377fe93bdfdb837 diff --git a/migrations/Version20260326100000.php b/migrations/Version20260326100000.php new file mode 100644 index 0000000..9562b3e --- /dev/null +++ b/migrations/Version20260326100000.php @@ -0,0 +1,30 @@ +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'); + } +} diff --git a/src/Controller/EntityVersionController.php b/src/Controller/EntityVersionController.php new file mode 100644 index 0000000..82eb290 --- /dev/null +++ b/src/Controller/EntityVersionController.php @@ -0,0 +1,155 @@ + */ + 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); + } + } +} diff --git a/src/Entity/AuditLog.php b/src/Entity/AuditLog.php index 37d2a71..90b3cec 100644 --- a/src/Entity/AuditLog.php +++ b/src/Entity/AuditLog.php @@ -38,6 +38,9 @@ class AuditLog #[ORM\Column(type: Types::STRING, length: 36, nullable: true)] private ?string $actorProfileId = null; + #[ORM\Column(type: Types::INTEGER, nullable: true)] + private ?int $version = null; + #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')] private DateTimeImmutable $createdAt; @@ -48,6 +51,7 @@ class AuditLog ?array $diff = null, ?array $snapshot = null, ?string $actorProfileId = null, + ?int $version = null, ) { $this->entityType = $entityType; $this->entityId = $entityId; @@ -55,6 +59,7 @@ class AuditLog $this->diff = $diff; $this->snapshot = $snapshot; $this->actorProfileId = $actorProfileId; + $this->version = $version; } #[ORM\PrePersist] @@ -109,6 +114,18 @@ class AuditLog return $this->createdAt; } + public function getVersion(): ?int + { + return $this->version; + } + + public function setVersion(?int $version): static + { + $this->version = $version; + + return $this; + } + private function generateCuid(): string { // Keep the same lightweight CUID-like strategy used across the project. diff --git a/src/Entity/Composant.php b/src/Entity/Composant.php index e0e63c3..042aba4 100644 --- a/src/Entity/Composant.php +++ b/src/Entity/Composant.php @@ -145,6 +145,8 @@ class Composant #[Groups(['composant:read'])] private int $version = 1; + private bool $skipAudit = false; + #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')] #[Groups(['composant:read'])] private DateTimeImmutable $createdAt; @@ -454,4 +456,16 @@ class Composant return $this; } + + public function getSkipAudit(): bool + { + return $this->skipAudit; + } + + public function setSkipAudit(bool $skipAudit): static + { + $this->skipAudit = $skipAudit; + + return $this; + } } diff --git a/src/Entity/Machine.php b/src/Entity/Machine.php index ebb0dc8..b9c3e48 100644 --- a/src/Entity/Machine.php +++ b/src/Entity/Machine.php @@ -108,6 +108,15 @@ class Machine #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')] private DateTimeImmutable $createdAt; + #[ORM\Column(type: Types::INTEGER, options: ['default' => 1])] + private int $version = 1; + + /** + * Transient flag — when true, audit subscribers skip this entity. + * Used by EntityVersionService::restore() to avoid duplicate AuditLogs. + */ + private bool $skipAudit = false; + #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')] private DateTimeImmutable $updatedAt; @@ -265,4 +274,28 @@ class Machine { return $this->customFieldValues; } + + public function getVersion(): int + { + return $this->version; + } + + public function incrementVersion(): static + { + ++$this->version; + + return $this; + } + + public function getSkipAudit(): bool + { + return $this->skipAudit; + } + + public function setSkipAudit(bool $skipAudit): static + { + $this->skipAudit = $skipAudit; + + return $this; + } } diff --git a/src/Entity/Piece.php b/src/Entity/Piece.php index 82c758b..72d41a3 100644 --- a/src/Entity/Piece.php +++ b/src/Entity/Piece.php @@ -133,6 +133,8 @@ class Piece #[Groups(['piece:read'])] private int $version = 1; + private bool $skipAudit = false; + #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')] #[Groups(['piece:read'])] private DateTimeImmutable $createdAt; @@ -354,4 +356,16 @@ class Piece return $this; } + + public function getSkipAudit(): bool + { + return $this->skipAudit; + } + + public function setSkipAudit(bool $skipAudit): static + { + $this->skipAudit = $skipAudit; + + return $this; + } } diff --git a/src/Entity/Product.php b/src/Entity/Product.php index 630b718..e814c8f 100644 --- a/src/Entity/Product.php +++ b/src/Entity/Product.php @@ -124,6 +124,8 @@ class Product #[Groups(['product:read'])] private int $version = 1; + private bool $skipAudit = false; + #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')] #[Groups(['product:read'])] private DateTimeImmutable $createdAt; @@ -268,4 +270,16 @@ class Product return $this; } + + public function getSkipAudit(): bool + { + return $this->skipAudit; + } + + public function setSkipAudit(bool $skipAudit): static + { + $this->skipAudit = $skipAudit; + + return $this; + } } diff --git a/src/EventSubscriber/AbstractAuditSubscriber.php b/src/EventSubscriber/AbstractAuditSubscriber.php index d73ea23..76d25aa 100644 --- a/src/EventSubscriber/AbstractAuditSubscriber.php +++ b/src/EventSubscriber/AbstractAuditSubscriber.php @@ -52,7 +52,16 @@ abstract class AbstractAuditSubscriber implements EventSubscriber return; } - $uow = $em->getUnitOfWork(); + $uow = $em->getUnitOfWork(); + + // If any tracked entity has skipAudit=true, skip the entire subscriber. + // This is set by EntityVersionService::restore() to avoid duplicate audit logs. + foreach ($uow->getScheduledEntityUpdates() as $entity) { + if ($this->supports($entity) && method_exists($entity, 'getSkipAudit') && $entity->getSkipAudit()) { + return; + } + } + $actorProfileId = $this->resolveActorProfileId(); $entityType = $this->entityType(); @@ -106,7 +115,7 @@ abstract class AbstractAuditSubscriber implements EventSubscriber { $diff = []; foreach ($changeSet as $field => [$oldValue, $newValue]) { - if ('updatedAt' === $field || 'createdAt' === $field) { + if ('updatedAt' === $field || 'createdAt' === $field || 'version' === $field) { continue; } @@ -117,6 +126,11 @@ abstract class AbstractAuditSubscriber implements EventSubscriber continue; } + // Skip decimal formatting differences (e.g. "33.00" vs "33") + if (is_numeric($normalizedOld) && is_numeric($normalizedNew) && (float) $normalizedOld === (float) $normalizedNew) { + continue; + } + $diff[$field] = [ 'from' => $normalizedOld, 'to' => $normalizedNew, @@ -229,6 +243,43 @@ abstract class AbstractAuditSubscriber implements EventSubscriber return $base; } + /** + * If the entity has a version, increment it and return the new value. + * Recomputes the changeset so Doctrine picks up the version bump. + */ + protected function incrementEntityVersion(object $entity, EntityManagerInterface $em, UnitOfWork $uow): ?int + { + if (!method_exists($entity, 'incrementVersion') || !method_exists($entity, 'getVersion')) { + return null; + } + + // If the version was already changed (e.g. by a sync strategy), don't double-increment + $changeSet = $uow->getEntityChangeSet($entity); + if (isset($changeSet['version'])) { + return $entity->getVersion(); + } + + $entity->incrementVersion(); + $uow->recomputeSingleEntityChangeSet( + $em->getClassMetadata($entity::class), + $entity, + ); + + return $entity->getVersion(); + } + + /** + * Get the current version without incrementing (for create actions). + */ + protected function getEntityVersion(object $entity): ?int + { + if (!method_exists($entity, 'getVersion')) { + return null; + } + + return $entity->getVersion(); + } + protected function resolveActorProfileId(): ?string { try { @@ -260,7 +311,8 @@ abstract class AbstractAuditSubscriber implements EventSubscriber $diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity)); $snapshot = $this->snapshotEntity($entity); - $this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId)); + $version = $this->getEntityVersion($entity); + $this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId, $version)); } foreach ($uow->getScheduledEntityUpdates() as $entity) { @@ -275,8 +327,9 @@ abstract class AbstractAuditSubscriber implements EventSubscriber $diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity)); if ([] !== $diff) { + $version = $this->incrementEntityVersion($entity, $em, $uow); $snapshot = $this->snapshotEntity($entity); - $this->persistAuditLog($em, new AuditLog($entityType, $id, 'update', $diff, $snapshot, $actorProfileId)); + $this->persistAuditLog($em, new AuditLog($entityType, $id, 'update', $diff, $snapshot, $actorProfileId, $version)); } } @@ -303,7 +356,8 @@ abstract class AbstractAuditSubscriber implements EventSubscriber $diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity)); $snapshot = $this->snapshotEntity($entity); - $this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId)); + $version = $this->getEntityVersion($entity); + $this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId, $version)); } foreach ($uow->getScheduledEntityUpdates() as $entity) { @@ -352,8 +406,10 @@ abstract class AbstractAuditSubscriber implements EventSubscriber continue; } - $snapshot = $pendingSnapshots[$entityId] ?? $this->snapshotEntity($entity); - $this->persistAuditLog($em, new AuditLog($entityType, $entityId, 'update', $diff, $snapshot, $actorProfileId)); + $version = $this->incrementEntityVersion($entity, $em, $uow); + // Re-take snapshot after version increment so it captures the new version number + $snapshot = $this->snapshotEntity($entity); + $this->persistAuditLog($em, new AuditLog($entityType, $entityId, 'update', $diff, $snapshot, $actorProfileId, $version)); } } diff --git a/src/EventSubscriber/ComposantAuditSubscriber.php b/src/EventSubscriber/ComposantAuditSubscriber.php index d16090f..d18aca7 100644 --- a/src/EventSubscriber/ComposantAuditSubscriber.php +++ b/src/EventSubscriber/ComposantAuditSubscriber.php @@ -34,14 +34,64 @@ final class ComposantAuditSubscriber extends AbstractAuditSubscriber protected function snapshotEntity(object $entity): array { + $pieceSlots = []; + foreach ($entity->getPieceSlots() as $slot) { + $pieceSlots[] = [ + 'id' => $slot->getId(), + 'typePieceId' => $slot->getTypePiece()?->getId(), + 'selectedPieceId' => $slot->getSelectedPiece()?->getId(), + 'quantity' => $slot->getQuantity(), + 'position' => $slot->getPosition(), + ]; + } + + $subcomponentSlots = []; + foreach ($entity->getSubcomponentSlots() as $slot) { + $subcomponentSlots[] = [ + 'id' => $slot->getId(), + 'alias' => $slot->getAlias(), + 'familyCode' => $slot->getFamilyCode(), + 'typeComposantId' => $slot->getTypeComposant()?->getId(), + 'selectedComposantId' => $slot->getSelectedComposant()?->getId(), + 'position' => $slot->getPosition(), + ]; + } + + $productSlots = []; + foreach ($entity->getProductSlots() as $slot) { + $productSlots[] = [ + 'id' => $slot->getId(), + 'typeProductId' => $slot->getTypeProduct()?->getId(), + 'selectedProductId' => $slot->getSelectedProduct()?->getId(), + 'familyCode' => $slot->getFamilyCode(), + 'position' => $slot->getPosition(), + ]; + } + + $customFieldValues = []; + foreach ($entity->getCustomFieldValues() as $cfv) { + $customFieldValues[] = [ + 'id' => $cfv->getId(), + 'fieldName' => $cfv->getCustomField()?->getName(), + 'fieldId' => $cfv->getCustomField()?->getId(), + 'value' => $cfv->getValue(), + ]; + } + return [ - 'id' => $entity->getId(), - 'name' => $this->safeGet($entity, 'getName'), - 'reference' => $this->safeGet($entity, 'getReference'), - 'prix' => $this->safeGet($entity, 'getPrix'), - 'typeComposant' => $this->normalizeValue($this->safeGet($entity, 'getTypeComposant')), - 'product' => $this->normalizeValue($this->safeGet($entity, 'getProduct')), - 'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()), + 'id' => $entity->getId(), + 'name' => $this->safeGet($entity, 'getName'), + 'reference' => $this->safeGet($entity, 'getReference'), + 'description' => $this->safeGet($entity, 'getDescription'), + 'prix' => $this->safeGet($entity, 'getPrix'), + 'typeComposant' => $this->normalizeValue($this->safeGet($entity, 'getTypeComposant')), + 'product' => $this->normalizeValue($this->safeGet($entity, 'getProduct')), + 'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()), + 'pieceSlots' => $pieceSlots, + 'subcomponentSlots' => $subcomponentSlots, + 'productSlots' => $productSlots, + 'customFieldValues' => $customFieldValues, + 'version' => $this->safeGet($entity, 'getVersion'), ]; } } diff --git a/src/EventSubscriber/MachineAuditSubscriber.php b/src/EventSubscriber/MachineAuditSubscriber.php index 0cbeb90..de89960 100644 --- a/src/EventSubscriber/MachineAuditSubscriber.php +++ b/src/EventSubscriber/MachineAuditSubscriber.php @@ -36,13 +36,25 @@ final class MachineAuditSubscriber extends AbstractAuditSubscriber protected function snapshotEntity(object $entity): array { + $customFieldValues = []; + foreach ($entity->getCustomFieldValues() as $cfv) { + $customFieldValues[] = [ + 'id' => $cfv->getId(), + 'fieldName' => $cfv->getCustomField()?->getName(), + 'fieldId' => $cfv->getCustomField()?->getId(), + 'value' => $cfv->getValue(), + ]; + } + return [ - 'id' => $entity->getId(), - 'name' => $this->safeGet($entity, 'getName'), - 'reference' => $this->safeGet($entity, 'getReference'), - 'prix' => $this->safeGet($entity, 'getPrix'), - 'site' => $this->normalizeValue($this->safeGet($entity, 'getSite')), - 'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()), + 'id' => $entity->getId(), + 'name' => $this->safeGet($entity, 'getName'), + 'reference' => $this->safeGet($entity, 'getReference'), + 'prix' => $this->safeGet($entity, 'getPrix'), + 'site' => $this->normalizeValue($this->safeGet($entity, 'getSite')), + 'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()), + 'customFieldValues' => $customFieldValues, + 'version' => $this->safeGet($entity, 'getVersion'), ]; } } diff --git a/src/EventSubscriber/PieceAuditSubscriber.php b/src/EventSubscriber/PieceAuditSubscriber.php index d60c9a1..19094a8 100644 --- a/src/EventSubscriber/PieceAuditSubscriber.php +++ b/src/EventSubscriber/PieceAuditSubscriber.php @@ -34,15 +34,39 @@ final class PieceAuditSubscriber extends AbstractAuditSubscriber protected function snapshotEntity(object $entity): array { + $productSlots = []; + foreach ($entity->getProductSlots() as $slot) { + $productSlots[] = [ + 'id' => $slot->getId(), + 'typeProductId' => $slot->getTypeProduct()?->getId(), + 'selectedProductId' => $slot->getSelectedProduct()?->getId(), + 'familyCode' => $slot->getFamilyCode(), + 'position' => $slot->getPosition(), + ]; + } + + $customFieldValues = []; + foreach ($entity->getCustomFieldValues() as $cfv) { + $customFieldValues[] = [ + 'id' => $cfv->getId(), + 'fieldName' => $cfv->getCustomField()?->getName(), + 'fieldId' => $cfv->getCustomField()?->getId(), + 'value' => $cfv->getValue(), + ]; + } + return [ - 'id' => $entity->getId(), - 'name' => $this->safeGet($entity, 'getName'), - 'reference' => $this->safeGet($entity, 'getReference'), - 'prix' => $this->safeGet($entity, 'getPrix'), - 'typePiece' => $this->normalizeValue($this->safeGet($entity, 'getTypePiece')), - 'product' => $this->normalizeValue($this->safeGet($entity, 'getProduct')), - 'productIds' => $this->safeGet($entity, 'getProductIds') ?? [], - 'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()), + 'id' => $entity->getId(), + 'name' => $this->safeGet($entity, 'getName'), + 'reference' => $this->safeGet($entity, 'getReference'), + 'description' => $this->safeGet($entity, 'getDescription'), + 'prix' => $this->safeGet($entity, 'getPrix'), + 'typePiece' => $this->normalizeValue($this->safeGet($entity, 'getTypePiece')), + 'product' => $this->normalizeValue($this->safeGet($entity, 'getProduct')), + 'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()), + 'productSlots' => $productSlots, + 'customFieldValues' => $customFieldValues, + 'version' => $this->safeGet($entity, 'getVersion'), ]; } } diff --git a/src/EventSubscriber/ProductAuditSubscriber.php b/src/EventSubscriber/ProductAuditSubscriber.php index b6ec6e2..67f8dc2 100644 --- a/src/EventSubscriber/ProductAuditSubscriber.php +++ b/src/EventSubscriber/ProductAuditSubscriber.php @@ -34,13 +34,25 @@ final class ProductAuditSubscriber extends AbstractAuditSubscriber protected function snapshotEntity(object $entity): array { + $customFieldValues = []; + foreach ($entity->getCustomFieldValues() as $cfv) { + $customFieldValues[] = [ + 'id' => $cfv->getId(), + 'fieldName' => $cfv->getCustomField()?->getName(), + 'fieldId' => $cfv->getCustomField()?->getId(), + 'value' => $cfv->getValue(), + ]; + } + return [ - 'id' => $entity->getId(), - 'name' => $this->safeGet($entity, 'getName'), - 'reference' => $this->safeGet($entity, 'getReference'), - 'supplierPrice' => $this->safeGet($entity, 'getSupplierPrice'), - 'typeProduct' => $this->normalizeValue($this->safeGet($entity, 'getTypeProduct')), - 'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()), + 'id' => $entity->getId(), + 'name' => $this->safeGet($entity, 'getName'), + 'reference' => $this->safeGet($entity, 'getReference'), + 'supplierPrice' => $this->safeGet($entity, 'getSupplierPrice'), + 'typeProduct' => $this->normalizeValue($this->safeGet($entity, 'getTypeProduct')), + 'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()), + 'customFieldValues' => $customFieldValues, + 'version' => $this->safeGet($entity, 'getVersion'), ]; } } diff --git a/src/Repository/AuditLogRepository.php b/src/Repository/AuditLogRepository.php index 9c3587a..cc16977 100644 --- a/src/Repository/AuditLogRepository.php +++ b/src/Repository/AuditLogRepository.php @@ -35,6 +35,38 @@ final class AuditLogRepository extends ServiceEntityRepository ; } + /** + * @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() + ; + } + /** * @param array{entityType?: string, action?: string} $filters * diff --git a/src/Service/EntityVersionService.php b/src/Service/EntityVersionService.php new file mode 100644 index 0000000..c794be7 --- /dev/null +++ b/src/Service/EntityVersionService.php @@ -0,0 +1,753 @@ + 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, 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); + + $connection = $this->em->getConnection(); + $connection->beginTransaction(); + + try { + // Mark entity to skip audit subscriber (we create the AuditLog manually) + if (method_exists($entity, 'setSkipAudit')) { + $entity->setSkipAudit(true); + } + + $this->applyRestore($entityType, $entity, $snapshot, $restoreMode); + + // Increment version manually (since subscriber is skipped) + if (method_exists($entity, 'incrementVersion')) { + $entity->incrementVersion(); + } + + $this->em->flush(); + + $newVersion = method_exists($entity, 'getVersion') ? $entity->getVersion() : null; + + // Create the restore AuditLog manually with action = "restore" + $restoreAuditLog = new AuditLog( + $entityType, + $entityId, + 'restore', + ['restoredFromVersion' => $version, 'restoreMode' => $restoreMode], + $this->buildCurrentSnapshot($entityType, $entity), + $this->resolveActorProfileId(), + $newVersion, + ); + $this->em->persist($restoreAuditLog); + $this->em->flush(); + + $connection->commit(); + } catch (Throwable $e) { + $connection->rollBack(); + + throw $e; + } finally { + // Clear skip flag + if (method_exists($entity, 'setSkipAudit')) { + $entity->setSkipAudit(false); + } + } + + return [ + 'success' => true, + 'newVersion' => $newVersion, + 'restoredFromVersion' => $version, + 'restoreMode' => $restoreMode, + 'warnings' => $warnings, + ]; + } + + private function findEntity(string $entityType, string $entityId): ?object + { + return match ($entityType) { + 'machine' => $this->machines->find($entityId), + 'composant' => $this->composants->find($entityId), + 'piece' => $this->pieces->find($entityId), + 'product' => $this->products->find($entityId), + default => null, + }; + } + + private function checkSkeletonCompatibility(string $entityType, object $entity, array $snapshot): string + { + if ('machine' === $entityType) { + return 'full'; + } + + $currentTypeId = match ($entityType) { + 'composant' => $entity->getTypeComposant()?->getId(), + 'piece' => $entity->getTypePiece()?->getId(), + 'product' => $entity->getTypeProduct()?->getId(), + default => null, + }; + + $typeKey = match ($entityType) { + 'composant' => 'typeComposant', + 'piece' => 'typePiece', + 'product' => 'typeProduct', + default => null, + }; + + $snapshotTypeId = null; + if ($typeKey && isset($snapshot[$typeKey])) { + $snapshotTypeId = is_array($snapshot[$typeKey]) ? ($snapshot[$typeKey]['id'] ?? null) : $snapshot[$typeKey]; + } + + return $currentTypeId === $snapshotTypeId ? 'full' : 'partial'; + } + + private function checkIntegrity(string $entityType, array $snapshot, string $restoreMode): array + { + $warnings = []; + + // Check constructeurs (batch query) + if (!empty($snapshot['constructeurIds'])) { + $constructeurEntries = []; + foreach ($snapshot['constructeurIds'] as $entry) { + $id = is_array($entry) ? ($entry['id'] ?? null) : $entry; + $name = is_array($entry) ? ($entry['name'] ?? $id) : $id; + if ($id) { + $constructeurEntries[$id] = $name; + } + } + if ([] !== $constructeurEntries) { + $foundIds = array_map( + fn ($c) => $c->getId(), + $this->constructeurs->findBy(['id' => array_keys($constructeurEntries)]), + ); + foreach ($constructeurEntries as $id => $name) { + if (!in_array($id, $foundIds, true)) { + $warnings[] = [ + 'field' => 'constructeurIds', + 'message' => sprintf("Le fournisseur '%s' n'existe plus. Il ne sera pas restauré.", $name), + 'missingEntityId' => $id, + 'missingEntityName' => $name, + ]; + } + } + } + } + + // Machine: check site + if ('machine' === $entityType && !empty($snapshot['site'])) { + $siteId = is_array($snapshot['site']) ? ($snapshot['site']['id'] ?? null) : $snapshot['site']; + if ($siteId && null === $this->sites->find($siteId)) { + $siteName = is_array($snapshot['site']) ? ($snapshot['site']['name'] ?? $siteId) : $siteId; + $warnings[] = [ + 'field' => 'site', + 'message' => sprintf("Le site '%s' n'existe plus. Le site actuel sera conservé.", $siteName), + 'missingEntityId' => $siteId, + 'missingEntityName' => $siteName, + ]; + } + } + + if ('partial' === $restoreMode) { + return $warnings; + } + + // Full mode: check slot references (batch queries per slot type) + $slotChecks = match ($entityType) { + 'composant' => [ + ['key' => 'pieceSlots', 'refKey' => 'selectedPieceId', 'label' => 'pièce', 'repo' => $this->pieces], + ['key' => 'subcomponentSlots', 'refKey' => 'selectedComposantId', 'label' => 'sous-composant', 'repo' => $this->composants], + ['key' => 'productSlots', 'refKey' => 'selectedProductId', 'label' => 'produit', 'repo' => $this->products], + ], + 'piece' => [ + ['key' => 'productSlots', 'refKey' => 'selectedProductId', 'label' => 'produit', 'repo' => $this->products], + ], + default => [], + }; + + foreach ($slotChecks as $check) { + $slots = $snapshot[$check['key']] ?? []; + // Collect all referenced IDs for batch lookup + $refIds = []; + foreach ($slots as $i => $slot) { + $refId = $slot[$check['refKey']] ?? null; + if (null !== $refId) { + $refIds[$i] = $refId; + } + } + if ([] === $refIds) { + continue; + } + $foundEntities = $check['repo']->findBy(['id' => array_values(array_unique($refIds))]); + $foundIds = array_map(fn ($e) => $e->getId(), $foundEntities); + foreach ($refIds as $i => $refId) { + if (!in_array($refId, $foundIds, true)) { + $warnings[] = [ + 'field' => sprintf('%s[%d].%s', $check['key'], $i, $check['refKey']), + 'message' => sprintf("Le %s sélectionné dans le slot n'existe plus. Le slot sera restauré vide.", $check['label']), + 'missingEntityId' => $refId, + 'missingEntityName' => null, + ]; + } + } + } + + return $warnings; + } + + private function buildRestoreDiff(string $entityType, object $entity, array $snapshot, string $restoreMode): array + { + $diff = []; + $baseFields = self::BASE_FIELDS[$entityType] ?? []; + + foreach ($baseFields as $field) { + $getter = 'get'.ucfirst($field); + if (!method_exists($entity, $getter)) { + continue; + } + $current = $entity->{$getter}(); + $restored = $snapshot[$field] ?? null; + if ((string) ($current ?? '') !== (string) ($restored ?? '')) { + $diff[$field] = ['current' => $current, 'restored' => $restored]; + } + } + + // Constructeurs + $currentConstructeurIds = []; + if (method_exists($entity, 'getConstructeurs')) { + foreach ($entity->getConstructeurs() as $c) { + $currentConstructeurIds[] = $c->getId(); + } + } + $snapshotConstructeurIds = []; + foreach ($snapshot['constructeurIds'] ?? [] as $entry) { + $snapshotConstructeurIds[] = is_array($entry) ? ($entry['id'] ?? null) : $entry; + } + sort($currentConstructeurIds); + sort($snapshotConstructeurIds); + if ($currentConstructeurIds !== $snapshotConstructeurIds) { + $diff['constructeurIds'] = ['current' => $currentConstructeurIds, 'restored' => $snapshotConstructeurIds]; + } + + if ('full' === $restoreMode) { + // We signal slot restore as a single diff entry + $slotKeys = match ($entityType) { + 'composant' => ['pieceSlots', 'subcomponentSlots', 'productSlots'], + 'piece' => ['productSlots'], + default => [], + }; + foreach ($slotKeys as $key) { + if (!empty($snapshot[$key])) { + $diff[$key] = ['current' => '(structure actuelle)', 'restored' => sprintf('%d slot(s)', count($snapshot[$key]))]; + } + } + + // Custom field values diff + $snapshotCfvs = $snapshot['customFieldValues'] ?? []; + if ([] !== $snapshotCfvs && method_exists($entity, 'getCustomFieldValues')) { + $currentCfvsByFieldId = []; + foreach ($entity->getCustomFieldValues() as $cfv) { + $fieldId = $cfv->getCustomField()?->getId(); + if (null !== $fieldId) { + $currentCfvsByFieldId[$fieldId] = [ + 'fieldName' => $cfv->getCustomField()->getName(), + 'value' => $cfv->getValue(), + ]; + } + } + + foreach ($snapshotCfvs as $cfvData) { + $fieldId = $cfvData['fieldId'] ?? null; + $fieldName = $cfvData['fieldName'] ?? $fieldId; + if (!$fieldId) { + continue; + } + $currentValue = $currentCfvsByFieldId[$fieldId]['value'] ?? null; + $restoredValue = $cfvData['value'] ?? null; + if ((string) ($currentValue ?? '') !== (string) ($restoredValue ?? '')) { + $diff['customField:'.$fieldName] = ['current' => $currentValue, 'restored' => $restoredValue]; + } + } + } + } + + return $diff; + } + + private function applyRestore(string $entityType, object $entity, array $snapshot, string $restoreMode): void + { + // Restore base fields + $baseFields = self::BASE_FIELDS[$entityType] ?? []; + foreach ($baseFields as $field) { + $setter = 'set'.ucfirst($field); + if (method_exists($entity, $setter) && array_key_exists($field, $snapshot)) { + $entity->{$setter}($snapshot[$field]); + } + } + + // Restore constructeurs + $this->restoreConstructeurs($entity, $snapshot); + + // Machine: restore site + if ('machine' === $entityType && isset($snapshot['site'])) { + $siteId = is_array($snapshot['site']) ? ($snapshot['site']['id'] ?? null) : $snapshot['site']; + if ($siteId) { + $site = $this->sites->find($siteId); + if (null !== $site) { + $entity->setSite($site); + } + } + } + + if ('partial' === $restoreMode) { + return; + } + + // Full mode: restore slots + match ($entityType) { + 'composant' => $this->restoreComposantSlots($entity, $snapshot), + 'piece' => $this->restorePieceSlots($entity, $snapshot), + default => null, + }; + + // Full mode: restore custom field values + $this->restoreCustomFieldValues($entityType, $entity, $snapshot); + } + + private function restoreConstructeurs(object $entity, array $snapshot): void + { + if (!method_exists($entity, 'getConstructeurs') || !method_exists($entity, 'addConstructeur') || !method_exists($entity, 'removeConstructeur')) { + return; + } + + $targetIds = []; + foreach ($snapshot['constructeurIds'] ?? [] as $entry) { + $id = is_array($entry) ? ($entry['id'] ?? null) : $entry; + if ($id) { + $targetIds[] = $id; + } + } + + // Remove current constructeurs not in snapshot + foreach ($entity->getConstructeurs()->toArray() as $c) { + if (!in_array($c->getId(), $targetIds, true)) { + $entity->removeConstructeur($c); + } + } + + // Add missing constructeurs from snapshot + $currentIds = array_map(fn ($c) => $c->getId(), $entity->getConstructeurs()->toArray()); + foreach ($targetIds as $id) { + if (!in_array($id, $currentIds, true)) { + $constructeur = $this->constructeurs->find($id); + if (null !== $constructeur) { + $entity->addConstructeur($constructeur); + } + } + } + } + + private function restoreComposantSlots(Composant $entity, array $snapshot): void + { + // Clear existing slots + foreach ($entity->getPieceSlots()->toArray() as $slot) { + $this->em->remove($slot); + } + foreach ($entity->getSubcomponentSlots()->toArray() as $slot) { + $this->em->remove($slot); + } + foreach ($entity->getProductSlots()->toArray() as $slot) { + $this->em->remove($slot); + } + + // Flush removals first to avoid FK constraint conflicts with new slots + $this->em->flush(); + + // Recreate piece slots + foreach ($snapshot['pieceSlots'] ?? [] as $slotData) { + $slot = new ComposantPieceSlot(); + $slot->setComposant($entity); + $slot->setQuantity($slotData['quantity'] ?? 1); + $slot->setPosition($slotData['position'] ?? 0); + if (!empty($slotData['typePieceId'])) { + $slot->setTypePiece($this->modelTypes->find($slotData['typePieceId'])); + } + if (!empty($slotData['selectedPieceId'])) { + $piece = $this->pieces->find($slotData['selectedPieceId']); + if (null !== $piece) { + $slot->setSelectedPiece($piece); + } + } + $this->em->persist($slot); + } + + // Recreate subcomponent slots + foreach ($snapshot['subcomponentSlots'] ?? [] as $slotData) { + $slot = new ComposantSubcomponentSlot(); + $slot->setComposant($entity); + $slot->setAlias($slotData['alias'] ?? null); + $slot->setFamilyCode($slotData['familyCode'] ?? null); + $slot->setPosition($slotData['position'] ?? 0); + if (!empty($slotData['typeComposantId'])) { + $slot->setTypeComposant($this->modelTypes->find($slotData['typeComposantId'])); + } + if (!empty($slotData['selectedComposantId'])) { + $composant = $this->composants->find($slotData['selectedComposantId']); + if (null !== $composant) { + $slot->setSelectedComposant($composant); + } + } + $this->em->persist($slot); + } + + // Recreate product slots + foreach ($snapshot['productSlots'] ?? [] as $slotData) { + $slot = new ComposantProductSlot(); + $slot->setComposant($entity); + $slot->setFamilyCode($slotData['familyCode'] ?? null); + $slot->setPosition($slotData['position'] ?? 0); + if (!empty($slotData['typeProductId'])) { + $slot->setTypeProduct($this->modelTypes->find($slotData['typeProductId'])); + } + if (!empty($slotData['selectedProductId'])) { + $product = $this->products->find($slotData['selectedProductId']); + if (null !== $product) { + $slot->setSelectedProduct($product); + } + } + $this->em->persist($slot); + } + } + + private function restorePieceSlots(Piece $entity, array $snapshot): void + { + foreach ($entity->getProductSlots()->toArray() as $slot) { + $this->em->remove($slot); + } + + // Flush removals first to avoid FK constraint conflicts + $this->em->flush(); + + foreach ($snapshot['productSlots'] ?? [] as $slotData) { + $slot = new PieceProductSlot(); + $slot->setPiece($entity); + $slot->setFamilyCode($slotData['familyCode'] ?? null); + $slot->setPosition($slotData['position'] ?? 0); + if (!empty($slotData['typeProductId'])) { + $slot->setTypeProduct($this->modelTypes->find($slotData['typeProductId'])); + } + if (!empty($slotData['selectedProductId'])) { + $product = $this->products->find($slotData['selectedProductId']); + if (null !== $product) { + $slot->setSelectedProduct($product); + } + } + $this->em->persist($slot); + } + } + + private function restoreCustomFieldValues(string $entityType, object $entity, array $snapshot): void + { + $snapshotCfvs = $snapshot['customFieldValues'] ?? []; + if ([] === $snapshotCfvs) { + return; + } + + // Build a map of current CFVs by fieldId for lookup + $currentCfvsByFieldId = []; + if (method_exists($entity, 'getCustomFieldValues')) { + foreach ($entity->getCustomFieldValues() as $cfv) { + $fieldId = $cfv->getCustomField()?->getId(); + if (null !== $fieldId) { + $currentCfvsByFieldId[$fieldId] = $cfv; + } + } + } + + foreach ($snapshotCfvs as $cfvData) { + $fieldId = $cfvData['fieldId'] ?? null; + if (!$fieldId) { + continue; + } + + // Try to find the current CFV by fieldId (resilient to ID changes after sync) + $cfv = $currentCfvsByFieldId[$fieldId] ?? null; + + // Fallback: try by original ID if fieldId lookup failed + if (null === $cfv && !empty($cfvData['id'])) { + $cfv = $this->customFieldValues->find($cfvData['id']); + } + + if (null !== $cfv) { + $cfv->setValue($cfvData['value'] ?? null); + } + } + } + + /** + * Build a complete snapshot of the entity in its current state (after restore). + * Must be consistent with the snapshots built by the audit subscribers, + * so that a future restore from a "restore" version works correctly. + */ + private function buildCurrentSnapshot(string $entityType, object $entity): array + { + $snapshot = ['id' => $entity->getId()]; + + // Base fields + $baseFields = self::BASE_FIELDS[$entityType] ?? []; + foreach ($baseFields as $field) { + $getter = 'get'.ucfirst($field); + if (method_exists($entity, $getter)) { + $snapshot[$field] = $entity->{$getter}(); + } + } + + // Version + if (method_exists($entity, 'getVersion')) { + $snapshot['version'] = $entity->getVersion(); + } + + // Constructeurs + if (method_exists($entity, 'getConstructeurs')) { + $snapshot['constructeurIds'] = []; + foreach ($entity->getConstructeurs() as $c) { + $snapshot['constructeurIds'][] = ['id' => $c->getId(), 'name' => $c->getName()]; + } + } + + // Type (ModelType reference) + $typeGetters = ['composant' => 'getTypeComposant', 'piece' => 'getTypePiece', 'product' => 'getTypeProduct']; + $typeKeys = ['composant' => 'typeComposant', 'piece' => 'typePiece', 'product' => 'typeProduct']; + if (isset($typeGetters[$entityType]) && method_exists($entity, $typeGetters[$entityType])) { + $type = $entity->{$typeGetters[$entityType]}(); + $snapshot[$typeKeys[$entityType]] = $type ? ['id' => $type->getId(), 'name' => $type->getName(), 'code' => $type->getCode()] : null; + } + + // Machine: site + if ('machine' === $entityType && method_exists($entity, 'getSite')) { + $site = $entity->getSite(); + $snapshot['site'] = $site ? ['id' => $site->getId(), 'name' => $site->getName()] : null; + } + + // Composant/Piece: product + if (in_array($entityType, ['composant', 'piece'], true) && method_exists($entity, 'getProduct')) { + $product = $entity->getProduct(); + $snapshot['product'] = $product ? ['id' => $product->getId(), 'name' => $product->getName()] : null; + } + + // Slots + if ('composant' === $entityType) { + $snapshot['pieceSlots'] = []; + foreach ($entity->getPieceSlots() as $slot) { + $snapshot['pieceSlots'][] = [ + 'id' => $slot->getId(), 'typePieceId' => $slot->getTypePiece()?->getId(), + 'selectedPieceId' => $slot->getSelectedPiece()?->getId(), + 'quantity' => $slot->getQuantity(), 'position' => $slot->getPosition(), + ]; + } + $snapshot['subcomponentSlots'] = []; + foreach ($entity->getSubcomponentSlots() as $slot) { + $snapshot['subcomponentSlots'][] = [ + 'id' => $slot->getId(), 'alias' => $slot->getAlias(), 'familyCode' => $slot->getFamilyCode(), + 'typeComposantId' => $slot->getTypeComposant()?->getId(), + 'selectedComposantId' => $slot->getSelectedComposant()?->getId(), + 'position' => $slot->getPosition(), + ]; + } + $snapshot['productSlots'] = []; + foreach ($entity->getProductSlots() as $slot) { + $snapshot['productSlots'][] = [ + 'id' => $slot->getId(), 'typeProductId' => $slot->getTypeProduct()?->getId(), + 'selectedProductId' => $slot->getSelectedProduct()?->getId(), + 'familyCode' => $slot->getFamilyCode(), 'position' => $slot->getPosition(), + ]; + } + } + + if ('piece' === $entityType) { + $snapshot['productSlots'] = []; + foreach ($entity->getProductSlots() as $slot) { + $snapshot['productSlots'][] = [ + 'id' => $slot->getId(), 'typeProductId' => $slot->getTypeProduct()?->getId(), + 'selectedProductId' => $slot->getSelectedProduct()?->getId(), + 'familyCode' => $slot->getFamilyCode(), 'position' => $slot->getPosition(), + ]; + } + } + + // Custom field values + if (method_exists($entity, 'getCustomFieldValues')) { + $snapshot['customFieldValues'] = []; + foreach ($entity->getCustomFieldValues() as $cfv) { + $snapshot['customFieldValues'][] = [ + 'id' => $cfv->getId(), 'fieldName' => $cfv->getCustomField()?->getName(), + 'fieldId' => $cfv->getCustomField()?->getId(), 'value' => $cfv->getValue(), + ]; + } + } + + return $snapshot; + } + + /** + * Resolve the current actor profile ID from the session. + * Mirrors AbstractAuditSubscriber::resolveActorProfileId(). + */ + private function resolveActorProfileId(): ?string + { + try { + $session = $this->requestStack->getSession(); + $profileId = $session->get('profileId'); + if ($profileId) { + return (string) $profileId; + } + } catch (Throwable) { + // No session available (CLI context, etc.) + } + + return null; + } +} diff --git a/tests/Api/Controller/EntityVersionTest.php b/tests/Api/Controller/EntityVersionTest.php new file mode 100644 index 0000000..d58fccd --- /dev/null +++ b/tests/Api/Controller/EntityVersionTest.php @@ -0,0 +1,174 @@ +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); + } +}