299 lines
9.1 KiB
PHP
299 lines
9.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\EventSubscriber;
|
|
|
|
use App\Entity\AuditLog;
|
|
use App\Entity\ModelType;
|
|
use App\Entity\Product;
|
|
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
|
use Doctrine\Common\Collections\Collection;
|
|
use Doctrine\Common\EventSubscriber;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Doctrine\ORM\Event\OnFlushEventArgs;
|
|
use Doctrine\ORM\Events;
|
|
use Doctrine\ORM\PersistentCollection;
|
|
use Symfony\Component\HttpFoundation\RequestStack;
|
|
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
|
|
|
/**
|
|
* Record a lightweight, per-product audit trail.
|
|
*
|
|
* This MVP focuses on Product updates and captures:
|
|
* - scalar field changes (from Doctrine change sets)
|
|
* - constructeur collection changes (from collection updates)
|
|
*/
|
|
#[AsDoctrineListener(event: Events::onFlush)]
|
|
final class ProductAuditSubscriber implements EventSubscriber
|
|
{
|
|
public function __construct(private readonly RequestStack $requestStack)
|
|
{
|
|
}
|
|
|
|
public function getSubscribedEvents(): array
|
|
{
|
|
return [
|
|
Events::onFlush,
|
|
];
|
|
}
|
|
|
|
public function onFlush(OnFlushEventArgs $args): void
|
|
{
|
|
$em = $args->getObjectManager();
|
|
if (!$em instanceof EntityManagerInterface) {
|
|
return;
|
|
}
|
|
|
|
$uow = $em->getUnitOfWork();
|
|
$actorProfileId = $this->resolveActorProfileId();
|
|
$pendingUpdates = [];
|
|
$pendingSnapshots = [];
|
|
$pendingProducts = [];
|
|
|
|
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
|
if (!$entity instanceof Product) {
|
|
continue;
|
|
}
|
|
|
|
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
|
$snapshot = $this->snapshotProduct($entity);
|
|
$this->persistAuditLog($em, new AuditLog('product', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
|
|
}
|
|
|
|
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
|
if (!$entity instanceof Product) {
|
|
continue;
|
|
}
|
|
|
|
$productId = (string) $entity->getId();
|
|
if ($productId === '') {
|
|
continue;
|
|
}
|
|
|
|
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
|
if ($diff !== []) {
|
|
$pendingUpdates[$productId] = $this->mergeDiffs($pendingUpdates[$productId] ?? [], $diff);
|
|
$pendingSnapshots[$productId] = $this->snapshotProduct($entity);
|
|
$pendingProducts[$productId] = $entity;
|
|
}
|
|
}
|
|
|
|
foreach ($uow->getScheduledEntityDeletions() as $entity) {
|
|
if (!$entity instanceof Product) {
|
|
continue;
|
|
}
|
|
|
|
$snapshot = $this->snapshotProduct($entity);
|
|
$this->persistAuditLog($em, new AuditLog('product', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
|
|
}
|
|
|
|
// Capture constructeur collection updates, which are not included in the change set.
|
|
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
|
|
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingProducts);
|
|
}
|
|
foreach ($uow->getScheduledCollectionDeletions() as $collection) {
|
|
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingProducts);
|
|
}
|
|
|
|
foreach ($pendingUpdates as $productId => $diff) {
|
|
if ($diff === []) {
|
|
continue;
|
|
}
|
|
|
|
$product = $pendingProducts[$productId] ?? null;
|
|
if (!$product instanceof Product) {
|
|
continue;
|
|
}
|
|
|
|
$snapshot = $pendingSnapshots[$productId] ?? $this->snapshotProduct($product);
|
|
$this->persistAuditLog($em, new AuditLog('product', $productId, 'update', $diff, $snapshot, $actorProfileId));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
|
|
* @param array<string, array<string, mixed>> $pendingSnapshots
|
|
* @param array<string, Product> $pendingProducts
|
|
*/
|
|
private function collectCollectionUpdate(
|
|
object $collection,
|
|
array &$pendingUpdates,
|
|
array &$pendingSnapshots,
|
|
array &$pendingProducts,
|
|
): void {
|
|
if (!$collection instanceof PersistentCollection) {
|
|
return;
|
|
}
|
|
|
|
$owner = $collection->getOwner();
|
|
if (!$owner instanceof Product) {
|
|
return;
|
|
}
|
|
|
|
$productId = (string) $owner->getId();
|
|
if ($productId === '') {
|
|
return;
|
|
}
|
|
|
|
$mapping = $collection->getMapping();
|
|
$fieldName = $mapping['fieldName'] ?? null;
|
|
if ($fieldName !== 'constructeurs') {
|
|
return;
|
|
}
|
|
|
|
$before = $this->normalizeCollection($collection->getSnapshot());
|
|
$after = $this->normalizeCollection($collection->toArray());
|
|
|
|
if ($before === $after) {
|
|
return;
|
|
}
|
|
|
|
$diff = [
|
|
'constructeurIds' => [
|
|
'from' => $before,
|
|
'to' => $after,
|
|
],
|
|
];
|
|
|
|
$pendingUpdates[$productId] = $this->mergeDiffs($pendingUpdates[$productId] ?? [], $diff);
|
|
$pendingSnapshots[$productId] = $this->snapshotProduct($owner);
|
|
$pendingProducts[$productId] = $owner;
|
|
}
|
|
|
|
private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
|
|
{
|
|
$uow = $em->getUnitOfWork();
|
|
// Ensure identifiers and timestamps are set even when persisting during onFlush.
|
|
$log->initializeAuditLog();
|
|
$em->persist($log);
|
|
|
|
$meta = $em->getClassMetadata(AuditLog::class);
|
|
$uow->computeChangeSet($meta, $log);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, array{0:mixed, 1:mixed}> $changeSet
|
|
* @return array<string, array{from:mixed, to:mixed}>
|
|
*/
|
|
private function buildDiffFromChangeSet(array $changeSet): array
|
|
{
|
|
$diff = [];
|
|
foreach ($changeSet as $field => [$oldValue, $newValue]) {
|
|
// Skip noisy timestamps managed automatically.
|
|
if ($field === 'updatedAt' || $field === 'createdAt') {
|
|
continue;
|
|
}
|
|
|
|
$normalizedOld = $this->normalizeValue($oldValue);
|
|
$normalizedNew = $this->normalizeValue($newValue);
|
|
|
|
if ($normalizedOld === $normalizedNew) {
|
|
continue;
|
|
}
|
|
|
|
$diff[$field] = [
|
|
'from' => $normalizedOld,
|
|
'to' => $normalizedNew,
|
|
];
|
|
}
|
|
|
|
return $diff;
|
|
}
|
|
|
|
private function snapshotProduct(Product $product): array
|
|
{
|
|
return [
|
|
'id' => $product->getId(),
|
|
'name' => $product->getName(),
|
|
'reference' => $product->getReference(),
|
|
'supplierPrice' => $product->getSupplierPrice(),
|
|
'typeProduct' => $this->normalizeValue($product->getTypeProduct()),
|
|
'constructeurIds' => $this->normalizeCollection($product->getConstructeurs()),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, array{from:mixed, to:mixed}> $base
|
|
* @param array<string, array{from:mixed, to:mixed}> $extra
|
|
* @return array<string, array{from:mixed, to:mixed}>
|
|
*/
|
|
private function mergeDiffs(array $base, array $extra): array
|
|
{
|
|
foreach ($extra as $field => $change) {
|
|
$base[$field] = $change;
|
|
}
|
|
|
|
return $base;
|
|
}
|
|
|
|
/**
|
|
* @param iterable<mixed> $items
|
|
* @return list<string>
|
|
*/
|
|
private function normalizeCollection(iterable $items): array
|
|
{
|
|
$ids = [];
|
|
foreach ($items as $item) {
|
|
if (\is_object($item) && \method_exists($item, 'getId')) {
|
|
$id = $item->getId();
|
|
if ($id !== null && $id !== '') {
|
|
$ids[] = (string) $id;
|
|
}
|
|
}
|
|
}
|
|
|
|
sort($ids);
|
|
|
|
return array_values(array_unique($ids));
|
|
}
|
|
|
|
private function normalizeValue(mixed $value): mixed
|
|
{
|
|
if ($value === null || \is_scalar($value)) {
|
|
return $value;
|
|
}
|
|
|
|
if ($value instanceof \DateTimeInterface) {
|
|
return $value->format(\DateTimeInterface::ATOM);
|
|
}
|
|
|
|
if ($value instanceof ModelType) {
|
|
return [
|
|
'id' => $value->getId(),
|
|
'name' => $value->getName(),
|
|
'code' => $value->getCode(),
|
|
];
|
|
}
|
|
|
|
if ($value instanceof Collection) {
|
|
return $this->normalizeCollection($value);
|
|
}
|
|
|
|
if (\is_object($value) && \method_exists($value, 'getId')) {
|
|
return (string) $value->getId();
|
|
}
|
|
|
|
if (\is_array($value)) {
|
|
return $value;
|
|
}
|
|
|
|
return (string) $value;
|
|
}
|
|
|
|
private function resolveActorProfileId(): ?string
|
|
{
|
|
$session = $this->requestStack->getSession();
|
|
if (!$session instanceof SessionInterface) {
|
|
return null;
|
|
}
|
|
|
|
$profileId = $session->get('profileId');
|
|
if (!$profileId) {
|
|
return null;
|
|
}
|
|
|
|
return (string) $profileId;
|
|
}
|
|
}
|