97301dcd6c
Auto Tag Develop / tag (push) Successful in 7s
## Contexte Issu de la review ERP-62 (#44). `DoctrineClientRepository::createListQueryBuilder()` portait 3 `leftJoin+addSelect` to-many imbriqués (`categories × addresses × addresses.sites`) **partagés** entre : - la **liste paginée** (`ClientProvider`) — bornée, OK ; - l'**export XLSX** et **`?pagination=false`** — `getResult()` sans pagination → hydratation du **produit cartésien sur tout le référentiel** (1 client à 5 cat × 4 adr × 3 sites = 60 lignes SQL, × N clients). Défaut d'altitude : un « QueryBuilder de liste » (contrat = filtres) imposait une stratégie d'hydratation à tout appelant. ## Changements - **`createListQueryBuilder()`** redevient **filtres + tri seuls** — conforme au contrat de l'interface. - Nouvelle méthode **`hydrateListCollections(array $clients)`** : recharge les collections en **2 requêtes `WHERE id IN(...)` séparées** (catégories d'un côté, adresses+sites de l'autre) via l'identity map Doctrine. Casse le triple cartésien en `cat + (addr × site)`. - **3 appelants** branchés sur cette stratégie unique : - liste paginée : `fetchJoinCollection: false` (COUNT simple) + hydratation de la page ; - `?pagination=false` : hydratation après `getResult()` ; - export XLSX : hydratation après `getResult()`. ## Tests - `make test` : **465 OK**. - Nouveau test `ClientExportControllerTest::testExportPopulatesCategoryAndSiteColumns` : garde-fou sur les valeurs Catégories/Sites de l'export (qu'un oubli d'hydratation rendrait silencieusement vides). - `php-cs-fixer` : 0 correction. ## Notes - Benchmark « 1000+ clients » non exécuté (pas de jeu de données à cette échelle en dev) ; le cartésien est supprimé structurellement. - `addr × site` reste un join imbriqué (inévitable pour agréger les sites par adresse), désormais non multiplié par les catégories. Closes ERP-100. --------- Co-authored-by: admin malio <malio@yuno.malio.fr> Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #50 Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr> Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
221 lines
7.9 KiB
PHP
221 lines
7.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Module\Commercial\Api;
|
|
|
|
use App\Module\Commercial\Domain\Entity\ClientAddress;
|
|
use App\Module\Sites\Domain\Entity\Site;
|
|
use PhpOffice\PhpSpreadsheet\IOFactory;
|
|
|
|
/**
|
|
* Tests fonctionnels de l'export XLSX du repertoire clients (M1, § 4.6).
|
|
*
|
|
* Couvre : reponse 200 (Content-Type + Content-Disposition), exclusion des
|
|
* archives par defaut, respect des filtres ?search / ?categoryType, gating de
|
|
* la colonne SIREN selon commercial.clients.accounting.view, 403 sans
|
|
* commercial.clients.view, 401 anonyme.
|
|
*
|
|
* @internal
|
|
*/
|
|
final class ClientExportControllerTest extends AbstractCommercialApiTestCase
|
|
{
|
|
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
|
private const string EXPORT_URL = '/api/clients/export.xlsx';
|
|
|
|
public function testExportReturnsXlsxResponseWithAttachmentFilename(): void
|
|
{
|
|
$client = $this->createAdminClient();
|
|
$this->seedClient('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-clients-', $disposition);
|
|
self::assertMatchesRegularExpression(
|
|
'/filename="repertoire-clients-\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 entreprise', $headers[0]);
|
|
self::assertContains('Catégories', $headers);
|
|
self::assertContains('Sites', $headers);
|
|
self::assertContains('Date de création', $headers);
|
|
}
|
|
|
|
public function testExportExcludesArchivedByDefault(): void
|
|
{
|
|
$client = $this->createAdminClient();
|
|
$this->seedClient('Active One');
|
|
$this->seedClient('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->seedClient('Searchable Alpha');
|
|
$this->seedClient('Other Beta');
|
|
|
|
$names = $this->companyNames(
|
|
$client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(),
|
|
);
|
|
|
|
self::assertContains('SEARCHABLE ALPHA', $names);
|
|
self::assertNotContains('OTHER BETA', $names);
|
|
}
|
|
|
|
public function testExportRespectsCategoryCodeFilter(): void
|
|
{
|
|
$client = $this->createAdminClient();
|
|
$this->seedClient('Distrib Co', false, 'DISTRIBUTEUR');
|
|
$this->seedClient('Secteur Co', false, 'SECTEUR');
|
|
|
|
$names = $this->companyNames(
|
|
$client->request('GET', self::EXPORT_URL.'?categoryCode=DISTRIBUTEUR')->getContent(),
|
|
);
|
|
|
|
self::assertContains('DISTRIB CO', $names);
|
|
self::assertNotContains('SECTEUR CO', $names);
|
|
}
|
|
|
|
/**
|
|
* ERP-100 : depuis le decouplage hydratation/selection, le QueryBuilder de
|
|
* liste ne fetch-join plus les collections — l'export les recharge en lot via
|
|
* hydrateListCollections(). Ce test garde que les colonnes « Catégories » et
|
|
* « Site(s) » restent peuplees (un oubli d'hydratation les rendrait vides
|
|
* sans erreur).
|
|
*/
|
|
public function testExportPopulatesCategoryAndSiteColumns(): void
|
|
{
|
|
$client = $this->createAdminClient();
|
|
$seed = $this->seedClient('Hydrate Co', false, 'DISTRIBUTEUR');
|
|
|
|
$em = $this->getEm();
|
|
$site = $em->getRepository(Site::class)->findOneBy([]);
|
|
self::assertNotNull($site, 'Aucun site seede : impossible de tester la colonne Site(s).');
|
|
|
|
$address = new ClientAddress();
|
|
$address->setClient($seed);
|
|
$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 client (getName()).
|
|
self::assertStringContainsString('test_cli_cat_distributeur', $flat);
|
|
// Colonne « Site(s) » : site agrege depuis l'adresse (RG-1.10).
|
|
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();
|
|
$seed = $this->seedClient('Siren Co');
|
|
$em = $this->getEm();
|
|
$seed->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 clients.view.
|
|
$admin = $this->createAdminClient();
|
|
$seed = $this->seedClient('No Siren Co');
|
|
$em = $this->getEm();
|
|
$seed->setSiren('987654321');
|
|
$em->flush();
|
|
|
|
$creds = $this->createUserWithPermission('commercial.clients.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 testForbiddenWithoutClientsViewPermission(): 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);
|
|
}
|
|
|
|
/**
|
|
* 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 entreprise » (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));
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
));
|
|
}
|
|
}
|