diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 9ff45c5..b3c0490 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -51,12 +51,20 @@ "export": "Exporter", "empty": "Aucun client pour l'instant.", "column": { - "companyName": "Nom entreprise", - "contact": "Contact principal", - "phone": "Téléphone principal", - "email": "Email principal", + "companyName": "Nom", "categories": "Catégories", - "sites": "Site(s)" + "sites": "Site", + "lastActivity": "Dernière activité" + }, + "filters": { + "title": "Filtres", + "search": "Recherche", + "categories": "Catégories", + "sites": "Sites", + "status": "Statut", + "archivedOnly": "Voir les archivés", + "apply": "Voir les résultats", + "reset": "Réinitialiser" }, "tab": { "information": "Information", @@ -78,7 +86,8 @@ "updateSuccess": "Client mis à jour avec succès", "archiveSuccess": "Client archivé avec succès", "restoreSuccess": "Client restauré avec succès", - "error": "Une erreur est survenue. Réessayez." + "error": "Une erreur est survenue. Réessayez.", + "exportError": "L'export du répertoire clients a échoué. Réessayez." }, "validation": { "informationRequiredForCommercial": "Les informations de l'entreprise sont obligatoires pour le rôle Commerciale.", diff --git a/frontend/modules/commercial/composables/__tests__/useClientsRepository.spec.ts b/frontend/modules/commercial/composables/__tests__/useClientsRepository.spec.ts new file mode 100644 index 0000000..71e505e --- /dev/null +++ b/frontend/modules/commercial/composables/__tests__/useClientsRepository.spec.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { HydraCollection } from '~/shared/utils/api' +import type { Client } from '../useClientsRepository' + +// `useApi` est un auto-import Nuxt : on le stubbe globalement pour intercepter +// les appels declenches par usePaginatedList (que useClientsRepository enveloppe) +// et controler les reponses. Meme pattern que useCategoriesAdmin.spec.ts. +const mockGet = vi.hoisted(() => vi.fn()) +vi.stubGlobal('useApi', () => ({ + get: mockGet, + post: vi.fn(), + put: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), +})) + +// Import APRES le stub pour que useApi soit bien resolu au top-level du module. +const { useClientsRepository } = await import('../useClientsRepository') + +/** Envelope Hydra minimale (la liste reelle des membres importe peu ici). */ +function makeHydra(total: number): HydraCollection { + return { totalItems: total, member: [] } +} + +describe('useClientsRepository', () => { + beforeEach(() => { + mockGet.mockReset() + // 25 items → 3 pages a 10/page : permet de tester la navigation page 2. + mockGet.mockResolvedValue(makeHydra(25)) + }) + + it('cible la ressource /clients en page 1 par defaut', async () => { + const repo = useClientsRepository() + await repo.fetch() + + expect(mockGet).toHaveBeenLastCalledWith( + '/clients', + { page: 1, itemsPerPage: 10 }, + expect.objectContaining({ toast: false }), + ) + }) + + 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.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('repasse a une query propre apres reinitialisation des filtres', async () => { + const repo = useClientsRepository() + await repo.setFilters({ search: 'acme', archivedOnly: true }, { replace: true }) + await repo.setFilters({}, { replace: true }) + + expect(mockGet).toHaveBeenLastCalledWith( + '/clients', + { page: 1, itemsPerPage: 10 }, + expect.objectContaining({ toast: false }), + ) + }) +}) diff --git a/frontend/modules/commercial/composables/useClientsRepository.ts b/frontend/modules/commercial/composables/useClientsRepository.ts new file mode 100644 index 0000000..d347435 --- /dev/null +++ b/frontend/modules/commercial/composables/useClientsRepository.ts @@ -0,0 +1,53 @@ +import { usePaginatedList } from '~/shared/composables/usePaginatedList' + +/** + * Site Starseed rattache a une adresse du client, tel qu'embarque en LISTE + * (groupe site:read) pour la colonne « Site(s) » du Repertoire (badges colores). + */ +export interface ClientSite { + id: number + name: string + color: string +} + +/** + * Categorie rattachee au client, embarquee en LISTE (groupe category:read). + * Seul le `code` (stable, MAJUSCULE — ERP-78) est affiche dans la colonne + * « Catégories ». Les autres champs sont presents mais non utilises ici. + */ +export interface ClientCategory { + code: string + name?: string +} + +/** + * Vue MINIMALE d'un client pour le Repertoire (datatable). Volontairement + * partielle : seuls les champs des colonnes + l'id (navigation) sont types ici. + * Le detail complet (onglets) est hors perimetre de cet ecran (ERP-62). + */ +export interface Client { + id: number + companyName: string + categories: ClientCategory[] + sites: ClientSite[] + /** Date ISO de derniere modification (default:read) — colonne « Dernière activité ». */ + updatedAt: string | null + isArchived: boolean +} + +/** + * Repertoire clients (ERP-62) — simple enveloppe de `usePaginatedList` + * sur la ressource `/clients` (RG-13 : pagination serveur obligatoire ; jamais + * de chargement integral en memoire). + * + * 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 + * `usePaginatedList` (cf. sites.vue / categories.vue). Aucun reset au logout a + * gerer. + */ +export function useClientsRepository() { + return usePaginatedList({ url: '/clients' }) +} diff --git a/frontend/modules/commercial/pages/clients/index.vue b/frontend/modules/commercial/pages/clients/index.vue new file mode 100644 index 0000000..df25fda --- /dev/null +++ b/frontend/modules/commercial/pages/clients/index.vue @@ -0,0 +1,421 @@ + + + diff --git a/frontend/shared/utils/__tests__/phone.test.ts b/frontend/shared/utils/__tests__/phone.test.ts new file mode 100644 index 0000000..b18cb57 --- /dev/null +++ b/frontend/shared/utils/__tests__/phone.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest' +import { formatPhoneFR } from '../phone' + +describe('formatPhoneFR', () => { + it('formate un numero 10 chiffres en XX XX XX XX XX', () => { + expect(formatPhoneFR('0612345678')).toBe('06 12 34 56 78') + }) + + it('tolere une saisie deja pointee ou espacee', () => { + expect(formatPhoneFR('06.12.34.56.78')).toBe('06 12 34 56 78') + expect(formatPhoneFR('06 12 34 56 78')).toBe('06 12 34 56 78') + }) + + it('retourne une chaine vide pour une valeur vide ou nulle', () => { + expect(formatPhoneFR('')).toBe('') + expect(formatPhoneFR(null)).toBe('') + expect(formatPhoneFR(undefined)).toBe('') + }) + + it('groupe par 2 meme un nombre impair de chiffres (dernier groupe seul)', () => { + expect(formatPhoneFR('123')).toBe('12 3') + }) +}) diff --git a/frontend/shared/utils/phone.ts b/frontend/shared/utils/phone.ts new file mode 100644 index 0000000..d00f7a0 --- /dev/null +++ b/frontend/shared/utils/phone.ts @@ -0,0 +1,23 @@ +/** + * Formatage d'un numero de telephone francais en groupes de 2 chiffres + * (`XX XX XX XX XX`). + * + * Helper PARTAGE volontaire : les telephones sont presents un peu partout dans + * l'app (fiches clients, contacts, fournisseurs, prestataires...). Introduit ici + * comme util transverse stable plutot que duplique a chaque ecran. La signature + * `formatPhoneFR(value): string` est coordonnee avec ERP-66, qui pourra enrichir + * l'implementation (validation, indicatif international) sans casser les appelants. + * + * - Ne garde que les chiffres puis groupe par 2 (tolere une saisie deja espacee + * ou pointee, ex: `06.12.34.56.78` ou `0612345678`). + * - Retourne une chaine vide si la valeur est vide/nulle (cellule vide propre). + */ +export function formatPhoneFR(value: string | null | undefined): string { + const digits = (value ?? '').replace(/\D/g, '') + if (digits.length === 0) { + return '' + } + + // Groupe par paquets de 2 ; un dernier groupe impair reste tel quel. + return digits.match(/.{1,2}/g)?.join(' ') ?? digits +} diff --git a/src/Module/Commercial/Domain/Entity/Client.php b/src/Module/Commercial/Domain/Entity/Client.php index 30762cd..bdcac63 100644 --- a/src/Module/Commercial/Domain/Entity/Client.php +++ b/src/Module/Commercial/Domain/Entity/Client.php @@ -15,6 +15,7 @@ use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository; use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\CategoryInterface; +use App\Shared\Domain\Contract\SiteInterface; use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Trait\TimestampableBlamableTrait; use DateTimeImmutable; @@ -58,7 +59,11 @@ use Symfony\Component\Validator\Constraints as Assert; operations: [ new GetCollection( security: "is_granted('commercial.clients.view')", - normalizationContext: ['groups' => ['client:read', 'default:read']], + // La liste embarque les categories (avec leur code, groupe + // category:read) et les sites agreges des adresses (groupe + // site:read) pour alimenter les colonnes « Catégories » et + // « Site(s) » du Repertoire (ERP-62). Cf. getSites() plus bas. + normalizationContext: ['groups' => ['client:read', 'default:read', 'category:read', 'site:read']], provider: ClientProvider::class, ), new Get( @@ -86,7 +91,7 @@ use Symfony\Component\Validator\Constraints as Assert; ), new Post( security: "is_granted('commercial.clients.manage')", - normalizationContext: ['groups' => ['client:read', 'default:read']], + normalizationContext: ['groups' => ['client:read', 'default:read', 'category:read', 'site:read']], denormalizationContext: ['groups' => ['client:write:main']], processor: ClientProcessor::class, ), @@ -104,7 +109,7 @@ use Symfony\Component\Validator\Constraints as Assert; // autoriser/refuser onglet par onglet (RG-1.22 / RG-1.28) : les // champs accounting exigent accounting.manage, isArchived exige // archive, le reste (main/information) exige manage. - normalizationContext: ['groups' => ['client:read', 'default:read']], + normalizationContext: ['groups' => ['client:read', 'default:read', 'category:read', 'site:read']], denormalizationContext: ['groups' => [ 'client:write:main', 'client:write:information', @@ -659,6 +664,30 @@ class Client implements TimestampableInterface, BlamableInterface return $this; } + /** + * Sites distincts rattaches a au moins une adresse du client (RG-1.10). + * Le Client ne porte pas de sites en propre : ils vivent sur les adresses. + * Agrege en lecture seule pour la colonne « Site(s) » du Repertoire (badges + * colores) — expose en LISTE via le groupe client:read (les adresses + * completes restent reservees au detail, client:item:read). + * + * @return list + */ + #[Groups(['client:read'])] + public function getSites(): array + { + $sites = []; + foreach ($this->addresses as $address) { + foreach ($address->getSites() as $site) { + // Deduplication par identite d'objet : un meme site peut etre + // rattache a plusieurs adresses du client. + $sites[spl_object_id($site)] = $site; + } + } + + return array_values($sites); + } + // Embed gate sur le groupe COMPTABLE (et non client:item:read comme contacts/ // adresses) : client:read:accounting n'est ajoute au contexte que si l'user a // accounting.view (ClientReadGroupContextBuilder). Resultat : la cle `ribs` est 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 d2f7fb2..da60851 100644 --- a/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php +++ b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php @@ -34,19 +34,34 @@ 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 + // collections affichees par le Repertoire (colonnes Catégories / + // Site(s)) : sans cela, la serialisation declenche un N+1 (une + // requete par client, puis par adresse). Le Paginator ORM + // (fetchJoinCollection: true, cf. ClientProvider) gere le COUNT + // malgre ces jointures to-many. + ->leftJoin('c.categories', 'cat')->addSelect('cat') + ->leftJoin('c.addresses', 'addr')->addSelect('addr') + ->leftJoin('addr.sites', 'site')->addSelect('site') ->andWhere('c.deletedAt IS NULL') ->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; } @@ -73,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; } @@ -89,11 +107,84 @@ 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. + * Defensive : tolere des elements scalaires non-string (cast) et ignore le + * reste sans lever de TypeError, le contrat etant justement de normaliser une + * entree potentiellement brute (query params). + * + * @param array $values + * + * @return list + */ + private function normalizeStringList(array $values): array + { + $out = []; + foreach ($values as $value) { + if (is_string($value) || is_int($value) || is_float($value)) { + $trimmed = trim((string) $value); + if ('' !== $trimmed) { + $out[] = $trimmed; + } + } + } + + return $out; + } + + /** + * Nettoie une liste d'identifiants : cast int, retrait des <= 0, reindexation. + * Defensive (cf. normalizeStringList) : accepte des entiers ou des chaines + * numeriques ('1', '2') sans TypeError, ignore le reste. + * + * @param array $values + * + * @return list + */ + private function normalizeIntList(array $values): array + { + $out = []; + foreach ($values as $value) { + if (is_numeric($value) && (int) $value > 0) { + $out[] = (int) $value; + } + } + + return $out; + } } diff --git a/tests/Module/Commercial/Api/ClientApiTest.php b/tests/Module/Commercial/Api/ClientApiTest.php index 989c7f0..90827cc 100644 --- a/tests/Module/Commercial/Api/ClientApiTest.php +++ b/tests/Module/Commercial/Api/ClientApiTest.php @@ -4,6 +4,11 @@ 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; + /** * Tests fonctionnels de l'API /api/clients (M1) — branche ERP-55. * @@ -177,8 +182,8 @@ final class ClientApiTest extends AbstractCommercialApiTestCase public function testPostBrokerReferencingNonBrokerReturns422(): void { - $client = $this->createAdminClient(); - $cat = $this->createCategory('SECTEUR'); + $client = $this->createAdminClient(); + $cat = $this->createCategory('SECTEUR'); $notBroker = $this->seedClient('Pas Un Courtier', false, 'SECTEUR'); $client->request('POST', '/api/clients', [ @@ -325,4 +330,146 @@ final class ClientApiTest extends AbstractCommercialApiTestCase self::assertArrayHasKey('addresses', $data); self::assertArrayHasKey('ribs', $data); } + + /** + * ERP-62 : la LISTE doit alimenter les colonnes « Catégories » (codes) et + * « Site(s) » (badges name + color) du Repertoire. On verifie donc que la + * collection embarque le `code` de chaque categorie et les sites agreges des + * adresses (accessoire Client::getSites()). + */ + public function testListEmbedsCategoryCodesAndAggregatedSites(): void + { + $client = $this->createAdminClient(); + + // Client seede + une adresse rattachee a un site (fixtures Sites). + $seed = $this->seedClient('Embed List 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(); + + $member = $client->request('GET', '/api/clients?pagination=false', [ + 'headers' => ['Accept' => self::LD], + ])->toArray()['member']; + + $row = null; + foreach ($member as $candidate) { + if ('EMBED LIST CO' === $candidate['companyName']) { + $row = $candidate; + + break; + } + } + self::assertNotNull($row, 'Le client seede doit figurer dans la liste.'); + + // Colonne « Catégories » : chaque categorie embarquee porte son code. + self::assertNotEmpty($row['categories']); + self::assertArrayHasKey('code', $row['categories'][0]); + self::assertSame('DISTRIBUTEUR', $row['categories'][0]['code']); + + // Colonne « Site(s) » : sites agreges des adresses, avec name + color. + self::assertArrayHasKey('sites', $row); + self::assertNotEmpty($row['sites']); + self::assertArrayHasKey('name', $row['sites'][0]); + 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); + } }