[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
+32 -3
View File
@@ -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<SiteInterface>
*/
#[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
@@ -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<string> $categoryCodes
* @param list<int> $siteIds
*/
public function createListQueryBuilder(
bool $includeArchived = false,
?string $search = null,
?string $categoryCode = null,
array $categoryCodes = [],
array $siteIds = [],
bool $archivedOnly = false,
): QueryBuilder;
}
@@ -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<string>
*/
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<int>
*/
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;
}
}
@@ -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<Client> $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<string>
*/
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<int>
*/
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;
}
}
@@ -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<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;
}
@@ -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<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.
* 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<mixed> $values
*
* @return list<string>
*/
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<mixed> $values
*
* @return list<int>
*/
private function normalizeIntList(array $values): array
{
$out = [];
foreach ($values as $value) {
if (is_numeric($value) && (int) $value > 0) {
$out[] = (int) $value;
}
}
return $out;
}
}