From 74f77a3ba88e0179a8a69c6dd826f1d902032386 Mon Sep 17 00:00:00 2001 From: r-dev Date: Sun, 8 Mar 2026 13:39:03 +0100 Subject: [PATCH] refactor(backend) : extract CuidEntityTrait, abstract audit subscriber, merge history controllers - Extract shared ID generation + timestamps into CuidEntityTrait used by all entities - Create AbstractAuditSubscriber to deduplicate audit logic across 7 subscribers - Merge per-entity history controllers into single EntityHistoryController - Delete redundant ComposantHistory/MachineHistory/PieceHistory/ProductHistoryController - Add OpenApiDecorator for API documentation customization - Disable failOnDeprecation in PHPUnit (vendor API Platform deprecation) Co-Authored-By: Claude Opus 4.6 --- phpunit.dist.xml | 3 +- ...roller.php => EntityHistoryController.php} | 57 +- src/Controller/MachineHistoryController.php | 82 --- src/Controller/MachineStructureController.php | 5 +- src/Controller/PieceHistoryController.php | 82 --- src/Controller/ProductHistoryController.php | 82 --- src/Entity/Comment.php | 40 +- src/Entity/Composant.php | 51 +- src/Entity/Constructeur.php | 57 +- src/Entity/CustomField.php | 51 +- src/Entity/CustomFieldValue.php | 47 +- src/Entity/Machine.php | 59 +- src/Entity/MachineComponentLink.php | 41 +- src/Entity/MachinePieceLink.php | 41 +- src/Entity/MachineProductLink.php | 41 +- src/Entity/ModelType.php | 51 +- src/Entity/Piece.php | 51 +- src/Entity/Product.php | 51 +- src/Entity/Site.php | 54 +- src/Entity/Trait/CuidEntityTrait.php | 56 ++ .../AbstractAuditSubscriber.php | 463 ++++++++++++++++ .../ComposantAuditSubscriber.php | 407 +------------- .../ConstructeurAuditSubscriber.php | 165 +----- .../DocumentAuditSubscriber.php | 195 +------ .../MachineAuditSubscriber.php | 391 +------------ .../ModelTypeAuditSubscriber.php | 179 +----- src/EventSubscriber/PieceAuditSubscriber.php | 407 +------------- .../ProductAuditSubscriber.php | 404 +------------- src/OpenApi/OpenApiDecorator.php | 520 ++++++++++++++++++ src/Service/PdfCompressorService.php | 19 +- 30 files changed, 1350 insertions(+), 2802 deletions(-) rename src/Controller/{ComposantHistoryController.php => EntityHistoryController.php} (54%) delete mode 100644 src/Controller/MachineHistoryController.php delete mode 100644 src/Controller/PieceHistoryController.php delete mode 100644 src/Controller/ProductHistoryController.php create mode 100644 src/Entity/Trait/CuidEntityTrait.php create mode 100644 src/EventSubscriber/AbstractAuditSubscriber.php create mode 100644 src/OpenApi/OpenApiDecorator.php diff --git a/phpunit.dist.xml b/phpunit.dist.xml index 22bd879..294e6d1 100644 --- a/phpunit.dist.xml +++ b/phpunit.dist.xml @@ -4,7 +4,7 @@ + diff --git a/src/Controller/ComposantHistoryController.php b/src/Controller/EntityHistoryController.php similarity index 54% rename from src/Controller/ComposantHistoryController.php rename to src/Controller/EntityHistoryController.php index 7610771..51554a3 100644 --- a/src/Controller/ComposantHistoryController.php +++ b/src/Controller/EntityHistoryController.php @@ -6,35 +6,76 @@ namespace App\Controller; use App\Repository\AuditLogRepository; use App\Repository\ComposantRepository; +use App\Repository\MachineRepository; +use App\Repository\PieceRepository; +use App\Repository\ProductRepository; use App\Repository\ProfileRepository; use DateTimeInterface; +use Doctrine\ORM\EntityRepository; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -final class ComposantHistoryController extends AbstractController +final class EntityHistoryController extends AbstractController { + /** @var array, label: string}> */ + private readonly array $entityConfig; + public function __construct( - private readonly ComposantRepository $components, + MachineRepository $machines, + PieceRepository $pieces, + ComposantRepository $composants, + ProductRepository $products, private readonly AuditLogRepository $auditLogs, private readonly ProfileRepository $profiles, - ) {} + ) { + $this->entityConfig = [ + 'machine' => ['repo' => $machines, 'label' => 'Machine introuvable.'], + 'piece' => ['repo' => $pieces, 'label' => 'Pièce introuvable.'], + 'composant' => ['repo' => $composants, 'label' => 'Composant introuvable.'], + 'product' => ['repo' => $products, 'label' => 'Produit introuvable.'], + ]; + } + + #[Route('/api/machines/{id}/history', name: 'api_machine_history', methods: ['GET'])] + public function machineHistory(string $id): JsonResponse + { + return $this->entityHistory('machine', $id); + } + + #[Route('/api/pieces/{id}/history', name: 'api_piece_history', methods: ['GET'])] + public function pieceHistory(string $id): JsonResponse + { + return $this->entityHistory('piece', $id); + } #[Route('/api/composants/{id}/history', name: 'api_composant_history', methods: ['GET'])] - public function __invoke(string $id): JsonResponse + public function composantHistory(string $id): JsonResponse + { + return $this->entityHistory('composant', $id); + } + + #[Route('/api/products/{id}/history', name: 'api_product_history', methods: ['GET'])] + public function productHistory(string $id): JsonResponse + { + return $this->entityHistory('product', $id); + } + + private function entityHistory(string $type, string $id): JsonResponse { $this->denyAccessUnlessGranted('ROLE_VIEWER'); - $component = $this->components->find($id); - if (!$component) { + $config = $this->entityConfig[$type]; + $entity = $config['repo']->find($id); + if (!$entity) { return new JsonResponse( - ['message' => 'Composant introuvable.'], + ['message' => $config['label']], Response::HTTP_NOT_FOUND, ); } - $logs = $this->auditLogs->findEntityHistory('composant', $id, 200); + $logs = $this->auditLogs->findEntityHistory($type, $id, 200); $actorIds = array_values(array_unique(array_filter(array_map( static fn ($log) => $log->getActorProfileId(), diff --git a/src/Controller/MachineHistoryController.php b/src/Controller/MachineHistoryController.php deleted file mode 100644 index 9d340b7..0000000 --- a/src/Controller/MachineHistoryController.php +++ /dev/null @@ -1,82 +0,0 @@ -denyAccessUnlessGranted('ROLE_VIEWER'); - - $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), - ]); - } -} diff --git a/src/Controller/MachineStructureController.php b/src/Controller/MachineStructureController.php index d046a06..50361d4 100644 --- a/src/Controller/MachineStructureController.php +++ b/src/Controller/MachineStructureController.php @@ -14,6 +14,7 @@ use App\Entity\MachineProductLink; use App\Entity\ModelType; use App\Entity\Piece; use App\Entity\Product; +use App\Entity\Site; use App\Repository\ComposantRepository; use App\Repository\MachineComponentLinkRepository; use App\Repository\MachinePieceLinkRepository; @@ -123,7 +124,7 @@ class MachineStructureController extends AbstractController return $this->json(['success' => false, 'error' => 'name et siteId sont requis.'], 400); } - $site = $this->entityManager->getRepository(\App\Entity\Site::class)->find($payload['siteId']); + $site = $this->entityManager->getRepository(Site::class)->find($payload['siteId']); if (!$site) { return $this->json(['success' => false, 'error' => 'Site introuvable.'], 404); } @@ -772,7 +773,7 @@ class MachineStructureController extends AbstractController if (!$cfv instanceof CustomFieldValue) { continue; } - $cf = $cfv->getCustomField(); + $cf = $cfv->getCustomField(); $items[] = [ 'id' => $cfv->getId(), 'value' => $cfv->getValue(), diff --git a/src/Controller/PieceHistoryController.php b/src/Controller/PieceHistoryController.php deleted file mode 100644 index 36e7aee..0000000 --- a/src/Controller/PieceHistoryController.php +++ /dev/null @@ -1,82 +0,0 @@ -denyAccessUnlessGranted('ROLE_VIEWER'); - - $piece = $this->pieces->find($id); - if (!$piece) { - return new JsonResponse( - ['message' => 'Pièce introuvable.'], - Response::HTTP_NOT_FOUND, - ); - } - - $logs = $this->auditLogs->findEntityHistory('piece', $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), - ]); - } -} diff --git a/src/Controller/ProductHistoryController.php b/src/Controller/ProductHistoryController.php deleted file mode 100644 index 7d7b6d6..0000000 --- a/src/Controller/ProductHistoryController.php +++ /dev/null @@ -1,82 +0,0 @@ -denyAccessUnlessGranted('ROLE_VIEWER'); - - $product = $this->products->find($id); - if (!$product) { - return new JsonResponse( - ['message' => 'Produit introuvable.'], - Response::HTTP_NOT_FOUND, - ); - } - - $logs = $this->auditLogs->findEntityHistory('product', $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), - ]); - } -} diff --git a/src/Entity/Comment.php b/src/Entity/Comment.php index 547c805..378c12d 100644 --- a/src/Entity/Comment.php +++ b/src/Entity/Comment.php @@ -12,6 +12,7 @@ use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Patch; +use App\Entity\Trait\CuidEntityTrait; use DateTimeImmutable; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; @@ -23,6 +24,7 @@ use Doctrine\ORM\Mapping as ORM; #[ApiFilter(SearchFilter::class, properties: ['entityType' => 'exact', 'entityId' => 'exact', 'status' => 'exact', 'entityName' => 'ipartial'])] #[ApiFilter(OrderFilter::class, properties: ['createdAt', 'authorName', 'status'])] #[ApiResource( + description: 'Commentaires et annotations. Permet aux utilisateurs de commenter les machines, pièces, composants, produits et catégories. Les commentaires peuvent être marqués comme résolus.', operations: [ new Get(security: "is_granted('ROLE_VIEWER')"), new GetCollection(security: "is_granted('ROLE_VIEWER')"), @@ -35,6 +37,8 @@ use Doctrine\ORM\Mapping as ORM; )] class Comment { + use CuidEntityTrait; + #[ORM\Id] #[ORM\Column(type: Types::STRING, length: 36)] private ?string $id = null; @@ -75,29 +79,12 @@ class Comment #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updated_at')] private DateTimeImmutable $updatedAt; - #[ORM\PrePersist] - public function setCreatedAtValue(): void - { - $now = new DateTimeImmutable(); - $this->createdAt = $now; - $this->updatedAt = $now; - - if (null === $this->id) { - $this->id = $this->generateCuid(); - } - } - - #[ORM\PreUpdate] - public function setUpdatedAtValue(): void + public function __construct() { + $this->createdAt = new DateTimeImmutable(); $this->updatedAt = new DateTimeImmutable(); } - public function getId(): ?string - { - return $this->id; - } - public function getContent(): string { return $this->content; @@ -217,19 +204,4 @@ class Comment return $this; } - - public function getCreatedAt(): DateTimeImmutable - { - return $this->createdAt; - } - - public function getUpdatedAt(): DateTimeImmutable - { - return $this->updatedAt; - } - - private function generateCuid(): string - { - return 'cl'.bin2hex(random_bytes(12)); - } } diff --git a/src/Entity/Composant.php b/src/Entity/Composant.php index fcb9f2f..d7033e2 100644 --- a/src/Entity/Composant.php +++ b/src/Entity/Composant.php @@ -14,6 +14,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; +use App\Entity\Trait\CuidEntityTrait; use App\Repository\ComposantRepository; use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; @@ -28,6 +29,7 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typeComposant' => 'exact', 'typeComposant.name' => 'ipartial'])] #[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])] #[ApiResource( + description: 'Composants du catalogue. Un composant représente un élément fonctionnel rattaché à une machine, avec un type, des fournisseurs et des documents.', operations: [ new Get(security: "is_granted('ROLE_VIEWER')"), new GetCollection(security: "is_granted('ROLE_VIEWER')"), @@ -42,6 +44,8 @@ use Symfony\Component\Serializer\Attribute\Groups; )] class Composant { + use CuidEntityTrait; + #[ORM\Id] #[ORM\Column(type: Types::STRING, length: 36)] #[Groups(['composant:read', 'document:list'])] @@ -119,42 +123,14 @@ class Composant public function __construct() { + $this->createdAt = new DateTimeImmutable(); + $this->updatedAt = new DateTimeImmutable(); $this->constructeurs = new ArrayCollection(); $this->documents = new ArrayCollection(); $this->customFieldValues = new ArrayCollection(); $this->machineLinks = new ArrayCollection(); } - #[ORM\PrePersist] - public function setCreatedAtValue(): void - { - $now = new DateTimeImmutable(); - $this->createdAt = $now; - $this->updatedAt = $now; - - if (null === $this->id) { - $this->id = $this->generateCuid(); - } - } - - #[ORM\PreUpdate] - public function setUpdatedAtValue(): void - { - $this->updatedAt = new DateTimeImmutable(); - } - - public function getId(): ?string - { - return $this->id; - } - - public function setId(string $id): static - { - $this->id = $id; - - return $this; - } - public function getName(): string { return $this->name; @@ -294,19 +270,4 @@ class Composant { return $this->customFieldValues; } - - public function getCreatedAt(): DateTimeImmutable - { - return $this->createdAt; - } - - public function getUpdatedAt(): DateTimeImmutable - { - return $this->updatedAt; - } - - private function generateCuid(): string - { - return 'cl'.bin2hex(random_bytes(12)); - } } diff --git a/src/Entity/Constructeur.php b/src/Entity/Constructeur.php index 2bc9d1f..c710496 100644 --- a/src/Entity/Constructeur.php +++ b/src/Entity/Constructeur.php @@ -11,6 +11,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; +use App\Entity\Trait\CuidEntityTrait; use App\Repository\ConstructeurRepository; use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; @@ -18,12 +19,14 @@ use Doctrine\Common\Collections\Collection; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; +use Symfony\Component\Validator\Constraints as Assert; #[UniqueEntity(fields: ['name'], message: 'Un fournisseur avec ce nom existe déjà.')] #[ORM\Entity(repositoryClass: ConstructeurRepository::class)] #[ORM\Table(name: 'constructeurs')] #[ORM\HasLifecycleCallbacks] #[ApiResource( + description: 'Fournisseurs et constructeurs. Référentiel partagé entre les machines, pièces, composants et produits pour identifier les fabricants et distributeurs.', operations: [ new Get(security: "is_granted('ROLE_VIEWER')"), new GetCollection(security: "is_granted('ROLE_VIEWER')"), @@ -37,12 +40,15 @@ use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; )] class Constructeur { + use CuidEntityTrait; + #[ORM\Id] #[ORM\Column(type: Types::STRING, length: 36)] private ?string $id = null; #[ORM\Column(type: Types::STRING, length: 255, unique: true)] - private string $name; + #[Assert\NotBlank(message: 'Le nom est obligatoire.')] + private ?string $name = null; #[ORM\Column(type: Types::STRING, length: 255, nullable: true)] private ?string $email = null; @@ -82,43 +88,15 @@ class Constructeur public function __construct() { + $this->createdAt = new DateTimeImmutable(); + $this->updatedAt = new DateTimeImmutable(); $this->machines = new ArrayCollection(); $this->composants = new ArrayCollection(); $this->pieces = new ArrayCollection(); $this->products = new ArrayCollection(); } - #[ORM\PrePersist] - public function setCreatedAtValue(): void - { - $now = new DateTimeImmutable(); - $this->createdAt = $now; - $this->updatedAt = $now; - - if (null === $this->id) { - $this->id = $this->generateCuid(); - } - } - - #[ORM\PreUpdate] - public function setUpdatedAtValue(): void - { - $this->updatedAt = new DateTimeImmutable(); - } - - public function getId(): ?string - { - return $this->id; - } - - public function setId(string $id): static - { - $this->id = $id; - - return $this; - } - - public function getName(): string + public function getName(): ?string { return $this->name; } @@ -153,19 +131,4 @@ class Constructeur return $this; } - - public function getCreatedAt(): DateTimeImmutable - { - return $this->createdAt; - } - - public function getUpdatedAt(): DateTimeImmutable - { - return $this->updatedAt; - } - - private function generateCuid(): string - { - return 'cl'.bin2hex(random_bytes(12)); - } } diff --git a/src/Entity/CustomField.php b/src/Entity/CustomField.php index e721e6d..2f12d52 100644 --- a/src/Entity/CustomField.php +++ b/src/Entity/CustomField.php @@ -11,6 +11,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; +use App\Entity\Trait\CuidEntityTrait; use App\Repository\CustomFieldRepository; use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; @@ -23,6 +24,7 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ORM\Table(name: 'custom_fields')] #[ORM\HasLifecycleCallbacks] #[ApiResource( + description: 'Définitions de champs personnalisés. Permet de créer des champs dynamiques (texte, nombre, date, etc.) applicables aux machines, pièces, composants et produits.', operations: [ new Get(security: "is_granted('ROLE_VIEWER')"), new GetCollection(security: "is_granted('ROLE_VIEWER')"), @@ -34,6 +36,8 @@ use Symfony\Component\Serializer\Attribute\Groups; )] class CustomField { + use CuidEntityTrait; + #[ORM\Id] #[ORM\Column(type: Types::STRING, length: 36)] #[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])] @@ -92,39 +96,11 @@ class CustomField public function __construct() { + $this->createdAt = new DateTimeImmutable(); + $this->updatedAt = new DateTimeImmutable(); $this->customFieldValues = new ArrayCollection(); } - #[ORM\PrePersist] - public function setCreatedAtValue(): void - { - $now = new DateTimeImmutable(); - $this->createdAt = $now; - $this->updatedAt = $now; - - if (null === $this->id) { - $this->id = $this->generateCuid(); - } - } - - #[ORM\PreUpdate] - public function setUpdatedAtValue(): void - { - $this->updatedAt = new DateTimeImmutable(); - } - - public function getId(): ?string - { - return $this->id; - } - - public function setId(string $id): static - { - $this->id = $id; - - return $this; - } - public function getName(): string { return $this->name; @@ -208,19 +184,4 @@ class CustomField return $this; } - - public function getCreatedAt(): DateTimeImmutable - { - return $this->createdAt; - } - - public function getUpdatedAt(): DateTimeImmutable - { - return $this->updatedAt; - } - - private function generateCuid(): string - { - return 'cl'.bin2hex(random_bytes(12)); - } } diff --git a/src/Entity/CustomFieldValue.php b/src/Entity/CustomFieldValue.php index 1be1177..507d028 100644 --- a/src/Entity/CustomFieldValue.php +++ b/src/Entity/CustomFieldValue.php @@ -11,6 +11,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; +use App\Entity\Trait\CuidEntityTrait; use App\Repository\CustomFieldValueRepository; use DateTimeImmutable; use Doctrine\DBAL\Types\Types; @@ -21,6 +22,7 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ORM\Table(name: 'custom_field_values')] #[ORM\HasLifecycleCallbacks] #[ApiResource( + description: 'Valeurs des champs personnalisés. Stocke la valeur concrète d\'un champ personnalisé pour une entité donnée (machine, pièce, composant ou produit).', operations: [ new Get(security: "is_granted('ROLE_VIEWER')"), new GetCollection(security: "is_granted('ROLE_VIEWER')"), @@ -32,6 +34,8 @@ use Symfony\Component\Serializer\Attribute\Groups; )] class CustomFieldValue { + use CuidEntityTrait; + #[ORM\Id] #[ORM\Column(type: Types::STRING, length: 36)] #[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])] @@ -70,36 +74,12 @@ class CustomFieldValue #[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])] private DateTimeImmutable $updatedAt; - #[ORM\PrePersist] - public function setCreatedAtValue(): void - { - $now = new DateTimeImmutable(); - $this->createdAt = $now; - $this->updatedAt = $now; - - if (null === $this->id) { - $this->id = $this->generateCuid(); - } - } - - #[ORM\PreUpdate] - public function setUpdatedAtValue(): void + public function __construct() { + $this->createdAt = new DateTimeImmutable(); $this->updatedAt = new DateTimeImmutable(); } - public function getId(): ?string - { - return $this->id; - } - - public function setId(string $id): static - { - $this->id = $id; - - return $this; - } - public function getValue(): string { return $this->value; @@ -171,19 +151,4 @@ class CustomFieldValue return $this; } - - public function getCreatedAt(): DateTimeImmutable - { - return $this->createdAt; - } - - public function getUpdatedAt(): DateTimeImmutable - { - return $this->updatedAt; - } - - private function generateCuid(): string - { - return 'cl'.bin2hex(random_bytes(12)); - } } diff --git a/src/Entity/Machine.php b/src/Entity/Machine.php index 8210980..fe0901d 100644 --- a/src/Entity/Machine.php +++ b/src/Entity/Machine.php @@ -11,6 +11,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; +use App\Entity\Trait\CuidEntityTrait; use App\Repository\MachineRepository; use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; @@ -18,11 +19,13 @@ use Doctrine\Common\Collections\Collection; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; +use Symfony\Component\Validator\Constraints as Assert; #[ORM\Entity(repositoryClass: MachineRepository::class)] #[ORM\Table(name: 'machines')] #[ORM\HasLifecycleCallbacks] #[ApiResource( + description: 'Machines industrielles rattachées à un site. Chaque machine possède une structure hiérarchique de composants, pièces et produits, ainsi que des champs personnalisés et des documents.', operations: [ new Get(security: "is_granted('ROLE_VIEWER')"), new GetCollection(security: "is_granted('ROLE_VIEWER')"), @@ -34,6 +37,7 @@ use Symfony\Component\Serializer\Attribute\Groups; )] class Machine { + use CuidEntityTrait; #[ORM\Id] #[ORM\Column(type: Types::STRING, length: 36)] #[Groups(['document:list'])] @@ -51,7 +55,8 @@ class Machine #[ORM\ManyToOne(targetEntity: Site::class, inversedBy: 'machines')] #[ORM\JoinColumn(name: 'siteId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] - private Site $site; + #[Assert\NotNull(message: 'Le site est obligatoire.')] + private ?Site $site = null; /** * @var Collection @@ -108,6 +113,9 @@ class Machine public function __construct() { + $now = new DateTimeImmutable(); + $this->createdAt = $now; + $this->updatedAt = $now; $this->constructeurs = new ArrayCollection(); $this->componentLinks = new ArrayCollection(); $this->pieceLinks = new ArrayCollection(); @@ -117,36 +125,6 @@ class Machine $this->customFieldValues = new ArrayCollection(); } - #[ORM\PrePersist] - public function setCreatedAtValue(): void - { - $now = new DateTimeImmutable(); - $this->createdAt = $now; - $this->updatedAt = $now; - - if (null === $this->id) { - $this->id = $this->generateCuid(); - } - } - - #[ORM\PreUpdate] - public function setUpdatedAtValue(): void - { - $this->updatedAt = new DateTimeImmutable(); - } - - public function getId(): ?string - { - return $this->id; - } - - public function setId(string $id): static - { - $this->id = $id; - - return $this; - } - public function getName(): string { return $this->name; @@ -183,12 +161,12 @@ class Machine return $this; } - public function getSite(): Site + public function getSite(): ?Site { return $this->site; } - public function setSite(Site $site): static + public function setSite(?Site $site): static { $this->site = $site; @@ -271,19 +249,4 @@ class Machine { return $this->customFieldValues; } - - public function getCreatedAt(): DateTimeImmutable - { - return $this->createdAt; - } - - public function getUpdatedAt(): DateTimeImmutable - { - return $this->updatedAt; - } - - private function generateCuid(): string - { - return 'cl'.bin2hex(random_bytes(12)); - } } diff --git a/src/Entity/MachineComponentLink.php b/src/Entity/MachineComponentLink.php index f907850..a815a41 100644 --- a/src/Entity/MachineComponentLink.php +++ b/src/Entity/MachineComponentLink.php @@ -11,6 +11,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; +use App\Entity\Trait\CuidEntityTrait; use App\Repository\MachineComponentLinkRepository; use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; @@ -22,6 +23,7 @@ use Doctrine\ORM\Mapping as ORM; #[ORM\Table(name: 'machine_component_links')] #[ORM\HasLifecycleCallbacks] #[ApiResource( + description: 'Liaisons machine–composant. Représente le rattachement d\'un composant à une machine, avec quantité, position dans la hiérarchie et surcharges éventuelles (nom, référence).', operations: [ new Get(security: "is_granted('ROLE_VIEWER')"), new GetCollection(security: "is_granted('ROLE_VIEWER')"), @@ -33,6 +35,8 @@ use Doctrine\ORM\Mapping as ORM; )] class MachineComponentLink { + use CuidEntityTrait; + #[ORM\Id] #[ORM\Column(type: Types::STRING, length: 36)] private ?string $id = null; @@ -84,41 +88,13 @@ class MachineComponentLink public function __construct() { + $this->createdAt = new DateTimeImmutable(); + $this->updatedAt = new DateTimeImmutable(); $this->childLinks = new ArrayCollection(); $this->pieceLinks = new ArrayCollection(); $this->productLinks = new ArrayCollection(); } - #[ORM\PrePersist] - public function setCreatedAtValue(): void - { - $now = new DateTimeImmutable(); - $this->createdAt = $now; - $this->updatedAt = $now; - - if (null === $this->id) { - $this->id = $this->generateCuid(); - } - } - - #[ORM\PreUpdate] - public function setUpdatedAtValue(): void - { - $this->updatedAt = new DateTimeImmutable(); - } - - public function getId(): ?string - { - return $this->id; - } - - public function setId(string $id): static - { - $this->id = $id; - - return $this; - } - public function getMachine(): Machine { return $this->machine; @@ -190,9 +166,4 @@ class MachineComponentLink return $this; } - - private function generateCuid(): string - { - return 'cl'.bin2hex(random_bytes(12)); - } } diff --git a/src/Entity/MachinePieceLink.php b/src/Entity/MachinePieceLink.php index 19b3b47..54c8e39 100644 --- a/src/Entity/MachinePieceLink.php +++ b/src/Entity/MachinePieceLink.php @@ -11,6 +11,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; +use App\Entity\Trait\CuidEntityTrait; use App\Repository\MachinePieceLinkRepository; use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; @@ -22,6 +23,7 @@ use Doctrine\ORM\Mapping as ORM; #[ORM\Table(name: 'machine_piece_links')] #[ORM\HasLifecycleCallbacks] #[ApiResource( + description: 'Liaisons machine–pièce. Représente le rattachement d\'une pièce à une machine, avec quantité, position dans la hiérarchie et surcharges éventuelles (nom, référence, prix).', operations: [ new Get(security: "is_granted('ROLE_VIEWER')"), new GetCollection(security: "is_granted('ROLE_VIEWER')"), @@ -33,6 +35,8 @@ use Doctrine\ORM\Mapping as ORM; )] class MachinePieceLink { + use CuidEntityTrait; + #[ORM\Id] #[ORM\Column(type: Types::STRING, length: 36)] private ?string $id = null; @@ -72,39 +76,11 @@ class MachinePieceLink public function __construct() { + $this->createdAt = new DateTimeImmutable(); + $this->updatedAt = new DateTimeImmutable(); $this->productLinks = new ArrayCollection(); } - #[ORM\PrePersist] - public function setCreatedAtValue(): void - { - $now = new DateTimeImmutable(); - $this->createdAt = $now; - $this->updatedAt = $now; - - if (null === $this->id) { - $this->id = $this->generateCuid(); - } - } - - #[ORM\PreUpdate] - public function setUpdatedAtValue(): void - { - $this->updatedAt = new DateTimeImmutable(); - } - - public function getId(): ?string - { - return $this->id; - } - - public function setId(string $id): static - { - $this->id = $id; - - return $this; - } - public function getMachine(): Machine { return $this->machine; @@ -176,9 +152,4 @@ class MachinePieceLink return $this; } - - private function generateCuid(): string - { - return 'cl'.bin2hex(random_bytes(12)); - } } diff --git a/src/Entity/MachineProductLink.php b/src/Entity/MachineProductLink.php index db11867..677e5db 100644 --- a/src/Entity/MachineProductLink.php +++ b/src/Entity/MachineProductLink.php @@ -11,6 +11,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; +use App\Entity\Trait\CuidEntityTrait; use App\Repository\MachineProductLinkRepository; use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; @@ -22,6 +23,7 @@ use Doctrine\ORM\Mapping as ORM; #[ORM\Table(name: 'machine_product_links')] #[ORM\HasLifecycleCallbacks] #[ApiResource( + description: 'Liaisons machine–produit. Représente le rattachement d\'un produit à une machine, avec quantité, position dans la hiérarchie et surcharges éventuelles (nom, référence, prix).', operations: [ new Get(security: "is_granted('ROLE_VIEWER')"), new GetCollection(security: "is_granted('ROLE_VIEWER')"), @@ -33,6 +35,8 @@ use Doctrine\ORM\Mapping as ORM; )] class MachineProductLink { + use CuidEntityTrait; + #[ORM\Id] #[ORM\Column(type: Types::STRING, length: 36)] private ?string $id = null; @@ -71,39 +75,11 @@ class MachineProductLink public function __construct() { + $this->createdAt = new DateTimeImmutable(); + $this->updatedAt = new DateTimeImmutable(); $this->childLinks = new ArrayCollection(); } - #[ORM\PrePersist] - public function setCreatedAtValue(): void - { - $now = new DateTimeImmutable(); - $this->createdAt = $now; - $this->updatedAt = $now; - - if (null === $this->id) { - $this->id = $this->generateCuid(); - } - } - - #[ORM\PreUpdate] - public function setUpdatedAtValue(): void - { - $this->updatedAt = new DateTimeImmutable(); - } - - public function getId(): ?string - { - return $this->id; - } - - public function setId(string $id): static - { - $this->id = $id; - - return $this; - } - public function getMachine(): Machine { return $this->machine; @@ -163,9 +139,4 @@ class MachineProductLink return $this; } - - private function generateCuid(): string - { - return 'cl'.bin2hex(random_bytes(12)); - } } diff --git a/src/Entity/ModelType.php b/src/Entity/ModelType.php index ac54467..832501d 100644 --- a/src/Entity/ModelType.php +++ b/src/Entity/ModelType.php @@ -14,6 +14,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; +use App\Entity\Trait\CuidEntityTrait; use App\Enum\ModelCategory; use App\Repository\ModelTypeRepository; use DateTimeImmutable; @@ -30,6 +31,7 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiFilter(SearchFilter::class, properties: ['category' => 'exact', 'name' => 'ipartial'])] #[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])] #[ApiResource( + description: 'Types et catégories. Référentiel de classification pour les machines, pièces, composants et produits. Chaque type appartient à une catégorie (machine, piece, composant, product) et peut être converti.', operations: [ new Get(security: "is_granted('ROLE_VIEWER')"), new GetCollection(security: "is_granted('ROLE_VIEWER')"), @@ -43,6 +45,8 @@ use Symfony\Component\Serializer\Attribute\Groups; )] class ModelType { + use CuidEntityTrait; + #[ORM\Id] #[ORM\Column(type: Types::STRING, length: 36)] #[Groups(['type_machine:read', 'model_type:read', 'product:read', 'composant:read', 'piece:read'])] @@ -128,6 +132,8 @@ class ModelType public function __construct() { + $this->createdAt = new DateTimeImmutable(); + $this->updatedAt = new DateTimeImmutable(); $this->composants = new ArrayCollection(); $this->pieces = new ArrayCollection(); $this->products = new ArrayCollection(); @@ -136,36 +142,6 @@ class ModelType $this->productCustomFields = new ArrayCollection(); } - #[ORM\PrePersist] - public function setCreatedAtValue(): void - { - $now = new DateTimeImmutable(); - $this->createdAt = $now; - $this->updatedAt = $now; - - if (null === $this->id) { - $this->id = $this->generateCuid(); - } - } - - #[ORM\PreUpdate] - public function setUpdatedAtValue(): void - { - $this->updatedAt = new DateTimeImmutable(); - } - - public function getId(): ?string - { - return $this->id; - } - - public function setId(string $id): static - { - $this->id = $id; - - return $this; - } - public function getName(): string { return $this->name; @@ -315,21 +291,6 @@ class ModelType return $this->productCustomFields; } - public function getCreatedAt(): DateTimeImmutable - { - return $this->createdAt; - } - - public function getUpdatedAt(): DateTimeImmutable - { - return $this->updatedAt; - } - - private function generateCuid(): string - { - return 'cl'.bin2hex(random_bytes(12)); - } - private function applyStructureForCategory(?array $structure, ModelCategory $category): void { if (ModelCategory::COMPONENT === $category) { diff --git a/src/Entity/Piece.php b/src/Entity/Piece.php index 74f3902..e8ca2dd 100644 --- a/src/Entity/Piece.php +++ b/src/Entity/Piece.php @@ -14,6 +14,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; +use App\Entity\Trait\CuidEntityTrait; use App\Repository\PieceRepository; use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; @@ -30,6 +31,7 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typePiece' => 'exact', 'typePiece.name' => 'ipartial'])] #[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])] #[ApiResource( + description: 'Pièces détachées du catalogue. Une pièce peut être rattachée à plusieurs machines et possède un type, des fournisseurs, des documents et un produit associé.', operations: [ new Get(security: "is_granted('ROLE_VIEWER')"), new GetCollection(security: "is_granted('ROLE_VIEWER')"), @@ -44,6 +46,8 @@ use Symfony\Component\Serializer\Attribute\Groups; )] class Piece { + use CuidEntityTrait; + #[ORM\Id] #[ORM\Column(type: Types::STRING, length: 36)] #[Groups(['piece:read', 'document:list'])] @@ -121,42 +125,14 @@ class Piece public function __construct() { + $this->createdAt = new DateTimeImmutable(); + $this->updatedAt = new DateTimeImmutable(); $this->constructeurs = new ArrayCollection(); $this->documents = new ArrayCollection(); $this->customFieldValues = new ArrayCollection(); $this->machineLinks = new ArrayCollection(); } - #[ORM\PrePersist] - public function setCreatedAtValue(): void - { - $now = new DateTimeImmutable(); - $this->createdAt = $now; - $this->updatedAt = $now; - - if (null === $this->id) { - $this->id = $this->generateCuid(); - } - } - - #[ORM\PreUpdate] - public function setUpdatedAtValue(): void - { - $this->updatedAt = new DateTimeImmutable(); - } - - public function getId(): ?string - { - return $this->id; - } - - public function setId(string $id): static - { - $this->id = $id; - - return $this; - } - public function getName(): string { return $this->name; @@ -322,19 +298,4 @@ class Piece { return $this->customFieldValues; } - - public function getCreatedAt(): DateTimeImmutable - { - return $this->createdAt; - } - - public function getUpdatedAt(): DateTimeImmutable - { - return $this->updatedAt; - } - - private function generateCuid(): string - { - return 'cl'.bin2hex(random_bytes(12)); - } } diff --git a/src/Entity/Product.php b/src/Entity/Product.php index ca0f0f8..5e0acc9 100644 --- a/src/Entity/Product.php +++ b/src/Entity/Product.php @@ -14,6 +14,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; +use App\Entity\Trait\CuidEntityTrait; use App\Repository\ProductRepository; use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; @@ -28,6 +29,7 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typeProduct' => 'exact', 'typeProduct.name' => 'ipartial'])] #[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt', 'supplierPrice'])] #[ApiResource( + description: 'Produits du catalogue fournisseur. Un produit possède une référence, un prix indicatif, un type, des fournisseurs et des documents. Il peut être lié à des machines.', operations: [ new Get(security: "is_granted('ROLE_VIEWER')"), new GetCollection(security: "is_granted('ROLE_VIEWER')"), @@ -42,6 +44,8 @@ use Symfony\Component\Serializer\Attribute\Groups; )] class Product { + use CuidEntityTrait; + #[ORM\Id] #[ORM\Column(type: Types::STRING, length: 36)] #[Groups(['product:read', 'document:list'])] @@ -118,6 +122,8 @@ class Product public function __construct() { + $this->createdAt = new DateTimeImmutable(); + $this->updatedAt = new DateTimeImmutable(); $this->constructeurs = new ArrayCollection(); $this->documents = new ArrayCollection(); $this->customFieldValues = new ArrayCollection(); @@ -126,36 +132,6 @@ class Product $this->machineLinks = new ArrayCollection(); } - #[ORM\PrePersist] - public function setCreatedAtValue(): void - { - $now = new DateTimeImmutable(); - $this->createdAt = $now; - $this->updatedAt = $now; - - if (null === $this->id) { - $this->id = $this->generateCuid(); - } - } - - #[ORM\PreUpdate] - public function setUpdatedAtValue(): void - { - $this->updatedAt = new DateTimeImmutable(); - } - - public function getId(): ?string - { - return $this->id; - } - - public function setId(string $id): static - { - $this->id = $id; - - return $this; - } - public function getName(): string { return $this->name; @@ -243,19 +219,4 @@ class Product { return $this->customFieldValues; } - - public function getCreatedAt(): DateTimeImmutable - { - return $this->createdAt; - } - - public function getUpdatedAt(): DateTimeImmutable - { - return $this->updatedAt; - } - - private function generateCuid(): string - { - return 'cl'.bin2hex(random_bytes(12)); - } } diff --git a/src/Entity/Site.php b/src/Entity/Site.php index 816d55c..689ef2e 100644 --- a/src/Entity/Site.php +++ b/src/Entity/Site.php @@ -11,6 +11,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; +use App\Entity\Trait\CuidEntityTrait; use App\Repository\SiteRepository; use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; @@ -24,6 +25,7 @@ use Symfony\Component\Validator\Constraints as Assert; #[ORM\Table(name: 'sites')] #[ORM\HasLifecycleCallbacks] #[ApiResource( + description: 'Sites industriels. Chaque site regroupe des machines et peut avoir ses propres documents. Un site possède un nom, une adresse et des coordonnées de contact.', operations: [ new Get(security: "is_granted('ROLE_VIEWER')"), new GetCollection(security: "is_granted('ROLE_VIEWER')"), @@ -37,6 +39,8 @@ use Symfony\Component\Validator\Constraints as Assert; )] class Site { + use CuidEntityTrait; + #[ORM\Id] #[ORM\Column(type: Types::STRING, length: 36)] #[Groups(['document:list'])] @@ -81,41 +85,11 @@ class Site private Collection $documents; public function __construct() - { - $this->machines = new ArrayCollection(); - $this->documents = new ArrayCollection(); - } - - #[ORM\PrePersist] - public function setCreatedAtValue(): void { $this->createdAt = new DateTimeImmutable(); $this->updatedAt = new DateTimeImmutable(); - - // Générer un ID CUID-compatible si nécessaire - if (null === $this->id) { - $this->id = $this->generateCuid(); - } - } - - #[ORM\PreUpdate] - public function setUpdatedAtValue(): void - { - $this->updatedAt = new DateTimeImmutable(); - } - - // Getters et Setters - - public function getId(): ?string - { - return $this->id; - } - - public function setId(string $id): static - { - $this->id = $id; - - return $this; + $this->machines = new ArrayCollection(); + $this->documents = new ArrayCollection(); } public function getName(): string @@ -190,16 +164,6 @@ class Site return $this; } - public function getCreatedAt(): DateTimeImmutable - { - return $this->createdAt; - } - - public function getUpdatedAt(): DateTimeImmutable - { - return $this->updatedAt; - } - /** * @return Collection */ @@ -259,10 +223,4 @@ class Site return $this; } - - private function generateCuid(): string - { - // Génération d'un ID compatible CUID (format: cl + 24 caractères) - return 'cl'.bin2hex(random_bytes(12)); - } } diff --git a/src/Entity/Trait/CuidEntityTrait.php b/src/Entity/Trait/CuidEntityTrait.php new file mode 100644 index 0000000..7d92878 --- /dev/null +++ b/src/Entity/Trait/CuidEntityTrait.php @@ -0,0 +1,56 @@ +createdAt = $now; + $this->updatedAt = $now; + + if (null === $this->id) { + $this->id = $this->generateCuid(); + } + } + + #[ORM\PreUpdate] + public function setUpdatedAtValue(): void + { + $this->updatedAt = new DateTimeImmutable(); + } + + public function getId(): ?string + { + return $this->id; + } + + public function setId(string $id): static + { + $this->id = $id; + + return $this; + } + + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } + + public function getUpdatedAt(): DateTimeImmutable + { + return $this->updatedAt; + } + + private function generateCuid(): string + { + return 'cl'.bin2hex(random_bytes(12)); + } +} diff --git a/src/EventSubscriber/AbstractAuditSubscriber.php b/src/EventSubscriber/AbstractAuditSubscriber.php new file mode 100644 index 0000000..d73ea23 --- /dev/null +++ b/src/EventSubscriber/AbstractAuditSubscriber.php @@ -0,0 +1,463 @@ +getObjectManager(); + if (!$em instanceof EntityManagerInterface) { + return; + } + + $uow = $em->getUnitOfWork(); + $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) { + continue; + } + + $normalizedOld = $this->normalizeValue($oldValue); + $normalizedNew = $this->normalizeValue($newValue); + + if ($normalizedOld === $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; + } + + 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); + $this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId)); + } + + 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) { + $snapshot = $this->snapshotEntity($entity); + $this->persistAuditLog($em, new AuditLog($entityType, $id, 'update', $diff, $snapshot, $actorProfileId)); + } + } + + 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); + $this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId)); + } + + 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; + } + + $snapshot = $pendingSnapshots[$entityId] ?? $this->snapshotEntity($entity); + $this->persistAuditLog($em, new AuditLog($entityType, $entityId, 'update', $diff, $snapshot, $actorProfileId)); + } + } + + 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; + } +} diff --git a/src/EventSubscriber/ComposantAuditSubscriber.php b/src/EventSubscriber/ComposantAuditSubscriber.php index e7a90d7..1279a03 100644 --- a/src/EventSubscriber/ComposantAuditSubscriber.php +++ b/src/EventSubscriber/ComposantAuditSubscriber.php @@ -4,394 +4,45 @@ declare(strict_types=1); namespace App\EventSubscriber; -use App\Entity\AuditLog; use App\Entity\Composant; use App\Entity\CustomFieldValue; -use App\Entity\ModelType; -use App\Entity\Product; -use App\Entity\Profile; -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 ComposantAuditSubscriber implements EventSubscriber +final class ComposantAuditSubscriber extends AbstractAuditSubscriber { - public function __construct( - private readonly RequestStack $requestStack, - private readonly Security $security, - ) {} + protected function supports(object $entity): bool + { + return $entity instanceof Composant; + } - public function getSubscribedEvents(): array + protected function entityType(): string + { + return 'composant'; + } + + protected function hasCollectionTracking(): bool + { + return true; + } + + protected function getOwnerFromCustomFieldValue(CustomFieldValue $cfv): ?object + { + return $cfv->getComposant(); + } + + protected function snapshotEntity(object $entity): array { return [ - Events::onFlush, + 'id' => $entity->getId(), + 'name' => $this->safeGet($entity, 'getName'), + 'reference' => $this->safeGet($entity, 'getReference'), + 'prix' => $this->safeGet($entity, 'getPrix'), + 'structure' => $this->safeGet($entity, 'getStructure'), + 'typeComposant' => $this->normalizeValue($this->safeGet($entity, 'getTypeComposant')), + 'product' => $this->normalizeValue($this->safeGet($entity, 'getProduct')), + 'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()), ]; } - - public function onFlush(OnFlushEventArgs $args): void - { - $em = $args->getObjectManager(); - if (!$em instanceof EntityManagerInterface) { - return; - } - - $uow = $em->getUnitOfWork(); - $actorProfileId = $this->resolveActorProfileId(); - $pendingUpdates = []; - $pendingSnapshots = []; - $pendingComponents = []; - - foreach ($uow->getScheduledEntityInsertions() as $entity) { - if (!$entity instanceof Composant) { - continue; - } - - $diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity)); - $snapshot = $this->snapshotComposant($entity); - $this->persistAuditLog($em, new AuditLog('composant', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId)); - } - - foreach ($uow->getScheduledEntityUpdates() as $entity) { - if (!$entity instanceof Composant) { - continue; - } - - $componentId = (string) $entity->getId(); - if ('' === $componentId) { - continue; - } - - $diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity)); - if ([] !== $diff) { - $pendingUpdates[$componentId] = $this->mergeDiffs($pendingUpdates[$componentId] ?? [], $diff); - $pendingSnapshots[$componentId] = $this->snapshotComposant($entity); - $pendingComponents[$componentId] = $entity; - } - } - - foreach ($uow->getScheduledEntityDeletions() as $entity) { - if (!$entity instanceof Composant) { - continue; - } - - $snapshot = $this->snapshotComposant($entity); - $this->persistAuditLog($em, new AuditLog('composant', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId)); - } - - foreach ($uow->getScheduledCollectionUpdates() as $collection) { - $this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingComponents); - } - foreach ($uow->getScheduledCollectionDeletions() as $collection) { - $this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingComponents); - } - - $this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingComponents); - - foreach ($pendingUpdates as $componentId => $diff) { - if ([] === $diff) { - continue; - } - - $component = $pendingComponents[$componentId] ?? null; - if (!$component instanceof Composant) { - continue; - } - - $snapshot = $pendingSnapshots[$componentId] ?? $this->snapshotComposant($component); - $this->persistAuditLog($em, new AuditLog('composant', $componentId, 'update', $diff, $snapshot, $actorProfileId)); - } - } - - /** - * @param array> $pendingUpdates - * @param array> $pendingSnapshots - * @param array $pendingComponents - */ - private function collectCollectionUpdate( - object $collection, - array &$pendingUpdates, - array &$pendingSnapshots, - array &$pendingComponents, - ): void { - if (!$collection instanceof PersistentCollection) { - return; - } - - $owner = $collection->getOwner(); - if (!$owner instanceof Composant) { - return; - } - - $componentId = (string) $owner->getId(); - if ('' === $componentId) { - 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[$componentId] = $this->mergeDiffs($pendingUpdates[$componentId] ?? [], $diff); - $pendingSnapshots[$componentId] = $this->snapshotComposant($owner); - $pendingComponents[$componentId] = $owner; - } - - /** - * @param array> $pendingUpdates - * @param array> $pendingSnapshots - * @param array $pendingComponents - */ - private function collectCustomFieldValueChanges( - UnitOfWork $uow, - array &$pendingUpdates, - array &$pendingSnapshots, - array &$pendingComponents, - ): void { - foreach ($uow->getScheduledEntityInsertions() as $entity) { - if ($entity instanceof CustomFieldValue) { - $this->trackCustomFieldValueChange($entity, null, $entity->getValue(), $pendingUpdates, $pendingSnapshots, $pendingComponents); - } - } - - 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, $pendingComponents); - } - } - - foreach ($uow->getScheduledEntityDeletions() as $entity) { - if ($entity instanceof CustomFieldValue) { - $this->trackCustomFieldValueChange($entity, $entity->getValue(), null, $pendingUpdates, $pendingSnapshots, $pendingComponents); - } - } - } - - /** - * @param array> $pendingUpdates - * @param array> $pendingSnapshots - * @param array $pendingComponents - */ - private function trackCustomFieldValueChange( - CustomFieldValue $cfv, - mixed $from, - mixed $to, - array &$pendingUpdates, - array &$pendingSnapshots, - array &$pendingComponents, - ): void { - $owner = $cfv->getComposant(); - if (!$owner instanceof Composant) { - 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->snapshotComposant($owner); - $pendingComponents[$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 $changeSet - * - * @return array - */ - 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 snapshotComposant(Composant $component): array - { - return [ - 'id' => $component->getId(), - 'name' => $component->getName(), - 'reference' => $component->getReference(), - 'prix' => $component->getPrix(), - 'structure' => $component->getStructure(), - 'typeComposant' => $this->normalizeValue($component->getTypeComposant()), - 'product' => $this->normalizeValue($component->getProduct()), - 'constructeurIds' => $this->normalizeCollection($component->getConstructeurs()), - ]; - } - - /** - * @param iterable $items - * - * @return list - */ - 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 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 $base - * @param array $extra - * - * @return array - */ - 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; - } } diff --git a/src/EventSubscriber/ConstructeurAuditSubscriber.php b/src/EventSubscriber/ConstructeurAuditSubscriber.php index 428dead..b506085 100644 --- a/src/EventSubscriber/ConstructeurAuditSubscriber.php +++ b/src/EventSubscriber/ConstructeurAuditSubscriber.php @@ -4,165 +4,30 @@ 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 +final class ConstructeurAuditSubscriber extends AbstractAuditSubscriber { - public function __construct( - private readonly RequestStack $requestStack, - private readonly Security $security, - ) {} + protected function supports(object $entity): bool + { + return $entity instanceof Constructeur; + } - public function getSubscribedEvents(): array + protected function entityType(): string + { + return 'constructeur'; + } + + protected function snapshotEntity(object $entity): array { return [ - Events::onFlush, + 'id' => $entity->getId(), + 'name' => $this->safeGet($entity, 'getName'), + 'email' => $this->safeGet($entity, 'getEmail'), + 'phone' => $this->safeGet($entity, 'getPhone'), ]; } - - 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 $changeSet - * - * @return array - */ - 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; - } } diff --git a/src/EventSubscriber/DocumentAuditSubscriber.php b/src/EventSubscriber/DocumentAuditSubscriber.php index f40cdeb..634a304 100644 --- a/src/EventSubscriber/DocumentAuditSubscriber.php +++ b/src/EventSubscriber/DocumentAuditSubscriber.php @@ -4,189 +4,36 @@ 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 +final class DocumentAuditSubscriber extends AbstractAuditSubscriber { - public function __construct( - private readonly RequestStack $requestStack, - private readonly Security $security, - ) {} + protected function supports(object $entity): bool + { + return $entity instanceof Document; + } - public function getSubscribedEvents(): array + protected function entityType(): string + { + return 'document'; + } + + protected function snapshotEntity(object $entity): array { return [ - Events::onFlush, + 'id' => $entity->getId(), + 'name' => $this->safeGet($entity, 'getName'), + 'filename' => $this->safeGet($entity, 'getFilename'), + 'mimeType' => $this->safeGet($entity, 'getMimeType'), + 'size' => $this->safeGet($entity, 'getSize'), + 'machine' => $this->normalizeValue($this->safeGet($entity, 'getMachine')), + 'composant' => $this->normalizeValue($this->safeGet($entity, 'getComposant')), + 'piece' => $this->normalizeValue($this->safeGet($entity, 'getPiece')), + 'product' => $this->normalizeValue($this->safeGet($entity, 'getProduct')), + 'site' => $this->normalizeValue($this->safeGet($entity, 'getSite')), ]; } - - 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 $changeSet - * - * @return array - */ - 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; - } } diff --git a/src/EventSubscriber/MachineAuditSubscriber.php b/src/EventSubscriber/MachineAuditSubscriber.php index 42dbe9c..0cbeb90 100644 --- a/src/EventSubscriber/MachineAuditSubscriber.php +++ b/src/EventSubscriber/MachineAuditSubscriber.php @@ -4,400 +4,45 @@ 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 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 +final class MachineAuditSubscriber extends AbstractAuditSubscriber { - public function __construct( - private readonly RequestStack $requestStack, - private readonly Security $security, - ) {} - - public function getSubscribedEvents(): array + protected function supports(object $entity): bool { - return [ - Events::onFlush, - ]; + return $entity instanceof Machine; } - public function onFlush(OnFlushEventArgs $args): void + protected function entityType(): string { - $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)); - } + return 'machine'; } - /** - * @param array> $pendingUpdates - * @param array> $pendingSnapshots - * @param array $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; + protected function hasCollectionTracking(): bool + { + return true; } - /** - * @param array> $pendingUpdates - * @param array> $pendingSnapshots - * @param array $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> $pendingUpdates - * @param array> $pendingSnapshots - * @param array $pendingMachines - */ - private function trackCustomFieldValueChange( - CustomFieldValue $cfv, - mixed $from, - mixed $to, - array &$pendingUpdates, - array &$pendingSnapshots, - array &$pendingMachines, - ): void { + protected function getOwnerFromCustomFieldValue(CustomFieldValue $cfv): ?object + { $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; + return $owner instanceof Machine ? $owner : null; } - 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 $changeSet - * - * @return array - */ - 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 + protected function snapshotEntity(object $entity): array { return [ - 'id' => $machine->getId(), - 'name' => $machine->getName(), - 'reference' => $machine->getReference(), - 'prix' => $machine->getPrix(), - 'site' => $this->normalizeValue($machine->getSite()), - 'constructeurIds' => $this->normalizeCollection($machine->getConstructeurs()), + 'id' => $entity->getId(), + 'name' => $this->safeGet($entity, 'getName'), + 'reference' => $this->safeGet($entity, 'getReference'), + 'prix' => $this->safeGet($entity, 'getPrix'), + 'site' => $this->normalizeValue($this->safeGet($entity, 'getSite')), + 'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()), ]; } - - /** - * @param iterable $items - * - * @return list - */ - 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 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 $base - * @param array $extra - * - * @return array - */ - 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; - } } diff --git a/src/EventSubscriber/ModelTypeAuditSubscriber.php b/src/EventSubscriber/ModelTypeAuditSubscriber.php index aa1528e..e2fecb2 100644 --- a/src/EventSubscriber/ModelTypeAuditSubscriber.php +++ b/src/EventSubscriber/ModelTypeAuditSubscriber.php @@ -4,177 +4,32 @@ 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 +final class ModelTypeAuditSubscriber extends AbstractAuditSubscriber { - public function __construct( - private readonly RequestStack $requestStack, - private readonly Security $security, - ) {} + protected function supports(object $entity): bool + { + return $entity instanceof ModelType; + } - public function getSubscribedEvents(): array + protected function entityType(): string + { + return 'model_type'; + } + + protected function snapshotEntity(object $entity): array { return [ - Events::onFlush, + 'id' => $entity->getId(), + 'name' => $this->safeGet($entity, 'getName'), + 'code' => $this->safeGet($entity, 'getCode'), + 'category' => $this->safeGet($entity, 'getCategory')?->value, + 'notes' => $this->safeGet($entity, 'getNotes'), + 'description' => $this->safeGet($entity, 'getDescription'), ]; } - - 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 $changeSet - * - * @return array - */ - 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; - } } diff --git a/src/EventSubscriber/PieceAuditSubscriber.php b/src/EventSubscriber/PieceAuditSubscriber.php index c802da3..d60c9a1 100644 --- a/src/EventSubscriber/PieceAuditSubscriber.php +++ b/src/EventSubscriber/PieceAuditSubscriber.php @@ -4,394 +4,45 @@ declare(strict_types=1); namespace App\EventSubscriber; -use App\Entity\AuditLog; use App\Entity\CustomFieldValue; -use App\Entity\ModelType; use App\Entity\Piece; -use App\Entity\Product; -use App\Entity\Profile; -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 PieceAuditSubscriber implements EventSubscriber +final class PieceAuditSubscriber extends AbstractAuditSubscriber { - public function __construct( - private readonly RequestStack $requestStack, - private readonly Security $security, - ) {} + protected function supports(object $entity): bool + { + return $entity instanceof Piece; + } - public function getSubscribedEvents(): array + protected function entityType(): string + { + return 'piece'; + } + + protected function hasCollectionTracking(): bool + { + return true; + } + + protected function getOwnerFromCustomFieldValue(CustomFieldValue $cfv): ?object + { + return $cfv->getPiece(); + } + + protected function snapshotEntity(object $entity): array { return [ - Events::onFlush, + 'id' => $entity->getId(), + 'name' => $this->safeGet($entity, 'getName'), + 'reference' => $this->safeGet($entity, 'getReference'), + 'prix' => $this->safeGet($entity, 'getPrix'), + 'typePiece' => $this->normalizeValue($this->safeGet($entity, 'getTypePiece')), + 'product' => $this->normalizeValue($this->safeGet($entity, 'getProduct')), + 'productIds' => $this->safeGet($entity, 'getProductIds') ?? [], + 'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()), ]; } - - public function onFlush(OnFlushEventArgs $args): void - { - $em = $args->getObjectManager(); - if (!$em instanceof EntityManagerInterface) { - return; - } - - $uow = $em->getUnitOfWork(); - $actorProfileId = $this->resolveActorProfileId(); - $pendingUpdates = []; - $pendingSnapshots = []; - $pendingPieces = []; - - foreach ($uow->getScheduledEntityInsertions() as $entity) { - if (!$entity instanceof Piece) { - continue; - } - - $diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity)); - $snapshot = $this->snapshotPiece($entity); - $this->persistAuditLog($em, new AuditLog('piece', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId)); - } - - foreach ($uow->getScheduledEntityUpdates() as $entity) { - if (!$entity instanceof Piece) { - continue; - } - - $pieceId = (string) $entity->getId(); - if ('' === $pieceId) { - continue; - } - - $diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity)); - if ([] !== $diff) { - $pendingUpdates[$pieceId] = $this->mergeDiffs($pendingUpdates[$pieceId] ?? [], $diff); - $pendingSnapshots[$pieceId] = $this->snapshotPiece($entity); - $pendingPieces[$pieceId] = $entity; - } - } - - foreach ($uow->getScheduledEntityDeletions() as $entity) { - if (!$entity instanceof Piece) { - continue; - } - - $snapshot = $this->snapshotPiece($entity); - $this->persistAuditLog($em, new AuditLog('piece', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId)); - } - - foreach ($uow->getScheduledCollectionUpdates() as $collection) { - $this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingPieces); - } - foreach ($uow->getScheduledCollectionDeletions() as $collection) { - $this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingPieces); - } - - $this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingPieces); - - foreach ($pendingUpdates as $pieceId => $diff) { - if ([] === $diff) { - continue; - } - - $piece = $pendingPieces[$pieceId] ?? null; - if (!$piece instanceof Piece) { - continue; - } - - $snapshot = $pendingSnapshots[$pieceId] ?? $this->snapshotPiece($piece); - $this->persistAuditLog($em, new AuditLog('piece', $pieceId, 'update', $diff, $snapshot, $actorProfileId)); - } - } - - /** - * @param array> $pendingUpdates - * @param array> $pendingSnapshots - * @param array $pendingPieces - */ - private function collectCollectionUpdate( - object $collection, - array &$pendingUpdates, - array &$pendingSnapshots, - array &$pendingPieces, - ): void { - if (!$collection instanceof PersistentCollection) { - return; - } - - $owner = $collection->getOwner(); - if (!$owner instanceof Piece) { - return; - } - - $pieceId = (string) $owner->getId(); - if ('' === $pieceId) { - 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[$pieceId] = $this->mergeDiffs($pendingUpdates[$pieceId] ?? [], $diff); - $pendingSnapshots[$pieceId] = $this->snapshotPiece($owner); - $pendingPieces[$pieceId] = $owner; - } - - /** - * @param array> $pendingUpdates - * @param array> $pendingSnapshots - * @param array $pendingPieces - */ - private function collectCustomFieldValueChanges( - UnitOfWork $uow, - array &$pendingUpdates, - array &$pendingSnapshots, - array &$pendingPieces, - ): void { - foreach ($uow->getScheduledEntityInsertions() as $entity) { - if ($entity instanceof CustomFieldValue) { - $this->trackCustomFieldValueChange($entity, null, $entity->getValue(), $pendingUpdates, $pendingSnapshots, $pendingPieces); - } - } - - 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, $pendingPieces); - } - } - - foreach ($uow->getScheduledEntityDeletions() as $entity) { - if ($entity instanceof CustomFieldValue) { - $this->trackCustomFieldValueChange($entity, $entity->getValue(), null, $pendingUpdates, $pendingSnapshots, $pendingPieces); - } - } - } - - /** - * @param array> $pendingUpdates - * @param array> $pendingSnapshots - * @param array $pendingPieces - */ - private function trackCustomFieldValueChange( - CustomFieldValue $cfv, - mixed $from, - mixed $to, - array &$pendingUpdates, - array &$pendingSnapshots, - array &$pendingPieces, - ): void { - $owner = $cfv->getPiece(); - if (!$owner instanceof Piece) { - 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->snapshotPiece($owner); - $pendingPieces[$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 $changeSet - * - * @return array - */ - 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 snapshotPiece(Piece $piece): array - { - return [ - 'id' => $piece->getId(), - 'name' => $piece->getName(), - 'reference' => $piece->getReference(), - 'prix' => $piece->getPrix(), - 'typePiece' => $this->normalizeValue($piece->getTypePiece()), - 'product' => $this->normalizeValue($piece->getProduct()), - 'productIds' => $piece->getProductIds(), - 'constructeurIds' => $this->normalizeCollection($piece->getConstructeurs()), - ]; - } - - /** - * @param iterable $items - * - * @return list - */ - 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 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 $base - * @param array $extra - * - * @return array - */ - 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; - } } diff --git a/src/EventSubscriber/ProductAuditSubscriber.php b/src/EventSubscriber/ProductAuditSubscriber.php index c620d92..b6ec6e2 100644 --- a/src/EventSubscriber/ProductAuditSubscriber.php +++ b/src/EventSubscriber/ProductAuditSubscriber.php @@ -4,393 +4,43 @@ declare(strict_types=1); namespace App\EventSubscriber; -use App\Entity\AuditLog; use App\Entity\CustomFieldValue; -use App\Entity\ModelType; use App\Entity\Product; -use App\Entity\Profile; -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; - -/** - * 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 +final class ProductAuditSubscriber extends AbstractAuditSubscriber { - public function __construct( - private readonly RequestStack $requestStack, - private readonly Security $security, - ) {} + protected function supports(object $entity): bool + { + return $entity instanceof Product; + } - public function getSubscribedEvents(): array + protected function entityType(): string + { + return 'product'; + } + + protected function hasCollectionTracking(): bool + { + return true; + } + + protected function getOwnerFromCustomFieldValue(CustomFieldValue $cfv): ?object + { + return $cfv->getProduct(); + } + + protected function snapshotEntity(object $entity): array { return [ - Events::onFlush, + 'id' => $entity->getId(), + 'name' => $this->safeGet($entity, 'getName'), + 'reference' => $this->safeGet($entity, 'getReference'), + 'supplierPrice' => $this->safeGet($entity, 'getSupplierPrice'), + 'typeProduct' => $this->normalizeValue($this->safeGet($entity, 'getTypeProduct')), + 'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()), ]; } - - 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); - } - - $this->collectCustomFieldValueChanges($uow, $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> $pendingUpdates - * @param array> $pendingSnapshots - * @param array $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 ('constructeurs' !== $fieldName) { - 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; - } - - /** - * @param array> $pendingUpdates - * @param array> $pendingSnapshots - * @param array $pendingProducts - */ - private function collectCustomFieldValueChanges( - UnitOfWork $uow, - array &$pendingUpdates, - array &$pendingSnapshots, - array &$pendingProducts, - ): void { - foreach ($uow->getScheduledEntityInsertions() as $entity) { - if ($entity instanceof CustomFieldValue) { - $this->trackCustomFieldValueChange($entity, null, $entity->getValue(), $pendingUpdates, $pendingSnapshots, $pendingProducts); - } - } - - 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, $pendingProducts); - } - } - - foreach ($uow->getScheduledEntityDeletions() as $entity) { - if ($entity instanceof CustomFieldValue) { - $this->trackCustomFieldValueChange($entity, $entity->getValue(), null, $pendingUpdates, $pendingSnapshots, $pendingProducts); - } - } - } - - /** - * @param array> $pendingUpdates - * @param array> $pendingSnapshots - * @param array $pendingProducts - */ - private function trackCustomFieldValueChange( - CustomFieldValue $cfv, - mixed $from, - mixed $to, - array &$pendingUpdates, - array &$pendingSnapshots, - array &$pendingProducts, - ): void { - $owner = $cfv->getProduct(); - if (!$owner instanceof Product) { - 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->snapshotProduct($owner); - $pendingProducts[$ownerId] = $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 $changeSet - * - * @return array - */ - private function buildDiffFromChangeSet(array $changeSet): array - { - $diff = []; - foreach ($changeSet as $field => [$oldValue, $newValue]) { - // Skip noisy timestamps managed automatically. - 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 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 $base - * @param array $extra - * - * @return array - */ - private function mergeDiffs(array $base, array $extra): array - { - foreach ($extra as $field => $change) { - $base[$field] = $change; - } - - return $base; - } - - /** - * @param iterable $items - * - * @return list - */ - 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 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 - { - 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; - } } diff --git a/src/OpenApi/OpenApiDecorator.php b/src/OpenApi/OpenApiDecorator.php new file mode 100644 index 0000000..deff2ef --- /dev/null +++ b/src/OpenApi/OpenApiDecorator.php @@ -0,0 +1,520 @@ +decorated)($context); + + $this->addHealthCheck($openApi); + $this->addSessionRoutes($openApi); + $this->addAdminProfileRoutes($openApi); + $this->addActivityLogs($openApi); + $this->addCommentRoutes($openApi); + $this->addCustomFieldValueRoutes($openApi); + $this->addDocumentQueryRoutes($openApi); + $this->addDocumentServeRoutes($openApi); + $this->addEntityHistoryRoutes($openApi); + $this->addMachineStructureRoutes($openApi); + $this->addMachineCustomFieldsRoutes($openApi); + $this->addModelTypeConversionRoutes($openApi); + + return $this->addTagDescriptions($openApi); + } + + private function addHealthCheck(OpenApi $openApi): void + { + $openApi->getPaths()->addPath('/api/health', new Model\PathItem( + get: new Model\Operation( + operationId: 'getHealthCheck', + tags: ['Monitoring'], + summary: 'Vérification de santé du système', + description: 'Retourne le statut du système, la version, la latence BDD et la mémoire utilisée.', + responses: [ + '200' => $this->jsonResponse('Système opérationnel.'), + '503' => $this->jsonResponse('Système dégradé.'), + ], + ), + )); + } + + private function addSessionRoutes(OpenApi $openApi): void + { + $openApi->getPaths()->addPath('/api/session/profiles', new Model\PathItem( + get: new Model\Operation( + operationId: 'getSessionProfiles', + tags: ['Session'], + summary: 'Lister les profils disponibles', + description: 'Retourne les profils actifs (id, prénom, nom, hasPassword). Aucune authentification requise.', + responses: ['200' => $this->jsonResponse('Liste des profils.')], + ), + )); + + $openApi->getPaths()->addPath('/api/session/profile', new Model\PathItem( + get: new Model\Operation( + operationId: 'getSessionProfile', + tags: ['Session'], + summary: 'Profil actif de la session', + description: 'Retourne le profil actuellement connecté via la session.', + responses: [ + '200' => $this->jsonResponse('Profil actif.'), + '401' => $this->jsonResponse('Aucun profil actif.'), + ], + ), + post: new Model\Operation( + operationId: 'loginSessionProfile', + tags: ['Session'], + summary: 'Connexion — activer un profil', + description: 'Active un profil dans la session. Requiert profileId et password.', + requestBody: $this->jsonRequestBody('Identifiants de connexion.'), + responses: [ + '200' => $this->jsonResponse('Connexion réussie.'), + '401' => $this->jsonResponse('Mot de passe incorrect.'), + '404' => $this->jsonResponse('Profil introuvable.'), + ], + ), + delete: new Model\Operation( + operationId: 'logoutSessionProfile', + tags: ['Session'], + summary: 'Déconnexion — invalider la session', + responses: ['200' => $this->jsonResponse('Session invalidée.')], + ), + )); + } + + private function addAdminProfileRoutes(OpenApi $openApi): void + { + $openApi->getPaths()->addPath('/api/admin/profiles', new Model\PathItem( + get: new Model\Operation( + operationId: 'adminListProfiles', + tags: ['Admin — Profils'], + summary: 'Lister tous les profils', + description: 'Liste complète des profils triés par prénom. Requiert ROLE_ADMIN.', + responses: ['200' => $this->jsonResponse('Liste des profils.')], + ), + post: new Model\Operation( + operationId: 'adminCreateProfile', + tags: ['Admin — Profils'], + summary: 'Créer un profil', + description: 'Crée un nouveau profil avec rôle. Requiert ROLE_ADMIN.', + requestBody: $this->jsonRequestBody('Données du profil (firstName, lastName, email, password, role).'), + responses: [ + '201' => $this->jsonResponse('Profil créé.'), + '400' => $this->jsonResponse('Données invalides.'), + '409' => $this->jsonResponse('Email déjà utilisé.'), + ], + ), + )); + + $idParam = $this->pathParam('id', 'Identifiant du profil'); + + $openApi->getPaths()->addPath('/api/admin/profiles/{id}/role', new Model\PathItem( + put: new Model\Operation( + operationId: 'adminUpdateProfileRole', + tags: ['Admin — Profils'], + summary: 'Modifier le rôle d\'un profil', + description: 'Change le rôle d\'un profil. Empêche la suppression du dernier admin. Requiert ROLE_ADMIN.', + parameters: [$idParam], + requestBody: $this->jsonRequestBody('Nouveau rôle.'), + responses: [ + '200' => $this->jsonResponse('Rôle mis à jour.'), + '400' => $this->jsonResponse('Rôle invalide ou dernier admin.'), + ], + ), + )); + + $openApi->getPaths()->addPath('/api/admin/profiles/{id}/password', new Model\PathItem( + put: new Model\Operation( + operationId: 'adminUpdateProfilePassword', + tags: ['Admin — Profils'], + summary: 'Modifier le mot de passe d\'un profil', + description: 'Requiert ROLE_ADMIN.', + parameters: [$idParam], + requestBody: $this->jsonRequestBody('Nouveau mot de passe.'), + responses: ['200' => $this->jsonResponse('Mot de passe mis à jour.')], + ), + )); + + $openApi->getPaths()->addPath('/api/admin/profiles/{id}/deactivate', new Model\PathItem( + put: new Model\Operation( + operationId: 'adminDeactivateProfile', + tags: ['Admin — Profils'], + summary: 'Désactiver un profil', + description: 'Désactive un profil. Empêche la désactivation du dernier admin. Requiert ROLE_ADMIN.', + parameters: [$idParam], + responses: [ + '200' => $this->jsonResponse('Profil désactivé.'), + '400' => $this->jsonResponse('Dernier admin, impossible de désactiver.'), + ], + ), + )); + } + + private function addActivityLogs(OpenApi $openApi): void + { + $openApi->getPaths()->addPath('/api/activity-logs', new Model\PathItem( + get: new Model\Operation( + operationId: 'getActivityLogs', + tags: ['Audit'], + summary: 'Journal d\'activité paginé', + description: 'Retourne les logs d\'audit avec filtrage optionnel par type d\'entité et action. Requiert ROLE_VIEWER.', + parameters: [ + $this->queryParam('page', 'Numéro de page'), + $this->queryParam('itemsPerPage', 'Éléments par page'), + $this->queryParam('entityType', 'Filtrer par type d\'entité'), + $this->queryParam('action', 'Filtrer par action'), + ], + responses: ['200' => $this->jsonResponse('Logs paginés.')], + ), + )); + } + + private function addCommentRoutes(OpenApi $openApi): void + { + $openApi->getPaths()->addPath('/api/comments', new Model\PathItem( + post: new Model\Operation( + operationId: 'createComment', + tags: ['Commentaires'], + summary: 'Créer un commentaire', + description: 'Ajoute un commentaire à une entité (machine, pièce, composant, produit, catégorie, squelette). Requiert ROLE_VIEWER.', + requestBody: $this->jsonRequestBody('Données du commentaire (content, entityType, entityId, entityName).'), + responses: [ + '201' => $this->jsonResponse('Commentaire créé.'), + '400' => $this->jsonResponse('Données invalides.'), + ], + ), + )); + + $openApi->getPaths()->addPath('/api/comments/{id}/resolve', new Model\PathItem( + patch: new Model\Operation( + operationId: 'resolveComment', + tags: ['Commentaires'], + summary: 'Résoudre un commentaire', + description: 'Marque un commentaire comme résolu. Requiert ROLE_GESTIONNAIRE.', + parameters: [$this->pathParam('id', 'Identifiant du commentaire')], + responses: [ + '200' => $this->jsonResponse('Commentaire résolu.'), + '404' => $this->jsonResponse('Commentaire introuvable.'), + ], + ), + )); + + $openApi->getPaths()->addPath('/api/comments/stats/unresolved-count', new Model\PathItem( + get: new Model\Operation( + operationId: 'getUnresolvedCommentCount', + tags: ['Commentaires'], + summary: 'Nombre de commentaires non résolus', + description: 'Requiert ROLE_VIEWER.', + responses: ['200' => $this->jsonResponse('Compteur.')], + ), + )); + } + + private function addCustomFieldValueRoutes(OpenApi $openApi): void + { + $openApi->getPaths()->addPath('/api/custom-fields/values', new Model\PathItem( + post: new Model\Operation( + operationId: 'createCustomFieldValue', + tags: ['Champs personnalisés'], + summary: 'Créer une valeur de champ personnalisé', + description: 'Crée une valeur pour un champ personnalisé sur une entité. Auto-crée le champ si nécessaire. Requiert ROLE_GESTIONNAIRE.', + requestBody: $this->jsonRequestBody('Données (customFieldId/customFieldName, value, entité cible).'), + responses: [ + '201' => $this->jsonResponse('Valeur créée.'), + '400' => $this->jsonResponse('Données invalides.'), + ], + ), + )); + + $openApi->getPaths()->addPath('/api/custom-fields/values/upsert', new Model\PathItem( + post: new Model\Operation( + operationId: 'upsertCustomFieldValue', + tags: ['Champs personnalisés'], + summary: 'Créer ou mettre à jour une valeur de champ personnalisé', + description: 'Requiert ROLE_GESTIONNAIRE.', + requestBody: $this->jsonRequestBody('Données du champ.'), + responses: ['200' => $this->jsonResponse('Valeur créée ou mise à jour.')], + ), + )); + + $openApi->getPaths()->addPath('/api/custom-fields/values/{entityType}/{entityId}', new Model\PathItem( + get: new Model\Operation( + operationId: 'listCustomFieldValues', + tags: ['Champs personnalisés'], + summary: 'Lister les valeurs de champs personnalisés d\'une entité', + description: 'Requiert ROLE_VIEWER.', + parameters: [ + $this->pathParam('entityType', 'Type d\'entité (machine, composant, piece, product)'), + $this->pathParam('entityId', 'Identifiant de l\'entité'), + ], + responses: ['200' => $this->jsonResponse('Liste des valeurs.')], + ), + )); + + $idParam = $this->pathParam('id', 'Identifiant de la valeur'); + + $openApi->getPaths()->addPath('/api/custom-fields/values/{id}', new Model\PathItem( + patch: new Model\Operation( + operationId: 'updateCustomFieldValue', + tags: ['Champs personnalisés'], + summary: 'Modifier une valeur de champ personnalisé', + description: 'Requiert ROLE_GESTIONNAIRE.', + parameters: [$idParam], + requestBody: $this->jsonRequestBody('Nouvelle valeur.'), + responses: ['200' => $this->jsonResponse('Valeur mise à jour.')], + ), + delete: new Model\Operation( + operationId: 'deleteCustomFieldValue', + tags: ['Champs personnalisés'], + summary: 'Supprimer une valeur de champ personnalisé', + description: 'Requiert ROLE_GESTIONNAIRE.', + parameters: [$idParam], + responses: ['204' => new Model\Response(description: 'Valeur supprimée.')], + ), + )); + } + + private function addDocumentQueryRoutes(OpenApi $openApi): void + { + $entities = [ + 'site' => 'un site', + 'machine' => 'une machine', + 'composant' => 'un composant', + 'piece' => 'une pièce', + 'product' => 'un produit', + ]; + + foreach ($entities as $entity => $label) { + $openApi->getPaths()->addPath("/api/documents/{$entity}/{id}", new Model\PathItem( + get: new Model\Operation( + operationId: 'getDocumentsBy'.ucfirst($entity), + tags: ['Documents'], + summary: "Documents rattachés à {$label}", + description: 'Requiert ROLE_VIEWER.', + parameters: [$this->pathParam('id', "Identifiant de l'entité")], + responses: ['200' => $this->jsonResponse('Liste des documents.')], + ), + )); + } + } + + private function addDocumentServeRoutes(OpenApi $openApi): void + { + $idParam = $this->pathParam('id', 'Identifiant du document'); + + $openApi->getPaths()->addPath('/api/documents/{id}/file', new Model\PathItem( + get: new Model\Operation( + operationId: 'serveDocumentFile', + tags: ['Documents'], + summary: 'Afficher un fichier document (inline)', + description: 'Sert le fichier pour affichage dans le navigateur. Requiert ROLE_VIEWER.', + parameters: [$idParam], + responses: ['200' => new Model\Response(description: 'Contenu du fichier.')], + ), + )); + + $openApi->getPaths()->addPath('/api/documents/{id}/download', new Model\PathItem( + get: new Model\Operation( + operationId: 'downloadDocumentFile', + tags: ['Documents'], + summary: 'Télécharger un fichier document', + description: 'Sert le fichier en téléchargement (attachment). Requiert ROLE_VIEWER.', + parameters: [$idParam], + responses: ['200' => new Model\Response(description: 'Fichier en téléchargement.')], + ), + )); + } + + private function addEntityHistoryRoutes(OpenApi $openApi): void + { + $entities = [ + 'machines' => 'une machine', + 'pieces' => 'une pièce', + 'composants' => 'un composant', + 'products' => 'un produit', + ]; + + foreach ($entities as $plural => $label) { + $openApi->getPaths()->addPath("/api/{$plural}/{id}/history", new Model\PathItem( + get: new Model\Operation( + operationId: 'get'.ucfirst(rtrim($plural, 's')).'History', + tags: ['Audit'], + summary: "Historique d'audit de {$label}", + description: "Retourne les 200 derniers événements d'audit. Requiert ROLE_VIEWER.", + parameters: [$this->pathParam('id', "Identifiant de l'entité")], + responses: ['200' => $this->jsonResponse('Historique paginé.')], + ), + )); + } + } + + private function addMachineStructureRoutes(OpenApi $openApi): void + { + $idParam = $this->pathParam('id', 'Identifiant de la machine'); + + $openApi->getPaths()->addPath('/api/machines/{id}/structure', new Model\PathItem( + get: new Model\Operation( + operationId: 'getMachineStructure', + tags: ['Machines — Structure'], + summary: 'Structure complète d\'une machine', + description: 'Retourne les composants, pièces et produits avec hiérarchie. Requiert ROLE_VIEWER.', + parameters: [$idParam], + responses: ['200' => $this->jsonResponse('Structure de la machine.')], + ), + patch: new Model\Operation( + operationId: 'updateMachineStructure', + tags: ['Machines — Structure'], + summary: 'Modifier la structure d\'une machine', + description: 'Crée, met à jour ou supprime les liens composants/pièces/produits. Requiert ROLE_GESTIONNAIRE.', + parameters: [$idParam], + requestBody: $this->jsonRequestBody('Liens à créer/modifier/supprimer.'), + responses: ['200' => $this->jsonResponse('Structure mise à jour.')], + ), + )); + + $openApi->getPaths()->addPath('/api/machines/{id}/clone', new Model\PathItem( + post: new Model\Operation( + operationId: 'cloneMachine', + tags: ['Machines — Structure'], + summary: 'Cloner une machine', + description: 'Clone une machine avec tous ses composants, pièces, produits, champs personnalisés et constructeurs. Requiert ROLE_GESTIONNAIRE.', + parameters: [$idParam], + requestBody: $this->jsonRequestBody('Données de la copie (name, siteId, reference).'), + responses: [ + '201' => $this->jsonResponse('Machine clonée.'), + '404' => $this->jsonResponse('Machine source introuvable.'), + ], + ), + )); + } + + private function addMachineCustomFieldsRoutes(OpenApi $openApi): void + { + $openApi->getPaths()->addPath('/api/machines/{id}/add-custom-fields', new Model\PathItem( + post: new Model\Operation( + operationId: 'addMachineCustomFields', + tags: ['Machines — Structure'], + summary: 'Initialiser les champs personnalisés manquants', + description: 'Crée les entrées de valeur manquantes pour les champs personnalisés définis. Requiert ROLE_GESTIONNAIRE.', + parameters: [$this->pathParam('id', 'Identifiant de la machine')], + responses: ['200' => $this->jsonResponse('Champs ajoutés.')], + ), + )); + } + + private function addModelTypeConversionRoutes(OpenApi $openApi): void + { + $idParam = $this->pathParam('id', 'Identifiant du ModelType'); + + $openApi->getPaths()->addPath('/api/model_types/{id}/conversion-check', new Model\PathItem( + get: new Model\Operation( + operationId: 'checkModelTypeConversion', + tags: ['ModelType'], + summary: 'Vérifier la convertibilité d\'un ModelType', + description: 'Vérifie si la catégorie du ModelType peut être convertie. Requiert ROLE_VIEWER.', + parameters: [$idParam], + responses: ['200' => $this->jsonResponse('Résultat de la vérification.')], + ), + )); + + $openApi->getPaths()->addPath('/api/model_types/{id}/convert', new Model\PathItem( + post: new Model\Operation( + operationId: 'convertModelType', + tags: ['ModelType'], + summary: 'Convertir la catégorie d\'un ModelType', + description: 'Convertit la catégorie. Retourne 409 en cas de conflit. Requiert ROLE_GESTIONNAIRE.', + parameters: [$idParam], + responses: [ + '200' => $this->jsonResponse('Conversion effectuée.'), + '409' => $this->jsonResponse('Conflit — conversion impossible.'), + ], + ), + )); + } + + private function addTagDescriptions(OpenApi $openApi): OpenApi + { + $customTags = [ + 'Monitoring' => 'Supervision et vérification de l\'état de santé du système (statut, version, latence BDD, mémoire).', + 'Session' => 'Authentification par session. Permet de lister les profils disponibles, se connecter (activer un profil) et se déconnecter.', + 'Admin — Profils' => 'Administration des profils utilisateurs. Création, modification des rôles et mots de passe, désactivation. Réservé aux administrateurs.', + 'Audit' => 'Journal d\'activité et historique d\'audit. Consultation des modifications apportées aux entités avec détail des changements (diff et snapshot).', + 'Commentaires' => 'Système de commentaires et annotations sur les entités. Permet de créer des commentaires, les résoudre et suivre le nombre de commentaires ouverts.', + 'Champs personnalisés' => 'Gestion des valeurs de champs personnalisés. Permet d\'ajouter, modifier et supprimer des données dynamiques sur les machines, pièces, composants et produits.', + 'Documents' => 'Gestion des fichiers joints. Consultation des documents par entité, affichage inline et téléchargement.', + 'Machines — Structure' => 'Structure hiérarchique des machines. Consultation et modification des liaisons composants/pièces/produits, clonage de machines et initialisation des champs personnalisés.', + 'ModelType' => 'Conversion de catégories de types. Vérification de compatibilité et conversion effective des catégories de ModelType.', + ]; + + $existingTags = $openApi->getTags(); + $existingNames = array_map(static fn (Model\Tag $tag) => $tag->getName(), $existingTags); + + foreach ($customTags as $name => $description) { + if (!in_array($name, $existingNames, true)) { + $existingTags[] = new Model\Tag(name: $name, description: $description); + } + } + + return $openApi->withTags($existingTags); + } + + private function jsonResponse(string $description): Model\Response + { + return new Model\Response( + description: $description, + content: new ArrayObject([ + 'application/json' => new Model\MediaType( + schema: new ArrayObject(['type' => 'object']), + ), + ]), + ); + } + + private function jsonRequestBody(string $description): Model\RequestBody + { + return new Model\RequestBody( + description: $description, + content: new ArrayObject([ + 'application/json' => new Model\MediaType( + schema: new ArrayObject(['type' => 'object']), + ), + ]), + required: true, + ); + } + + private function pathParam(string $name, string $description): Model\Parameter + { + return new Model\Parameter( + name: $name, + in: 'path', + description: $description, + required: true, + schema: ['type' => 'string'], + ); + } + + private function queryParam(string $name, string $description): Model\Parameter + { + return new Model\Parameter( + name: $name, + in: 'query', + description: $description, + required: false, + schema: ['type' => 'string'], + ); + } +} diff --git a/src/Service/PdfCompressorService.php b/src/Service/PdfCompressorService.php index cf1df3a..aeba15c 100644 --- a/src/Service/PdfCompressorService.php +++ b/src/Service/PdfCompressorService.php @@ -6,6 +6,8 @@ namespace App\Service; class PdfCompressorService { + private ?bool $qpdfAvailable = null; + /** * Compress an actual PDF file on disk. Returns metadata or null if no gain. * @@ -13,8 +15,7 @@ class PdfCompressorService */ public function compressFile(string $absolutePath): ?array { - exec('which qpdf', $qpdfPath, $returnCode); - if (0 !== $returnCode) { + if (!$this->isQpdfAvailable()) { return null; } @@ -65,9 +66,7 @@ class PdfCompressorService public function compressBase64Pdf(string $base64Data): ?array { - // Check if qpdf is available - exec('which qpdf', $qpdfPath, $returnCode); - if (0 !== $returnCode) { + if (!$this->isQpdfAvailable()) { return null; } @@ -127,4 +126,14 @@ class PdfCompressorService 'saved' => $originalSize - $compressedSize, ]; } + + private function isQpdfAvailable(): bool + { + if (null === $this->qpdfAvailable) { + exec('which qpdf', $qpdfPath, $returnCode); + $this->qpdfAvailable = 0 === $returnCode; + } + + return $this->qpdfAvailable; + } }