85963ec3ff
- config/packages/test/doctrine.yaml : force dbal profiling en test pour que doctrine.debug_data_holder existe sous APP_DEBUG=0 (CI). Le test anti-N+1 SupplierListTest passait en local (debug=1) mais cassait en CI. - RBACMatrix/SupplierApi : les 422 RG-2.03 et RG-2.14 assertent desormais le propertyPath / message (plus seulement le code) — un 422 orthogonal ne peut plus faire passer le test. - RBACMatrix : gating bureau/commerciale verifie l'ensemble des champs comptables (accountNumber/nTva/tvaMode/paymentType), plus seulement siren/ribs. - violationsByPath() mutualise dans AbstractSupplierApiTestCase (dedup).
340 lines
13 KiB
PHP
340 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Module\Commercial\Api;
|
|
|
|
use App\Module\Catalog\Domain\Entity\Category;
|
|
use App\Module\Catalog\Domain\Entity\CategoryType;
|
|
use App\Module\Commercial\Domain\Entity\Bank;
|
|
use App\Module\Commercial\Domain\Entity\PaymentDelay;
|
|
use App\Module\Commercial\Domain\Entity\PaymentType;
|
|
use App\Module\Commercial\Domain\Entity\Supplier;
|
|
use App\Module\Commercial\Domain\Entity\SupplierAddress;
|
|
use App\Module\Commercial\Domain\Entity\SupplierContact;
|
|
use App\Module\Commercial\Domain\Entity\SupplierRib;
|
|
use App\Module\Commercial\Domain\Entity\TvaMode;
|
|
use App\Module\Sites\Domain\Entity\Site;
|
|
use DateTimeImmutable;
|
|
|
|
/**
|
|
* Base des tests fonctionnels du repertoire fournisseurs (M2). Jumelle de la base
|
|
* clients (M1), elle ajoute les factories specifiques fournisseur au-dessus de
|
|
* {@see AbstractCommercialApiTestCase} (qui apporte deja createCategory sous le
|
|
* type CLIENT, createUserWithPermission, authenticatedClient...).
|
|
*
|
|
* Donnees (RETEX M1 — pas de fixtures globales pour les tests) : chaque test seede
|
|
* ses fournisseurs en base via les helpers ci-dessous, puis le tearDown les purge.
|
|
* Les referentiels comptables (tva_mode / payment_delay / payment_type / bank) et
|
|
* les categories FOURNISSEUR (Negociant, Cooperative...) sont seedes par les
|
|
* fixtures applicatives (make test-db-setup) ; on les recupere par code.
|
|
*
|
|
* Categories : `supplierCategory('NEGOCIANT')` fetch-or-create une categorie de
|
|
* type FOURNISSEUR (requis par RG-2.10) — fetch-or-create par code pour rester
|
|
* idempotent et auto-suffisant (ne depend pas du seed, que d'autres tests de la
|
|
* suite peuvent purger). Pour fabriquer une categorie d'un AUTRE type (test de
|
|
* rejet RG-2.10), utiliser `createCategory()` du parent, qui cree sous CLIENT.
|
|
*
|
|
* Cleanup : le tearDown purge les fournisseurs AVANT le parent (qui supprime les
|
|
* categories `test_cli_cat_*`) : la jointure supplier_category est ON DELETE
|
|
* CASCADE cote supplier mais RESTRICT cote category — le DELETE DQL sur Supplier
|
|
* declenche le cascade BDD sur supplier_category / _contact / _address, liberant
|
|
* les categories pour la purge du parent.
|
|
*
|
|
* @internal
|
|
*/
|
|
abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase
|
|
{
|
|
protected const string LD = 'application/ld+json';
|
|
protected const string MERGE = 'application/merge-patch+json';
|
|
|
|
/** IBAN/BIC valides (Assert\Iban / Assert\Bic) reutilises par les seeds. */
|
|
protected const string VALID_IBAN = 'FR1420041010050500013M02606';
|
|
protected const string VALID_BIC = 'BNPAFRPPXXX';
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
$this->getEm()->createQuery('DELETE FROM '.Supplier::class)->execute();
|
|
parent::tearDown();
|
|
}
|
|
|
|
/**
|
|
* Fetch-or-create une categorie de type FOURNISSEUR par code (defaut
|
|
* Negociant). Type FOURNISSEUR exige par RG-2.10 : un POST fournisseur portant
|
|
* cette categorie passe la validation. Idempotent (lookup par code, aligne sur
|
|
* l'index unique partiel uq_category_code) et auto-suffisant : ne depend pas du
|
|
* seed CategoryFixtures (que d'autres tests de la suite peuvent purger). Une
|
|
* categorie creee ici porte le prefixe de nom de test -> purgee par le parent.
|
|
*/
|
|
protected function supplierCategory(string $code = 'NEGOCIANT'): Category
|
|
{
|
|
$em = $this->getEm();
|
|
$existing = $em->getRepository(Category::class)->findOneBy(['code' => $code, 'deletedAt' => null]);
|
|
if (null !== $existing) {
|
|
return $existing;
|
|
}
|
|
|
|
$category = new Category();
|
|
$category->setName(self::TEST_CATEGORY_PREFIX.'fr_'.strtolower($code));
|
|
$category->setCode($code);
|
|
$category->setCategoryType($this->supplierCategoryType());
|
|
$em->persist($category);
|
|
$em->flush();
|
|
|
|
return $category;
|
|
}
|
|
|
|
/**
|
|
* Recupere (ou cree) le type FOURNISSEUR. Idempotent : la contrainte d'unicite
|
|
* sur category_type.code interdit les doublons.
|
|
*/
|
|
protected function supplierCategoryType(): CategoryType
|
|
{
|
|
$em = $this->getEm();
|
|
$existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'FOURNISSEUR']);
|
|
if (null !== $existing) {
|
|
return $existing;
|
|
}
|
|
|
|
$type = new CategoryType();
|
|
$type->setCode('FOURNISSEUR');
|
|
$type->setLabel('Fournisseur');
|
|
$em->persist($type);
|
|
$em->flush();
|
|
|
|
return $type;
|
|
}
|
|
|
|
/**
|
|
* Seede directement un Supplier minimal (sans passer par l'API), pour les
|
|
* tests de liste / archivage / serialisation. Nom stocke en MAJUSCULES pour
|
|
* refleter l'etat normalise (RG-2.12) qu'aurait produit le SupplierProcessor.
|
|
* Porte une categorie FOURNISSEUR (defaut Negociant).
|
|
*/
|
|
protected function seedSupplier(string $companyName, bool $isArchived = false, string $categoryCode = 'NEGOCIANT'): Supplier
|
|
{
|
|
$em = $this->getEm();
|
|
$supplier = new Supplier();
|
|
$supplier->setCompanyName(mb_strtoupper($companyName, 'UTF-8'));
|
|
$supplier->addCategory($this->supplierCategory($categoryCode));
|
|
$supplier->setIsArchived($isArchived);
|
|
if ($isArchived) {
|
|
$supplier->setArchivedAt(new DateTimeImmutable());
|
|
}
|
|
$em->persist($supplier);
|
|
$em->flush();
|
|
|
|
return $supplier;
|
|
}
|
|
|
|
/**
|
|
* Seede un fournisseur COMPLET (sans passer par l'API — validations
|
|
* applicatives non rejouees mais CHECK BDD respectes) : onglet Information
|
|
* rempli, bloc comptable non nul (SIREN + refs), >= 1 RIB, >= 1 adresse
|
|
* multi-sites (>= 2 sites, triageProvider=true) avec >= 1 categorie
|
|
* FOURNISSEUR, >= 1 contact, >= 1 categorie sur le fournisseur. Sert de socle
|
|
* au contrat de serialisation et a la DoD (§ 4.0.bis).
|
|
*
|
|
* @param string $paymentTypeCode code du type de reglement a poser (defaut LCR,
|
|
* coherent avec le RIB seede ; RG-2.08)
|
|
*/
|
|
protected function seedCompleteSupplier(string $companyName, string $paymentTypeCode = 'LCR'): Supplier
|
|
{
|
|
$em = $this->getEm();
|
|
|
|
// Nom unique parmi les actifs (index partiel uq_supplier_company_name_active).
|
|
$suffix = substr(bin2hex(random_bytes(3)), 0, 6);
|
|
|
|
$supplier = new Supplier();
|
|
$supplier->setCompanyName(mb_strtoupper($companyName.' '.$suffix, 'UTF-8'));
|
|
$supplier->addCategory($this->supplierCategory('NEGOCIANT'));
|
|
|
|
// Onglet Information complet (RG-2.03 : exige pour la Commerciale).
|
|
$supplier->setDescription('Fournisseur de test complet.');
|
|
$supplier->setCompetitors('Concurrent A, Concurrent B');
|
|
$supplier->setFoundedAt(new DateTimeImmutable('2008-04-01'));
|
|
$supplier->setEmployeesCount(42);
|
|
$supplier->setRevenueAmount('1500000.00');
|
|
$supplier->setDirectorName('Jean Dupont');
|
|
$supplier->setProfitAmount('120000.00');
|
|
$supplier->setVolumeForecast(8000);
|
|
|
|
// Bloc comptable non nul (gating par omission cote Commerciale).
|
|
$supplier->setSiren('123456789');
|
|
$supplier->setAccountNumber('F0001');
|
|
$supplier->setNTva('FR00123456789');
|
|
$supplier->setTvaMode($this->tvaMode('FRANCE_VENTES'));
|
|
$supplier->setPaymentDelay($this->paymentDelay('J30'));
|
|
$supplier->setPaymentType($this->paymentType($paymentTypeCode));
|
|
if ('VIREMENT' === $paymentTypeCode) {
|
|
$supplier->setBank($this->bank('SG'));
|
|
}
|
|
$em->persist($supplier);
|
|
|
|
// >= 2 sites fixtures pour une adresse multi-sites (RG-2.06).
|
|
$sites = $em->getRepository(Site::class)->findBy([], null, 2);
|
|
self::assertGreaterThanOrEqual(2, count($sites), 'Au moins 2 sites fixtures requis (SitesFixtures).');
|
|
|
|
$contact = new SupplierContact();
|
|
$contact->setSupplier($supplier);
|
|
$contact->setFirstName('Marie');
|
|
$contact->setLastName('Martin');
|
|
$contact->setJobTitle('Responsable achats');
|
|
$contact->setPhonePrimary('0612345678');
|
|
$contact->setEmail('marie.martin@seed.test');
|
|
$supplier->addContact($contact);
|
|
$em->persist($contact);
|
|
|
|
$address = new SupplierAddress();
|
|
$address->setSupplier($supplier);
|
|
$address->setAddressType('DEPART');
|
|
$address->setPostalCode('86000');
|
|
$address->setCity('Poitiers');
|
|
$address->setStreet('12 rue des Acacias');
|
|
$address->setBennes(3);
|
|
// triageProvider=true : prouve qu'un booleen `true` est bien serialise
|
|
// (piege n°3 du M1 — la cle etait droppee).
|
|
$address->setTriageProvider(true);
|
|
foreach ($sites as $site) {
|
|
$address->addSite($site);
|
|
}
|
|
$address->addCategory($this->supplierCategory('NEGOCIANT'));
|
|
$address->addContact($contact);
|
|
$supplier->addAddress($address);
|
|
$em->persist($address);
|
|
|
|
$rib = new SupplierRib();
|
|
$rib->setSupplier($supplier);
|
|
$rib->setLabel('Compte principal');
|
|
$rib->setBic(self::VALID_BIC);
|
|
$rib->setIban(self::VALID_IBAN);
|
|
$supplier->addRib($rib);
|
|
$em->persist($rib);
|
|
|
|
$em->flush();
|
|
|
|
return $supplier;
|
|
}
|
|
|
|
/**
|
|
* Ajoute un contact a un fournisseur deja persiste (seed direct).
|
|
*/
|
|
protected function addContact(
|
|
Supplier $supplier,
|
|
?string $firstName = 'Marie',
|
|
?string $lastName = 'Martin',
|
|
?string $phonePrimary = null,
|
|
?string $email = null,
|
|
int $position = 0,
|
|
): SupplierContact {
|
|
$contact = new SupplierContact();
|
|
$contact->setSupplier($supplier);
|
|
$contact->setFirstName($firstName);
|
|
$contact->setLastName($lastName);
|
|
$contact->setPhonePrimary($phonePrimary);
|
|
$contact->setEmail($email);
|
|
$contact->setPosition($position);
|
|
$supplier->addContact($contact);
|
|
$this->getEm()->persist($contact);
|
|
$this->getEm()->flush();
|
|
|
|
return $contact;
|
|
}
|
|
|
|
/**
|
|
* Ajoute un RIB a un fournisseur deja persiste (seed direct).
|
|
*/
|
|
protected function addRib(Supplier $supplier, string $label = 'Compte principal'): SupplierRib
|
|
{
|
|
$rib = new SupplierRib();
|
|
$rib->setSupplier($supplier);
|
|
$rib->setLabel($label);
|
|
$rib->setBic(self::VALID_BIC);
|
|
$rib->setIban(self::VALID_IBAN);
|
|
$supplier->addRib($rib);
|
|
$this->getEm()->persist($rib);
|
|
$this->getEm()->flush();
|
|
|
|
return $rib;
|
|
}
|
|
|
|
/**
|
|
* Payload minimal valide de l'onglet principal (companyName + 1 categorie
|
|
* FOURNISSEUR). Si $categoryId est null, la categorie Negociant seedee est
|
|
* utilisee.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
protected function validMainPayload(string $companyName, ?int $categoryId = null): array
|
|
{
|
|
$categoryId ??= $this->supplierCategory('NEGOCIANT')->getId();
|
|
|
|
return [
|
|
'companyName' => $companyName,
|
|
'categories' => ['/api/categories/'.$categoryId],
|
|
];
|
|
}
|
|
|
|
protected function paymentType(string $code): PaymentType
|
|
{
|
|
return $this->referential(PaymentType::class, $code);
|
|
}
|
|
|
|
protected function paymentDelay(string $code): PaymentDelay
|
|
{
|
|
return $this->referential(PaymentDelay::class, $code);
|
|
}
|
|
|
|
protected function tvaMode(string $code): TvaMode
|
|
{
|
|
return $this->referential(TvaMode::class, $code);
|
|
}
|
|
|
|
protected function bank(string $code): Bank
|
|
{
|
|
return $this->referential(Bank::class, $code);
|
|
}
|
|
|
|
/**
|
|
* Recupere un referentiel comptable seede (CommercialReferentialFixtures) par
|
|
* code. Echoue explicitement si absent (fixtures non chargees).
|
|
*
|
|
* @template T of object
|
|
*
|
|
* @param class-string<T> $entityClass
|
|
*
|
|
* @return T
|
|
*/
|
|
private function referential(string $entityClass, string $code): object
|
|
{
|
|
$entity = $this->getEm()->getRepository($entityClass)->findOneBy(['code' => $code]);
|
|
|
|
self::assertNotNull(
|
|
$entity,
|
|
sprintf('Referentiel %s "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $entityClass, $code),
|
|
);
|
|
|
|
return $entity;
|
|
}
|
|
|
|
/**
|
|
* Indexe les violations d'un corps de reponse 422 par propertyPath. Permet
|
|
* d'asserter qu'un 422 porte bien sur le champ attendu (et n'est pas un 422
|
|
* orthogonal) : un test qui se contente du code 422 passerait meme si la RG
|
|
* visee etait cassee pour une autre raison.
|
|
*
|
|
* @param array<string, mixed> $body corps decode de la reponse (toArray(false))
|
|
*
|
|
* @return array<string, string> propertyPath => message
|
|
*/
|
|
protected function violationsByPath(array $body): array
|
|
{
|
|
$byPath = [];
|
|
foreach ($body['violations'] ?? [] as $v) {
|
|
$byPath[$v['propertyPath']] = $v['message'];
|
|
}
|
|
|
|
return $byPath;
|
|
}
|
|
}
|