This commit is contained in:
Matthieu
2026-03-31 17:57:59 +02:00
parent 1b1dab65b6
commit 476060cf7d
45 changed files with 8547 additions and 648 deletions

View File

@@ -376,7 +376,8 @@ private function onFlushComplex(EntityManagerInterface $em, UnitOfWork $uow, ?st
}
$version = $this->incrementEntityVersion($entity, $em, $uow);
$snapshot = $pendingSnapshots[$entityId] ?? $this->snapshotEntity($entity);
// 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));
}
}
@@ -513,7 +514,6 @@ protected function snapshotEntity(object $entity): array
'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,
@@ -691,6 +691,7 @@ use App\Repository\ProfileRepository;
use App\Repository\SiteRepository;
use DateTimeInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
final class EntityVersionService
{
@@ -711,6 +712,7 @@ final class EntityVersionService
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,
@@ -814,11 +816,49 @@ final class EntityVersionService
$restoreMode = $this->checkSkeletonCompatibility($entityType, $entity, $snapshot);
$warnings = $this->checkIntegrity($entityType, $snapshot, $restoreMode);
$this->applyRestore($entityType, $entity, $snapshot, $restoreMode);
$connection = $this->em->getConnection();
$connection->beginTransaction();
$this->em->flush();
try {
// Mark entity to skip audit subscriber (we create the AuditLog manually)
if (method_exists($entity, 'setSkipAudit')) {
$entity->setSkipAudit(true);
}
$newVersion = method_exists($entity, 'getVersion') ? $entity->getVersion() : null;
$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,
@@ -872,18 +912,30 @@ final class EntityVersionService
{
$warnings = [];
// Check constructeurs
// 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 && 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,
];
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,
];
}
}
}
}
@@ -906,7 +958,7 @@ final class EntityVersionService
return $warnings;
}
// Full mode: check slot references
// Full mode: check slot references (batch queries per slot type)
$slotChecks = match ($entityType) {
'composant' => [
['key' => 'pieceSlots', 'refKey' => 'selectedPieceId', 'label' => 'pièce', 'repo' => $this->pieces],
@@ -921,9 +973,21 @@ final class EntityVersionService
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 && null === $check['repo']->find($refId)) {
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']),
@@ -1074,6 +1138,9 @@ final class EntityVersionService
$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();
@@ -1136,6 +1203,9 @@ final class EntityVersionService
$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);
@@ -1161,18 +1231,160 @@ final class EntityVersionService
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) {
$cfvId = $cfvData['id'] ?? null;
if (!$cfvId) {
$fieldId = $cfvData['fieldId'] ?? null;
if (!$fieldId) {
continue;
}
$cfv = $this->customFieldValues->find($cfvId);
// 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;
}
}
```
@@ -1189,6 +1401,91 @@ git commit -m "feat(versioning) : add EntityVersionService with restore logic"
---
## Task 7b: skipAudit flag on entities + subscriber check
The `restore()` method creates its own AuditLog with `action = "restore"`. The audit subscribers must skip entities flagged with `skipAudit = true` to avoid a duplicate `update` AuditLog.
**Files:**
- Modify: `src/Entity/Machine.php`, `src/Entity/Composant.php`, `src/Entity/Piece.php`, `src/Entity/Product.php`
- Modify: `src/EventSubscriber/AbstractAuditSubscriber.php`
- [ ] **Step 1: Add skipAudit flag to each entity**
Add to Machine, Composant, Piece, Product (transient property, NOT mapped to DB):
```php
/**
* Transient flag — when true, audit subscribers skip this entity.
* Used by EntityVersionService::restore() to avoid duplicate AuditLogs.
*/
private bool $skipAudit = false;
public function getSkipAudit(): bool
{
return $this->skipAudit;
}
public function setSkipAudit(bool $skipAudit): static
{
$this->skipAudit = $skipAudit;
return $this;
}
```
- [ ] **Step 2: Add skipAudit check in AbstractAuditSubscriber**
In `onFlush()` method, add an early return that scans all scheduled entities for the `skipAudit` flag. This covers ALL paths (simple, complex, collections, CFV changes) and avoids any duplicate AuditLogs:
```php
public function onFlush(OnFlushEventArgs $args): void
{
$em = $args->getObjectManager();
if (!$em instanceof EntityManagerInterface) {
return;
}
$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();
if ($this->hasCollectionTracking()) {
$this->onFlushComplex($em, $uow, $actorProfileId, $entityType);
} else {
$this->onFlushSimple($em, $uow, $actorProfileId, $entityType);
}
}
```
This replaces the existing `onFlush()` method. The check is at the top level so it covers entity updates, collection changes, and custom field value changes — all paths that `onFlushComplex` processes.
- [ ] **Step 3: Run php-cs-fixer**
Run: `make php-cs-fixer-allow-risky`
- [ ] **Step 4: Run tests**
Run: `make test`
Expected: All tests pass.
- [ ] **Step 5: Commit**
```bash
git add src/Entity/Machine.php src/Entity/Composant.php src/Entity/Piece.php src/Entity/Product.php src/EventSubscriber/AbstractAuditSubscriber.php
git commit -m "feat(versioning) : add skipAudit flag for restore-originated flushes"
```
---
## Task 8: EntityVersionController — REST endpoints
**Files:**
@@ -1570,6 +1867,35 @@ git commit -m "test(versioning) : add EntityVersionTest for list, preview and re
---
## Task 9b: Frontend — add `restore` action label to historyDisplayUtils
**Files:**
- Modify: `Inventory_frontend/app/shared/utils/historyDisplayUtils.ts`
- [ ] **Step 1: Add `restore` case to `historyActionLabel`**
In `historyDisplayUtils.ts`, update `historyActionLabel`:
```typescript
export const historyActionLabel = (action: string): string => {
if (action === 'create') return 'Création'
if (action === 'delete') return 'Suppression'
if (action === 'restore') return 'Restauration'
return 'Modification'
}
```
- [ ] **Step 2: Commit in frontend repo**
```bash
cd Inventory_frontend
git add app/shared/utils/historyDisplayUtils.ts
git commit -m "feat(versioning) : add restore action label to historyDisplayUtils"
cd ..
```
---
## Task 10: Frontend — useEntityVersions composable
**Files:**
@@ -1578,7 +1904,7 @@ git commit -m "test(versioning) : add EntityVersionTest for list, preview and re
- [ ] **Step 1: Create the composable**
```typescript
import { ref } from 'vue'
import { ref, toValue } from 'vue'
import { useApi } from '~/composables/useApi'
import type { MaybeRef } from 'vue'
@@ -1631,8 +1957,8 @@ export function useEntityVersions(deps: Deps) {
const error = ref<string | null>(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 type = toValue(deps.entityType)
const id = toValue(deps.entityId)
const base = ENTITY_ENDPOINTS[type]
return `${base}/${id}`
}
@@ -1665,7 +1991,7 @@ export function useEntityVersions(deps: Deps) {
}
const restore = async (version: number): Promise<RestoreResult | null> => {
const result = await post<RestoreResult>(`${getPath()}/versions/${version}/restore`)
const result = await post<RestoreResult>(`${getPath()}/versions/${version}/restore`, {})
if (!result.success || !result.data) {
return null
}
@@ -1952,10 +2278,13 @@ const confirmRestore = async () => {
restoring.value = true
const result = await restore(targetVersion.value)
restoring.value = false
modalVisible.value = false
if (result?.success) {
modalVisible.value = false
await fetchVersions()
emit('restored')
} else {
error.value = 'La restauration a échoué.'
modalVisible.value = false
}
}
@@ -2017,7 +2346,7 @@ In `component/[id]/edit.vue`, add after the `EntityHistorySection` block (after
entity-type="composant"
:entity-id="String(route.params.id)"
:field-labels="historyFieldLabels"
@restored="window.location.reload()"
@restored="fetchComponent()"
/>
```
@@ -2046,9 +2375,26 @@ Add the import:
import EntityVersionList from '~/components/common/EntityVersionList.vue'
```
- [ ] **Step 4: Read and modify product/[id]/edit.vue**
- [ ] **Step 4: Add EntityVersionList to product/[id]/edit.vue**
Read the file first, then add `EntityVersionList` following the same pattern as the other pages.
In `product/[id]/edit.vue`, add after the `EntityHistorySection` block and before the save buttons:
```vue
<EntityVersionList
entity-type="product"
:entity-id="String(route.params.id)"
:field-labels="historyFieldLabels"
@restored="loadProduct()"
/>
```
Where `loadProduct` is the existing function that calls `getProduct(id)` and populates the form. If no such function is exposed, extract the onMounted data-loading logic into a named function that can be called from `@restored`.
Add the import:
```js
import EntityVersionList from '~/components/common/EntityVersionList.vue'
```
- [ ] **Step 5: Run lint**