Compare commits

..

4 Commits

Author SHA1 Message Date
Matthieu 380b0c9ef1 test(commercial) : SupplierExportControllerTest sur base fournisseurs (catégories FOURNISSEUR, dédup F3) (ERP-113)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m29s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m6s
Fait étendre SupplierExportControllerTest à AbstractSupplierApiTestCase au
lieu d'AbstractCommercialApiTestCase. Supprime le seedSupplier() privé (qui
seedait des catégories de type CLIENT via createCategory(), violant RG-2.10)
et le tearDown() redondant, désormais portés par la base sur des catégories
FOURNISSEUR.

Le contact principal utilise le helper addContact() de la base ; le téléphone
secondaire, non porté par ce helper, est posé via le setter sur le contact
retourné. L'assertion de la colonne Catégories dérive le libellé attendu de
supplierCategory('NEGOCIANT') au lieu de hardcoder le préfixe de nom de test.
2026-06-08 09:49:33 +02:00
Matthieu 1343c1b797 test(commercial) : fix CI anti-N+1 (profiling test) + durcissement 422/gating M2 fournisseurs (ERP-92)
- 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).
2026-06-08 09:49:33 +02:00
Matthieu 2a17e9c45c test(commercial) : tests PHPUnit M2 fournisseurs (matrice RG + contrat sérialisation + DoD JSON réel) (ERP-92)
Suite fonctionnelle M2 assertant sur le CORPS JSON (jamais les annotations),
jumelle de la suite clients M1 :
- contrat de sérialisation : 4 régressions M1 re-testées (RIB gaté absent pour
  Commerciale, booléens triageProvider/isArchived présents, embed
  categories[].code/name, embed sites[].name/postalCode objet) + enveloppe AP4
  (member/totalItems/view, archivés exclus) + suppression du contact inline ;
- matrice RBAC réelle (app:seed-rbac) bureau/compta/commerciale/usine 200/403,
  gating accounting par omission de clé, mode strict PATCH (RG-2.16) ;
- RG-2.03/2.04/2.05/2.06/2.07/2.08/2.09/2.10/2.11/2.12/2.14/2.15/2.17 ;
- sous-ressources contacts/adresses/ribs (CRUD, sécurité, normalisation) ;
- anti N+1 liste (compte de requêtes constant), audit Supplier + RIB iban/bic.

Fix de contrat découvert et corrigé (sinon DoD figée sur un contrat faux) :
les référentiels comptables (TvaMode/PaymentType/PaymentDelay/Bank) ne portaient
que le groupe client:read:accounting (M1) → sur un fournisseur ils sortaient en
IRI nu. Ajout de supplier:read:accounting → objet {id, code, label} embarqué.

makefile : test-db-setup recrée l'index partiel uq_supplier_company_name_active
(droppé par schema:update comme pour le client) — oubli M2.

DoD § 4.0.bis : réponses JSON RÉELLES (liste + détail admin/commerciale) collées,
capturées via SupplierSerializationContractTest.
2026-06-08 09:49:33 +02:00
matthieu b35deed8fe feat(commercial) : fixtures Doctrine fournisseurs (≈13 suppliers complets + sous-collections) (ERP-112) (#72)
Auto Tag Develop / tag (push) Successful in 6s
## ERP-112 — Fixtures Doctrine fournisseurs (M2)

`SupplierFixtures` (calquée sur `ClientFixtures` / ERP-68) : ~13 fournisseurs de démonstration couvrant les cas pivots du répertoire fournisseurs (M2), chargés par `make db-reset`.

### Contenu
- **13 fournisseurs** (dont **2 archivés** — RG-2.17), `companyName` variés (UPPERCASE serveur), mono et multi-catégories de type FOURNISSEUR (RG-2.10).
- **19 contacts** (1 à 3 par fournisseur, dont un avec téléphone secondaire et un nommé par le seul nom — RG-2.04).
- **15 adresses** multi-types PROSPECT / DEPART / RENDU (RG-2.09) et multi-sites 86/17/82 (RG-2.06), avec `bennes` et `triageProvider`.
- **3 RIB**, compta complète sur une partie (siren, tvaMode, paymentDelay, paymentType).

### Cas pivots
- VIREMENT → banque renseignée (RG-2.07) ; LCR → 1 puis 2 RIB (RG-2.08) ; CHEQUE et NON_SOUMISE sans RIB.
- Onglet Information complet (dont `volumeForecast`, spécifique fournisseur).
- Cohérence gating comptable (un rôle sans `accounting.view` ne voit pas la compta) — support des tests ERP-92 et du golden path front.

### Notes
- **Idempotent** (lookup par companyName normalisé, aligné sur `uq_supplier_company_name_active`) ; rejouable sans doublon même purger désactivé.
- Référentiels comptables **réutilisés de M1** (tva_modes / payment_delays / payment_types / banks) — aucune nouvelle table.
- Données de démonstration **dev uniquement** : early return en env `test` (les tests seedent leurs propres données).

### Vérifications
- `make db-reset` : 13 fournisseurs (2 archivés), 19 contacts, 15 adresses, 3 RIB chargés sans erreur.
- Idempotence `--append` : compteurs inchangés.
- `make php-cs-fixer-allow-risky` : 0 fichier à corriger.
- `make test` : 574 tests OK.

Base : `feature/ERP-92-tests-phpunit-m2` (sommet de la pile M2).
---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #72
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-08 07:49:28 +00:00
3 changed files with 523 additions and 67 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.92'
app.version: '0.1.89'
@@ -0,0 +1,510 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\DataFixtures;
use App\Module\Catalog\Infrastructure\DataFixtures\CategoryFixtures;
use App\Module\Commercial\Application\Service\SupplierFieldNormalizer;
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\Infrastructure\DataFixtures\SitesFixtures;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SiteProviderInterface;
use DateTimeImmutable;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
use RuntimeException;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Fixtures dev/test du module Commercial : ~13 fournisseurs de demonstration
* couvrant l'ensemble des cas metier RG-2.xx du repertoire fournisseurs (M2),
* jumelles des fixtures Client (ERP-68). Theme metier : negoce / recyclage de
* metaux (d'ou les champs `bennes` et `triageProvider` sur les adresses).
*
* Cas pivots couverts (criteres d'acceptation ERP-112) :
* - reglement VIREMENT avec banque renseignee (RG-2.07) ;
* - reglement LCR avec 1 puis 2 RIB (RG-2.08) ; CHEQUE et NON_SOUMISE sans RIB ;
* - adresses multi-types PROSPECT / DEPART / RENDU (RG-2.09) et multi-sites
* (86 / 17 / 82, RG-2.06) ; bennes + prestataire de triage ;
* - 1 a 3 contacts dont un avec telephone secondaire et un nomme par le seul
* nom (RG-2.04) ;
* - 2 fournisseurs archives (isArchived + archivedAt) pour l'exclusion de la
* liste (RG-2.17) ;
* - mono et multi-categories de type FOURNISSEUR (RG-2.10) ;
* - onglet Information complet (dont volumeForecast, specifique fournisseur).
*
* Resolution inter-modules conforme a la regle n°1 (pas d'import direct) :
* - categories resolues via le contrat Shared CategoryInterface
* (resolve_target_entities -> Category) ;
* - sites resolus via le contrat Shared SiteProviderInterface.
*
* Normalisation : les valeurs sont fournies BRUTES (casse libre, telephones
* formates) et normalisees par SupplierFieldNormalizer avant persist, exactement
* comme le ferait le SupplierProcessor via l'API (companyName UPPERCASE,
* first/last Capitalize, telephones chiffres seuls, emails lowercase).
*
* Coherence gating comptable (RG-2.16) : les scalaires comptables (siren,
* tvaMode, paymentType, bank...) et les RIB ne sont visibles qu'avec
* accounting.view. Les donnees sont posees pour que les roles SANS cette
* permission (ex. Commerciale) ne voient pas de compta — support des tests
* ERP-92 et du golden path front.
*
* Idempotence : lookup par companyName normalise (coherent avec l'index unique
* partiel uq_supplier_company_name_active). Un fournisseur deja present n'est pas
* reconstruit (ses sous-collections ne sont pas redupliquees). Rejouable sans
* doublon meme si le purger Doctrine est desactive.
*
* Audit / Blamable : persist hors contexte HTTP -> created_by / updated_by
* restent null (« Systeme » cote front), c'est attendu. Les donnees respectent
* les CHECK BDD (chk_supplier_contact_name : firstName OU lastName ;
* chk_supplier_address_type : PROSPECT | DEPART | RENDU) ET la coherence des
* validators d'entite (RG-2.07/2.08 : VIREMENT => banque, LCR => >= 1 RIB).
*
* Depend de CategoryFixtures (categories FOURNISSEUR), SitesFixtures (sites) et
* CommercialReferentialFixtures (referentiels comptables — REUTILISES de M1,
* aucune nouvelle table).
*
* Portee : DONNEES DE DEMONSTRATION (dev uniquement). En environnement `test`,
* la fixture ne charge rien : les tests seedent et nettoient leurs propres
* fournisseurs et comptent sur une table `supplier` vierge — y injecter 13
* fournisseurs de demo casserait les comptages de liste et les cleanups. Meme
* garde-fou que ClientFixtures / CategoryFixtures.
*/
class SupplierFixtures extends Fixture implements DependentFixtureInterface
{
/** Cache des categories resolues par nom (evite des requetes repetees). */
private array $categoryCache = [];
/** Cache des sites resolus par nom. */
private array $siteCache = [];
/** ObjectManager courant, capture en debut de load (resolution categories). */
private ObjectManager $manager;
public function __construct(
private readonly SupplierFieldNormalizer $normalizer,
private readonly SiteProviderInterface $siteProvider,
#[Autowire('%kernel.environment%')]
private readonly string $environment,
) {}
/**
* @return array<int, class-string>
*/
public function getDependencies(): array
{
return [
CategoryFixtures::class,
SitesFixtures::class,
CommercialReferentialFixtures::class,
];
}
public function load(ObjectManager $manager): void
{
// Donnees de demo : dev uniquement. En test, on laisse la table vierge.
if ('test' === $this->environment) {
return;
}
$this->manager = $manager;
// === Fournisseur basique — VIREMENT + banque (RG-2.07), compta complete ===
[$negoce, $isNew] = $this->ensureSupplier($manager, 'Négoce Métaux Atlantique', ['Négociant']);
if ($isNew) {
$negoce->setSiren('841611054');
$negoce->setAccountNumber('F0001');
$negoce->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
$negoce->setNTva('FR12841611054');
$negoce->setPaymentDelay($this->paymentDelay($manager, 'J30'));
$negoce->setPaymentType($this->paymentType($manager, 'VIREMENT'));
$negoce->setBank($this->bank($manager, 'SG'));
$this->addContact($negoce, 'Jean', 'Dubois', 'Responsable achats', '05 49 00 00 01', null, 'jean.dubois@negoce-metaux.fr');
$this->addAddress($negoce, 'DEPART', ['Chatellerault'], '86100', 'Châtellerault', '12 rue de la Ferraille', bennes: 4, triageProvider: true, categoryNames: ['Négociant']);
}
// === LCR avec 1 RIB (RG-2.08) + 2 contacts ===
[$coop, $isNew] = $this->ensureSupplier($manager, 'Coopérative Agricole du Sud-Ouest', ['Coopérative']);
if ($isNew) {
$coop->setSiren('775680459');
$coop->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
$coop->setPaymentDelay($this->paymentDelay($manager, 'J15'));
$coop->setPaymentType($this->paymentType($manager, 'LCR'));
$this->addContact($coop, 'Sophie', 'Marchand', 'Directrice', '05 56 10 20 30', '06 11 22 33 44', 'sophie.marchand@coop-so.fr', 0);
$this->addContact($coop, 'Marc', 'Girard', 'Acheteur', '05 56 10 20 31', null, 'marc.girard@coop-so.fr', 1);
$this->addAddress($coop, 'RENDU', ['Pommevic'], '82400', 'Pommevic', '8 route des Cooperateurs', bennes: 2);
$this->addRib($coop, 'Compte principal', 'BNPAFRPPXXX', 'FR1420041010050500013M02606', 0);
}
// === Prospect seul (adresse PROSPECT), compta minimale ===
[$producteur, $isNew] = $this->ensureSupplier($manager, 'Producteur Bio Charente', ['Producteur']);
if ($isNew) {
$this->addContact($producteur, 'Claire', 'Moreau', 'Gérante', '05 49 21 22 23', null, 'claire.moreau@bio-charente.fr');
$this->addAddress($producteur, 'PROSPECT', ['Saint-Jean'], '17400', 'Fontenet', '1 chemin des Producteurs');
}
// === Multi-categories M2M + LCR avec 2 RIB + 3 contacts ===
[$grossiste, $isNew] = $this->ensureSupplier($manager, 'Grossiste Multi-Métaux', ['Grossiste', 'Négociant']);
if ($isNew) {
$grossiste->setSiren('552081317');
$grossiste->setAccountNumber('F0004');
$grossiste->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
$grossiste->setNTva('FR45552081317');
$grossiste->setPaymentDelay($this->paymentDelay($manager, 'J30'));
$grossiste->setPaymentType($this->paymentType($manager, 'LCR'));
$this->addContact($grossiste, 'Thomas', 'Petit', 'Directeur des achats', '05 56 31 32 33', '06 01 02 03 04', 'thomas.petit@grossiste-mm.fr', 0);
$this->addContact($grossiste, 'Julie', 'Roux', 'Assistante commerciale', '05 56 31 32 34', null, 'julie.roux@grossiste-mm.fr', 1);
$this->addContact($grossiste, 'Hélène', 'Faure', 'Logistique', '05 56 31 32 35', null, 'helene.faure@grossiste-mm.fr', 2);
$this->addAddress($grossiste, 'DEPART', ['Chatellerault'], '86100', 'Châtellerault', '20 zone des Activités', streetComplement: 'Bâtiment C', bennes: 6, triageProvider: true, categoryNames: ['Grossiste', 'Négociant']);
$this->addRib($grossiste, 'Compte principal', 'BNPAFRPPXXX', 'FR7630006000011234567890189', 0);
$this->addRib($grossiste, 'Compte secondaire', 'SOGEFRPPXXX', 'FR7630001007941234567890185', 1);
}
// === VIREMENT + banque, TVA intracom (importateur), multi-sites sur l'adresse ===
[$import, $isNew] = $this->ensureSupplier($manager, 'Import Recyclage International', ['Importateur']);
if ($isNew) {
$import->setSiren('409512012');
$import->setTvaMode($this->tvaMode($manager, 'INTRACOM_VENTES'));
$import->setNTva('FR90409512012');
$import->setPaymentDelay($this->paymentDelay($manager, 'J30'));
$import->setPaymentType($this->paymentType($manager, 'VIREMENT'));
$import->setBank($this->bank($manager, 'CIC'));
$this->addContact($import, 'Paul', 'Garnier', 'Import manager', '05 56 44 55 66', null, 'paul.garnier@import-recyclage.fr', 0);
$this->addContact($import, null, 'Bernard', 'Douanes', '05 56 44 55 67', null, 'douanes@import-recyclage.fr', 1);
$this->addAddress($import, 'RENDU', ['Pommevic', 'Saint-Jean'], '82400', 'Pommevic', '3 quai des Importateurs', bennes: 8);
}
// === Multi-adresses PROSPECT / DEPART / RENDU (RG-2.09) + VIREMENT/banque ===
[$ferrailleur, $isNew] = $this->ensureSupplier($manager, 'Ferrailleur Grand Ouest', ['Négociant']);
if ($isNew) {
$ferrailleur->setSiren('732829320');
$ferrailleur->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
$ferrailleur->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION'));
$ferrailleur->setPaymentType($this->paymentType($manager, 'VIREMENT'));
$ferrailleur->setBank($this->bank($manager, 'CA'));
$this->addContact($ferrailleur, 'Olivier', 'Renard', 'Responsable site', '05 49 61 62 63', null, 'olivier.renard@ferrailleur-go.fr', 0);
$this->addContact($ferrailleur, 'Nadia', 'Benali', 'Pesée', '05 49 61 62 64', null, 'nadia.benali@ferrailleur-go.fr', 1);
// Prospect (site en cours de demarchage).
$this->addAddress($ferrailleur, 'PROSPECT', ['Chatellerault'], '86100', 'Châtellerault', '5 avenue de la Prospection', position: 0);
// Depart (collecte) multi-sites avec bennes + triage.
$this->addAddress($ferrailleur, 'DEPART', ['Saint-Jean', 'Pommevic'], '17400', 'Fontenet', '4 rue de la Collecte', bennes: 5, triageProvider: true, categoryNames: ['Négociant'], position: 1);
// Rendu (livraison).
$this->addAddress($ferrailleur, 'RENDU', ['Pommevic'], '82400', 'Pommevic', '7 boulevard du Rendu', bennes: 3, position: 2);
}
// === Onglet Information complet (dont volumeForecast) + VIREMENT/banque ===
[$holding, $isNew] = $this->ensureSupplier($manager, 'Holding Recyclage Premium', ['Importateur']);
if ($isNew) {
$holding->setDescription('Holding de recyclage diversifiée, présente sur le Grand Sud-Ouest.');
$holding->setCompetitors('Groupe Atlantique Recyclage, Sud Métaux');
$holding->setFoundedAt(new DateTimeImmutable('2008-09-01'));
$holding->setEmployeesCount(180);
$holding->setRevenueAmount('24500000.00');
$holding->setDirectorName('Antoine Lefèvre');
$holding->setProfitAmount('1850000.00');
$holding->setVolumeForecast(120000);
$holding->setSiren('318471925');
$holding->setAccountNumber('F0007');
$holding->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
$holding->setNTva('FR33318471925');
$holding->setPaymentDelay($this->paymentDelay($manager, 'J30'));
$holding->setPaymentType($this->paymentType($manager, 'VIREMENT'));
$holding->setBank($this->bank($manager, 'SG'));
$this->addContact($holding, 'Antoine', 'Lefèvre', 'PDG', '05 56 51 52 53', null, 'antoine.lefevre@holding-recyclage.fr');
$this->addAddress($holding, 'DEPART', ['Chatellerault'], '86100', 'Châtellerault', '1 allée des Investisseurs', bennes: 5, triageProvider: true, categoryNames: ['Importateur']);
}
// === Coop minimale — contact par le seul nom (RG-2.04), sans compta ===
[$coopMin, $isNew] = $this->ensureSupplier($manager, 'Coop Métaux Réunis', ['Coopérative']);
if ($isNew) {
$this->addContact($coopMin, null, 'Caron', 'Président', '05 49 81 82 83', null, 'president@coop-metaux-reunis.fr');
$this->addAddress($coopMin, 'DEPART', ['Saint-Jean'], '17400', 'Fontenet', '6 chemin du Village');
}
// === Reglement CHEQUE (sans banque ni RIB requis) ===
[$petit, $isNew] = $this->ensureSupplier($manager, 'Petit Négoce Local', ['Négociant']);
if ($isNew) {
$petit->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
$petit->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION'));
$petit->setPaymentType($this->paymentType($manager, 'CHEQUE'));
$this->addContact($petit, 'Luc', 'Martin', 'Gérant', '05 56 71 72 73', null, 'luc.martin@petit-negoce.fr');
$this->addAddress($petit, 'RENDU', ['Chatellerault'], '86100', 'Châtellerault', '15 rue du Commerce');
}
// === Reglement NON_SOUMISE + adresse multi-sites avec triage ===
[$recup, $isNew] = $this->ensureSupplier($manager, 'Récupération Métaux Express', ['Grossiste']);
if ($isNew) {
$recup->setSiren('490212019');
$recup->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
$recup->setPaymentDelay($this->paymentDelay($manager, 'J15'));
$recup->setPaymentType($this->paymentType($manager, 'NON_SOUMISE'));
$this->addContact($recup, 'Marie', 'Lemoine', 'Responsable', '05 49 77 88 99', null, 'marie.lemoine@recup-express.fr', 0);
$this->addContact($recup, 'Pierre', 'Durand', 'Chauffeur', '05 49 77 88 98', null, 'pierre.durand@recup-express.fr', 1);
$this->addAddress($recup, 'DEPART', ['Saint-Jean', 'Chatellerault'], '17400', 'Fontenet', '10 zone industrielle', bennes: 7, triageProvider: true, categoryNames: ['Grossiste']);
}
// === Centre de tri — focus bennes/triage + multi-categories ===
[$centre, $isNew] = $this->ensureSupplier($manager, 'Centre de Tri Sud', ['Producteur', 'Coopérative']);
if ($isNew) {
$centre->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION'));
$this->addContact($centre, 'Camille', 'Faure', 'Chef de centre', '05 56 91 92 93', null, 'camille.faure@centre-tri-sud.fr');
$this->addAddress($centre, 'DEPART', ['Pommevic'], '82400', 'Pommevic', '2 route du Tri', bennes: 12, triageProvider: true, categoryNames: ['Producteur']);
}
// === Fournisseur archive #1 (RG-2.17) ===
[$ancien, $isNew] = $this->ensureSupplier($manager, 'Ancien Fournisseur Fermé', ['Producteur'], isArchived: true);
if ($isNew) {
$this->addContact($ancien, null, 'Lambert', 'Ancien contact', '05 49 99 99 99', null, 'contact@ancien-fournisseur.fr');
$this->addAddress($ancien, 'DEPART', ['Chatellerault'], '86100', 'Châtellerault', '99 rue Fermée');
}
// === Fournisseur archive #2 (RG-2.17) ===
[$disparu, $isNew] = $this->ensureSupplier($manager, 'Négoce Disparu', ['Grossiste'], isArchived: true);
if ($isNew) {
$this->addContact($disparu, 'Gérard', 'Blanc', 'Ex-gérant', '05 56 00 00 00', null, 'gerard.blanc@negoce-disparu.fr');
$this->addAddress($disparu, 'RENDU', ['Saint-Jean'], '17400', 'Fontenet', '0 impasse Oubliée');
}
$manager->flush();
}
/**
* Cree un fournisseur (base normalisee + categories de type FOURNISSEUR)
* s'il n'existe pas encore, sinon retourne l'existant. Retourne
* [Supplier, isNew] : isNew=false bloque la reconstruction des
* sous-collections (idempotence sans doublon).
*
* @param list<string> $categoryNames categories de type FOURNISSEUR (RG-2.10)
*
* @return array{0: Supplier, 1: bool}
*/
private function ensureSupplier(
ObjectManager $manager,
string $companyName,
array $categoryNames,
bool $isArchived = false,
): array {
$normalizedName = (string) $this->normalizer->normalizeCompanyName($companyName);
$existing = $manager->getRepository(Supplier::class)->findOneBy(['companyName' => $normalizedName]);
if ($existing instanceof Supplier) {
return [$existing, false];
}
$supplier = new Supplier();
$supplier->setCompanyName($normalizedName);
foreach ($categoryNames as $categoryName) {
$supplier->addCategory($this->category($manager, $categoryName));
}
if ($isArchived) {
$supplier->setIsArchived(true);
$supplier->setArchivedAt(new DateTimeImmutable());
}
$manager->persist($supplier);
return [$supplier, true];
}
/**
* Ajoute un contact normalise au fournisseur (cascade persist via
* Supplier.contacts). Au moins firstName OU lastName est toujours fourni
* (RG-2.04, chk_supplier_contact_name).
*/
private function addContact(
Supplier $supplier,
?string $firstName,
?string $lastName,
?string $jobTitle,
?string $phonePrimary,
?string $phoneSecondary,
?string $email,
int $position = 0,
): void {
$contact = new SupplierContact();
$contact->setFirstName($this->normalizer->normalizePersonName($firstName));
$contact->setLastName($this->normalizer->normalizePersonName($lastName));
$contact->setJobTitle($jobTitle);
$contact->setPhonePrimary($this->normalizer->normalizePhone($phonePrimary));
$contact->setPhoneSecondary($this->normalizer->normalizePhone($phoneSecondary));
$contact->setEmail($this->normalizer->normalizeEmail($email));
$contact->setPosition($position);
$supplier->addContact($contact);
}
/**
* Ajoute une adresse au fournisseur (cascade persist via Supplier.addresses).
* Le type d'adresse est exclusif (PROSPECT | DEPART | RENDU — RG-2.09,
* chk_supplier_address_type) ; au moins un site est rattache (RG-2.06) ; les
* categories d'adresse sont de type FOURNISSEUR (RG-2.10).
*
* @param list<string> $siteNames au moins un site (RG-2.06)
* @param list<string> $categoryNames categories de type FOURNISSEUR (RG-2.10)
*/
private function addAddress(
Supplier $supplier,
string $addressType,
array $siteNames,
string $postalCode,
string $city,
string $street,
?string $streetComplement = null,
?int $bennes = null,
bool $triageProvider = false,
array $categoryNames = [],
int $position = 0,
): void {
$address = new SupplierAddress();
$address->setAddressType($addressType);
$address->setPostalCode($postalCode);
$address->setCity($city);
$address->setStreet($street);
$address->setStreetComplement($streetComplement);
$address->setBennes($bennes);
$address->setTriageProvider($triageProvider);
$address->setPosition($position);
foreach ($siteNames as $siteName) {
$address->addSite($this->site($siteName));
}
foreach ($categoryNames as $categoryName) {
$address->addCategory($this->category($this->manager, $categoryName));
}
$supplier->addAddress($address);
}
/**
* Ajoute un RIB au fournisseur (cascade persist via Supplier.ribs). IBAN/BIC
* valides (Assert\Iban/Bic non rejouee sur persist direct mais donnees
* coherentes pour le golden path / les tests).
*/
private function addRib(Supplier $supplier, string $label, string $bic, string $iban, int $position = 0): void
{
$rib = new SupplierRib();
$rib->setLabel($label);
$rib->setBic($bic);
$rib->setIban($iban);
$rib->setPosition($position);
$supplier->addRib($rib);
}
/**
* Resout une categorie par son nom via le contrat Shared CategoryInterface
* (resolve_target_entities -> Category), sans importer le module Catalog
* (regle n°1). Mise en cache par nom.
*/
private function category(ObjectManager $manager, string $name): CategoryInterface
{
if (isset($this->categoryCache[$name])) {
return $this->categoryCache[$name];
}
$category = $manager->getRepository(CategoryInterface::class)->findOneBy([
'name' => $name,
'deletedAt' => null,
]);
if (!$category instanceof CategoryInterface) {
throw new RuntimeException(sprintf(
'Categorie "%s" introuvable : CategoryFixtures doit tourner avant SupplierFixtures.',
$name,
));
}
return $this->categoryCache[$name] = $category;
}
/**
* Resout un site par son nom via le contrat Shared SiteProviderInterface,
* sans importer le module Sites (regle n°1). Mise en cache par nom.
*/
private function site(string $name): SiteInterface
{
if (isset($this->siteCache[$name])) {
return $this->siteCache[$name];
}
$site = $this->siteProvider->findByName($name);
if (!$site instanceof SiteInterface) {
throw new RuntimeException(sprintf(
'Site "%s" introuvable : SitesFixtures doit tourner avant SupplierFixtures.',
$name,
));
}
return $this->siteCache[$name] = $site;
}
private function tvaMode(ObjectManager $manager, string $code): TvaMode
{
$mode = $manager->getRepository(TvaMode::class)->findOneBy(['code' => $code]);
if (!$mode instanceof TvaMode) {
throw new RuntimeException(sprintf(
'TvaMode "%s" introuvable : CommercialReferentialFixtures doit tourner avant SupplierFixtures.',
$code,
));
}
return $mode;
}
private function paymentDelay(ObjectManager $manager, string $code): PaymentDelay
{
$delay = $manager->getRepository(PaymentDelay::class)->findOneBy(['code' => $code]);
if (!$delay instanceof PaymentDelay) {
throw new RuntimeException(sprintf(
'PaymentDelay "%s" introuvable : CommercialReferentialFixtures doit tourner avant SupplierFixtures.',
$code,
));
}
return $delay;
}
private function paymentType(ObjectManager $manager, string $code): PaymentType
{
$type = $manager->getRepository(PaymentType::class)->findOneBy(['code' => $code]);
if (!$type instanceof PaymentType) {
throw new RuntimeException(sprintf(
'PaymentType "%s" introuvable : CommercialReferentialFixtures doit tourner avant SupplierFixtures.',
$code,
));
}
return $type;
}
private function bank(ObjectManager $manager, string $code): Bank
{
$bank = $manager->getRepository(Bank::class)->findOneBy(['code' => $code]);
if (!$bank instanceof Bank) {
throw new RuntimeException(sprintf(
'Bank "%s" introuvable : CommercialReferentialFixtures doit tourner avant SupplierFixtures.',
$code,
));
}
return $bank;
}
}
@@ -4,11 +4,8 @@ declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Module\Commercial\Domain\Entity\SupplierAddress;
use App\Module\Commercial\Domain\Entity\SupplierContact;
use App\Module\Sites\Domain\Entity\Site;
use DateTimeImmutable;
use PhpOffice\PhpSpreadsheet\IOFactory;
/**
@@ -23,24 +20,11 @@ use PhpOffice\PhpSpreadsheet\IOFactory;
*
* @internal
*/
final class SupplierExportControllerTest extends AbstractCommercialApiTestCase
final class SupplierExportControllerTest extends AbstractSupplierApiTestCase
{
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
private const string EXPORT_URL = '/api/suppliers/export.xlsx';
/**
* Les fournisseurs doivent etre purges AVANT les categories de test (le parent
* supprime les categories `test_cli_cat_*`) : la jointure supplier_category est
* en ON DELETE CASCADE cote supplier mais RESTRICT cote category. Le DELETE DQL
* sur Supplier declenche le cascade BDD sur supplier_category / _contact /
* _address (et leurs sous-jointures), liberant les categories pour le parent.
*/
protected function tearDown(): void
{
$this->getEm()->createQuery('DELETE FROM '.Supplier::class)->execute();
parent::tearDown();
}
public function testExportReturnsXlsxResponseWithAttachmentFilename(): void
{
$client = $this->createAdminClient();
@@ -110,9 +94,13 @@ final class SupplierExportControllerTest extends AbstractCommercialApiTestCase
$supplier = $this->seedSupplier('Contact Co');
// position 1 (secondaire) insere en premier...
$this->addContact($supplier, 'Secondaire', 'Bob', 1, '0600000001', '0600000002', 'bob@contact.co');
$this->addContact($supplier, 'Bob', 'Secondaire', '0600000001', 'bob@contact.co', 1);
// ...position 0 (principal) insere ensuite : c'est lui qui doit gagner.
$this->addContact($supplier, 'Principal', 'Alice', 0, '0612345678', '0698765432', 'alice@contact.co');
$principal = $this->addContact($supplier, 'Alice', 'Principal', '0612345678', 'alice@contact.co', 0);
// Le telephone secondaire n'est pas porte par le helper de base : on le pose
// directement sur le contact principal pour alimenter la colonne dediee.
$principal->setPhoneSecondary('0698765432');
$this->getEm()->flush();
$row = $this->rowFor($client->request('GET', self::EXPORT_URL)->getContent(), 'CONTACT CO');
@@ -149,8 +137,10 @@ final class SupplierExportControllerTest extends AbstractCommercialApiTestCase
$flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent()));
// Colonne « Catégories » : libelle de la categorie du fournisseur (getName()).
self::assertStringContainsString('test_cli_cat_negociant', $flat);
// Colonne « Catégories » : libelle de la categorie FOURNISSEUR du fournisseur
// (getName()). On le derive du helper de base (idempotent) plutot que de
// hardcoder le prefixe de nom de test.
self::assertStringContainsString((string) $this->supplierCategory('NEGOCIANT')->getName(), $flat);
// Colonne « Sites » : site agrege depuis l'adresse (RG-2.06).
self::assertStringContainsString((string) $site->getName(), $flat);
}
@@ -206,50 +196,6 @@ final class SupplierExportControllerTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(401);
}
/**
* Seede directement un Supplier en base (sans passer par l'API), pour les
* tests de liste / archivage. Stocke le nom en MAJUSCULES pour refleter l'etat
* normalise (RG-2.12) qu'aurait produit le SupplierProcessor via l'API.
*/
private function seedSupplier(string $companyName, bool $isArchived = false, string $categoryCode = 'SECTEUR'): Supplier
{
$em = $this->getEm();
$supplier = new Supplier();
$supplier->setCompanyName(mb_strtoupper($companyName, 'UTF-8'));
$supplier->addCategory($this->createCategory($categoryCode));
$supplier->setIsArchived($isArchived);
if ($isArchived) {
$supplier->setArchivedAt(new DateTimeImmutable());
}
$em->persist($supplier);
$em->flush();
return $supplier;
}
private function addContact(
Supplier $supplier,
string $lastName,
string $firstName,
int $position,
?string $phonePrimary = null,
?string $phoneSecondary = null,
?string $email = null,
): void {
$contact = new SupplierContact();
$contact->setSupplier($supplier);
$contact->setLastName($lastName);
$contact->setFirstName($firstName);
$contact->setPosition($position);
$contact->setPhonePrimary($phonePrimary);
$contact->setPhoneSecondary($phoneSecondary);
$contact->setEmail($email);
$supplier->addContact($contact);
$this->getEm()->persist($contact);
$this->getEm()->flush();
}
/**
* Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
*
@@ -284,7 +230,7 @@ final class SupplierExportControllerTest extends AbstractCommercialApiTestCase
/**
* Renvoie la ligne de donnees dont la 1re colonne (nom) vaut $companyName.
*
* @return array<int, mixed>|null
* @return null|array<int, mixed>
*/
private function rowFor(string $binary, string $companyName): ?array
{