From e986980d68eb81beccbd847956bea76bb44e2469 Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 2 Jun 2026 14:49:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(commercial)=20:=20filtres=20r=C3=A9pertoir?= =?UTF-8?q?e=20clients=20via=20drawer=20(recherche,=20cat=C3=A9gories,=20s?= =?UTF-8?q?ites,=20archiv=C3=A9s)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- frontend/i18n/locales/fr.json | 11 +- .../__tests__/useClientsRepository.spec.ts | 48 ++-- .../composables/useClientsRepository.ts | 33 +-- .../commercial/pages/clients/index.vue | 246 ++++++++++++++++-- .../Repository/ClientRepositoryInterface.php | 20 +- .../State/Provider/ClientProvider.php | 50 +++- .../Controller/ClientExportController.php | 51 +++- .../Doctrine/DoctrineClientRepository.php | 89 ++++++- tests/Module/Commercial/Api/ClientApiTest.php | 92 +++++++ 9 files changed, 542 insertions(+), 98 deletions(-) diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 0f08585..d7d7957 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -49,7 +49,6 @@ "title": "Répertoire clients", "add": "Ajouter", "export": "Exporter", - "showArchived": "Voir les archivés", "empty": "Aucun client pour l'instant.", "column": { "companyName": "Nom", @@ -57,6 +56,16 @@ "sites": "Site", "lastActivity": "Dernière activité" }, + "filters": { + "title": "Filtres", + "search": "Recherche", + "categories": "Catégories", + "sites": "Sites", + "status": "Statut", + "archivedOnly": "Voir les archivés", + "apply": "Appliquer", + "reset": "Réinitialiser" + }, "tab": { "information": "Information", "contact": "Contact", diff --git a/frontend/modules/commercial/composables/__tests__/useClientsRepository.spec.ts b/frontend/modules/commercial/composables/__tests__/useClientsRepository.spec.ts index ce1503b..71e505e 100644 --- a/frontend/modules/commercial/composables/__tests__/useClientsRepository.spec.ts +++ b/frontend/modules/commercial/composables/__tests__/useClientsRepository.spec.ts @@ -29,11 +29,10 @@ describe('useClientsRepository', () => { mockGet.mockResolvedValue(makeHydra(25)) }) - it('charge /clients sans includeArchived par defaut (clients actifs)', async () => { + it('cible la ressource /clients en page 1 par defaut', async () => { const repo = useClientsRepository() await repo.fetch() - expect(repo.includeArchived.value).toBe(false) expect(mockGet).toHaveBeenLastCalledWith( '/clients', { page: 1, itemsPerPage: 10 }, @@ -41,35 +40,42 @@ describe('useClientsRepository', () => { ) }) - it('pousse le filtre serveur includeArchived=true quand le toggle est actif', async () => { - const repo = useClientsRepository() - await repo.fetch() - await repo.setIncludeArchived(true) - - expect(repo.includeArchived.value).toBe(true) - expect(mockGet).toHaveBeenLastCalledWith( - '/clients', - { includeArchived: true, page: 1, itemsPerPage: 10 }, - expect.objectContaining({ toast: false }), - ) - }) - - it('retombe en page 1 lorsqu on bascule le toggle archives', async () => { + it('pousse les filtres du drawer (categories multi, sites, archives) et retombe en page 1', async () => { const repo = useClientsRepository() await repo.fetch() await repo.goToPage(2) expect(repo.currentPage.value).toBe(2) - await repo.setIncludeArchived(true) + await repo.setFilters( + { + search: 'acme', + 'categoryCode[]': ['DISTRIBUTEUR', 'COURTIER'], + 'siteId[]': ['1', '2'], + archivedOnly: true, + }, + { replace: true }, + ) + expect(repo.currentPage.value).toBe(1) + expect(mockGet).toHaveBeenLastCalledWith( + '/clients', + { + search: 'acme', + 'categoryCode[]': ['DISTRIBUTEUR', 'COURTIER'], + 'siteId[]': ['1', '2'], + archivedOnly: true, + page: 1, + itemsPerPage: 10, + }, + expect.objectContaining({ toast: false }), + ) }) - it('retire le filtre (query propre) quand le toggle repasse a false', async () => { + it('repasse a une query propre apres reinitialisation des filtres', async () => { const repo = useClientsRepository() - await repo.setIncludeArchived(true) - await repo.setIncludeArchived(false) + await repo.setFilters({ search: 'acme', archivedOnly: true }, { replace: true }) + await repo.setFilters({}, { replace: true }) - expect(repo.includeArchived.value).toBe(false) expect(mockGet).toHaveBeenLastCalledWith( '/clients', { page: 1, itemsPerPage: 10 }, diff --git a/frontend/modules/commercial/composables/useClientsRepository.ts b/frontend/modules/commercial/composables/useClientsRepository.ts index fcdf900..d347435 100644 --- a/frontend/modules/commercial/composables/useClientsRepository.ts +++ b/frontend/modules/commercial/composables/useClientsRepository.ts @@ -1,4 +1,3 @@ -import { ref } from 'vue' import { usePaginatedList } from '~/shared/composables/usePaginatedList' /** @@ -41,11 +40,8 @@ export interface Client { * sur la ressource `/clients` (RG-13 : pagination serveur obligatoire ; jamais * de chargement integral en memoire). * - * N'ajoute qu'un seul comportement metier : le toggle « Voir les archivés ». - * Desactive par defaut (la liste n'expose que les clients actifs — RG-1.24). - * Active, il pousse le filtre serveur `?includeArchived=true` (consomme par le - * ClientProvider, RG-1.25) et — garantie de `usePaginatedList` — retombe en - * page 1. + * Les filtres (recherche, categories, sites, archives) sont pilotes par la page + * via `setFilters` du composable partage — la remise en page 1 est garantie. * * Volontairement PAR INSTANCE (pas de singleton module-level) : l'etat tableau * est propre a l'ecran Repertoire et meurt avec lui, comme tout consommateur de @@ -53,28 +49,5 @@ export interface Client { * gerer. */ export function useClientsRepository() { - // Etat local du toggle « Voir les archivés » — JAMAIS reflete dans l'URL - // (regle ABSOLUE n°6). - const includeArchived = ref(false) - - const list = usePaginatedList({ url: '/clients' }) - - /** - * Bascule l'inclusion des clients archives et relance la liste. La remise - * en page 1 est assuree par `setFilters` (usePaginatedList). Quand le toggle - * repasse a false, on RETIRE le filtre (valeur `undefined`) plutot que - * d'envoyer `includeArchived=false`, pour une query propre. - */ - async function setIncludeArchived(value: boolean): Promise { - includeArchived.value = value - await list.setFilters( - value ? { includeArchived: true } : { includeArchived: undefined }, - ) - } - - return { - ...list, - includeArchived, - setIncludeArchived, - } + return usePaginatedList({ url: '/clients' }) } diff --git a/frontend/modules/commercial/pages/clients/index.vue b/frontend/modules/commercial/pages/clients/index.vue index 466bc8e..ccf5d4a 100644 --- a/frontend/modules/commercial/pages/clients/index.vue +++ b/frontend/modules/commercial/pages/clients/index.vue @@ -11,20 +11,19 @@ icon-position="left" @click="goToCreate" /> + + - -
- -
- +
+ + + + + + + + + + + + + +
+ +
+
+ + + +
+ +
+
+ + + + + +
+ + +
diff --git a/src/Module/Commercial/Domain/Repository/ClientRepositoryInterface.php b/src/Module/Commercial/Domain/Repository/ClientRepositoryInterface.php index 0f6041f..2bc081b 100644 --- a/src/Module/Commercial/Domain/Repository/ClientRepositoryInterface.php +++ b/src/Module/Commercial/Domain/Repository/ClientRepositoryInterface.php @@ -16,21 +16,31 @@ interface ClientRepositoryInterface /** * Construit un QueryBuilder de liste pour le repertoire clients. * - Exclut toujours les clients soft-deletes (deleted_at IS NOT NULL, RG-1.24). - * - Exclut les archives sauf si $includeArchived = true (RG-1.25). + * - Archivage (RG-1.25) : + * - $archivedOnly = true -> uniquement les archives (is_archived = true) ; + * - sinon $includeArchived = true -> actifs + archives (echappatoire) ; + * - sinon (defaut) -> uniquement les actifs (is_archived = false). + * $archivedOnly a la priorite sur $includeArchived. * - 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. - * - $categoryCode : restreint aux clients possedant au moins une categorie - * du code donne (ERP-78 : filtrage par code de Category, plus par type). - * Ignore si null/vide. + * - $categoryCodes : restreint aux clients possedant au moins une categorie + * dont le code est dans la liste (OR — ERP-78). Liste vide = pas de filtre. + * - $siteIds : restreint aux clients ayant au moins une adresse rattachee a + * l'un des sites donnes (OR — RG-1.10). Liste vide = pas de filtre. * * 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. + * + * @param list $categoryCodes + * @param list $siteIds */ public function createListQueryBuilder( bool $includeArchived = false, ?string $search = null, - ?string $categoryCode = null, + array $categoryCodes = [], + array $siteIds = [], + bool $archivedOnly = false, ): QueryBuilder; } diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Provider/ClientProvider.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Provider/ClientProvider.php index 8125e03..7c47760 100644 --- a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Provider/ClientProvider.php +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Provider/ClientProvider.php @@ -64,14 +64,20 @@ final class ClientProvider implements ProviderInterface { $filters = $context['filters'] ?? []; $includeArchived = $this->readBool($filters['includeArchived'] ?? false); + $archivedOnly = $this->readBool($filters['archivedOnly'] ?? false); $search = $filters['search'] ?? null; - $categoryCode = $filters['categoryCode'] ?? null; + // categoryCode accepte un code unique (?categoryCode=DISTRIBUTEUR, selects + // RG-1.03) OU une liste (?categoryCode[]=A&categoryCode[]=B, drawer multi). + $categoryCodes = $this->readStringList($filters['categoryCode'] ?? []); + $siteIds = $this->readIntList($filters['siteId'] ?? []); // Filtrage delegue au repository (logique partagee avec l'export XLSX). $qb = $this->repository->createListQueryBuilder( $includeArchived, is_string($search) ? $search : null, - is_string($categoryCode) ? $categoryCode : null, + $categoryCodes, + $siteIds, + $archivedOnly, ); // Echappatoire ?pagination=false : collection complete sans Paginator @@ -127,4 +133,44 @@ final class ClientProvider implements ProviderInterface return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true); } + + /** + * Normalise un filtre en liste de chaines. Tolere un code unique (string) + * ou une liste (?key[]=a&key[]=b). Trim + retrait des vides. + * + * @return list + */ + private function readStringList(mixed $raw): array + { + $values = is_array($raw) ? $raw : [$raw]; + + $out = []; + foreach ($values as $value) { + if (is_string($value) && '' !== trim($value)) { + $out[] = trim($value); + } + } + + return $out; + } + + /** + * Normalise un filtre en liste d'identifiants entiers positifs. Tolere une + * valeur unique ou une liste (?key[]=1&key[]=2). + * + * @return list + */ + private function readIntList(mixed $raw): array + { + $values = is_array($raw) ? $raw : [$raw]; + + $out = []; + foreach ($values as $value) { + if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) { + $out[] = (int) $value; + } + } + + return $out; + } } diff --git a/src/Module/Commercial/Infrastructure/Controller/ClientExportController.php b/src/Module/Commercial/Infrastructure/Controller/ClientExportController.php index 3aca1b9..44bd8f5 100644 --- a/src/Module/Commercial/Infrastructure/Controller/ClientExportController.php +++ b/src/Module/Commercial/Infrastructure/Controller/ClientExportController.php @@ -52,12 +52,19 @@ final class ClientExportController public function __invoke(Request $request): Response { $includeArchived = $this->readBool($request->query->get('includeArchived')); + $archivedOnly = $this->readBool($request->query->get('archivedOnly')); $search = $request->query->getString('search') ?: null; - $categoryCode = $request->query->getString('categoryCode') ?: null; + + // Memes filtres que la vue liste : categoryCode/siteId tolerent une valeur + // unique ou une liste (?categoryCode[]=A&siteId[]=1). On lit via all() pour + // ne pas lever d'exception sur une valeur scalaire. + $query = $request->query->all(); + $categoryCodes = $this->readStringList($query['categoryCode'] ?? []); + $siteIds = $this->readIntList($query['siteId'] ?? []); /** @var list $clients */ $clients = $this->repository - ->createListQueryBuilder($includeArchived, $search, $categoryCode) + ->createListQueryBuilder($includeArchived, $search, $categoryCodes, $siteIds, $archivedOnly) ->getQuery() ->getResult() ; @@ -198,4 +205,44 @@ final class ClientExportController { return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true); } + + /** + * Normalise un filtre en liste de chaines (valeur unique ou liste). + * Aligne sur ClientProvider pour un comportement identique a la liste. + * + * @return list + */ + private function readStringList(mixed $raw): array + { + $values = is_array($raw) ? $raw : [$raw]; + + $out = []; + foreach ($values as $value) { + if (is_string($value) && '' !== trim($value)) { + $out[] = trim($value); + } + } + + return $out; + } + + /** + * Normalise un filtre en liste d'identifiants entiers positifs (valeur unique + * ou liste). Aligne sur ClientProvider. + * + * @return list + */ + private function readIntList(mixed $raw): array + { + $values = is_array($raw) ? $raw : [$raw]; + + $out = []; + foreach ($values as $value) { + if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) { + $out[] = (int) $value; + } + } + + return $out; + } } diff --git a/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php index a29a252..5b7eefe 100644 --- a/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php +++ b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php @@ -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 $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 $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 $values + * + * @return list + */ + 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 $values + * + * @return list + */ + private function normalizeIntList(array $values): array + { + return array_values(array_filter($values, static fn (int $v): bool => $v > 0)); + } } diff --git a/tests/Module/Commercial/Api/ClientApiTest.php b/tests/Module/Commercial/Api/ClientApiTest.php index f91ae03..90827cc 100644 --- a/tests/Module/Commercial/Api/ClientApiTest.php +++ b/tests/Module/Commercial/Api/ClientApiTest.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace App\Tests\Module\Commercial\Api; +use ApiPlatform\Symfony\Bundle\Test\Client; +use App\Module\Commercial\Domain\Entity\Client as ClientEntity; use App\Module\Commercial\Domain\Entity\ClientAddress; use App\Module\Sites\Domain\Entity\Site; @@ -380,4 +382,94 @@ final class ClientApiTest extends AbstractCommercialApiTestCase self::assertArrayHasKey('color', $row['sites'][0]); self::assertSame($site->getName(), $row['sites'][0]['name']); } + + /** + * ERP-62 (drawer) : filtre Catégories multi (?categoryCode[]=A&categoryCode[]=B) + * — union des clients possedant l'un OU l'autre code. + */ + public function testListFilterByMultipleCategoryCodes(): void + { + $client = $this->createAdminClient(); + $this->seedClient('Filtre Distrib Co', false, 'DISTRIBUTEUR'); + $this->seedClient('Filtre Courtier Co', false, 'COURTIER'); + $this->seedClient('Filtre Secteur Co', false, 'SECTEUR'); + + $names = $this->companyNames($client, '/api/clients?pagination=false&categoryCode[]=DISTRIBUTEUR&categoryCode[]=COURTIER'); + + self::assertContains('FILTRE DISTRIB CO', $names); + self::assertContains('FILTRE COURTIER CO', $names); + self::assertNotContains('FILTRE SECTEUR CO', $names); + } + + /** + * ERP-62 (drawer) : filtre Sites (?siteId[]=X) — clients ayant >= 1 adresse + * rattachee au site donne. + */ + public function testListFilterBySite(): void + { + $client = $this->createAdminClient(); + $em = $this->getEm(); + + $sites = $em->getRepository(Site::class)->findBy([], null, 2); + self::assertCount(2, $sites, 'Deux sites seedes requis pour ce test.'); + [$siteA, $siteB] = $sites; + + $onSiteA = $this->seedClient('Client Sur Site A'); + $this->attachAddressWithSite($onSiteA, $siteA); + + $onSiteB = $this->seedClient('Client Sur Site B'); + $this->attachAddressWithSite($onSiteB, $siteB); + + $names = $this->companyNames($client, '/api/clients?pagination=false&siteId[]='.$siteA->getId()); + + self::assertContains('CLIENT SUR SITE A', $names); + self::assertNotContains('CLIENT SUR SITE B', $names); + } + + /** + * ERP-62 (drawer) : statut « Archivés » (?archivedOnly=true) — uniquement les + * archives, contrairement a includeArchived qui ajoute les archives aux actifs. + */ + public function testListArchivedOnlyReturnsOnlyArchived(): void + { + $client = $this->createAdminClient(); + $this->seedClient('Actif Visible Co'); + $this->seedClient('Archive Visible Co', true); + + $names = $this->companyNames($client, '/api/clients?pagination=false&archivedOnly=true'); + + self::assertContains('ARCHIVE VISIBLE CO', $names); + self::assertNotContains('ACTIF VISIBLE CO', $names); + } + + /** + * Rattache une adresse minimale portant un site au client (les sites vivent + * sur les adresses, RG-1.10). + */ + private function attachAddressWithSite(ClientEntity $client, Site $site): void + { + $em = $this->getEm(); + $address = new ClientAddress(); + $address->setClient($client); + $address->setPostalCode('86100'); + $address->setCity('Châtellerault'); + $address->setStreet('1 rue du Test'); + $address->addSite($site); + $em->persist($address); + $em->flush(); + } + + /** + * Helper : recupere les companyName d'une collection /api/clients. + * + * @return list + */ + private function companyNames(Client $client, string $url): array + { + $members = $client->request('GET', $url, [ + 'headers' => ['Accept' => self::LD], + ])->toArray()['member']; + + return array_map(static fn (array $c): string => $c['companyName'], $members); + } }