feat(commercial) : add clients XLSX export endpoint
This commit is contained in:
@@ -18,6 +18,18 @@ interface ClientRepositoryInterface
|
|||||||
* - Exclut toujours les clients soft-deletes (deleted_at IS NOT NULL, RG-1.24).
|
* - Exclut toujours les clients soft-deletes (deleted_at IS NOT NULL, RG-1.24).
|
||||||
* - Exclut les archives sauf si $includeArchived = true (RG-1.25).
|
* - Exclut les archives sauf si $includeArchived = true (RG-1.25).
|
||||||
* - Tri par defaut : companyName ASC (RG-1.26).
|
* - Tri par defaut : companyName ASC (RG-1.26).
|
||||||
|
* - $search : recherche fuzzy insensible a la casse sur companyName +
|
||||||
|
* lastName + email (metacaracteres LIKE echappes). Ignore si null/vide.
|
||||||
|
* - $categoryType : restreint aux clients possedant au moins une categorie
|
||||||
|
* du type donne (code). Ignore si null/vide.
|
||||||
|
*
|
||||||
|
* Filtrage centralise ICI (et non dans les providers/controllers) pour que
|
||||||
|
* la liste paginee (ClientProvider) et l'export (ClientExportController)
|
||||||
|
* partagent strictement la meme logique de selection.
|
||||||
*/
|
*/
|
||||||
public function createListQueryBuilder(bool $includeArchived = false): QueryBuilder;
|
public function createListQueryBuilder(
|
||||||
|
bool $includeArchived = false,
|
||||||
|
?string $search = null,
|
||||||
|
?string $categoryType = null,
|
||||||
|
): QueryBuilder;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ use ApiPlatform\State\Pagination\Pagination;
|
|||||||
use ApiPlatform\State\ProviderInterface;
|
use ApiPlatform\State\ProviderInterface;
|
||||||
use App\Module\Commercial\Domain\Entity\Client;
|
use App\Module\Commercial\Domain\Entity\Client;
|
||||||
use App\Module\Commercial\Domain\Repository\ClientRepositoryInterface;
|
use App\Module\Commercial\Domain\Repository\ClientRepositoryInterface;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use Doctrine\ORM\QueryBuilder;
|
|
||||||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
@@ -46,7 +44,6 @@ final class ClientProvider implements ProviderInterface
|
|||||||
#[Autowire(service: 'App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository')]
|
#[Autowire(service: 'App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository')]
|
||||||
private readonly ClientRepositoryInterface $repository,
|
private readonly ClientRepositoryInterface $repository,
|
||||||
private readonly Pagination $pagination,
|
private readonly Pagination $pagination,
|
||||||
private readonly EntityManagerInterface $em,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Client|iterable|Paginator|null
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Client|iterable|Paginator|null
|
||||||
@@ -67,10 +64,15 @@ final class ClientProvider implements ProviderInterface
|
|||||||
{
|
{
|
||||||
$filters = $context['filters'] ?? [];
|
$filters = $context['filters'] ?? [];
|
||||||
$includeArchived = $this->readBool($filters['includeArchived'] ?? false);
|
$includeArchived = $this->readBool($filters['includeArchived'] ?? false);
|
||||||
|
$search = $filters['search'] ?? null;
|
||||||
|
$categoryType = $filters['categoryType'] ?? null;
|
||||||
|
|
||||||
$qb = $this->repository->createListQueryBuilder($includeArchived);
|
// Filtrage delegue au repository (logique partagee avec l'export XLSX).
|
||||||
$this->applySearch($qb, $filters['search'] ?? null);
|
$qb = $this->repository->createListQueryBuilder(
|
||||||
$this->applyCategoryType($qb, $filters['categoryType'] ?? null);
|
$includeArchived,
|
||||||
|
is_string($search) ? $search : null,
|
||||||
|
is_string($categoryType) ? $categoryType : null,
|
||||||
|
);
|
||||||
|
|
||||||
// Echappatoire ?pagination=false : collection complete sans Paginator
|
// Echappatoire ?pagination=false : collection complete sans Paginator
|
||||||
// (cf. convention ERP-72 — utile pour un <select> cote front).
|
// (cf. convention ERP-72 — utile pour un <select> cote front).
|
||||||
@@ -114,55 +116,6 @@ final class ClientProvider implements ProviderInterface
|
|||||||
return $client;
|
return $client;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Recherche fuzzy insensible a la casse sur companyName + lastName + email.
|
|
||||||
* Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester
|
|
||||||
* litteraux.
|
|
||||||
*/
|
|
||||||
private function applySearch(QueryBuilder $qb, mixed $search): void
|
|
||||||
{
|
|
||||||
if (!is_string($search) || '' === trim($search)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
|
|
||||||
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
|
|
||||||
|
|
||||||
$qb->andWhere(
|
|
||||||
'LOWER(c.companyName) LIKE :search '
|
|
||||||
.'OR LOWER(c.lastName) LIKE :search '
|
|
||||||
.'OR LOWER(c.email) LIKE :search',
|
|
||||||
)->setParameter('search', $pattern);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restreint aux clients possedant au moins une categorie du type donne.
|
|
||||||
* Sous-requete IN (plutot qu'un JOIN sur la collection M2M) pour ne pas
|
|
||||||
* perturber le DISTINCT / ORDER BY de la requete paginee principale.
|
|
||||||
*/
|
|
||||||
private function applyCategoryType(QueryBuilder $qb, mixed $categoryType): void
|
|
||||||
{
|
|
||||||
if (!is_string($categoryType) || '' === trim($categoryType)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sous-requete construite via l'EntityManager (et non
|
|
||||||
// $repository->createQueryBuilder()) : createQueryBuilder() n'est pas
|
|
||||||
// declaree sur ClientRepositoryInterface, l'appeler exposerait un detail
|
|
||||||
// d'implementation Doctrine hors du contrat (fuite d'abstraction).
|
|
||||||
$sub = $this->em->createQueryBuilder()
|
|
||||||
->select('c2.id')
|
|
||||||
->from(Client::class, 'c2')
|
|
||||||
->join('c2.categories', 'cat2')
|
|
||||||
->join('cat2.categoryType', 'ct2')
|
|
||||||
->where('ct2.code = :categoryType')
|
|
||||||
;
|
|
||||||
|
|
||||||
$qb->andWhere($qb->expr()->in('c.id', $sub->getDQL()))
|
|
||||||
->setParameter('categoryType', trim($categoryType))
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
|
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Commercial\Infrastructure\Controller;
|
||||||
|
|
||||||
|
use App\Module\Commercial\Domain\Entity\Client;
|
||||||
|
use App\Module\Commercial\Domain\Repository\ClientRepositoryInterface;
|
||||||
|
use App\Shared\Domain\Contract\CategoryInterface;
|
||||||
|
use App\Shared\Domain\Contract\SiteInterface;
|
||||||
|
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export XLSX du repertoire clients (M1, spec-back § 4.6).
|
||||||
|
*
|
||||||
|
* Controller Symfony custom (et non operation API Platform) car il produit un
|
||||||
|
* binaire de fichier, pas une representation Hydra. `priority: 1` est
|
||||||
|
* OBLIGATOIRE sur la route : sans cela API Platform capterait
|
||||||
|
* `/api/clients/export.xlsx` comme l'item `GET /api/clients/{id}.{_format}`
|
||||||
|
* (id="export", _format="xlsx") — cf. CLAUDE.md « controller custom sous /api ».
|
||||||
|
*
|
||||||
|
* Separation des responsabilites :
|
||||||
|
* - le COMMENT (generation du fichier) est delegue au service Shared
|
||||||
|
* {@see SpreadsheetExporterInterface} — generique, reutilisable, sans metier ;
|
||||||
|
* - le QUOI vit ICI : selection des clients (memes filtres que
|
||||||
|
* `GET /api/clients`, via {@see ClientRepositoryInterface::createListQueryBuilder()})
|
||||||
|
* et mapping metier des colonnes.
|
||||||
|
*
|
||||||
|
* La colonne SIREN n'est ajoutee que si l'utilisateur a la permission
|
||||||
|
* `commercial.clients.accounting.view` (gating identique a la lecture).
|
||||||
|
*/
|
||||||
|
#[AsController]
|
||||||
|
final class ClientExportController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(service: 'App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository')]
|
||||||
|
private readonly ClientRepositoryInterface $repository,
|
||||||
|
private readonly SpreadsheetExporterInterface $exporter,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
#[Route('/api/clients/export.xlsx', name: 'commercial_clients_export_xlsx', methods: ['GET'], priority: 1)]
|
||||||
|
#[IsGranted('commercial.clients.view')]
|
||||||
|
public function __invoke(Request $request): Response
|
||||||
|
{
|
||||||
|
$includeArchived = $this->readBool($request->query->get('includeArchived'));
|
||||||
|
$search = $request->query->getString('search') ?: null;
|
||||||
|
$categoryType = $request->query->getString('categoryType') ?: null;
|
||||||
|
|
||||||
|
/** @var list<Client> $clients */
|
||||||
|
$clients = $this->repository
|
||||||
|
->createListQueryBuilder($includeArchived, $search, $categoryType)
|
||||||
|
->getQuery()
|
||||||
|
->getResult()
|
||||||
|
;
|
||||||
|
|
||||||
|
$withSiren = $this->security->isGranted('commercial.clients.accounting.view');
|
||||||
|
|
||||||
|
$binary = $this->exporter->export(
|
||||||
|
'Répertoire clients',
|
||||||
|
$this->buildHeaders($withSiren),
|
||||||
|
$this->buildRows($clients, $withSiren),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->buildResponse($binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Colonnes dans l'ordre impose par la spec § 4.6. SIREN inseree avant la
|
||||||
|
* date de creation, uniquement si l'utilisateur a accounting.view.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function buildHeaders(bool $withSiren): array
|
||||||
|
{
|
||||||
|
$headers = [
|
||||||
|
'Nom entreprise',
|
||||||
|
'Nom contact principal',
|
||||||
|
'Prénom',
|
||||||
|
'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<Client> $clients
|
||||||
|
*
|
||||||
|
* @return iterable<list<null|scalar>>
|
||||||
|
*/
|
||||||
|
private function buildRows(array $clients, bool $withSiren): iterable
|
||||||
|
{
|
||||||
|
foreach ($clients as $client) {
|
||||||
|
$row = [
|
||||||
|
$client->getCompanyName(),
|
||||||
|
$client->getLastName(),
|
||||||
|
$client->getFirstName(),
|
||||||
|
$client->getPhonePrimary(),
|
||||||
|
$client->getPhoneSecondary(),
|
||||||
|
$client->getEmail(),
|
||||||
|
$this->formatCategories($client),
|
||||||
|
$this->formatSites($client),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($withSiren) {
|
||||||
|
$row[] = $client->getSiren();
|
||||||
|
}
|
||||||
|
|
||||||
|
$row[] = $client->getCreatedAt()?->format('d/m/Y');
|
||||||
|
|
||||||
|
yield $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Libelles des categories du client, dedupliques, tries, joints par virgule.
|
||||||
|
*/
|
||||||
|
private function formatCategories(Client $client): string
|
||||||
|
{
|
||||||
|
$names = [];
|
||||||
|
foreach ($client->getCategories() as $category) {
|
||||||
|
// @var CategoryInterface $category
|
||||||
|
$name = $category->getName();
|
||||||
|
if (null !== $name && '' !== $name) {
|
||||||
|
$names[$name] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->joinSorted($names);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le Client ne porte pas de sites en propre : ils sont rattaches aux
|
||||||
|
* adresses (RG-1.10). La colonne « Sites » agrege donc l'union distincte des
|
||||||
|
* sites de toutes les adresses du client (decision validee 01/06).
|
||||||
|
*/
|
||||||
|
private function formatSites(Client $client): string
|
||||||
|
{
|
||||||
|
$names = [];
|
||||||
|
foreach ($client->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<string, true> $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-clients-%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 ClientProvider pour un comportement identique a la liste.
|
||||||
|
*/
|
||||||
|
private function readBool(mixed $raw): bool
|
||||||
|
{
|
||||||
|
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,8 +31,11 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
|
|||||||
$this->getEntityManager()->flush();
|
$this->getEntityManager()->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createListQueryBuilder(bool $includeArchived = false): QueryBuilder
|
public function createListQueryBuilder(
|
||||||
{
|
bool $includeArchived = false,
|
||||||
|
?string $search = null,
|
||||||
|
?string $categoryType = null,
|
||||||
|
): QueryBuilder {
|
||||||
$qb = $this->createQueryBuilder('c')
|
$qb = $this->createQueryBuilder('c')
|
||||||
->andWhere('c.deletedAt IS NULL')
|
->andWhere('c.deletedAt IS NULL')
|
||||||
->orderBy('c.companyName', 'ASC')
|
->orderBy('c.companyName', 'ASC')
|
||||||
@@ -42,6 +45,54 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
|
|||||||
$qb->andWhere('c.isArchived = false');
|
$qb->andWhere('c.isArchived = false');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->applySearch($qb, $search);
|
||||||
|
$this->applyCategoryType($qb, $categoryType);
|
||||||
|
|
||||||
return $qb;
|
return $qb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recherche fuzzy insensible a la casse sur companyName + lastName + email.
|
||||||
|
* Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester
|
||||||
|
* litteraux.
|
||||||
|
*/
|
||||||
|
private function applySearch(QueryBuilder $qb, ?string $search): void
|
||||||
|
{
|
||||||
|
if (null === $search || '' === trim($search)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
|
||||||
|
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
|
||||||
|
|
||||||
|
$qb->andWhere(
|
||||||
|
'LOWER(c.companyName) LIKE :search '
|
||||||
|
.'OR LOWER(c.lastName) LIKE :search '
|
||||||
|
.'OR LOWER(c.email) LIKE :search',
|
||||||
|
)->setParameter('search', $pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restreint aux clients possedant au moins une categorie du type donne.
|
||||||
|
* Sous-requete IN (plutot qu'un JOIN sur la collection M2M) pour ne pas
|
||||||
|
* perturber le DISTINCT / ORDER BY de la requete principale.
|
||||||
|
*/
|
||||||
|
private function applyCategoryType(QueryBuilder $qb, ?string $categoryType): void
|
||||||
|
{
|
||||||
|
if (null === $categoryType || '' === trim($categoryType)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sub = $this->getEntityManager()->createQueryBuilder()
|
||||||
|
->select('c2.id')
|
||||||
|
->from(Client::class, 'c2')
|
||||||
|
->join('c2.categories', 'cat2')
|
||||||
|
->join('cat2.categoryType', 'ct2')
|
||||||
|
->where('ct2.code = :categoryType')
|
||||||
|
;
|
||||||
|
|
||||||
|
$qb->andWhere($qb->expr()->in('c.id', $sub->getDQL()))
|
||||||
|
->setParameter('categoryType', trim($categoryType))
|
||||||
|
;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Commercial\Api;
|
||||||
|
|
||||||
|
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 testExportRespectsCategoryTypeFilter(): 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.'?categoryType=DISTRIBUTEUR')->getContent(),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertContains('DISTRIB CO', $names);
|
||||||
|
self::assertNotContains('SECTEUR CO', $names);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user