From e265a008bc89438947685da2620e9b6a150ea6bb Mon Sep 17 00:00:00 2001 From: THOLOT DECHENE Matthieu Date: Mon, 8 Jun 2026 07:33:38 +0000 Subject: [PATCH] feat(commercial) : validators M2 fournisseurs (RG-2.03/2.07/2.08/2.10) (ERP-89) (#68) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Étape 4/7 du M2 fournisseurs — stackée sur #67 (ERP-88). ## Périmètre (RG-2.03 / 2.07 / 2.08 / 2.10) Décision figée ERP-89 : les RG inter-champs passent par `Assert\Callback` + `->atPath()` sur l'entité Supplier (et non dans le Processor), pour que chaque 422 porte un `propertyPath` consommable par `extractApiViolations` (mapping inline, pas un toast — ERP-101). - **RG-2.10** — `Supplier::validateCategoryType()` → `atPath('categories')` : catégories de type FOURNISSEUR uniquement sur `supplier.categories` (miroir d'ERP-88 côté adresse). - **RG-2.07** — `Supplier::validatePaymentTypeConsistency()` → `atPath('bank')` : VIREMENT impose une banque. - **RG-2.08** — même Callback → `atPath('ribs')` : LCR impose ≥ 1 RIB (le 409 sur DELETE du dernier RIB en LCR reste porté par ERP-88). - **RG-2.03** — `SupplierInformationCompletenessValidator` (8 champs Information dont `volumeForecast`), invoqué par le `SupplierProcessor` après détection back du rôle Commerciale via `BusinessRoleAwareInterface`. Le Processor ne porte que rôle / mode strict / gating. ## Tests (16, verts) - `SupplierValidationTest` — Callbacks RG-2.07/2.08/2.10, assertion par propertyPath. - `SupplierInformationCompletenessValidatorTest` — complétude / champs manquants / zéros valides. - `SupplierProcessorTest` — détection rôle RG-2.03 (POST + PATCH main-only + non-Commerciale). `make test` : 499 tests OK. `php-cs-fixer` : clean. --------- Co-authored-by: admin malio Co-authored-by: Matthieu Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/68 Co-authored-by: THOLOT DECHENE Matthieu Co-committed-by: THOLOT DECHENE Matthieu --- ...pplierInformationCompletenessValidator.php | 79 +++++++ .../Commercial/Domain/Entity/Supplier.php | 74 +++++++ .../State/Processor/SupplierProcessor.php | 57 ++++- .../Domain/Entity/SupplierValidationTest.php | 172 +++++++++++++++ ...erInformationCompletenessValidatorTest.php | 129 ++++++++++++ .../Commercial/Unit/SupplierProcessorTest.php | 199 ++++++++++++++++++ 6 files changed, 703 insertions(+), 7 deletions(-) create mode 100644 src/Module/Commercial/Application/Validator/SupplierInformationCompletenessValidator.php create mode 100644 tests/Module/Commercial/Domain/Entity/SupplierValidationTest.php create mode 100644 tests/Module/Commercial/Unit/SupplierInformationCompletenessValidatorTest.php create mode 100644 tests/Module/Commercial/Unit/SupplierProcessorTest.php diff --git a/src/Module/Commercial/Application/Validator/SupplierInformationCompletenessValidator.php b/src/Module/Commercial/Application/Validator/SupplierInformationCompletenessValidator.php new file mode 100644 index 0000000..f5db864 --- /dev/null +++ b/src/Module/Commercial/Application/Validator/SupplierInformationCompletenessValidator.php @@ -0,0 +1,79 @@ + valeur courante de l'onglet Information. + $fields = [ + 'description' => $supplier->getDescription(), + 'competitors' => $supplier->getCompetitors(), + 'foundedAt' => $supplier->getFoundedAt(), + 'employeesCount' => $supplier->getEmployeesCount(), + 'revenueAmount' => $supplier->getRevenueAmount(), + 'directorName' => $supplier->getDirectorName(), + 'profitAmount' => $supplier->getProfitAmount(), + 'volumeForecast' => $supplier->getVolumeForecast(), + ]; + + $violations = new ConstraintViolationList(); + + foreach ($fields as $property => $value) { + if ($this->isMissing($value)) { + $violations->add(new ConstraintViolation( + sprintf('Ce champ est obligatoire pour le rôle Commerciale (champ "%s").', $property), + null, + [], + $supplier, + $property, + $value, + )); + } + } + + if (count($violations) > 0) { + throw new ValidationException($violations); + } + } + + /** + * Une valeur est manquante si null ou, pour une chaine, vide apres trim. Les + * zeros numeriques (employeesCount = 0, profitAmount = "0.00", + * volumeForecast = 0) sont des valeurs valides : on ne les considere pas + * manquants. + */ + private function isMissing(mixed $value): bool + { + if (null === $value) { + return true; + } + + return is_string($value) && '' === trim($value); + } +} diff --git a/src/Module/Commercial/Domain/Entity/Supplier.php b/src/Module/Commercial/Domain/Entity/Supplier.php index 128a77a..85c9b36 100644 --- a/src/Module/Commercial/Domain/Entity/Supplier.php +++ b/src/Module/Commercial/Domain/Entity/Supplier.php @@ -25,6 +25,7 @@ use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\SerializedName; use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\Context\ExecutionContextInterface; /** * Fournisseur (M2 Commercial) — entite racine du repertoire fournisseurs, @@ -133,6 +134,20 @@ class Supplier implements TimestampableInterface, BlamableInterface { use TimestampableBlamableTrait; + /** + * RG-2.10 : seules les categories de ce type sont autorisees sur le + * fournisseur (entite principale). Miroir de SupplierAddress (ERP-88). + * S'appuie sur CategoryInterface::getCategoryTypeCode() (pas d'import du + * module Catalog — regle ABSOLUE n°1). + */ + private const string REQUIRED_CATEGORY_TYPE_CODE = 'FOURNISSEUR'; + + /** RG-2.07 : code du type de reglement imposant une banque. */ + private const string PAYMENT_TYPE_VIREMENT = 'VIREMENT'; + + /** RG-2.08 : code du type de reglement imposant au moins un RIB. */ + private const string PAYMENT_TYPE_LCR = 'LCR'; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] @@ -280,6 +295,65 @@ class Supplier implements TimestampableInterface, BlamableInterface $this->ribs = new ArrayCollection(); } + /** + * RG-2.10 : toute categorie posee sur le fournisseur doit etre de type + * FOURNISSEUR -> sinon 422 avec violation sur le champ `categories` + * (propertyPath aligne ERP-101, message FR ERP-107). Miroir de + * SupplierAddress::validateCategoryType (ERP-88). S'appuie sur + * CategoryInterface::getCategoryTypeCode() (pas d'import du module Catalog — + * regle ABSOLUE n°1). Joue avant la base via la validation API Platform, sur + * POST (categories ∈ supplier:write:main) comme sur PATCH. + */ + #[Assert\Callback] + public function validateCategoryType(ExecutionContextInterface $context): void + { + foreach ($this->categories as $category) { + if ($category instanceof CategoryInterface + && self::REQUIRED_CATEGORY_TYPE_CODE !== $category->getCategoryTypeCode()) { + $context->buildViolation('Type de catégorie non autorisé (FOURNISSEUR attendu).') + ->atPath('categories') + ->addViolation() + ; + + return; + } + } + } + + /** + * RG-2.07 / RG-2.08 : coherence du type de reglement comptable. Decision + * figee ERP-89 : ces RG inter-champs passent par une contrainte d'entite + * (Assert\Callback + ->atPath()) et NON par le SupplierProcessor, afin que + * chaque 422 porte un propertyPath exploitable par extractApiViolations + * (mapping inline sous le champ, pas un toast — convention ERP-101). + * - RG-2.07 : paymentType = VIREMENT impose une banque -> violation sur `bank`. + * - RG-2.08 : paymentType = LCR impose au moins un RIB -> violation sur `ribs` + * (le 409 sur DELETE du dernier RIB en LCR est porte par ERP-88). + * + * Ces champs vivant dans le groupe d'ecriture comptable (absent du POST, qui + * n'expose que supplier:write:main), la contrainte ne mord en pratique que + * sur le PATCH de l'onglet Comptabilite. + */ + #[Assert\Callback] + public function validatePaymentTypeConsistency(ExecutionContextInterface $context): void + { + $paymentCode = $this->paymentType?->getCode(); + + if (self::PAYMENT_TYPE_VIREMENT === $paymentCode && null === $this->bank) { + $context->buildViolation('La banque est obligatoire pour le type de règlement Virement.') + ->atPath('bank') + ->addViolation() + ; + } + + if (self::PAYMENT_TYPE_LCR === $paymentCode && $this->ribs->isEmpty()) { + $context->buildViolation('Au moins un RIB est obligatoire pour le type de règlement LCR.') + ->atPath('ribs') + ->addViolation() + ; + } + } + public function getId(): ?int { return $this->id; diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierProcessor.php index 2474623..b2685d1 100644 --- a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierProcessor.php +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierProcessor.php @@ -7,7 +7,10 @@ namespace App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use App\Module\Commercial\Application\Service\SupplierFieldNormalizer; +use App\Module\Commercial\Application\Validator\SupplierInformationCompletenessValidator; use App\Module\Commercial\Domain\Entity\Supplier; +use App\Shared\Domain\Contract\BusinessRoleAwareInterface; +use App\Shared\Domain\Security\BusinessRoles; use DateTimeImmutable; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\EntityManagerInterface; @@ -40,14 +43,19 @@ use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; * collisions d'unicite en 409 (RG-2.11 doublon de nom ; RG-2.15 conflit de * restauration). * - * Hors perimetre ERP-87 (ticket #5 « Validators ») : RG-2.03 (completude - * Information pour la Commerciale), RG-2.07 (Virement -> banque), RG-2.08 (LCR -> - * RIB), RG-2.10 (categorie de type FOURNISSEUR). Ces regles metier seront - * branchees ici via des validators dedies au ticket suivant. + * Validators metier (ERP-89). Decision figee : ce processor ne porte QUE + * RG-2.03 (completude Information exigee pour le role Commerciale — detection du + * role cote back, non exprimable en contrainte d'entite). Les RG inter-champs + * RG-2.07 (Virement -> banque), RG-2.08 (LCR -> >= 1 RIB) et RG-2.10 (categorie + * de type FOURNISSEUR) sont portees par des Assert\Callback + ->atPath() sur + * l'entite Supplier (jouees par API Platform AVANT ce processor), pour que + * chaque 422 porte un propertyPath consommable par extractApiViolations + * (mapping inline, pas un toast — convention ERP-101). * - * Note : la validation Symfony (Assert\NotBlank, Assert\Count sur categories...) - * est jouee par API Platform AVANT ce processor ; on n'y traite donc que les - * regles non exprimables en simples contraintes d'attribut. + * Note : la validation Symfony (Assert\NotBlank, Assert\Count sur categories, + * les Callback RG-2.07/2.08/2.10...) est jouee par API Platform AVANT ce + * processor ; on n'y traite donc que les regles non exprimables en simples + * contraintes d'entite (RG-2.03, qui depend du role de l'utilisateur courant). * * @implements ProcessorInterface */ @@ -94,6 +102,7 @@ final class SupplierProcessor implements ProcessorInterface #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] private readonly ProcessorInterface $persistProcessor, private readonly SupplierFieldNormalizer $normalizer, + private readonly SupplierInformationCompletenessValidator $informationValidator, private readonly Security $security, private readonly RequestStack $requestStack, private readonly EntityManagerInterface $em, @@ -117,6 +126,8 @@ final class SupplierProcessor implements ProcessorInterface // normalisees des deux cotes (l'etat persiste l'a deja ete). $this->guardManage($data); + $this->validateInformationCompleteness($data); + try { return $this->persistProcessor->process($data, $operation, $uriVariables, $context); } catch (UniqueConstraintViolationException $e) { @@ -244,6 +255,38 @@ final class SupplierProcessor implements ProcessorInterface } } + /** + * RG-2.03 : si l'utilisateur porte le role metier Commerciale, TOUS les + * champs de l'onglet Information sont obligatoires sur POST comme sur TOUT + * PATCH — independamment des champs reellement envoyes. Garantit qu'un + * fournisseur cree/edite par une Commerciale ne reste jamais avec un onglet + * Information incomplet. Pour les autres roles, ces champs restent optionnels. + * + * Consequence (cf. spec § 7, miroir RG-1.04) : le POST n'exposant que + * supplier:write:main, une Commerciale obtient 422 sur tout POST tant que + * l'Information n'est pas complete -> la completude se fait via les PATCH + * supplier:write:information. + */ + private function validateInformationCompleteness(Supplier $data): void + { + if ($this->currentUserIsCommerciale()) { + $this->informationValidator->validate($data); + } + } + + /** + * Detection du role metier Commerciale cote back (jamais front), via le + * contrat BusinessRoleAwareInterface (pas d'import de User — regle ABSOLUE + * n°1). Identique au ClientProcessor (M1). + */ + private function currentUserIsCommerciale(): bool + { + $user = $this->security->getUser(); + + return $user instanceof BusinessRoleAwareInterface + && $user->hasBusinessRole(BusinessRoles::COMMERCIALE); + } + /** * Champs « metier » (onglets principal + Information, hors comptabilite et * archivage) dont la valeur courante differe de l'etat persiste. Memes diff --git a/tests/Module/Commercial/Domain/Entity/SupplierValidationTest.php b/tests/Module/Commercial/Domain/Entity/SupplierValidationTest.php new file mode 100644 index 0000000..8ef77a9 --- /dev/null +++ b/tests/Module/Commercial/Domain/Entity/SupplierValidationTest.php @@ -0,0 +1,172 @@ + banque), RG-2.08 (LCR -> >= 1 RIB). + * + * On valide l'entite avec le validator Symfony (mapping par attributs) et on + * assert le propertyPath exact de chaque violation (contrat ERP-101 : + * exploitable par extractApiViolations). Pas de base : les Callback ne touchent + * que des champs en memoire (categories via un double CategoryInterface). + * + * @internal + */ +final class SupplierValidationTest extends TestCase +{ + private ValidatorInterface $validator; + + protected function setUp(): void + { + $this->validator = Validation::createValidatorBuilder() + ->enableAttributeMapping() + ->getValidator() + ; + } + + // === RG-2.10 : categories de type FOURNISSEUR === + + public function testFournisseurCategoryIsAccepted(): void + { + $supplier = $this->validSupplier(); + + self::assertSame([], $this->violationPaths($supplier)); + } + + public function testNonFournisseurCategoryIsRejectedOnCategoriesPath(): void + { + $supplier = new Supplier(); + $supplier->setCompanyName('Recycla SAS'); + $supplier->addCategory($this->category('CLIENT')); + + self::assertContains('categories', $this->violationPaths($supplier)); + } + + // === RG-2.07 : Virement impose une banque === + + public function testVirementWithoutBankIsRejectedOnBankPath(): void + { + $supplier = $this->validSupplier(); + $supplier->setPaymentType($this->paymentType('VIREMENT')); + + self::assertContains('bank', $this->violationPaths($supplier)); + } + + public function testVirementWithBankPasses(): void + { + $supplier = $this->validSupplier(); + $supplier->setPaymentType($this->paymentType('VIREMENT')); + $supplier->setBank(new Bank()); + + self::assertNotContains('bank', $this->violationPaths($supplier)); + } + + // === RG-2.08 : LCR impose au moins un RIB === + + public function testLcrWithoutRibIsRejectedOnRibsPath(): void + { + $supplier = $this->validSupplier(); + $supplier->setPaymentType($this->paymentType('LCR')); + + self::assertContains('ribs', $this->violationPaths($supplier)); + } + + public function testLcrWithRibPasses(): void + { + $supplier = $this->validSupplier(); + $supplier->setPaymentType($this->paymentType('LCR')); + $supplier->addRib(new SupplierRib()); + + self::assertNotContains('ribs', $this->violationPaths($supplier)); + } + + public function testNeutralPaymentTypeRequiresNeitherBankNorRib(): void + { + // Un type de reglement neutre (ni VIREMENT ni LCR) n'exige ni banque ni RIB. + $supplier = $this->validSupplier(); + $supplier->setPaymentType($this->paymentType('CHEQUE')); + + $paths = $this->violationPaths($supplier); + self::assertNotContains('bank', $paths); + self::assertNotContains('ribs', $paths); + } + + /** + * Fournisseur valide (nom + 1 categorie FOURNISSEUR), sans onglet + * Comptabilite renseigne : sert de base aux tests RG-2.07/2.08. + */ + private function validSupplier(): Supplier + { + $supplier = new Supplier(); + $supplier->setCompanyName('Recycla SAS'); + $supplier->addCategory($this->category('FOURNISSEUR')); + + return $supplier; + } + + /** + * @return list propertyPaths des violations levees par le validator + */ + private function violationPaths(Supplier $supplier): array + { + $paths = []; + foreach ($this->validator->validate($supplier) as $violation) { + $paths[] = $violation->getPropertyPath(); + } + + return $paths; + } + + /** + * Double minimal de CategoryInterface (pas d'acces base) renvoyant le code de + * type de categorie voulu — seul element regarde par validateCategoryType. + */ + private function category(string $typeCode): CategoryInterface + { + return new class($typeCode) implements CategoryInterface { + public function __construct(private readonly string $typeCode) {} + + public function getId(): ?int + { + return 1; + } + + public function getName(): ?string + { + return 'Categorie test'; + } + + public function getCode(): ?string + { + return 'TEST'; + } + + public function getCategoryTypeCode(): ?string + { + return $this->typeCode; + } + }; + } + + private function paymentType(string $code): PaymentType + { + $type = new PaymentType(); + $type->setCode($code); + $type->setLabel($code); + + return $type; + } +} diff --git a/tests/Module/Commercial/Unit/SupplierInformationCompletenessValidatorTest.php b/tests/Module/Commercial/Unit/SupplierInformationCompletenessValidatorTest.php new file mode 100644 index 0000000..40cc545 --- /dev/null +++ b/tests/Module/Commercial/Unit/SupplierInformationCompletenessValidatorTest.php @@ -0,0 +1,129 @@ +completeSupplier(); + + $this->validator()->validate($supplier); + + // Aucune exception levee : la completude est satisfaite. + $this->addToAssertionCount(1); + } + + public function testEmptyInformationListsEveryMissingField(): void + { + $supplier = new Supplier(); + $supplier->setCompanyName('Recycla SAS'); // onglet principal, hors Information + + try { + $this->validator()->validate($supplier); + self::fail('Une ValidationException etait attendue (onglet Information vide).'); + } catch (ValidationException $e) { + $paths = []; + foreach ($e->getConstraintViolationList() as $violation) { + $paths[] = $violation->getPropertyPath(); + } + + // Les 8 champs Information (dont volumeForecast, NEW vs Client) sont + // tous signales d'un coup, chacun sous son propre propertyPath. + sort($paths); + self::assertSame([ + 'competitors', + 'description', + 'directorName', + 'employeesCount', + 'foundedAt', + 'profitAmount', + 'revenueAmount', + 'volumeForecast', + ], $paths); + } + } + + public function testPartialInformationReportsOnlyMissingFields(): void + { + $supplier = $this->completeSupplier(); + $supplier->setDirectorName(null); + $supplier->setVolumeForecast(null); + + try { + $this->validator()->validate($supplier); + self::fail('Une ValidationException etait attendue (2 champs manquants).'); + } catch (ValidationException $e) { + $paths = []; + foreach ($e->getConstraintViolationList() as $violation) { + $paths[] = $violation->getPropertyPath(); + } + + sort($paths); + self::assertSame(['directorName', 'volumeForecast'], $paths); + } + } + + public function testZeroNumericValuesAreNotMissing(): void + { + // employeesCount = 0, profitAmount = "0.00", volumeForecast = 0 sont des + // valeurs valides (un zero n'est pas une absence) -> pas de violation. + $supplier = $this->completeSupplier(); + $supplier->setEmployeesCount(0); + $supplier->setProfitAmount('0.00'); + $supplier->setVolumeForecast(0); + + $this->validator()->validate($supplier); + + $this->addToAssertionCount(1); + } + + public function testBlankStringIsMissing(): void + { + // Une chaine vide apres trim compte comme manquante. + $supplier = $this->completeSupplier(); + $supplier->setDescription(' '); + + $this->expectException(ValidationException::class); + $this->validator()->validate($supplier); + } + + /** + * Fournisseur dont l'onglet Information est entierement renseigne. + */ + private function completeSupplier(): Supplier + { + $supplier = new Supplier(); + $supplier->setCompanyName('Recycla SAS'); + $supplier->setDescription('Specialiste du recyclage'); + $supplier->setCompetitors('Concurrent A, Concurrent B'); + $supplier->setFoundedAt(new DateTimeImmutable('2010-01-01')); + $supplier->setEmployeesCount(42); + $supplier->setRevenueAmount('1000000.00'); + $supplier->setDirectorName('Marie Durand'); + $supplier->setProfitAmount('150000.00'); + $supplier->setVolumeForecast(5000); + + return $supplier; + } + + private function validator(): SupplierInformationCompletenessValidator + { + return new SupplierInformationCompletenessValidator(); + } +} diff --git a/tests/Module/Commercial/Unit/SupplierProcessorTest.php b/tests/Module/Commercial/Unit/SupplierProcessorTest.php new file mode 100644 index 0000000..19b26f4 --- /dev/null +++ b/tests/Module/Commercial/Unit/SupplierProcessorTest.php @@ -0,0 +1,199 @@ + 422, meme + // sur un POST (les champs Information n'y sont pas renseignables). + $supplier = $this->minimalSupplier(); + $supplier->setDescription('Une description'); // les autres champs Information restent null + + $processor = $this->makeProcessor( + payload: ['description' => 'Une description'], + user: $this->commercialeUser(), + ); + + $this->expectException(ValidationException::class); + $processor->process($supplier, $this->operation()); + } + + public function testCommercialeIncompleteInformationOnMainOnlyPatchIsUnprocessable(): void + { + // RG-2.03 : pour une Commerciale, la completude Information est exigee + // meme quand le payload ne touche PAS l'onglet Information (ici + // companyName seul) -> 422. + $supplier = $this->minimalSupplier(); + $supplier->setCompanyName('Renamed Co'); + + $processor = $this->makeProcessor( + granted: ['commercial.suppliers.manage'], + payload: ['companyName' => 'Renamed Co'], + user: $this->commercialeUser(), + managed: true, + originalData: [ + 'companyName' => 'TEST CO', + 'isArchived' => false, + ], + ); + + $this->expectException(ValidationException::class); + $processor->process($supplier, $this->operation()); + } + + public function testCommercialeCompleteInformationPasses(): void + { + // RG-2.03 satisfaite : tous les champs Information renseignes -> 200. + $supplier = $this->completeInformationSupplier(); + + $processor = $this->makeProcessor( + granted: ['commercial.suppliers.manage'], + payload: ['description' => 'desc'], + user: $this->commercialeUser(), + ); + + self::assertInstanceOf(Supplier::class, $processor->process($supplier, $this->operation())); + } + + public function testNonCommercialeSkipsInformationCompleteness(): void + { + // Meme onglet Information incomplet, mais user non-Commerciale -> aucun + // blocage (la completude est specifique a la Commerciale). + $supplier = $this->minimalSupplier(); + $supplier->setDescription('Une description'); + + $processor = $this->makeProcessor( + payload: ['description' => 'Une description'], + user: null, + ); + + self::assertInstanceOf(Supplier::class, $processor->process($supplier, $this->operation())); + } + + /** + * @param list $granted Permissions accordees a l'utilisateur courant + * @param array $payload Corps JSON simule de la requete + * @param bool $managed true = entite geree par l'ORM (PATCH), false = creation (POST) + * @param array $originalData Etat persiste simule (getOriginalEntityData) + */ + private function makeProcessor( + array $granted = [], + array $payload = [], + ?UserInterface $user = null, + bool $managed = false, + array $originalData = [], + ): SupplierProcessor { + $persist = new class implements ProcessorInterface { + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + return $data; + } + }; + + $security = $this->createStub(Security::class); + $security->method('isGranted')->willReturnCallback( + static fn (mixed $attribute): bool => is_string($attribute) && in_array($attribute, $granted, true), + ); + $security->method('getUser')->willReturn($user); + + $requestStack = new RequestStack(); + $requestStack->push(new Request([], [], [], [], [], [], json_encode($payload, JSON_THROW_ON_ERROR))); + + $uow = $this->createMock(UnitOfWork::class); + $uow->method('getOriginalEntityData')->willReturn($originalData); + + $em = $this->createMock(EntityManagerInterface::class); + $em->method('contains')->willReturn($managed); + $em->method('getUnitOfWork')->willReturn($uow); + + return new SupplierProcessor( + $persist, + new SupplierFieldNormalizer(), + new SupplierInformationCompletenessValidator(), + $security, + $requestStack, + $em, + ); + } + + private function minimalSupplier(): Supplier + { + $supplier = new Supplier(); + $supplier->setCompanyName('Test Co'); + + return $supplier; + } + + private function completeInformationSupplier(): Supplier + { + $supplier = $this->minimalSupplier(); + $supplier->setDescription('desc'); + $supplier->setCompetitors('concurrents'); + $supplier->setFoundedAt(new DateTimeImmutable('2010-01-01')); + $supplier->setEmployeesCount(10); + $supplier->setRevenueAmount('1000.00'); + $supplier->setDirectorName('Marie Durand'); + $supplier->setProfitAmount('100.00'); + $supplier->setVolumeForecast(500); + + return $supplier; + } + + private function operation(): Operation + { + return $this->createStub(Operation::class); + } + + private function commercialeUser(): UserInterface + { + return new class implements UserInterface, BusinessRoleAwareInterface { + public function hasBusinessRole(string $roleCode): bool + { + return BusinessRoles::COMMERCIALE === $roleCode; + } + + public function getRoles(): array + { + return ['ROLE_USER']; + } + + public function eraseCredentials(): void {} + + public function getUserIdentifier(): string + { + return 'commerciale-test'; + } + }; + } +}