feat(commercial) : filtres répertoire clients via drawer (recherche, catégories, sites, archivés)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m47s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m3s

Front :
- Bouton « Filtres » (à droite d'Ajouter) ouvrant un drawer accordion (façon
  audit-log) : Recherche, Catégories (multi), Sites (multi), Statut (archivés).
  État brouillon → appliqué, 100 % local. Compteur de filtres actifs sur le bouton.
- Suppression du toggle « Voir les archivés » (remplacé par le bool du drawer).
- Export et liste partagent les mêmes filtres.
- useClientsRepository redevient un simple wrapper de usePaginatedList.

Back (contrat liste partagé liste + export) :
- createListQueryBuilder : categoryCodes[] (OR), siteIds[] (clients ayant ≥1
  adresse sur le site), archivedOnly (archives seules, prioritaire sur
  includeArchived). search inchangé.
- ClientProvider + ClientExportController lisent les nouveaux params (valeur
  unique ou liste ?key[]=). Tests fonctionnels (catégories multi, site, archivés).
This commit is contained in:
2026-06-02 14:49:21 +02:00
parent e6ac130bf1
commit e986980d68
9 changed files with 542 additions and 98 deletions
@@ -34,7 +34,9 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
public function createListQueryBuilder(
bool $includeArchived = false,
?string $search = null,
?string $categoryCode = null,
array $categoryCodes = [],
array $siteIds = [],
bool $archivedOnly = false,
): QueryBuilder {
$qb = $this->createQueryBuilder('c')
// Jointures + addSelect pour hydrater en une seule requete les
@@ -50,12 +52,16 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
->orderBy('c.companyName', 'ASC')
;
if (!$includeArchived) {
// Perimetre d'archivage : archivedOnly prioritaire sur includeArchived.
if ($archivedOnly) {
$qb->andWhere('c.isArchived = true');
} elseif (!$includeArchived) {
$qb->andWhere('c.isArchived = false');
}
$this->applySearch($qb, $search);
$this->applyCategoryCode($qb, $categoryCode);
$this->applyCategoryCodes($qb, $categoryCodes);
$this->applySiteIds($qb, $siteIds);
return $qb;
}
@@ -82,15 +88,18 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
}
/**
* Restreint aux clients possedant au moins une categorie du code donne
* (ERP-78 : filtrage par code de Category, plus par type). Alimente notamment
* les selects « distributeur » (categoryCode=DISTRIBUTEUR) et « courtier »
* (COURTIER) cote front (RG-1.03). Sous-requete IN (plutot qu'un JOIN sur la
* collection M2M) pour ne pas perturber le DISTINCT / ORDER BY principal.
* Restreint aux clients possedant au moins une categorie dont le code figure
* dans la liste (OR — ERP-78). Alimente le filtre « Catégories » du drawer
* (multi) ainsi que les selects « distributeur »/« courtier » (un seul code,
* RG-1.03). Sous-requete IN (plutot qu'un JOIN sur la collection M2M) pour ne
* pas perturber le DISTINCT / ORDER BY principal.
*
* @param list<string> $categoryCodes
*/
private function applyCategoryCode(QueryBuilder $qb, ?string $categoryCode): void
private function applyCategoryCodes(QueryBuilder $qb, array $categoryCodes): void
{
if (null === $categoryCode || '' === trim($categoryCode)) {
$codes = $this->normalizeStringList($categoryCodes);
if ([] === $codes) {
return;
}
@@ -98,11 +107,67 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
->select('c2.id')
->from(Client::class, 'c2')
->join('c2.categories', 'cat2')
->where('cat2.code = :categoryCode')
->where('cat2.code IN (:categoryCodes)')
;
$qb->andWhere($qb->expr()->in('c.id', $sub->getDQL()))
->setParameter('categoryCode', trim($categoryCode))
->setParameter('categoryCodes', $codes)
;
}
/**
* Restreint aux clients ayant au moins une adresse rattachee a l'un des
* sites donnes (OR — RG-1.10 : les sites vivent sur les adresses, pas sur le
* client). Sous-requete IN pour ne pas perturber le tri/pagination principal.
*
* @param list<int> $siteIds
*/
private function applySiteIds(QueryBuilder $qb, array $siteIds): void
{
$ids = $this->normalizeIntList($siteIds);
if ([] === $ids) {
return;
}
$sub = $this->getEntityManager()->createQueryBuilder()
->select('c3.id')
->from(Client::class, 'c3')
->join('c3.addresses', 'addr3')
->join('addr3.sites', 'site3')
->where('site3.id IN (:siteIds)')
;
$qb->andWhere($qb->expr()->in('c.id', $sub->getDQL()))
->setParameter('siteIds', $ids)
;
}
/**
* Nettoie une liste de chaines : trim, retrait des vides, reindexation.
*
* @param list<string> $values
*
* @return list<string>
*/
private function normalizeStringList(array $values): array
{
$cleaned = array_filter(
array_map(static fn (string $v): string => trim($v), $values),
static fn (string $v): bool => '' !== $v,
);
return array_values($cleaned);
}
/**
* Nettoie une liste d'identifiants : cast int, retrait des <= 0, reindexation.
*
* @param list<int> $values
*
* @return list<int>
*/
private function normalizeIntList(array $values): array
{
return array_values(array_filter($values, static fn (int $v): bool => $v > 0));
}
}