From 97459e798fb98272b34e95a3ab0970c278281eca Mon Sep 17 00:00:00 2001 From: THOLOT DECHENE Matthieu Date: Mon, 8 Jun 2026 07:48:59 +0000 Subject: [PATCH] feat(commercial) : export XLSX fournisseurs (ERP-91) (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export XLSX du répertoire fournisseurs (spec-back M2 § 4.6), jumeau de l'export client M1. **Stack : cible `feature/ERP-90-rbac-fournisseurs`** (ERP-84→91 non encore mergés dans develop). ## Périmètre - **`SupplierExportController`** avec `#[Route(priority: 1)]` (anti-conflit API Platform `{id}`) + `is_granted('commercial.suppliers.view')`. - Mêmes filtres que la liste (`includeArchived`/`archivedOnly`/`search`/`categoryCode`/`siteId`) via `createListQueryBuilder()` partagé avec le `SupplierProvider` ; non archivés par défaut. - Colonnes : Nom fournisseur, **Contact principal** (Nom + Prénom du `SupplierContact` de plus petit `position`, ERP-106), Tél principal, Tél secondaire, Email, Catégories (CSV), Sites (CSV), **SIREN omis sans `accounting.view`**, Date de création. - Fichier `repertoire-fournisseurs-{YYYYMMDD}.xlsx`. - **`hydrateContacts()`** ajouté au repository : chargement batché des contacts en une requête `IN` (anti-N+1). Méthode dédiée à l'export — la liste paginée n'embarque pas les contacts, on ne lui impose pas ce coût. ## Correctif hors-périmètre (signalé) Tables `supplier*` ajoutées à `ColumnCommentsCatalog` : leurs `COMMENT ON COLUMN` (posés par la migration ERP-85) étaient dropés par le `schema:update --force` du `test-db-setup` et non restaurés (catalogue = source rejouée par `app:apply-column-comments`), cassant `ColumnsHaveSqlCommentTest` dès un re-setup de la base de test. Trou laissé par ERP-85/86, vert tant que personne ne re-setup la base. ## Tests - `SupplierExportControllerTest` (9 cas) : réponse/filename, exclusion archives, filtre search, contact principal, colonnes catégories/sites, gating SIREN avec/sans `accounting.view`, 403, 401. - `make test` : 508 tests / 2035 assertions, 0 échec. `php-cs-fixer` clean. --------- Co-authored-by: Matthieu Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/70 Co-authored-by: THOLOT DECHENE Matthieu Co-committed-by: THOLOT DECHENE Matthieu --- .../SupplierRepositoryInterface.php | 12 + .../Controller/SupplierExportController.php | 291 ++++++++++++++++ .../Doctrine/DoctrineSupplierRepository.php | 25 ++ .../Database/ColumnCommentsCatalog.php | 89 +++++ .../Api/SupplierExportControllerTest.php | 312 ++++++++++++++++++ 5 files changed, 729 insertions(+) create mode 100644 src/Module/Commercial/Infrastructure/Controller/SupplierExportController.php create mode 100644 tests/Module/Commercial/Api/SupplierExportControllerTest.php diff --git a/src/Module/Commercial/Domain/Repository/SupplierRepositoryInterface.php b/src/Module/Commercial/Domain/Repository/SupplierRepositoryInterface.php index 67bd69e..6c061b5 100644 --- a/src/Module/Commercial/Domain/Repository/SupplierRepositoryInterface.php +++ b/src/Module/Commercial/Domain/Repository/SupplierRepositoryInterface.php @@ -65,4 +65,16 @@ interface SupplierRepositoryInterface * @param list $suppliers */ public function hydrateListCollections(array $suppliers): void; + + /** + * Hydrate en lot la collection `contacts` sur un jeu de fournisseurs DEJA + * charges (memes instances via l'identity map). Reservee a l'export XLSX + * (§ 4.6) qui a besoin du contact principal : la LISTE paginee n'embarque + * pas les contacts (§ 2.12), d'ou une methode dediee plutot qu'une passe + * supplementaire dans {@see self::hydrateListCollections()} — on n'impose pas + * le cout du chargement des contacts au chemin liste. + * + * @param list $suppliers + */ + public function hydrateContacts(array $suppliers): void; } diff --git a/src/Module/Commercial/Infrastructure/Controller/SupplierExportController.php b/src/Module/Commercial/Infrastructure/Controller/SupplierExportController.php new file mode 100644 index 0000000..037e4dc --- /dev/null +++ b/src/Module/Commercial/Infrastructure/Controller/SupplierExportController.php @@ -0,0 +1,291 @@ +readBool($request->query->get('includeArchived')); + $archivedOnly = $this->readBool($request->query->get('archivedOnly')); + $search = $request->query->getString('search') ?: null; + + // Memes filtres que la vue liste : categoryCode/siteId tolerent une valeur + // unique ou une liste (?categoryCode[]=A&siteId[]=1). On lit via all() pour + // ne pas lever d'exception sur une valeur scalaire. + $query = $request->query->all(); + $categoryCodes = $this->readStringList($query['categoryCode'] ?? []); + $siteIds = $this->readIntList($query['siteId'] ?? []); + + /** @var list $suppliers */ + $suppliers = $this->repository + ->createListQueryBuilder($includeArchived, $search, $categoryCodes, $siteIds, $archivedOnly) + ->getQuery() + ->getResult() + ; + + // Hydratation batchee des collections affichees (§ 2.12) : le QB de + // selection ne fetch-join pas les to-many. On remplit categories + sites en + // lot (colonnes « Catégories » / « Sites »), puis les contacts (colonnes du + // contact principal) — chacune en requetes IN bornees, anti N+1. + $this->repository->hydrateListCollections($suppliers); + $this->repository->hydrateContacts($suppliers); + + $withSiren = $this->security->isGranted('commercial.suppliers.accounting.view'); + + $binary = $this->exporter->export( + 'Répertoire fournisseurs', + $this->buildHeaders($withSiren), + $this->buildRows($suppliers, $withSiren), + ); + + return $this->buildResponse($binary); + } + + /** + * Colonnes de l'export (spec § 4.6). SIREN inseree avant la date de creation, + * uniquement si l'utilisateur a accounting.view. + * + * @return list + */ + private function buildHeaders(bool $withSiren): array + { + $headers = [ + 'Nom fournisseur', + 'Contact principal', + 'Téléphone principal', + 'Téléphone secondaire', + 'Email', + 'Catégories', + 'Sites', + ]; + + if ($withSiren) { + $headers[] = 'SIREN'; + } + + $headers[] = 'Date de création'; + + return $headers; + } + + /** + * @param list $suppliers + * + * @return iterable> + */ + private function buildRows(array $suppliers, bool $withSiren): iterable + { + foreach ($suppliers as $supplier) { + $contact = $this->principalContact($supplier); + + $row = [ + $supplier->getCompanyName(), + null !== $contact ? $this->formatContactName($contact) : '', + $contact?->getPhonePrimary() ?? '', + $contact?->getPhoneSecondary() ?? '', + $contact?->getEmail() ?? '', + $this->formatCategories($supplier), + $this->formatSites($supplier), + ]; + + if ($withSiren) { + $row[] = $supplier->getSiren(); + } + + $row[] = $supplier->getCreatedAt()?->format('d/m/Y'); + + yield $row; + } + } + + /** + * Contact principal du fournisseur : le SupplierContact de plus petit + * `position` (decision D2, spec § 4.6). Null si le fournisseur n'a aucun + * contact (les colonnes contact restent vides). + */ + private function principalContact(Supplier $supplier): ?SupplierContact + { + $contacts = $supplier->getContacts()->toArray(); + if ([] === $contacts) { + return null; + } + + usort( + $contacts, + static fn (SupplierContact $a, SupplierContact $b): int => $a->getPosition() <=> $b->getPosition(), + ); + + return $contacts[0]; + } + + /** + * Libelle du contact principal « Nom Prénom » (spec § 4.6). Les deux parties + * sont optionnelles (RG-2.04 : au moins l'une des deux), d'ou le trim final. + */ + private function formatContactName(SupplierContact $contact): string + { + return trim(sprintf('%s %s', $contact->getLastName() ?? '', $contact->getFirstName() ?? '')); + } + + /** + * Libelles des categories du fournisseur, dedupliques, tries, joints par + * virgule. + */ + private function formatCategories(Supplier $supplier): string + { + $names = []; + foreach ($supplier->getCategories() as $category) { + // @var CategoryInterface $category + $name = $category->getName(); + if (null !== $name && '' !== $name) { + $names[$name] = true; + } + } + + return $this->joinSorted($names); + } + + /** + * Le fournisseur ne porte pas de sites en propre : ils sont rattaches aux + * adresses (RG-2.06). La colonne « Sites » agrege donc l'union distincte des + * sites de toutes les adresses du fournisseur. + */ + private function formatSites(Supplier $supplier): string + { + $names = []; + foreach ($supplier->getAddresses() as $address) { + foreach ($address->getSites() as $site) { + // @var SiteInterface $site + $name = $site->getName(); + if (null !== $name && '' !== $name) { + $names[$name] = true; + } + } + } + + return $this->joinSorted($names); + } + + /** + * @param array $names ensemble de libelles (cles) + */ + private function joinSorted(array $names): string + { + $list = array_keys($names); + sort($list); + + return implode(', ', $list); + } + + private function buildResponse(string $binary): Response + { + $filename = sprintf('repertoire-fournisseurs-%s.xlsx', new DateTimeImmutable()->format('Ymd')); + + $response = new Response($binary); + $response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + $response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename)); + + return $response; + } + + /** + * Lit un flag booleen issu des query params. Accepte true / "true" / "1". + * Aligne sur SupplierProvider pour un comportement identique a la liste. + */ + private function readBool(mixed $raw): bool + { + return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true); + } + + /** + * Normalise un filtre en liste de chaines (valeur unique ou liste). + * Aligne sur SupplierProvider pour un comportement identique a la liste. + * + * @return list + */ + private function readStringList(mixed $raw): array + { + $values = is_array($raw) ? $raw : [$raw]; + + $out = []; + foreach ($values as $value) { + if (is_string($value) && '' !== trim($value)) { + $out[] = trim($value); + } + } + + return $out; + } + + /** + * Normalise un filtre en liste d'identifiants entiers positifs (valeur unique + * ou liste). Aligne sur SupplierProvider. + * + * @return list + */ + private function readIntList(mixed $raw): array + { + $values = is_array($raw) ? $raw : [$raw]; + + $out = []; + foreach ($values as $value) { + if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) { + $out[] = (int) $value; + } + } + + return $out; + } +} diff --git a/src/Module/Commercial/Infrastructure/Doctrine/DoctrineSupplierRepository.php b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineSupplierRepository.php index 95d503a..05fefe0 100644 --- a/src/Module/Commercial/Infrastructure/Doctrine/DoctrineSupplierRepository.php +++ b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineSupplierRepository.php @@ -102,6 +102,31 @@ class DoctrineSupplierRepository extends ServiceEntityRepository implements Supp ; } + public function hydrateContacts(array $suppliers): void + { + $ids = []; + foreach ($suppliers as $supplier) { + $id = $supplier->getId(); + if (null !== $id) { + $ids[] = $id; + } + } + if ([] === $ids) { + return; + } + + // Une seule requete IN bornee : remplit la collection `contacts` des MEMES + // instances Supplier (identity map). Tri par position pour que le « contact + // principal » (plus petit position) soit deterministe a l'export (§ 4.6). + $this->createQueryBuilder('s') + ->leftJoin('s.contacts', 'sc')->addSelect('sc') + ->where('s.id IN (:ids)')->setParameter('ids', $ids) + ->orderBy('sc.position', 'ASC') + ->getQuery() + ->getResult() + ; + } + /** * Recherche fuzzy insensible a la casse sur companyName ET sur les contacts * lies (firstName / lastName / email) — decision D1, refonte-contact (§ 4.1). diff --git a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php index faa1188..07d5a40 100644 --- a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php +++ b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php @@ -256,6 +256,95 @@ final class ColumnCommentsCatalog 'iban' => 'IBAN du compte (≤ 34 caracteres).', 'position' => 'Ordre d affichage du RIB dans la liste du client (croissant).', ] + self::timestampableBlamableComments(), + + // === M2 Commercial (ERP-85) — miroir des COMMENT de la migration + // Version20260605130000 pour le chemin schema:update (dev/test). === + + 'supplier' => [ + '_table' => 'Repertoire fournisseurs (M2 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M3).', + 'id' => 'Identifiant interne auto-incremente.', + 'company_name' => 'Raison sociale du fournisseur (stockee en MAJUSCULES). Unique case-insensitive parmi les actifs non archives/non supprimes (uq_supplier_company_name_active, § 2.6).', + 'description' => 'Onglet Information : description libre. Obligatoire pour le role Commerciale (RG-2.03), optionnel sinon.', + 'competitors' => 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-2.03).', + 'founded_at' => 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-2.03).', + 'employees_count' => 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-2.03).', + 'revenue_amount' => 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-2.03).', + 'director_name' => 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-2.03).', + 'profit_amount' => 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-2.03).', + 'volume_forecast' => 'Onglet Information : volume previsionnel (entier >= 0) — specifique fournisseur. Obligatoire role Commerciale (RG-2.03).', + 'siren' => 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (§ 2.6).', + 'account_number' => 'Onglet Comptabilite : numero de compte comptable du fournisseur.', + 'tva_mode_id' => 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id (referentiel partage M1), ON DELETE RESTRICT.', + 'n_tva' => 'Onglet Comptabilite : numero de TVA intracommunautaire.', + 'payment_delay_id' => 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id (M1), ON DELETE RESTRICT.', + 'payment_type_id' => 'Onglet Comptabilite : type de reglement — FK -> payment_type.id (M1), ON DELETE RESTRICT. Pilote RG-2.07 (Banque si VIREMENT) et RG-2.08 (RIB).', + 'bank_id' => 'Onglet Comptabilite : banque — FK -> bank.id (M1), ON DELETE RESTRICT. Obligatoire ssi payment_type = VIREMENT (RG-2.07), null sinon.', + 'is_archived' => 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission commercial.suppliers.archive.', + 'archived_at' => 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration.', + 'deleted_at' => 'Horodatage du soft-delete technique (HP M3) — non expose par l API au M2. Null = ligne active.', + ] + self::timestampableBlamableComments(), + + 'supplier_category' => [ + '_table' => 'Jointure M2M supplier <-> category (Catalog) — categories de type FOURNISSEUR du fournisseur, au moins une obligatoire (RG-2.10).', + 'supplier_id' => 'FK -> supplier.id, ON DELETE CASCADE — fournisseur porteur de la categorie.', + 'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie de type FOURNISSEUR rattachee au fournisseur (RG-2.10).', + ], + + 'supplier_contact' => [ + '_table' => 'Contacts d un fournisseur (1:n) — au moins firstName OU lastName par contact (RG-2.04).', + 'id' => 'Identifiant interne auto-incremente.', + 'supplier_id' => 'FK -> supplier.id, ON DELETE CASCADE — fournisseur proprietaire du contact.', + 'first_name' => 'Prenom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-2.04, chk_supplier_contact_name).', + 'last_name' => 'Nom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-2.04, chk_supplier_contact_name).', + 'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).', + 'phone_primary' => 'Telephone principal du contact — chiffres uniquement (normalisation serveur).', + 'phone_secondary' => 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).', + 'email' => 'Email du contact (lowercase serveur).', + 'position' => 'Ordre d affichage du contact dans la liste du fournisseur (croissant).', + ] + self::timestampableBlamableComments(), + + 'supplier_address' => [ + '_table' => 'Adresses d un fournisseur (1:n) — type PROSPECT/DEPART/RENDU exclusif (RG-2.09), >= 1 site rattache (RG-2.06).', + 'id' => 'Identifiant interne auto-incremente.', + 'supplier_id' => 'FK -> supplier.id, ON DELETE CASCADE — fournisseur proprietaire de l adresse.', + 'address_type' => 'Type d adresse : PROSPECT | DEPART | RENDU (radio exclusif par construction — RG-2.09, chk_supplier_address_type).', + 'country' => 'Pays de l adresse — defaut France.', + 'postal_code' => 'Code postal (4-5 chiffres attendus).', + 'city' => 'Ville — preremplie depuis le code postal via API BAN cote front.', + 'street' => 'Numero et voie de l adresse.', + 'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.', + 'bennes' => 'Nombre de bennes sur le site fournisseur (entier nullable) — specifique fournisseur.', + 'triage_provider' => 'Le fournisseur est prestataire de triage sur cette adresse. Faux par defaut.', + 'position' => 'Ordre d affichage de l adresse dans la liste du fournisseur (croissant).', + ] + self::timestampableBlamableComments(), + + 'supplier_address_site' => [ + '_table' => 'Jointure M2M supplier_address <-> site (Sites) — sites rattaches a l adresse (>= 1 obligatoire, RG-2.06).', + 'supplier_address_id' => 'FK -> supplier_address.id, ON DELETE CASCADE — adresse concernee.', + 'site_id' => 'FK -> site.id, ON DELETE RESTRICT — site rattache a l adresse.', + ], + + 'supplier_address_contact' => [ + '_table' => 'Jointure M2M supplier_address <-> supplier_contact — contacts associes a une adresse.', + 'supplier_address_id' => 'FK -> supplier_address.id, ON DELETE CASCADE — adresse concernee.', + 'supplier_contact_id' => 'FK -> supplier_contact.id, ON DELETE CASCADE — contact associe a l adresse.', + ], + + 'supplier_address_category' => [ + '_table' => 'Jointure M2M supplier_address <-> category — categories d adresse de type FOURNISSEUR (RG-2.10).', + 'supplier_address_id' => 'FK -> supplier_address.id, ON DELETE CASCADE — adresse concernee.', + 'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse de type FOURNISSEUR (RG-2.10).', + ], + + 'supplier_rib' => [ + '_table' => 'Coordonnees bancaires d un fournisseur (1:n) — >= 1 RIB attendu selon le type de reglement (RG-2.08). Tous les champs audites (pas d AuditIgnore).', + 'id' => 'Identifiant interne auto-incremente.', + 'supplier_id' => 'FK -> supplier.id, ON DELETE CASCADE — fournisseur proprietaire du RIB.', + 'label' => 'Libelle du RIB (ex: compte principal).', + 'bic' => 'Code BIC/SWIFT de la banque (8 ou 11 caracteres).', + 'iban' => 'IBAN du compte (≤ 34 caracteres).', + 'position' => 'Ordre d affichage du RIB dans la liste du fournisseur (croissant).', + ] + self::timestampableBlamableComments(), ]; } diff --git a/tests/Module/Commercial/Api/SupplierExportControllerTest.php b/tests/Module/Commercial/Api/SupplierExportControllerTest.php new file mode 100644 index 0000000..bbcd763 --- /dev/null +++ b/tests/Module/Commercial/Api/SupplierExportControllerTest.php @@ -0,0 +1,312 @@ +getEm()->createQuery('DELETE FROM '.Supplier::class)->execute(); + parent::tearDown(); + } + + public function testExportReturnsXlsxResponseWithAttachmentFilename(): void + { + $client = $this->createAdminClient(); + $this->seedSupplier('Export Alpha'); + + $response = $client->request('GET', self::EXPORT_URL); + + self::assertResponseIsSuccessful(); + $headers = $response->getHeaders(false); + self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? ''); + + $disposition = $headers['content-disposition'][0] ?? ''; + self::assertStringContainsString('attachment; filename="repertoire-fournisseurs-', $disposition); + self::assertMatchesRegularExpression( + '/filename="repertoire-fournisseurs-\d{8}\.xlsx"/', + $disposition, + ); + + // Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes. + $grid = $this->gridFromResponse($response->getContent()); + $headers = $grid[0]; + self::assertSame('Nom fournisseur', $headers[0]); + self::assertContains('Contact principal', $headers); + self::assertContains('Téléphone principal', $headers); + self::assertContains('Téléphone secondaire', $headers); + self::assertContains('Email', $headers); + self::assertContains('Catégories', $headers); + self::assertContains('Sites', $headers); + self::assertContains('Date de création', $headers); + } + + public function testExportExcludesArchivedByDefault(): void + { + $client = $this->createAdminClient(); + $this->seedSupplier('Active One'); + $this->seedSupplier('Archived One', true); + + $names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent()); + + self::assertContains('ACTIVE ONE', $names); + self::assertNotContains('ARCHIVED ONE', $names); + } + + public function testExportRespectsSearchFilter(): void + { + $client = $this->createAdminClient(); + $this->seedSupplier('Searchable Alpha'); + $this->seedSupplier('Other Beta'); + + $names = $this->companyNames( + $client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(), + ); + + self::assertContains('SEARCHABLE ALPHA', $names); + self::assertNotContains('OTHER BETA', $names); + } + + /** + * Les colonnes contact sont alimentees par le CONTACT PRINCIPAL : le contact + * de plus petit `position` (decision D2, § 4.6). On seede deux contacts en + * ordre de position inverse pour garantir que c'est bien le principal (et non + * le premier insere) qui alimente la ligne. + */ + public function testExportUsesPrincipalContactColumns(): void + { + $client = $this->createAdminClient(); + $supplier = $this->seedSupplier('Contact Co'); + + // position 1 (secondaire) insere en premier... + $this->addContact($supplier, 'Secondaire', 'Bob', 1, '0600000001', '0600000002', 'bob@contact.co'); + // ...position 0 (principal) insere ensuite : c'est lui qui doit gagner. + $this->addContact($supplier, 'Principal', 'Alice', 0, '0612345678', '0698765432', 'alice@contact.co'); + + $row = $this->rowFor($client->request('GET', self::EXPORT_URL)->getContent(), 'CONTACT CO'); + + self::assertNotNull($row, 'Ligne « CONTACT CO » introuvable dans l\'export.'); + self::assertSame('Principal Alice', $row[1]); + self::assertSame('0612345678', $row[2]); + self::assertSame('0698765432', $row[3]); + self::assertSame('alice@contact.co', $row[4]); + } + + /** + * Colonnes « Catégories » et « Sites » : un oubli d'hydratation les rendrait + * vides sans erreur (cf. ERP-100 cote client). Le site est porte par l'adresse + * (RG-2.06). + */ + public function testExportPopulatesCategoryAndSiteColumns(): void + { + $client = $this->createAdminClient(); + $supplier = $this->seedSupplier('Hydrate Co', false, 'NEGOCIANT'); + + $em = $this->getEm(); + $site = $em->getRepository(Site::class)->findOneBy([]); + self::assertNotNull($site, 'Aucun site seede : impossible de tester la colonne Sites.'); + + $address = new SupplierAddress(); + $address->setSupplier($supplier); + $address->setAddressType('DEPART'); + $address->setPostalCode('86100'); + $address->setCity('Châtellerault'); + $address->setStreet('1 rue du Test'); + $address->addSite($site); + $em->persist($address); + $em->flush(); + + $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 « Sites » : site agrege depuis l'adresse (RG-2.06). + self::assertStringContainsString((string) $site->getName(), $flat); + } + + public function testSirenColumnPresentWithAccountingView(): void + { + // L'admin bypass le RBAC : il a donc accounting.view -> colonne SIREN. + $client = $this->createAdminClient(); + $supplier = $this->seedSupplier('Siren Co'); + $em = $this->getEm(); + $supplier->setSiren('123456789'); + $em->flush(); + + $grid = $this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent()); + + self::assertContains('SIREN', $grid[0]); + self::assertStringContainsString('123456789', $this->flatten($grid)); + } + + public function testSirenColumnAbsentWithoutAccountingView(): void + { + // Seed via admin, puis relecture par un user qui n'a QUE suppliers.view. + $admin = $this->createAdminClient(); + $supplier = $this->seedSupplier('No Siren Co'); + $em = $this->getEm(); + $supplier->setSiren('987654321'); + $em->flush(); + + $creds = $this->createUserWithPermission('commercial.suppliers.view'); + $viewer = $this->authenticatedClient($creds['username'], $creds['password']); + + $grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent()); + + self::assertNotContains('SIREN', $grid[0]); + self::assertStringNotContainsString('987654321', $this->flatten($grid)); + } + + public function testForbiddenWithoutSuppliersViewPermission(): void + { + $creds = $this->createUserWithPermission('core.users.view'); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + $client->request('GET', self::EXPORT_URL); + + self::assertResponseStatusCodeSame(403); + } + + public function testUnauthorizedWhenAnonymous(): void + { + $client = self::createClient(); + $client->request('GET', self::EXPORT_URL); + + 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. + * + * @return array> + */ + private function gridFromResponse(string $binary): array + { + $tmp = tempnam(sys_get_temp_dir(), 'xlsx_export_test_'); + self::assertIsString($tmp); + file_put_contents($tmp, $binary); + + try { + return IOFactory::load($tmp)->getActiveSheet()->toArray(); + } finally { + @unlink($tmp); + } + } + + /** + * Extrait la colonne « Nom fournisseur » (1re colonne) des lignes de donnees. + * + * @return list + */ + private function companyNames(string $binary): array + { + $grid = $this->gridFromResponse($binary); + $rows = array_slice($grid, 1); // saute l'en-tete + + return array_values(array_map(static fn (array $row): string => (string) ($row[0] ?? ''), $rows)); + } + + /** + * Renvoie la ligne de donnees dont la 1re colonne (nom) vaut $companyName. + * + * @return array|null + */ + private function rowFor(string $binary, string $companyName): ?array + { + foreach (array_slice($this->gridFromResponse($binary), 1) as $row) { + if ((string) ($row[0] ?? '') === $companyName) { + return $row; + } + } + + return null; + } + + /** + * Aplatit toute la grille en une chaine, pour les assertions de presence. + * + * @param array> $grid + */ + private function flatten(array $grid): string + { + return implode('|', array_map( + static fn (array $row): string => implode('|', array_map(static fn ($cell): string => (string) $cell, $row)), + $grid, + )); + } +}