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); } } 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 tracks constructeur collection changes. * Override to return true for entities with a constructeurs ManyToMany. */ 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 $changeSet * * @return array */ 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 $items * * @return list */ 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 $base * @param array $extra * * @return array */ 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(); } protected function resolveActorProfileId(): ?string { try { $session = $this->requestStack->getSession(); if ($session instanceof SessionInterface) { $profileId = $session->get('profileId'); if ($profileId) { return (string) $profileId; } } } catch (Throwable) { // No session available (CLI context, etc.) } $user = $this->security->getUser(); if ($user instanceof Profile) { return $user->getId(); } return null; } 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)); } foreach ($uow->getScheduledCollectionUpdates() as $collection) { $this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingEntities); } foreach ($uow->getScheduledCollectionDeletions() as $collection) { $this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingEntities); } $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 collectCollectionUpdate( object $collection, array &$pendingUpdates, array &$pendingSnapshots, array &$pendingEntities, ): void { if (!$collection instanceof PersistentCollection) { return; } $owner = $collection->getOwner(); if (null === $owner || !$this->supports($owner)) { return; } $ownerId = (string) $owner->getId(); if ('' === $ownerId) { return; } $mapping = $collection->getMapping(); $fieldName = $mapping['fieldName'] ?? null; if ('constructeurs' !== $fieldName) { return; } $before = $this->normalizeCollection($collection->getSnapshot()); $after = $this->normalizeCollection($collection->toArray()); if ($before === $after) { return; } $diff = [ 'constructeurIds' => [ 'from' => $before, 'to' => $after, ], ]; $pendingUpdates[$ownerId] = $this->mergeDiffs($pendingUpdates[$ownerId] ?? [], $diff); $pendingSnapshots[$ownerId] = $this->snapshotEntity($owner); $pendingEntities[$ownerId] = $owner; } 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; } }