3003ced157
Auto Tag Develop / tag (push) Successful in 10s
Deux endroits accèdent à $cfv->getCustomField()->getName() à chaque flush touchant un CustomFieldValue. Si la CustomField a été supprimée et que la FK n'est pas en ON DELETE CASCADE, le proxy lève EntityNotFoundException et fait crasher tout le flush (pas juste une lecture, comme dans le crash côté MachineStructureController). - ReferenceAutoGenerator::buildValueMap() : skip le CFV orphelin (la ref auto retombera proprement sur null via le check requiredFields existant). - AbstractAuditSubscriber::trackCustomFieldValueChange() : skip l'entrée d'audit pour ce CFV au lieu de propager l'exception.
449 lines
15 KiB
PHP
449 lines
15 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\EntityNotFoundException;
|
|
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;
|
|
}
|
|
|
|
try {
|
|
$cfName = $cfv->getCustomField()->getName();
|
|
} catch (EntityNotFoundException) {
|
|
return;
|
|
}
|
|
$fieldName = 'customField:'.$cfName;
|
|
$diff = [$fieldName => ['from' => $from, 'to' => $to]];
|
|
|
|
$pendingUpdates[$ownerId] = $this->mergeDiffs($pendingUpdates[$ownerId] ?? [], $diff);
|
|
$pendingSnapshots[$ownerId] = $this->snapshotEntity($owner);
|
|
$pendingEntities[$ownerId] = $owner;
|
|
}
|
|
}
|