AbstractAuditSubscriber déclarait $actorProfileResolver en private readonly via promoted property. MachineAuditSubscriber surcharge onFlush() et accède à $this->actorProfileResolver, mais private n'est pas hérité — PHP voyait null et levait "Call to a member function resolve() on null" sur chaque flush Doctrine touchant des link entities. Le passage à protected suit la convention déjà en place dans la classe (safeGet, normalizeValue, persistAuditLog, etc. sont protected). readonly préserve l'immutabilité de la dépendance DI. Ajoute aussi deux tests de régression pour le clone des contextFieldValues (symétrique au test composant existant) et nettoie deux lignes vides cosmétiques laissées par le refactor précédent. - testCloneMachineCopiesPieceContextFieldValues : vérifie que les CFV context d'un MachinePieceLink sont bien rattachées au nouveau lien après clone. - testCloneMachineLeavesSourceContextFieldValuesIntact : vérifie que la machine source garde ses CFV context après clone (invariant implicite).
443 lines
14 KiB
PHP
443 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\EventSubscriber;
|
|
|
|
use App\Entity\AuditLog;
|
|
use App\Entity\Composant;
|
|
use App\Entity\CustomFieldValue;
|
|
use App\Entity\Machine;
|
|
use App\Entity\ModelType;
|
|
use App\Entity\Piece;
|
|
use App\Entity\Product;
|
|
use App\Entity\Site;
|
|
use App\Service\ActorProfileResolver;
|
|
use BackedEnum;
|
|
use DateTimeInterface;
|
|
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\UnitOfWork;
|
|
use Error;
|
|
|
|
use function is_array;
|
|
use function is_object;
|
|
use function is_scalar;
|
|
use function method_exists;
|
|
|
|
abstract class AbstractAuditSubscriber implements EventSubscriber
|
|
{
|
|
public function __construct(
|
|
protected readonly ActorProfileResolver $actorProfileResolver,
|
|
) {}
|
|
|
|
public function getSubscribedEvents(): array
|
|
{
|
|
return [Events::onFlush];
|
|
}
|
|
|
|
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->actorProfileResolver->resolve();
|
|
$entityType = $this->entityType();
|
|
|
|
if ($this->hasCollectionTracking()) {
|
|
$this->onFlushComplex($em, $uow, $actorProfileId, $entityType);
|
|
} else {
|
|
$this->onFlushSimple($em, $uow, $actorProfileId, $entityType);
|
|
}
|
|
}
|
|
|
|
abstract protected function supports(object $entity): bool;
|
|
|
|
abstract protected function entityType(): string;
|
|
|
|
abstract protected function snapshotEntity(object $entity): array;
|
|
|
|
/**
|
|
* Override in subclasses that track custom field value changes.
|
|
* Return the owner entity if the CFV belongs to the tracked entity type.
|
|
*/
|
|
protected function getOwnerFromCustomFieldValue(CustomFieldValue $cfv): ?object
|
|
{
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Whether this subscriber uses the complex onFlush path (collection + custom field tracking).
|
|
*/
|
|
protected function hasCollectionTracking(): bool
|
|
{
|
|
return false;
|
|
}
|
|
|
|
protected function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
|
|
{
|
|
$uow = $em->getUnitOfWork();
|
|
$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}>
|
|
*/
|
|
protected function buildDiffFromChangeSet(array $changeSet): array
|
|
{
|
|
$diff = [];
|
|
foreach ($changeSet as $field => [$oldValue, $newValue]) {
|
|
if ('updatedAt' === $field || 'createdAt' === $field || 'version' === $field) {
|
|
continue;
|
|
}
|
|
|
|
$normalizedOld = $this->normalizeValue($oldValue);
|
|
$normalizedNew = $this->normalizeValue($newValue);
|
|
|
|
if ($normalizedOld === $normalizedNew) {
|
|
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,
|
|
];
|
|
}
|
|
|
|
return $diff;
|
|
}
|
|
|
|
/**
|
|
* @param iterable<mixed> $items
|
|
*
|
|
* @return list<array{id: string, name: string}|string>
|
|
*/
|
|
protected function normalizeCollection(iterable $items): array
|
|
{
|
|
$entries = [];
|
|
$seen = [];
|
|
foreach ($items as $item) {
|
|
if (is_object($item) && method_exists($item, 'getId')) {
|
|
$id = $item->getId();
|
|
if (null === $id || '' === $id || isset($seen[(string) $id])) {
|
|
continue;
|
|
}
|
|
$seen[(string) $id] = true;
|
|
if (method_exists($item, 'getName')) {
|
|
$entries[] = ['id' => (string) $id, 'name' => (string) $item->getName()];
|
|
} else {
|
|
$entries[] = (string) $id;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $entries;
|
|
}
|
|
|
|
protected function safeGet(object $entity, string $method): mixed
|
|
{
|
|
try {
|
|
return $entity->{$method}();
|
|
} catch (Error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
protected function normalizeValue(mixed $value): mixed
|
|
{
|
|
if (null === $value || is_scalar($value)) {
|
|
return $value;
|
|
}
|
|
|
|
if ($value instanceof DateTimeInterface) {
|
|
return $value->format(DateTimeInterface::ATOM);
|
|
}
|
|
|
|
if ($value instanceof BackedEnum) {
|
|
return $value->value;
|
|
}
|
|
|
|
if ($value instanceof ModelType) {
|
|
return [
|
|
'id' => $value->getId(),
|
|
'name' => $value->getName(),
|
|
'code' => $value->getCode(),
|
|
];
|
|
}
|
|
|
|
if ($value instanceof Product) {
|
|
return [
|
|
'id' => $value->getId(),
|
|
'name' => $value->getName(),
|
|
'reference' => $value->getReference(),
|
|
];
|
|
}
|
|
|
|
if ($value instanceof Site || $value instanceof Machine || $value instanceof Composant || $value instanceof Piece) {
|
|
return [
|
|
'id' => $value->getId(),
|
|
'name' => $value->getName(),
|
|
];
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* @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}>
|
|
*/
|
|
protected function mergeDiffs(array $base, array $extra): array
|
|
{
|
|
foreach ($extra as $field => $change) {
|
|
$base[$field] = $change;
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
private function onFlushSimple(EntityManagerInterface $em, UnitOfWork $uow, ?string $actorProfileId, string $entityType): void
|
|
{
|
|
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
|
if (!$this->supports($entity)) {
|
|
continue;
|
|
}
|
|
|
|
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
|
$snapshot = $this->snapshotEntity($entity);
|
|
$version = $this->getEntityVersion($entity);
|
|
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId, $version));
|
|
}
|
|
|
|
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
|
if (!$this->supports($entity)) {
|
|
continue;
|
|
}
|
|
|
|
$id = (string) $entity->getId();
|
|
if ('' === $id) {
|
|
continue;
|
|
}
|
|
|
|
$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, $version));
|
|
}
|
|
}
|
|
|
|
foreach ($uow->getScheduledEntityDeletions() as $entity) {
|
|
if (!$this->supports($entity)) {
|
|
continue;
|
|
}
|
|
|
|
$snapshot = $this->snapshotEntity($entity);
|
|
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
|
|
}
|
|
}
|
|
|
|
private function onFlushComplex(EntityManagerInterface $em, UnitOfWork $uow, ?string $actorProfileId, string $entityType): void
|
|
{
|
|
$pendingUpdates = [];
|
|
$pendingSnapshots = [];
|
|
$pendingEntities = [];
|
|
|
|
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
|
if (!$this->supports($entity)) {
|
|
continue;
|
|
}
|
|
|
|
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
|
$snapshot = $this->snapshotEntity($entity);
|
|
$version = $this->getEntityVersion($entity);
|
|
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId, $version));
|
|
}
|
|
|
|
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
|
if (!$this->supports($entity)) {
|
|
continue;
|
|
}
|
|
|
|
$entityId = (string) $entity->getId();
|
|
if ('' === $entityId) {
|
|
continue;
|
|
}
|
|
|
|
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
|
if ([] !== $diff) {
|
|
$pendingUpdates[$entityId] = $this->mergeDiffs($pendingUpdates[$entityId] ?? [], $diff);
|
|
$pendingSnapshots[$entityId] = $this->snapshotEntity($entity);
|
|
$pendingEntities[$entityId] = $entity;
|
|
}
|
|
}
|
|
|
|
foreach ($uow->getScheduledEntityDeletions() as $entity) {
|
|
if (!$this->supports($entity)) {
|
|
continue;
|
|
}
|
|
|
|
$snapshot = $this->snapshotEntity($entity);
|
|
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
|
|
}
|
|
|
|
// Note: scheduled collection updates/deletions are intentionally not
|
|
// tracked here — constructeurs are now persisted as ConstructeurLink
|
|
// entities (OneToMany), so Doctrine no longer fires collection events
|
|
// for them. Custom field values are handled below.
|
|
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingEntities);
|
|
|
|
foreach ($pendingUpdates as $entityId => $diff) {
|
|
if ([] === $diff) {
|
|
continue;
|
|
}
|
|
|
|
$entity = $pendingEntities[$entityId] ?? null;
|
|
if (null === $entity || !$this->supports($entity)) {
|
|
continue;
|
|
}
|
|
|
|
$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));
|
|
}
|
|
}
|
|
|
|
private function collectCustomFieldValueChanges(
|
|
UnitOfWork $uow,
|
|
array &$pendingUpdates,
|
|
array &$pendingSnapshots,
|
|
array &$pendingEntities,
|
|
): void {
|
|
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
|
if ($entity instanceof CustomFieldValue) {
|
|
$this->trackCustomFieldValueChange($entity, null, $entity->getValue(), $pendingUpdates, $pendingSnapshots, $pendingEntities);
|
|
}
|
|
}
|
|
|
|
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
|
if (!$entity instanceof CustomFieldValue) {
|
|
continue;
|
|
}
|
|
$changeSet = $uow->getEntityChangeSet($entity);
|
|
if (!isset($changeSet['value'])) {
|
|
continue;
|
|
}
|
|
[$oldVal, $newVal] = $changeSet['value'];
|
|
if ($oldVal !== $newVal) {
|
|
$this->trackCustomFieldValueChange($entity, $oldVal, $newVal, $pendingUpdates, $pendingSnapshots, $pendingEntities);
|
|
}
|
|
}
|
|
|
|
foreach ($uow->getScheduledEntityDeletions() as $entity) {
|
|
if ($entity instanceof CustomFieldValue) {
|
|
$this->trackCustomFieldValueChange($entity, $entity->getValue(), null, $pendingUpdates, $pendingSnapshots, $pendingEntities);
|
|
}
|
|
}
|
|
}
|
|
|
|
private function trackCustomFieldValueChange(
|
|
CustomFieldValue $cfv,
|
|
mixed $from,
|
|
mixed $to,
|
|
array &$pendingUpdates,
|
|
array &$pendingSnapshots,
|
|
array &$pendingEntities,
|
|
): void {
|
|
$owner = $this->getOwnerFromCustomFieldValue($cfv);
|
|
if (null === $owner) {
|
|
return;
|
|
}
|
|
|
|
$ownerId = (string) $owner->getId();
|
|
if ('' === $ownerId) {
|
|
return;
|
|
}
|
|
|
|
$fieldName = 'customField:'.$cfv->getCustomField()->getName();
|
|
$diff = [$fieldName => ['from' => $from, 'to' => $to]];
|
|
|
|
$pendingUpdates[$ownerId] = $this->mergeDiffs($pendingUpdates[$ownerId] ?? [], $diff);
|
|
$pendingSnapshots[$ownerId] = $this->snapshotEntity($owner);
|
|
$pendingEntities[$ownerId] = $owner;
|
|
}
|
|
}
|