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

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