[ERP-62] Page Répertoire clients (datatable + Ajouter / Exporter) (#44)
Auto Tag Develop / tag (push) Successful in 8s

## ERP-62 — Page Répertoire clients (datatable + Ajouter / Exporter)

Tâche Lesstime #480. **Stacke sur ERP-61** (clés i18n `commercial.clients.*`) — non encore mergé : la diff vers `develop` inclut le commit ERP-61 tant qu'il n'est pas mergé.

### Front
- Page `/clients` (route à plat) : `MalioDataTable` 6 colonnes (Nom entreprise / Contact / Téléphone formaté / Email / codes Catégories / badges Site(s)), toggle « Voir les archivés » (état 100 % local), boutons **+ Ajouter** (visible si `commercial.clients.manage`) et **Exporter** (visible si `view`, télécharge `clients/export.xlsx` via `useApi`), clic ligne → `/clients/{id}`, empty state.
- Composable `useClientsRepository` = wrapper de `usePaginatedList<Client>({ url: '/clients' })` + toggle `includeArchived` (repasse page 1).
- Util `formatPhoneFR` (signature cible à coordonner avec ERP-66 / 1.13) + clé i18n `showArchived`.

### Back — ⚠️ MAJ contrat de sérialisation (incluse dans cette MR)
Le `GET /api/clients` n'exposait ni les codes catégories ni les sites en liste (le bloc Lesstime l'affirmait à tort). Corrigé :
- `Client` : `category:read` + `site:read` ajoutés aux `normalizationContext` (GetCollection/Get/Post/Patch) + accesseur agrégé `getSites()` (`#[Groups(client:read)]`).
- `DoctrineClientRepository::createListQueryBuilder` : jointures + `addSelect` (categories / addresses / sites) anti N+1.
- Aucune migration (pure sérialisation).

### Tests
- Back : `ClientApiTest` (codes catégories + sites name/color en liste). `make test`  454.
- Front : `useClientsRepository.spec.ts` + `phone.test.ts`. `vitest`  111. `nuxi typecheck`  (mes fichiers).

### Non couvert
Golden path navigateur non joué : dev-nuxt (conteneur) cassé (résolution `@malio/layer-ui/tailwind.config.ts`) + BDD sans clients démo (nécessite `make db-reset`). Aspects front restants traités séparément.

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #44
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #44.
This commit is contained in:
2026-06-02 14:16:29 +00:00
committed by Autin
parent 79dffccc79
commit ee1521384e
12 changed files with 1016 additions and 32 deletions
+149 -2
View File
@@ -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<string>
*/
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);
}
}