feat(commercial) : add clients XLSX export endpoint

This commit is contained in:
Matthieu
2026-06-01 15:44:00 +02:00
parent 58ef41434b
commit 9f3037c5c4
5 changed files with 460 additions and 58 deletions
@@ -18,6 +18,18 @@ interface ClientRepositoryInterface
* - Exclut toujours les clients soft-deletes (deleted_at IS NOT NULL, RG-1.24).
* - Exclut les archives sauf si $includeArchived = true (RG-1.25).
* - 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 App\Module\Commercial\Domain\Entity\Client;
use App\Module\Commercial\Domain\Repository\ClientRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
@@ -46,7 +44,6 @@ final class ClientProvider implements ProviderInterface
#[Autowire(service: 'App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository')]
private readonly ClientRepositoryInterface $repository,
private readonly Pagination $pagination,
private readonly EntityManagerInterface $em,
) {}
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'] ?? [];
$includeArchived = $this->readBool($filters['includeArchived'] ?? false);
$search = $filters['search'] ?? null;
$categoryType = $filters['categoryType'] ?? null;
$qb = $this->repository->createListQueryBuilder($includeArchived);
$this->applySearch($qb, $filters['search'] ?? null);
$this->applyCategoryType($qb, $filters['categoryType'] ?? null);
// Filtrage delegue au repository (logique partagee avec l'export XLSX).
$qb = $this->repository->createListQueryBuilder(
$includeArchived,
is_string($search) ? $search : null,
is_string($categoryType) ? $categoryType : null,
);
// Echappatoire ?pagination=false : collection complete sans Paginator
// (cf. convention ERP-72 — utile pour un <select> cote front).
@@ -114,55 +116,6 @@ final class ClientProvider implements ProviderInterface
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".
*/
@@ -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();
}
public function createListQueryBuilder(bool $includeArchived = false): QueryBuilder
{
public function createListQueryBuilder(
bool $includeArchived = false,
?string $search = null,
?string $categoryType = null,
): QueryBuilder {
$qb = $this->createQueryBuilder('c')
->andWhere('c.deletedAt IS NULL')
->orderBy('c.companyName', 'ASC')
@@ -42,6 +45,54 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
$qb->andWhere('c.isArchived = false');
}
$this->applySearch($qb, $search);
$this->applyCategoryType($qb, $categoryType);
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,
));
}
}