diff --git a/frontend/modules/commercial/components/SupplierAddressBlock.vue b/frontend/modules/commercial/components/SupplierAddressBlock.vue index 30b59b5..2c6eec6 100644 --- a/frontend/modules/commercial/components/SupplierAddressBlock.vue +++ b/frontend/modules/commercial/components/SupplierAddressBlock.vue @@ -10,26 +10,19 @@ @click="$emit('remove')" /> - -
- - {{ errors.addressType }} -
+ + props.modelValue) -// Nom de groupe radio unique par bloc (sinon les radios de blocs differents se -// partageraient la selection). Derive du titre (« Adresse 1 », « Adresse 2 »...). -const radioName = computed(() => `supplier-address-type-${props.title.replace(/\s+/g, '-')}`) - const addressTypeOptions = computed<{ value: SupplierAddressType, label: string }[]>(() => [ { value: 'PROSPECT', label: t('commercial.suppliers.form.address.addressTypeProspect') }, { value: 'DEPART', label: t('commercial.suppliers.form.address.addressTypeDepart') }, diff --git a/frontend/modules/commercial/components/__tests__/SupplierAddressBlock.spec.ts b/frontend/modules/commercial/components/__tests__/SupplierAddressBlock.spec.ts index fd2ec2b..a2983b8 100644 --- a/frontend/modules/commercial/components/__tests__/SupplierAddressBlock.spec.ts +++ b/frontend/modules/commercial/components/__tests__/SupplierAddressBlock.spec.ts @@ -57,7 +57,6 @@ function mountBlock(overrides: Record = {}, errors?: Record = {}, errors?: Record { - it('rend les 3 options de type d\'adresse (Prospect / Départ / Rendu)', () => { +describe('SupplierAddressBlock — specificites M2 (type, bennes, triage)', () => { + it('rend un select de type d\'adresse (en attendant l\'arbitrage metier)', () => { const wrapper = mountBlock() - expect(wrapper.findAll('malio-radio-button-stub')).toHaveLength(3) + const addressTypeSelect = wrapper.findAll('malio-select-stub').find( + el => el.attributes('label') === 'commercial.suppliers.form.address.addressType', + ) + expect(addressTypeSelect).toBeDefined() }) it('rend le stepper Bennes et la case Prestation de triage (champs specifiques fournisseur)', () => { @@ -88,9 +90,12 @@ describe('SupplierAddressBlock — specificites M2 (radio type, bennes, triage)' }) describe('SupplierAddressBlock — mapping erreur par champ (ERP-101)', () => { - it('affiche l\'erreur serveur du type d\'adresse (propertyPath addressType)', () => { - const wrapper = mountBlock({}, { addressType: 'Le type d\'adresse doit être Prospect, Départ ou Rendu.' }) - expect(wrapper.text()).toContain('Le type d\'adresse doit être Prospect, Départ ou Rendu.') + it('affiche l\'erreur serveur du type d\'adresse (propertyPath addressType) sur le select', () => { + const wrapper = mountBlock({}, { addressType: 'Le type d\'adresse est obligatoire.' }) + const addressTypeSelect = wrapper.findAll('malio-select-stub').find( + el => el.attributes('label') === 'commercial.suppliers.form.address.addressType', + ) + expect(addressTypeSelect?.attributes('error')).toBe('Le type d\'adresse est obligatoire.') }) it('affiche les erreurs serveur sur sites et categories', () => { diff --git a/frontend/modules/commercial/utils/__tests__/supplierEdit.spec.ts b/frontend/modules/commercial/utils/__tests__/supplierEdit.spec.ts index e1a2665..11b2570 100644 --- a/frontend/modules/commercial/utils/__tests__/supplierEdit.spec.ts +++ b/frontend/modules/commercial/utils/__tests__/supplierEdit.spec.ts @@ -79,6 +79,13 @@ describe('buildAddressPayload (sous-ressource supplier_address — specificites expect(payload.addressType).toBe('PROSPECT') }) + it('omet addressType quand aucun radio n\'est choisi (-> 422 NotBlank au lieu d\'un 400 de type)', () => { + // emptyAddress() laisse addressType a null : la cle doit etre absente du + // payload pour que le back renvoie une 422 propertyPath addressType. + const payload = buildAddressPayload(emptyAddress()) + expect('addressType' in payload).toBe(false) + }) + it('n\'expose jamais d\'email de facturation (difference M1)', () => { const payload = buildAddressPayload({ ...emptyAddress(), addressType: 'DEPART' }) expect('billingEmail' in payload).toBe(false) diff --git a/frontend/modules/commercial/utils/supplierFormRules.ts b/frontend/modules/commercial/utils/supplierFormRules.ts index a765053..a2cbc75 100644 --- a/frontend/modules/commercial/utils/supplierFormRules.ts +++ b/frontend/modules/commercial/utils/supplierFormRules.ts @@ -192,7 +192,11 @@ export function isRibRequiredForPaymentType(code: string | null | undefined): bo // OMETTRE la cle du payload quand elle est vide -> le back produit une 422 NotBlank // avec propertyPath, mappee en rouge sous le champ. export const MAIN_REQUIRED_NON_NULLABLE_KEYS = ['companyName'] as const -export const ADDRESS_REQUIRED_NON_NULLABLE_KEYS = ['postalCode', 'city', 'street'] as const +// addressType : colonne non-nullable + NotBlank cote back. Envoyer `null` (radio +// non choisi) provoque un 400 de TYPE a la deserialisation AVANT le Validator +// (« must be string, NULL given ») -> pas de violation, pas d'erreur inline. On +// omet donc la cle quand elle est vide pour obtenir une 422 NotBlank propertyPath. +export const ADDRESS_REQUIRED_NON_NULLABLE_KEYS = ['addressType', 'postalCode', 'city', 'street'] as const export const RIB_REQUIRED_NON_NULLABLE_KEYS = ['label', 'bic', 'iban'] as const /** diff --git a/src/Module/Commercial/Application/Validator/SupplierAccountingCompletenessValidator.php b/src/Module/Commercial/Application/Validator/SupplierAccountingCompletenessValidator.php new file mode 100644 index 0000000..486a7bb --- /dev/null +++ b/src/Module/Commercial/Application/Validator/SupplierAccountingCompletenessValidator.php @@ -0,0 +1,78 @@ + valeur courante des champs obligatoires de l'onglet. + $fields = [ + 'siren' => $supplier->getSiren(), + 'accountNumber' => $supplier->getAccountNumber(), + 'tvaMode' => $supplier->getTvaMode(), + 'nTva' => $supplier->getNTva(), + 'paymentDelay' => $supplier->getPaymentDelay(), + 'paymentType' => $supplier->getPaymentType(), + ]; + + $violations = new ConstraintViolationList(); + + foreach ($fields as $property => $value) { + if ($this->isMissing($value)) { + $violations->add(new ConstraintViolation( + 'Ce champ est obligatoire.', + 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 + * references (TvaMode / PaymentDelay / PaymentType) ne sont manquantes que + * lorsqu'elles valent null. + */ + private function isMissing(mixed $value): bool + { + if (null === $value) { + return true; + } + + return is_string($value) && '' === trim($value); + } +} diff --git a/src/Module/Commercial/Application/Validator/SupplierInformationCompletenessValidator.php b/src/Module/Commercial/Application/Validator/SupplierInformationCompletenessValidator.php deleted file mode 100644 index d6d4f03..0000000 --- a/src/Module/Commercial/Application/Validator/SupplierInformationCompletenessValidator.php +++ /dev/null @@ -1,82 +0,0 @@ - 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( - // Pas de nom de champ technique dans le message : la violation est - // deja rattachee au bon champ via son propertyPath (mappe inline - // cote front par useFormErrors). - 'Ce champ est obligatoire pour le rôle Commerciale.', - 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 7c7d7f1..30709a1 100644 --- a/src/Module/Commercial/Domain/Entity/Supplier.php +++ b/src/Module/Commercial/Domain/Entity/Supplier.php @@ -328,8 +328,11 @@ class Supplier implements TimestampableInterface, BlamableInterface * 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). + * - RG-2.08 : paymentType = LCR impose au moins un RIB -> violation sur + * `paymentType` (miroir client : `ribs` n'a pas de champ de formulaire ou + * s'ancrer quand la liste est vide ; l'erreur s'affiche donc sous le select + * « Type de règlement », bindé cote front). 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 @@ -349,7 +352,7 @@ class Supplier implements TimestampableInterface, BlamableInterface 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') + ->atPath('paymentType') ->addViolation() ; } diff --git a/src/Module/Commercial/Domain/Entity/SupplierAddress.php b/src/Module/Commercial/Domain/Entity/SupplierAddress.php index 6667675..94a3aaf 100644 --- a/src/Module/Commercial/Domain/Entity/SupplierAddress.php +++ b/src/Module/Commercial/Domain/Entity/SupplierAddress.php @@ -199,12 +199,14 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface #[Groups(['supplier:item:read', 'supplier:write:addresses'])] private Collection $contacts; - // RG-2.10 : categories d'adresse de type FOURNISSEUR (controle au Processor). + // RG-2.10 : au moins une categorie de type FOURNISSEUR par adresse (le type est + // controle par validateCategoryType ; le minimum par Assert\Count, miroir sites). /** @var Collection */ #[ORM\ManyToMany(targetEntity: CategoryInterface::class)] #[ORM\JoinTable(name: 'supplier_address_category')] #[ORM\JoinColumn(name: 'supplier_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')] #[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')] + #[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')] #[Groups(['supplier:item:read', 'supplier:write:addresses'])] private Collection $categories; diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierProcessor.php index e66eb3c..c94a604 100644 --- a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierProcessor.php +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierProcessor.php @@ -7,10 +7,8 @@ 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\Application\Validator\SupplierAccountingCompletenessValidator; 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; @@ -43,19 +41,17 @@ use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; * collisions d'unicite en 409 (RG-2.11 doublon de nom ; RG-2.15 conflit de * restauration). * - * 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). + * Validators metier (ERP-89). Ce processor porte la completude Comptabilite : a + * la validation complete de l'onglet (les six scalaires obligatoires presents + * dans le payload), chacun doit etre renseigne. (RG-2.03 « Information obligatoire + * pour la Commerciale » a ete retiree, miroir client M1 — l'onglet Information est + * desormais entierement facultatif, quel que soit le role.) * - * 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). + * 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). * * @implements ProcessorInterface */ @@ -78,6 +74,14 @@ final class SupplierProcessor implements ProcessorInterface 'paymentType', 'bank', ]; + /** + * Champs comptables obligatoires a la validation complete de l'onglet + * (spec-front M2 § Onglet Comptabilite). bank est exclu : conditionnel (RG-2.07). + */ + private const array ACCOUNTING_REQUIRED_FIELDS = [ + 'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', 'paymentType', + ]; + /** Champ d'archivage (groupe supplier:write:archive). */ private const string ARCHIVE_FIELD = 'isArchived'; @@ -102,7 +106,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 SupplierAccountingCompletenessValidator $accountingValidator, private readonly Security $security, private readonly RequestStack $requestStack, private readonly EntityManagerInterface $em, @@ -132,7 +136,7 @@ final class SupplierProcessor implements ProcessorInterface // normalisees des deux cotes (l'etat persiste l'a deja ete). $this->guardManage($data); - $this->validateInformationCompleteness($data); + $this->validateAccountingCompleteness($data); try { return $this->persistProcessor->process($data, $operation, $uriVariables, $context); @@ -262,35 +266,23 @@ 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. + * spec-front M2 § Onglet Comptabilite : a la validation COMPLETE de l'onglet + * (les six champs obligatoires presents dans le payload — le front les envoie + * toujours ensemble), chacun doit etre renseigne, sinon 422 par champ. On ne + * declenche pas sur un PATCH ciblant un sous-ensemble de champs comptables : + * ce n'est pas une validation d'onglet (edition ponctuelle preservee). bank / + * RIB restent geres par validatePaymentTypeConsistency sur l'entite (RG-2.07 / + * RG-2.08). Miroir du ClientProcessor (M1). */ - private function validateInformationCompleteness(Supplier $data): void + private function validateAccountingCompleteness(Supplier $data): void { - if ($this->currentUserIsCommerciale()) { - $this->informationValidator->validate($data); + // Declenche uniquement si TOUS les champs requis sont presents dans le + // payload (= soumission d'onglet, pas un PATCH partiel cible). + if ([] !== array_diff(self::ACCOUNTING_REQUIRED_FIELDS, $this->payloadKeys())) { + return; } - } - /** - * 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); + $this->accountingValidator->validate($data); } /** diff --git a/tests/Module/Commercial/Api/AbstractSupplierApiTestCase.php b/tests/Module/Commercial/Api/AbstractSupplierApiTestCase.php index e7c5b97..97f4270 100644 --- a/tests/Module/Commercial/Api/AbstractSupplierApiTestCase.php +++ b/tests/Module/Commercial/Api/AbstractSupplierApiTestCase.php @@ -152,7 +152,8 @@ abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase $supplier->setCompanyName(mb_strtoupper($companyName.' '.$suffix, 'UTF-8')); $supplier->addCategory($this->supplierCategory('NEGOCIANT')); - // Onglet Information complet (RG-2.03 : exige pour la Commerciale). + // Onglet Information complet : donnees de reference pour les tests de + // lecture / serialisation / comptabilite (l'Information est facultative). $supplier->setDescription('Fournisseur de test complet.'); $supplier->setCompetitors('Concurrent A, Concurrent B'); $supplier->setFoundedAt(new DateTimeImmutable('2008-04-01')); diff --git a/tests/Module/Commercial/Api/SupplierAccountingApiTest.php b/tests/Module/Commercial/Api/SupplierAccountingApiTest.php index 0819aee..9d19219 100644 --- a/tests/Module/Commercial/Api/SupplierAccountingApiTest.php +++ b/tests/Module/Commercial/Api/SupplierAccountingApiTest.php @@ -49,7 +49,7 @@ final class SupplierAccountingApiTest extends AbstractSupplierApiTestCase // === RG-2.08 : LCR impose au moins un RIB === - public function testLcrWithoutRibReturns422OnRibsPath(): void + public function testLcrWithoutRibReturns422OnPaymentTypePath(): void { $client = $this->createAdminClient(); $seed = $this->seedSupplier('Lcr No Rib'); @@ -60,7 +60,9 @@ final class SupplierAccountingApiTest extends AbstractSupplierApiTestCase ]); self::assertResponseStatusCodeSame(422); - self::assertArrayHasKey('ribs', $this->violationsByPath($response->toArray(false))); + // Miroir client : violation portee sur `paymentType` (select « Type de + // règlement »), `ribs` n'ayant pas de champ de formulaire pour l'ancrer. + self::assertArrayHasKey('paymentType', $this->violationsByPath($response->toArray(false))); } public function testLcrWithRibReturns200(): void @@ -77,5 +79,58 @@ final class SupplierAccountingApiTest extends AbstractSupplierApiTestCase self::assertResponseStatusCodeSame(200); } + // === Completude de l'onglet Comptabilite (six scalaires obligatoires) === + + /** + * spec-front M2 § Onglet Comptabilite : a la validation COMPLETE de l'onglet + * (les six champs requis presents dans le payload), chacun vide doit renvoyer + * une 422 sur son propre propertyPath (mapping inline front, ERP-101). Miroir + * du comportement client (ClientAccountingCompletenessValidator). + */ + public function testIncompleteAccountingTabReturns422OnEachField(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedSupplier('Accounting Incomplete'); + + $response = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE, 'Accept' => self::LD], + 'json' => [ + 'siren' => null, + 'accountNumber' => null, + 'tvaMode' => null, + 'nTva' => null, + 'paymentDelay' => null, + 'paymentType' => null, + ], + ]); + + self::assertResponseStatusCodeSame(422); + $paths = $this->violationsByPath($response->toArray(false)); + self::assertArrayHasKey('siren', $paths); + self::assertArrayHasKey('accountNumber', $paths); + self::assertArrayHasKey('tvaMode', $paths); + self::assertArrayHasKey('nTva', $paths); + self::assertArrayHasKey('paymentDelay', $paths); + self::assertArrayHasKey('paymentType', $paths); + } + + /** + * Un PATCH ciblant un sous-ensemble de champs comptables n'est PAS une + * validation d'onglet : la completude ne se declenche pas (edition ponctuelle + * preservee, cf. validateAccountingCompleteness). + */ + public function testPartialAccountingPatchSkipsCompleteness(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedSupplier('Accounting Partial'); + + $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['nTva' => 'FR12345678901'], + ]); + + self::assertResponseStatusCodeSame(200); + } + // violationsByPath() : helper mutualise dans AbstractSupplierApiTestCase. } diff --git a/tests/Module/Commercial/Api/SupplierRBACMatrixTest.php b/tests/Module/Commercial/Api/SupplierRBACMatrixTest.php index 9d5b3dd..55cc6d4 100644 --- a/tests/Module/Commercial/Api/SupplierRBACMatrixTest.php +++ b/tests/Module/Commercial/Api/SupplierRBACMatrixTest.php @@ -13,8 +13,8 @@ use Symfony\Component\Console\Output\NullOutput; /** * Matrice RBAC complete du repertoire fournisseurs par role metier (spec-back M2 * § 2.9 + ERP-90). Valide 200/403 par verbe et par onglet pour - * bureau / compta / commerciale / usine, le gating des champs comptables en - * lecture (omission de cle) et le durcissement RG-2.03 (Commerciale) au POST/PATCH. + * bureau / compta / commerciale / usine et le gating des champs comptables en + * lecture (omission de cle). * * Les comptes demo et la matrice sont seedes via la commande reelle * `app:seed-rbac --with-demo-users` (le MEME chemin qu'en recette), idempotente — @@ -23,7 +23,7 @@ use Symfony\Component\Console\Output\NullOutput; * Matrice § 2.9 (ERP-90) — rappel : * - bureau : suppliers.view + manage (ni accounting, ni archive) * - compta : suppliers.view + accounting.view + accounting.manage (PAS manage) - * - commerciale : suppliers.view + manage (PAS accounting), durcie RG-2.03 + * - commerciale : suppliers.view + manage (PAS accounting) * - usine : aucune permission (403 partout) * - archive : admin seul (aucun role metier) * @@ -93,7 +93,7 @@ final class SupplierRBACMatrixTest extends AbstractSupplierApiTestCase $client->request('GET', '/api/suppliers', ['headers' => ['Accept' => self::LD]]); self::assertResponseStatusCodeSame(200); - // manage : creation OK (bureau n'est pas gate par RG-2.03) + // manage : creation OK $client->request('POST', '/api/suppliers', [ 'headers' => ['Content-Type' => self::LD], 'json' => $this->validMainPayload('Bureau Created', $cat->getId()), @@ -211,15 +211,13 @@ final class SupplierRBACMatrixTest extends AbstractSupplierApiTestCase self::assertResponseStatusCodeSame(200); // manage : la creation passe la security d'operation (pas un 403 comme - // Compta) mais bute sur RG-2.03 (onglet Information incomplet) -> 422. - $response = $client->request('POST', '/api/suppliers', [ + // Compta) -> 201. L'onglet Information est facultatif (RG-2.03 retiree, + // miroir client M1) : une Commerciale cree avec le seul onglet principal. + $client->request('POST', '/api/suppliers', [ 'headers' => ['Content-Type' => self::LD], 'json' => $this->validMainPayload('Commerciale Post'), ]); - self::assertResponseStatusCodeSame(422); - // Le 422 doit bien etre celui de RG-2.03 (onglet Information) et non un - // 422 orthogonal : on exige une violation sur un champ de completude. - self::assertArrayHasKey('description', $this->violationsByPath($response->toArray(false))); + self::assertResponseStatusCodeSame(201); // PAS accounting : edition onglet Comptabilite refusee $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [ @@ -251,50 +249,6 @@ final class SupplierRBACMatrixTest extends AbstractSupplierApiTestCase self::assertArrayNotHasKey('ribs', $data); } - public function testRG203CommercialePostIncompleteIs422AdminIs201(): void - { - $cat = $this->supplierCategory('NEGOCIANT'); - - // RG-2.03 : Commerciale POST sans onglet Information complet -> 422. - $commerciale = $this->authAs('commerciale'); - $response = $commerciale->request('POST', '/api/suppliers', [ - 'headers' => ['Content-Type' => self::LD], - 'json' => $this->validMainPayload('RG203 Commerciale', $cat->getId()), - ]); - self::assertResponseStatusCodeSame(422); - self::assertArrayHasKey('description', $this->violationsByPath($response->toArray(false))); - - // Meme payload par un Admin (non gate par RG-2.03) -> 201. - $admin = $this->createAdminClient(); - $admin->request('POST', '/api/suppliers', [ - 'headers' => ['Content-Type' => self::LD], - 'json' => $this->validMainPayload('RG203 Admin', $cat->getId()), - ]); - self::assertResponseStatusCodeSame(201); - } - - public function testRG203CommercialePatchIncompleteIs422(): void - { - // RG-2.03 : tout PATCH par une Commerciale exige l'Information complete. - // Le fournisseur seede a une Information vide -> meme un PATCH du nom -> 422. - $seed = $this->seedSupplier('Commerciale Patch Incomplete'); - $commerciale = $this->authAs('commerciale'); - - $response = $commerciale->request('PATCH', '/api/suppliers/'.$seed->getId(), [ - 'headers' => ['Content-Type' => self::MERGE], - 'json' => ['companyName' => 'Commerciale Renamed'], - ]); - self::assertResponseStatusCodeSame(422); - self::assertArrayHasKey('description', $this->violationsByPath($response->toArray(false))); - - // Le meme PATCH par un Admin passe (non gate par RG-2.03) -> 200. - $admin = $this->createAdminClient(); - $admin->request('PATCH', '/api/suppliers/'.$seed->getId(), [ - 'headers' => ['Content-Type' => self::MERGE], - 'json' => ['companyName' => 'Admin Renamed'], - ]); - self::assertResponseStatusCodeSame(200); - } private function authAs(string $role): Client { diff --git a/tests/Module/Commercial/Api/SupplierSubResourceApiTest.php b/tests/Module/Commercial/Api/SupplierSubResourceApiTest.php index 3ded3be..ac1c8b8 100644 --- a/tests/Module/Commercial/Api/SupplierSubResourceApiTest.php +++ b/tests/Module/Commercial/Api/SupplierSubResourceApiTest.php @@ -172,8 +172,9 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase public function testPostAddressWithIncoherentCityAndPostalCodeReturns201(): void { $this->skipIfSitesModuleDisabled(); - $client = $this->createAdminClient(); - $seed = $this->seedSupplier('Address Incoherent'); + $client = $this->createAdminClient(); + $seed = $this->seedSupplier('Address Incoherent'); + $category = $this->supplierCategory('NEGOCIANT'); // RG-2.05 : pas de controle strict de coherence CP/ville cote serveur. $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [ @@ -184,6 +185,7 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase 'city' => 'Marseille', 'street' => '1 rue du Test', 'sites' => [$this->firstSiteIri()], + 'categories' => ['/api/categories/'.$category->getId()], ], ]); @@ -217,9 +219,10 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase public function testPostAddressWithEachValidTypeReturns201(): void { $this->skipIfSitesModuleDisabled(); - $client = $this->createAdminClient(); - $seed = $this->seedSupplier('Address Types'); - $siteIri = $this->firstSiteIri(); + $client = $this->createAdminClient(); + $seed = $this->seedSupplier('Address Types'); + $siteIri = $this->firstSiteIri(); + $category = $this->supplierCategory('NEGOCIANT'); foreach (['PROSPECT', 'DEPART', 'RENDU'] as $type) { $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [ @@ -230,6 +233,7 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [$siteIri], + 'categories' => ['/api/categories/'.$category->getId()], ], ]); self::assertResponseStatusCodeSame(201, sprintf('addressType=%s doit etre accepte.', $type)); diff --git a/tests/Module/Commercial/Domain/Entity/SupplierValidationTest.php b/tests/Module/Commercial/Domain/Entity/SupplierValidationTest.php index 04ec47f..511f966 100644 --- a/tests/Module/Commercial/Domain/Entity/SupplierValidationTest.php +++ b/tests/Module/Commercial/Domain/Entity/SupplierValidationTest.php @@ -87,12 +87,14 @@ final class SupplierValidationTest extends TestCase // === RG-2.08 : LCR impose au moins un RIB === - public function testLcrWithoutRibIsRejectedOnRibsPath(): void + public function testLcrWithoutRibIsRejectedOnPaymentTypePath(): void { $supplier = $this->validSupplier(); $supplier->setPaymentType($this->paymentType('LCR')); - self::assertContains('ribs', $this->violationPaths($supplier)); + // Miroir client : la violation LCR -> >= 1 RIB est portee sur `paymentType` + // (affichee sous le select « Type de règlement », `ribs` n'ayant pas de champ). + self::assertContains('paymentType', $this->violationPaths($supplier)); } public function testLcrWithRibPasses(): void @@ -101,7 +103,7 @@ final class SupplierValidationTest extends TestCase $supplier->setPaymentType($this->paymentType('LCR')); $supplier->addRib(new SupplierRib()); - self::assertNotContains('ribs', $this->violationPaths($supplier)); + self::assertNotContains('paymentType', $this->violationPaths($supplier)); } public function testNeutralPaymentTypeRequiresNeitherBankNorRib(): void diff --git a/tests/Module/Commercial/Unit/SupplierInformationCompletenessValidatorTest.php b/tests/Module/Commercial/Unit/SupplierInformationCompletenessValidatorTest.php deleted file mode 100644 index 40cc545..0000000 --- a/tests/Module/Commercial/Unit/SupplierInformationCompletenessValidatorTest.php +++ /dev/null @@ -1,129 +0,0 @@ -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 deleted file mode 100644 index 250ae0a..0000000 --- a/tests/Module/Commercial/Unit/SupplierProcessorTest.php +++ /dev/null @@ -1,244 +0,0 @@ - 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())); - } - - public function testAdminIncompleteInformationPasses(): void - { - // Distinct du cas user=null : un utilisateur AUTHENTIFIE mais non-Commerciale - // (ici un admin, BusinessRoleAwareInterface renvoyant false pour tout role - // metier) n'est pas soumis a la completude Information -> 200 malgre un - // onglet Information incomplet. Prouve que le gate porte bien sur le ROLE - // metier Commerciale, et pas sur « il y a un utilisateur connecte ». - $supplier = $this->minimalSupplier(); - $supplier->setDescription('Une description'); - - $processor = $this->makeProcessor( - payload: ['description' => 'Une description'], - user: $this->adminUser(), - ); - - 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); - } - - /** - * Utilisateur authentifie non-Commerciale (profil admin) : porte - * BusinessRoleAwareInterface mais ne reconnait aucun role metier. Sert a - * distinguer « pas de role Commerciale » de « pas d'utilisateur » (null). - */ - private function adminUser(): UserInterface - { - return new class implements UserInterface, BusinessRoleAwareInterface { - public function hasBusinessRole(string $roleCode): bool - { - return false; - } - - public function getRoles(): array - { - return ['ROLE_ADMIN']; - } - - public function eraseCredentials(): void {} - - public function getUserIdentifier(): string - { - return 'admin-test'; - } - }; - } - - 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'; - } - }; - } -}