Compare commits

...

3 Commits

Author SHA1 Message Date
Matthieu
adc44b99d3 fix(machines) : fix skeleton creation — pagination, duplication, custom fields
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:40:09 +01:00
Matthieu
60afeb4cfd chore(frontend) : update submodule — Playwright e2e setup
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 09:07:37 +01:00
Matthieu
02ff8b1a96 feat(audit) : extend audit logging to machines, constructeurs, model types, documents and conversions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 14:51:26 +01:00
9 changed files with 1122 additions and 7 deletions

View File

@@ -1 +1 @@
1.6.0 1.6.2

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\AuditLogRepository;
use App\Repository\MachineRepository;
use App\Repository\ProfileRepository;
use DateTimeInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class MachineHistoryController
{
public function __construct(
private readonly MachineRepository $machines,
private readonly AuditLogRepository $auditLogs,
private readonly ProfileRepository $profiles,
) {}
#[Route('/api/machines/{id}/history', name: 'api_machine_history', methods: ['GET'])]
public function __invoke(string $id): JsonResponse
{
$machine = $this->machines->find($id);
if (!$machine) {
return new JsonResponse(
['message' => 'Machine introuvable.'],
Response::HTTP_NOT_FOUND,
);
}
$logs = $this->auditLogs->findEntityHistory('machine', $id, 200);
$actorIds = array_values(array_unique(array_filter(array_map(
static fn ($log) => $log->getActorProfileId(),
$logs,
))));
$actorMap = [];
if ([] !== $actorIds) {
$profiles = $this->profiles->findBy(['id' => $actorIds]);
foreach ($profiles as $profile) {
$label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
if ('' === $label) {
$label = $profile->getEmail() ?? $profile->getId();
}
$actorMap[$profile->getId()] = $label;
}
}
$items = array_map(
static function ($log) use ($actorMap) {
$actorId = $log->getActorProfileId();
return [
'id' => $log->getId(),
'action' => $log->getAction(),
'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
'actor' => $actorId
? [
'id' => $actorId,
'label' => $actorMap[$actorId] ?? $actorId,
]
: null,
'diff' => $log->getDiff(),
'snapshot' => $log->getSnapshot(),
];
},
$logs,
);
return new JsonResponse([
'items' => array_values($items),
'total' => count($items),
]);
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Entity\Composant; use App\Entity\Composant;
use App\Entity\CustomField;
use App\Entity\Machine; use App\Entity\Machine;
use App\Entity\MachineComponentLink; use App\Entity\MachineComponentLink;
use App\Entity\MachinePieceLink; use App\Entity\MachinePieceLink;
@@ -341,21 +342,28 @@ class MachineSkeletonController extends AbstractController
$componentIndex = $this->indexNormalizedLinks($normalizedComponentLinks); $componentIndex = $this->indexNormalizedLinks($normalizedComponentLinks);
$normalizedPieceLinks = $this->normalizePieceLinks($pieceLinks); $normalizedPieceLinks = $this->normalizePieceLinks($pieceLinks);
// Build component hierarchy // Build component hierarchy track which IDs are children
foreach ($normalizedComponentLinks as &$link) { $childIds = [];
foreach ($normalizedComponentLinks as $link) {
$parentId = $link['parentComponentLinkId'] ?? null; $parentId = $link['parentComponentLinkId'] ?? null;
if ($parentId && isset($componentIndex[$parentId])) { if ($parentId && isset($componentIndex[$parentId])) {
$componentIndex[$parentId]['childLinks'][] = &$link; $componentIndex[$parentId]['childLinks'][] = $link;
$childIds[$link['id']] = true;
} }
} }
unset($link);
// Add pieces to components recursively // Add pieces to components recursively
$this->attachPiecesToComponents($componentIndex, $normalizedPieceLinks); $this->attachPiecesToComponents($componentIndex, $normalizedPieceLinks);
// Only return root-level components (exclude children already nested)
$rootComponents = array_filter(
$componentIndex,
static fn (array $link) => !isset($childIds[$link['id']]),
);
return [ return [
'machine' => $this->normalizeMachine($machine), 'machine' => $this->normalizeMachine($machine),
'componentLinks' => array_values($componentIndex), 'componentLinks' => array_values($rootComponents),
'pieceLinks' => $normalizedPieceLinks, 'pieceLinks' => $normalizedPieceLinks,
'productLinks' => $this->normalizeProductLinks($productLinks), 'productLinks' => $this->normalizeProductLinks($productLinks),
]; ];

View File

@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\AuditLog;
use App\Entity\Constructeur;
use App\Entity\Profile;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Throwable;
use function is_scalar;
#[AsDoctrineListener(event: Events::onFlush)]
final class ConstructeurAuditSubscriber implements EventSubscriber
{
public function __construct(
private readonly RequestStack $requestStack,
private readonly Security $security,
) {}
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();
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!$entity instanceof Constructeur) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
$snapshot = $this->snapshotConstructeur($entity);
$this->persistAuditLog($em, new AuditLog('constructeur', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof Constructeur) {
continue;
}
$id = (string) $entity->getId();
if ('' === $id) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ([] !== $diff) {
$snapshot = $this->snapshotConstructeur($entity);
$this->persistAuditLog($em, new AuditLog('constructeur', $id, 'update', $diff, $snapshot, $actorProfileId));
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if (!$entity instanceof Constructeur) {
continue;
}
$snapshot = $this->snapshotConstructeur($entity);
$this->persistAuditLog($em, new AuditLog('constructeur', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
}
}
private 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}>
*/
private function buildDiffFromChangeSet(array $changeSet): array
{
$diff = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) {
if ('updatedAt' === $field || 'createdAt' === $field) {
continue;
}
$normalizedOld = $this->normalizeValue($oldValue);
$normalizedNew = $this->normalizeValue($newValue);
if ($normalizedOld === $normalizedNew) {
continue;
}
$diff[$field] = [
'from' => $normalizedOld,
'to' => $normalizedNew,
];
}
return $diff;
}
private function snapshotConstructeur(Constructeur $constructeur): array
{
return [
'id' => $constructeur->getId(),
'name' => $constructeur->getName(),
'email' => $constructeur->getEmail(),
'phone' => $constructeur->getPhone(),
];
}
private function normalizeValue(mixed $value): mixed
{
if (null === $value || is_scalar($value)) {
return $value;
}
if ($value instanceof DateTimeInterface) {
return $value->format(DateTimeInterface::ATOM);
}
return (string) $value;
}
private 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;
}
}

View File

@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\AuditLog;
use App\Entity\Composant;
use App\Entity\Document;
use App\Entity\Machine;
use App\Entity\Piece;
use App\Entity\Product;
use App\Entity\Profile;
use App\Entity\Site;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Throwable;
use function is_object;
use function is_scalar;
use function method_exists;
#[AsDoctrineListener(event: Events::onFlush)]
final class DocumentAuditSubscriber implements EventSubscriber
{
public function __construct(
private readonly RequestStack $requestStack,
private readonly Security $security,
) {}
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();
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!$entity instanceof Document) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
$snapshot = $this->snapshotDocument($entity);
$this->persistAuditLog($em, new AuditLog('document', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof Document) {
continue;
}
$id = (string) $entity->getId();
if ('' === $id) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ([] !== $diff) {
$snapshot = $this->snapshotDocument($entity);
$this->persistAuditLog($em, new AuditLog('document', $id, 'update', $diff, $snapshot, $actorProfileId));
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if (!$entity instanceof Document) {
continue;
}
$snapshot = $this->snapshotDocument($entity);
$this->persistAuditLog($em, new AuditLog('document', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
}
}
private 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}>
*/
private function buildDiffFromChangeSet(array $changeSet): array
{
$diff = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) {
if ('updatedAt' === $field || 'createdAt' === $field) {
continue;
}
$normalizedOld = $this->normalizeValue($oldValue);
$normalizedNew = $this->normalizeValue($newValue);
if ($normalizedOld === $normalizedNew) {
continue;
}
$diff[$field] = [
'from' => $normalizedOld,
'to' => $normalizedNew,
];
}
return $diff;
}
private function snapshotDocument(Document $document): array
{
return [
'id' => $document->getId(),
'name' => $document->getName(),
'filename' => $document->getFilename(),
'mimeType' => $document->getMimeType(),
'size' => $document->getSize(),
'machine' => $this->normalizeValue($document->getMachine()),
'composant' => $this->normalizeValue($document->getComposant()),
'piece' => $this->normalizeValue($document->getPiece()),
'product' => $this->normalizeValue($document->getProduct()),
'site' => $this->normalizeValue($document->getSite()),
];
}
private 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 Machine || $value instanceof Composant || $value instanceof Piece || $value instanceof Product || $value instanceof Site) {
return [
'id' => $value->getId(),
'name' => $value->getName(),
];
}
if (is_object($value) && method_exists($value, 'getId')) {
return (string) $value->getId();
}
return (string) $value;
}
private 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;
}
}

View File

@@ -0,0 +1,412 @@
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\AuditLog;
use App\Entity\CustomFieldValue;
use App\Entity\Machine;
use App\Entity\ModelType;
use App\Entity\Product;
use App\Entity\Profile;
use App\Entity\Site;
use App\Entity\TypeMachine;
use DateTimeInterface;
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 Doctrine\ORM\UnitOfWork;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Throwable;
use function is_array;
use function is_object;
use function is_scalar;
use function method_exists;
#[AsDoctrineListener(event: Events::onFlush)]
final class MachineAuditSubscriber implements EventSubscriber
{
public function __construct(
private readonly RequestStack $requestStack,
private readonly Security $security,
) {}
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 = [];
$pendingMachines = [];
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!$entity instanceof Machine) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
$snapshot = $this->snapshotMachine($entity);
$this->persistAuditLog($em, new AuditLog('machine', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof Machine) {
continue;
}
$machineId = (string) $entity->getId();
if ('' === $machineId) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ([] !== $diff) {
$pendingUpdates[$machineId] = $this->mergeDiffs($pendingUpdates[$machineId] ?? [], $diff);
$pendingSnapshots[$machineId] = $this->snapshotMachine($entity);
$pendingMachines[$machineId] = $entity;
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if (!$entity instanceof Machine) {
continue;
}
$snapshot = $this->snapshotMachine($entity);
$this->persistAuditLog($em, new AuditLog('machine', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingMachines);
}
foreach ($uow->getScheduledCollectionDeletions() as $collection) {
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingMachines);
}
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingMachines);
foreach ($pendingUpdates as $machineId => $diff) {
if ([] === $diff) {
continue;
}
$machine = $pendingMachines[$machineId] ?? null;
if (!$machine instanceof Machine) {
continue;
}
$snapshot = $pendingSnapshots[$machineId] ?? $this->snapshotMachine($machine);
$this->persistAuditLog($em, new AuditLog('machine', $machineId, '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, Machine> $pendingMachines
*/
private function collectCollectionUpdate(
object $collection,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingMachines,
): void {
if (!$collection instanceof PersistentCollection) {
return;
}
$owner = $collection->getOwner();
if (!$owner instanceof Machine) {
return;
}
$machineId = (string) $owner->getId();
if ('' === $machineId) {
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[$machineId] = $this->mergeDiffs($pendingUpdates[$machineId] ?? [], $diff);
$pendingSnapshots[$machineId] = $this->snapshotMachine($owner);
$pendingMachines[$machineId] = $owner;
}
/**
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
* @param array<string, array<string, mixed>> $pendingSnapshots
* @param array<string, Machine> $pendingMachines
*/
private function collectCustomFieldValueChanges(
UnitOfWork $uow,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingMachines,
): void {
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if ($entity instanceof CustomFieldValue) {
$this->trackCustomFieldValueChange($entity, null, $entity->getValue(), $pendingUpdates, $pendingSnapshots, $pendingMachines);
}
}
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, $pendingMachines);
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if ($entity instanceof CustomFieldValue) {
$this->trackCustomFieldValueChange($entity, $entity->getValue(), null, $pendingUpdates, $pendingSnapshots, $pendingMachines);
}
}
}
/**
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
* @param array<string, array<string, mixed>> $pendingSnapshots
* @param array<string, Machine> $pendingMachines
*/
private function trackCustomFieldValueChange(
CustomFieldValue $cfv,
mixed $from,
mixed $to,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingMachines,
): void {
$owner = $cfv->getMachine();
if (!$owner instanceof Machine) {
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->snapshotMachine($owner);
$pendingMachines[$ownerId] = $owner;
}
private 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}>
*/
private function buildDiffFromChangeSet(array $changeSet): array
{
$diff = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) {
if ('updatedAt' === $field || 'createdAt' === $field) {
continue;
}
$normalizedOld = $this->normalizeValue($oldValue);
$normalizedNew = $this->normalizeValue($newValue);
if ($normalizedOld === $normalizedNew) {
continue;
}
$diff[$field] = [
'from' => $normalizedOld,
'to' => $normalizedNew,
];
}
return $diff;
}
private function snapshotMachine(Machine $machine): array
{
return [
'id' => $machine->getId(),
'name' => $machine->getName(),
'reference' => $machine->getReference(),
'prix' => $machine->getPrix(),
'site' => $this->normalizeValue($machine->getSite()),
'typeMachine' => $this->normalizeValue($machine->getTypeMachine()),
'constructeurIds' => $this->normalizeCollection($machine->getConstructeurs()),
];
}
/**
* @param iterable<mixed> $items
*
* @return list<array{id: string, name: string}|string>
*/
private 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;
}
private 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 Site) {
return [
'id' => $value->getId(),
'name' => $value->getName(),
];
}
if ($value instanceof TypeMachine) {
return [
'id' => $value->getId(),
'name' => $value->getName(),
];
}
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 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}>
*/
private function mergeDiffs(array $base, array $extra): array
{
foreach ($extra as $field => $change) {
$base[$field] = $change;
}
return $base;
}
private 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;
}
}

View File

@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\AuditLog;
use App\Entity\ModelType;
use App\Entity\Profile;
use App\Enum\ModelCategory;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Throwable;
use function is_array;
use function is_scalar;
#[AsDoctrineListener(event: Events::onFlush)]
final class ModelTypeAuditSubscriber implements EventSubscriber
{
public function __construct(
private readonly RequestStack $requestStack,
private readonly Security $security,
) {}
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();
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!$entity instanceof ModelType) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
$snapshot = $this->snapshotModelType($entity);
$this->persistAuditLog($em, new AuditLog('model_type', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof ModelType) {
continue;
}
$id = (string) $entity->getId();
if ('' === $id) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ([] !== $diff) {
$snapshot = $this->snapshotModelType($entity);
$this->persistAuditLog($em, new AuditLog('model_type', $id, 'update', $diff, $snapshot, $actorProfileId));
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if (!$entity instanceof ModelType) {
continue;
}
$snapshot = $this->snapshotModelType($entity);
$this->persistAuditLog($em, new AuditLog('model_type', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
}
}
private 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}>
*/
private function buildDiffFromChangeSet(array $changeSet): array
{
$diff = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) {
if ('updatedAt' === $field || 'createdAt' === $field) {
continue;
}
$normalizedOld = $this->normalizeValue($oldValue);
$normalizedNew = $this->normalizeValue($newValue);
if ($normalizedOld === $normalizedNew) {
continue;
}
$diff[$field] = [
'from' => $normalizedOld,
'to' => $normalizedNew,
];
}
return $diff;
}
private function snapshotModelType(ModelType $modelType): array
{
return [
'id' => $modelType->getId(),
'name' => $modelType->getName(),
'code' => $modelType->getCode(),
'category' => $modelType->getCategory()->value,
'notes' => $modelType->getNotes(),
'description' => $modelType->getDescription(),
];
}
private 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 ModelCategory) {
return $value->value;
}
if (is_array($value)) {
return $value;
}
return (string) $value;
}
private 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;
}
}

View File

@@ -4,10 +4,14 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use App\Entity\Profile;
use App\Enum\ModelCategory; use App\Enum\ModelCategory;
use App\Repository\ModelTypeRepository; use App\Repository\ModelTypeRepository;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\DBAL\Connection; use Doctrine\DBAL\Connection;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Throwable; use Throwable;
final class ModelTypeCategoryConversionService final class ModelTypeCategoryConversionService
@@ -15,6 +19,8 @@ final class ModelTypeCategoryConversionService
public function __construct( public function __construct(
private readonly Connection $connection, private readonly Connection $connection,
private readonly ModelTypeRepository $modelTypes, private readonly ModelTypeRepository $modelTypes,
private readonly RequestStack $requestStack,
private readonly Security $security,
) {} ) {}
/** /**
@@ -76,6 +82,11 @@ final class ModelTypeCategoryConversionService
$category = $modelType->getCategory(); $category = $modelType->getCategory();
$direction = ModelCategory::PIECE === $category ? 'piece_to_component' : 'component_to_piece';
$names = $check['names'];
$modelName = $modelType->getName();
$modelCode = $modelType->getCode();
$this->connection->beginTransaction(); $this->connection->beginTransaction();
try { try {
@@ -85,6 +96,8 @@ final class ModelTypeCategoryConversionService
$count = $this->convertComponentToPiece($modelTypeId); $count = $this->convertComponentToPiece($modelTypeId);
} }
$this->logConversionAudit($modelTypeId, $modelName, $modelCode, $direction, $count, $names);
$this->connection->commit(); $this->connection->commit();
return ['success' => true, 'convertedCount' => $count, 'error' => null]; return ['success' => true, 'convertedCount' => $count, 'error' => null];
@@ -415,4 +428,67 @@ final class ModelTypeCategoryConversionService
return $count; return $count;
} }
/**
* @param list<string> $names
*/
private function logConversionAudit(
string $modelTypeId,
string $modelName,
string $modelCode,
string $direction,
int $convertedCount,
array $names,
): void {
$now = new DateTimeImmutable()->format('Y-m-d H:i:s');
$id = 'cl'.bin2hex(random_bytes(12));
$snapshot = [
'id' => $modelTypeId,
'name' => $modelName,
'code' => $modelCode,
];
$diff = [
'direction' => ['from' => null, 'to' => $direction],
'convertedCount' => ['from' => null, 'to' => $convertedCount],
'convertedNames' => ['from' => null, 'to' => $names],
];
$this->connection->executeStatement(
'INSERT INTO audit_logs (id, entitytype, entityid, action, diff, snapshot, actorprofileid, createdat)
VALUES (:id, :entityType, :entityId, :action, :diff, :snapshot, :actor, :now)',
[
'id' => $id,
'entityType' => 'model_type',
'entityId' => $modelTypeId,
'action' => 'convert',
'diff' => json_encode($diff),
'snapshot' => json_encode($snapshot),
'actor' => $this->resolveActorProfileId(),
'now' => $now,
],
);
}
private function resolveActorProfileId(): ?string
{
try {
$session = $this->requestStack->getSession();
if ($session instanceof SessionInterface) {
$profileId = $session->get('profileId');
if ($profileId) {
return (string) $profileId;
}
}
} catch (Throwable) {
}
$user = $this->security->getUser();
if ($user instanceof Profile) {
return $user->getId();
}
return null;
}
} }