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

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

Frontend: update submodule pointer

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

View File

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

View File

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

View File

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

View File

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

View File

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