From 3f6ce153bb52a807645f5c358accd89ef11db4e4 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 26 Mar 2026 17:58:53 +0100 Subject: [PATCH] feat(reference-auto) : add automatic reference generation for pieces ModelType defines a formula with placeholders ({serie}{diametre}{type}). ReferenceAutoGenerator resolves it from CustomFieldValues with trim+uppercase normalisation. ReferenceAutoSubscriber (onFlush) recalculates on Piece/CFV insert/update/delete. Co-Authored-By: Claude Opus 4.6 (1M context) --- config/services.yaml | 5 + src/Entity/ModelType.php | 32 ++++ src/Entity/Piece.php | 19 ++ .../ReferenceAutoSubscriber.php | 77 ++++++++ src/Service/ReferenceAutoGenerator.php | 54 ++++++ tests/Api/Entity/PieceReferenceAutoTest.php | 178 ++++++++++++++++++ tests/Service/ReferenceAutoGeneratorTest.php | 167 ++++++++++++++++ 7 files changed, 532 insertions(+) create mode 100644 src/EventSubscriber/ReferenceAutoSubscriber.php create mode 100644 src/Service/ReferenceAutoGenerator.php create mode 100644 tests/Api/Entity/PieceReferenceAutoTest.php create mode 100644 tests/Service/ReferenceAutoGeneratorTest.php diff --git a/config/services.yaml b/config/services.yaml index 8d0e4d0..d811e2d 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -64,3 +64,8 @@ when@test: autowire: true autoconfigure: true public: true + + App\Service\ReferenceAutoGenerator: + autowire: true + autoconfigure: true + public: true diff --git a/src/Entity/ModelType.php b/src/Entity/ModelType.php index 7f68799..ea89bc7 100644 --- a/src/Entity/ModelType.php +++ b/src/Entity/ModelType.php @@ -73,6 +73,14 @@ class ModelType #[Groups(['type_machine:read', 'model_type:read', 'model_type:write'])] private ?string $description = null; + #[ORM\Column(type: Types::TEXT, nullable: true)] + #[Groups(['model_type:read', 'model_type:write'])] + private ?string $referenceFormula = null; + + #[ORM\Column(type: Types::JSON, nullable: true)] + #[Groups(['model_type:read', 'model_type:write'])] + private ?array $requiredFieldsForReference = null; + #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')] #[Groups(['model_type:read'])] private DateTimeImmutable $createdAt; @@ -215,6 +223,30 @@ class ModelType return $this; } + public function getReferenceFormula(): ?string + { + return $this->referenceFormula; + } + + public function setReferenceFormula(?string $referenceFormula): static + { + $this->referenceFormula = $referenceFormula; + + return $this; + } + + public function getRequiredFieldsForReference(): ?array + { + return $this->requiredFieldsForReference; + } + + public function setRequiredFieldsForReference(?array $requiredFieldsForReference): static + { + $this->requiredFieldsForReference = $requiredFieldsForReference; + + return $this; + } + #[Groups(['model_type:read', 'product:read', 'composant:read', 'piece:read'])] public function getStructure(): ?array { diff --git a/src/Entity/Piece.php b/src/Entity/Piece.php index 72d41a3..a22b138 100644 --- a/src/Entity/Piece.php +++ b/src/Entity/Piece.php @@ -63,6 +63,10 @@ class Piece #[Groups(['piece:read'])] private ?string $reference = null; + #[ORM\Column(type: Types::STRING, length: 255, nullable: true)] + #[Groups(['piece:read'])] + private ?string $referenceAuto = null; + #[ORM\Column(type: Types::TEXT, nullable: true)] #[Groups(['piece:read'])] private ?string $description = null; @@ -179,6 +183,21 @@ class Piece return $this; } + public function getReferenceAuto(): ?string + { + return $this->referenceAuto; + } + + /** + * @internal used by ReferenceAutoSubscriber only — not part of the public API + */ + public function setReferenceAuto(?string $referenceAuto): static + { + $this->referenceAuto = $referenceAuto; + + return $this; + } + public function getDescription(): ?string { return $this->description; diff --git a/src/EventSubscriber/ReferenceAutoSubscriber.php b/src/EventSubscriber/ReferenceAutoSubscriber.php new file mode 100644 index 0000000..3856263 --- /dev/null +++ b/src/EventSubscriber/ReferenceAutoSubscriber.php @@ -0,0 +1,77 @@ +getObjectManager(); + $uow = $em->getUnitOfWork(); + + $piecesToRecalculate = []; + + foreach ($uow->getScheduledEntityInsertions() as $entity) { + if ($entity instanceof Piece) { + $piecesToRecalculate[$entity->getId()] = $entity; + } + } + + foreach ($uow->getScheduledEntityUpdates() as $entity) { + if ($entity instanceof Piece) { + $piecesToRecalculate[$entity->getId()] = $entity; + } + } + + // For CFV insertions: the new CFV is not yet in the DB, so Piece's lazy-loaded + // collection won't contain it. We must add it manually so the generator sees it. + foreach ($uow->getScheduledEntityInsertions() as $entity) { + if ($entity instanceof CustomFieldValue && $entity->getPiece()) { + $piece = $entity->getPiece(); + if (!$piece->getCustomFieldValues()->contains($entity)) { + $piece->getCustomFieldValues()->add($entity); + } + $piecesToRecalculate[$piece->getId()] = $piece; + } + } + + foreach ($uow->getScheduledEntityUpdates() as $entity) { + if ($entity instanceof CustomFieldValue && $entity->getPiece()) { + $piece = $entity->getPiece(); + $piecesToRecalculate[$piece->getId()] = $piece; + } + } + + // For CFV deletions: remove from collection so the generator doesn't see stale values. + foreach ($uow->getScheduledEntityDeletions() as $entity) { + if ($entity instanceof CustomFieldValue && $entity->getPiece()) { + $piece = $entity->getPiece(); + $piece->getCustomFieldValues()->removeElement($entity); + $piecesToRecalculate[$piece->getId()] = $piece; + } + } + + $meta = $em->getClassMetadata(Piece::class); + + foreach ($piecesToRecalculate as $piece) { + $newRef = $this->generator->generate($piece); + + if ($piece->getReferenceAuto() !== $newRef) { + $piece->setReferenceAuto($newRef); + $uow->recomputeSingleEntityChangeSet($meta, $piece); + } + } + } +} diff --git a/src/Service/ReferenceAutoGenerator.php b/src/Service/ReferenceAutoGenerator.php new file mode 100644 index 0000000..560e847 --- /dev/null +++ b/src/Service/ReferenceAutoGenerator.php @@ -0,0 +1,54 @@ +getTypePiece(); + + if (!$modelType || !$modelType->getReferenceFormula()) { + return null; + } + + $valueMap = $this->buildValueMap($piece); + + $requiredFields = $modelType->getRequiredFieldsForReference(); + + if ($requiredFields) { + foreach ($requiredFields as $fieldName) { + if (!isset($valueMap[$fieldName]) || '' === $valueMap[$fieldName]) { + return null; + } + } + } + + return preg_replace_callback('/\{(\w+)\}/', static function (array $matches) use ($valueMap): string { + return $valueMap[$matches[1]] ?? ''; + }, $modelType->getReferenceFormula()); + } + + /** + * Build a map of fieldName → normalized value from the Piece's CustomFieldValues. + * + * @return array + */ + private function buildValueMap(Piece $piece): array + { + $map = []; + + /** @var CustomFieldValue $cfv */ + foreach ($piece->getCustomFieldValues() as $cfv) { + $normalized = mb_strtoupper(trim($cfv->getValue())); + $map[$cfv->getCustomField()->getName()] = $normalized; + } + + return $map; + } +} diff --git a/tests/Api/Entity/PieceReferenceAutoTest.php b/tests/Api/Entity/PieceReferenceAutoTest.php new file mode 100644 index 0000000..749cd1e --- /dev/null +++ b/tests/Api/Entity/PieceReferenceAutoTest.php @@ -0,0 +1,178 @@ +createModelType('Roulement', 'ROUL-010', ModelCategory::PIECE); + $mt->setReferenceFormula('{serie}{diametre}{type}'); + $mt->setRequiredFieldsForReference(['serie', 'diametre', 'type']); + + $em = $this->getEntityManager(); + $em->flush(); + + $cfSerie = $this->createCustomField('serie', 'text', typePiece: $mt); + $cfDiametre = $this->createCustomField('diametre', 'text', typePiece: $mt); + $cfType = $this->createCustomField('type', 'text', typePiece: $mt); + + $piece = $this->createPiece('Roulement Auto', null, $mt); + + $this->createCustomFieldValue($cfSerie, '22', piece: $piece); + $this->createCustomFieldValue($cfDiametre, '07', piece: $piece); + $this->createCustomFieldValue($cfType, 'K', piece: $piece); + + $client = $this->createViewerClient(); + $client->request('GET', self::iri('pieces', $piece->getId())); + + $this->assertResponseIsSuccessful(); + $this->assertJsonContains(['referenceAuto' => '2207K']); + } + + public function testReferenceAutoNullWhenNoFormula(): void + { + $mt = $this->createModelType('Galet', 'GAL-010', ModelCategory::PIECE); + $piece = $this->createPiece('Galet Auto', null, $mt); + + $client = $this->createViewerClient(); + $response = $client->request('GET', self::iri('pieces', $piece->getId())); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + self::assertArrayNotHasKey('referenceAuto', $data, 'referenceAuto should not be serialized when null'); + } + + public function testReferenceAutoNullWhenRequiredFieldsMissing(): void + { + $mt = $this->createModelType('Palier', 'PAL-010', ModelCategory::PIECE); + $mt->setReferenceFormula('SNU {taille}'); + $mt->setRequiredFieldsForReference(['taille']); + + $em = $this->getEntityManager(); + $em->flush(); + + $piece = $this->createPiece('Palier Sans Champ', null, $mt); + + $client = $this->createViewerClient(); + $response = $client->request('GET', self::iri('pieces', $piece->getId())); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + self::assertArrayNotHasKey('referenceAuto', $data, 'referenceAuto should not be serialized when required fields missing'); + } + + public function testReferenceAutoUpdatedWhenCustomFieldValueChanges(): void + { + $mt = $this->createModelType('Joint', 'JOINT-010', ModelCategory::PIECE); + $mt->setReferenceFormula('U{taille}'); + + $em = $this->getEntityManager(); + $em->flush(); + + $cfTaille = $this->createCustomField('taille', 'text', typePiece: $mt); + $piece = $this->createPiece('Joint Upd', null, $mt); + $cfv = $this->createCustomFieldValue($cfTaille, '507', piece: $piece); + + $client = $this->createViewerClient(); + $client->request('GET', self::iri('pieces', $piece->getId())); + + $this->assertResponseIsSuccessful(); + $this->assertJsonContains(['referenceAuto' => 'U507']); + + // Update CFV value via API + $gClient = $this->createGestionnaireClient(); + $gClient->request('PATCH', self::iri('custom_field_values', $cfv->getId()), [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['value' => '608'], + ]); + + $this->assertResponseIsSuccessful(); + + // Re-read the Piece to check updated referenceAuto + $viewer = $this->createViewerClient(); + $viewer->request('GET', self::iri('pieces', $piece->getId())); + $this->assertJsonContains(['referenceAuto' => 'U608']); + } + + public function testReferenceAutoNullAfterRequiredCfvDeleted(): void + { + $mt = $this->createModelType('Joint Del', 'JOINT-011', ModelCategory::PIECE); + $mt->setReferenceFormula('U{taille}'); + $mt->setRequiredFieldsForReference(['taille']); + + $em = $this->getEntityManager(); + $em->flush(); + + $cfTaille = $this->createCustomField('taille', 'text', typePiece: $mt); + $piece = $this->createPiece('Joint Del', null, $mt); + $cfv = $this->createCustomFieldValue($cfTaille, '507', piece: $piece); + + $client = $this->createViewerClient(); + $client->request('GET', self::iri('pieces', $piece->getId())); + $this->assertJsonContains(['referenceAuto' => 'U507']); + + // Delete the CFV + $gClient = $this->createGestionnaireClient(); + $gClient->request('DELETE', self::iri('custom_field_values', $cfv->getId())); + $this->assertResponseStatusCodeSame(204); + + // Re-read piece — referenceAuto should now be absent (null = not serialized) + $viewer = $this->createViewerClient(); + $response = $viewer->request('GET', self::iri('pieces', $piece->getId())); + $data = $response->toArray(); + self::assertArrayNotHasKey('referenceAuto', $data, 'referenceAuto should be null after required CFV deleted'); + } + + public function testReferenceAutoIsReadOnlyViaApi(): void + { + $piece = $this->createPiece('ReadOnly Test'); + + $client = $this->createGestionnaireClient(); + $client->request('PATCH', self::iri('pieces', $piece->getId()), [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['referenceAuto' => 'HACKED'], + ]); + + $this->assertResponseIsSuccessful(); + + // referenceAuto should still be null (no formula) — subscriber overwrites + $viewer = $this->createViewerClient(); + $response = $viewer->request('GET', self::iri('pieces', $piece->getId())); + $data = $response->toArray(); + self::assertArrayNotHasKey('referenceAuto', $data, 'referenceAuto should not be settable via API'); + } + + public function testReferenceAutoNormalizesLowercaseValues(): void + { + $mt = $this->createModelType('Roulement Norm', 'ROUL-011', ModelCategory::PIECE); + $mt->setReferenceFormula('{serie}{diametre}{type}'); + + $em = $this->getEntityManager(); + $em->flush(); + + $cfSerie = $this->createCustomField('serie', 'text', typePiece: $mt); + $cfDiametre = $this->createCustomField('diametre', 'text', typePiece: $mt); + $cfType = $this->createCustomField('type', 'text', typePiece: $mt); + + $piece = $this->createPiece('Roulement Norm', null, $mt); + + $this->createCustomFieldValue($cfSerie, '22', piece: $piece); + $this->createCustomFieldValue($cfDiametre, '07', piece: $piece); + $this->createCustomFieldValue($cfType, 'k', piece: $piece); + + $client = $this->createViewerClient(); + $client->request('GET', self::iri('pieces', $piece->getId())); + + $this->assertResponseIsSuccessful(); + $this->assertJsonContains(['referenceAuto' => '2207K']); + } +} diff --git a/tests/Service/ReferenceAutoGeneratorTest.php b/tests/Service/ReferenceAutoGeneratorTest.php new file mode 100644 index 0000000..ac3e8da --- /dev/null +++ b/tests/Service/ReferenceAutoGeneratorTest.php @@ -0,0 +1,167 @@ +createModelType('Roulement', 'ROUL-001', ModelCategory::PIECE); + $mt->setReferenceFormula('{serie}{diametre}{type}'); + $mt->setRequiredFieldsForReference(['serie', 'diametre', 'type']); + + $em = $this->getEntityManager(); + $em->flush(); + + $cfSerie = $this->createCustomField('serie', 'text', typePiece: $mt); + $cfDiametre = $this->createCustomField('diametre', 'text', typePiece: $mt); + $cfType = $this->createCustomField('type', 'text', typePiece: $mt); + + $piece = $this->createPiece('Roulement Test', null, $mt); + + $this->createCustomFieldValue($cfSerie, '22', piece: $piece); + $this->createCustomFieldValue($cfDiametre, '07', piece: $piece); + $this->createCustomFieldValue($cfType, 'K', piece: $piece); + + $em->refresh($piece); + + $generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator'); + $result = $generator->generate($piece); + + self::assertSame('2207K', $result); + } + + public function testGenerateNormalizesValues(): void + { + $mt = $this->createModelType('Roulement Norm', 'ROUL-002', ModelCategory::PIECE); + $mt->setReferenceFormula('{serie}{diametre}{type}'); + $mt->setRequiredFieldsForReference(['serie', 'diametre', 'type']); + + $em = $this->getEntityManager(); + $em->flush(); + + $cfSerie = $this->createCustomField('serie', 'text', typePiece: $mt); + $cfDiametre = $this->createCustomField('diametre', 'text', typePiece: $mt); + $cfType = $this->createCustomField('type', 'text', typePiece: $mt); + + $piece = $this->createPiece('Roulement Norm', null, $mt); + + $this->createCustomFieldValue($cfSerie, ' 22 ', piece: $piece); + $this->createCustomFieldValue($cfDiametre, '07', piece: $piece); + $this->createCustomFieldValue($cfType, 'k', piece: $piece); + + $em->refresh($piece); + + $generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator'); + $result = $generator->generate($piece); + + self::assertSame('2207K', $result); + } + + public function testGenerateReturnsNullWithoutFormula(): void + { + $mt = $this->createModelType('Galet', 'GAL-001', ModelCategory::PIECE); + $piece = $this->createPiece('Galet Test', null, $mt); + + $generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator'); + $result = $generator->generate($piece); + + self::assertNull($result); + } + + public function testGenerateReturnsNullWhenNoModelType(): void + { + $piece = $this->createPiece('Orphan Piece'); + + $generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator'); + $result = $generator->generate($piece); + + self::assertNull($result); + } + + public function testGenerateReturnsNullWhenRequiredFieldsMissing(): void + { + $mt = $this->createModelType('Palier', 'PAL-001', ModelCategory::PIECE); + $mt->setReferenceFormula('SNU {taille}'); + $mt->setRequiredFieldsForReference(['taille']); + + $em = $this->getEntityManager(); + $em->flush(); + + $piece = $this->createPiece('Palier Test', null, $mt); + + $generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator'); + $result = $generator->generate($piece); + + self::assertNull($result); + } + + public function testGenerateReturnsNullWhenRequiredFieldEmpty(): void + { + $mt = $this->createModelType('Palier Vide', 'PAL-003', ModelCategory::PIECE); + $mt->setReferenceFormula('SNU {taille}'); + $mt->setRequiredFieldsForReference(['taille']); + + $em = $this->getEntityManager(); + $em->flush(); + + $cfTaille = $this->createCustomField('taille', 'text', typePiece: $mt); + $piece = $this->createPiece('Palier Vide', null, $mt); + $this->createCustomFieldValue($cfTaille, ' ', piece: $piece); + + $em->refresh($piece); + + $generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator'); + $result = $generator->generate($piece); + + self::assertNull($result); + } + + public function testGenerateWithStaticTextInFormula(): void + { + $mt = $this->createModelType('Joint', 'JOINT-001', ModelCategory::PIECE); + $mt->setReferenceFormula('U{taille}'); + + $em = $this->getEntityManager(); + $em->flush(); + + $cfTaille = $this->createCustomField('taille', 'text', typePiece: $mt); + $piece = $this->createPiece('Joint Test', null, $mt); + $this->createCustomFieldValue($cfTaille, '507', piece: $piece); + + $em->refresh($piece); + + $generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator'); + $result = $generator->generate($piece); + + self::assertSame('U507', $result); + } + + public function testGenerateWithSpaceInFormula(): void + { + $mt = $this->createModelType('Palier2', 'PAL-002', ModelCategory::PIECE); + $mt->setReferenceFormula('SNU {taille}'); + + $em = $this->getEntityManager(); + $em->flush(); + + $cfTaille = $this->createCustomField('taille', 'text', typePiece: $mt); + $piece = $this->createPiece('Palier Test 2', null, $mt); + $this->createCustomFieldValue($cfTaille, '507', piece: $piece); + + $em->refresh($piece); + + $generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator'); + $result = $generator->generate($piece); + + self::assertSame('SNU 507', $result); + } +}