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')"
/>
-
-
- update('addressType', opt.value)"
- />
- {{ errors.addressType }}
-
+
+ update('addressType', v === null ? null : (v as SupplierAddressType))"
+ />
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';
- }
- };
- }
-}