feat(commercial) : export XLSX fournisseurs (ERP-91) (#70)
Auto Tag Develop / tag (push) Successful in 7s

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 <contact@malio.fr>
Reviewed-on: #70
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
This commit was merged in pull request #70.
This commit is contained in:
2026-06-08 07:48:59 +00:00
committed by admin malio
parent 58cbfe4437
commit 97459e798f
5 changed files with 729 additions and 0 deletions
@@ -0,0 +1,312 @@
<?php
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;
/**
* Tests fonctionnels de l'export XLSX du repertoire fournisseurs (M2, § 4.6).
* Jumeau du {@see ClientExportControllerTest} (M1).
*
* Couvre : reponse 200 (Content-Type + Content-Disposition), exclusion des
* archives par defaut, respect du filtre ?search, peuplement des colonnes
* contact principal / categories / sites, gating de la colonne SIREN selon
* commercial.suppliers.accounting.view, 403 sans commercial.suppliers.view,
* 401 anonyme.
*
* @internal
*/
final class SupplierExportControllerTest extends AbstractCommercialApiTestCase
{
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();
$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<int, array<int, mixed>>
*/
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<string>
*/
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<int, mixed>|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<int, array<int, mixed>> $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,
));
}
}