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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user