feat(transport) : exclut les courtiers du select clients (filtre excludeCategoryCode)
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 2m11s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 5m35s

This commit is contained in:
2026-06-29 09:32:37 +02:00
parent d737535688
commit 9cd1e096f7
6 changed files with 75 additions and 5 deletions
@@ -316,9 +316,9 @@ const clientOptions = ref<SelectOption[]>([])
const supplierOptions = ref<SelectOption[]>([])
const siteOptions = ref<SelectOption[]>([])
async function loadOptions(url: string, target: typeof clientOptions, labelOf: (m: Record<string, unknown>) => string): Promise<void> {
async function loadOptions(url: string, target: typeof clientOptions, labelOf: (m: Record<string, unknown>) => string, extraParams: Record<string, string> = {}): Promise<void> {
try {
const data = await api.get<{ member?: Record<string, unknown>[] }>(url, { pagination: 'false' }, { headers: { Accept: 'application/ld+json' }, toast: false })
const data = await api.get<{ member?: Record<string, unknown>[] }>(url, { pagination: 'false', ...extraParams }, { headers: { Accept: 'application/ld+json' }, toast: false })
target.value = (data.member ?? []).map(m => ({ value: String(m['@id']), label: labelOf(m) }))
}
catch {
@@ -348,7 +348,8 @@ onMounted(async () => {
}
}
loadCountries().catch(() => {})
void loadOptions('/clients', clientOptions, m => String(m.companyName ?? m['@id']))
// Exclut les courtiers (catégorie COURTIER) du select clients du module Transport.
void loadOptions('/clients', clientOptions, m => String(m.companyName ?? m['@id']), { excludeCategoryCode: 'COURTIER' })
void loadOptions('/suppliers', supplierOptions, m => String(m.companyName ?? m['@id']))
void loadOptions('/sites', siteOptions, m => String(m.name ?? m['@id']))
})
@@ -439,11 +439,12 @@ async function loadOptions(
url: string,
target: typeof clientOptions,
labelOf: (m: Record<string, unknown>) => string,
extraParams: Record<string, string> = {},
): Promise<void> {
try {
const data = await api.get<{ member?: Record<string, unknown>[] }>(
url,
{ pagination: 'false' },
{ pagination: 'false', ...extraParams },
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
target.value = (data.member ?? []).map(m => ({ value: String(m['@id']), label: labelOf(m) }))
@@ -455,7 +456,8 @@ async function loadOptions(
/** Charge les référentiels de l'onglet Prix (non bloquant : selects vides si échec). */
function loadPriceReferentials(): void {
void loadOptions('/clients', clientOptions, m => String(m.companyName ?? m['@id']))
// Exclut les courtiers (catégorie COURTIER) du select clients du module Transport.
void loadOptions('/clients', clientOptions, m => String(m.companyName ?? m['@id']), { excludeCategoryCode: 'COURTIER' })
void loadOptions('/suppliers', supplierOptions, m => String(m.companyName ?? m['@id']))
void loadOptions('/sites', siteOptions, m => String(m.name ?? m['@id']))
}
@@ -28,6 +28,10 @@ interface ClientRepositoryInterface
* 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.
* - $excludeCategoryCodes : EXCLUT les clients possedant au moins une
* categorie dont le code est dans la liste (NOT IN). Liste vide = pas de
* filtre. Utilise par le module Transport pour ecarter les courtiers
* (code COURTIER) des selects clients.
*
* Filtrage centralise ICI (et non dans les providers/controllers) pour que
* la liste paginee (ClientProvider) et l'export (ClientExportController)
@@ -41,6 +45,7 @@ interface ClientRepositoryInterface
*
* @param list<string> $categoryCodes
* @param list<int> $siteIds
* @param list<string> $excludeCategoryCodes
*/
public function createListQueryBuilder(
bool $includeArchived = false,
@@ -48,6 +53,7 @@ interface ClientRepositoryInterface
array $categoryCodes = [],
array $siteIds = [],
bool $archivedOnly = false,
array $excludeCategoryCodes = [],
): QueryBuilder;
/**
@@ -25,6 +25,8 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
* - tri par defaut companyName ASC — RG-1.26 ;
* - filtres ?search=... (fuzzy companyName + lastName + email) et
* ?categoryCode=<code> (clients ayant >= 1 categorie de ce code — ERP-78) ;
* - ?excludeCategoryCode=<code> : EXCLUT les clients ayant >= 1 categorie de ce
* code (NOT IN — utilise par le module Transport pour ecarter les courtiers) ;
* - pagination obligatoire (convention Starseed ERP-72) : Paginator ORM ;
* echappatoire ?pagination=false pour alimenter un <select> sans pagination.
*
@@ -70,6 +72,10 @@ final class ClientProvider implements ProviderInterface
// RG-1.03) OU une liste (?categoryCode[]=A&categoryCode[]=B, drawer multi).
$categoryCodes = $this->readStringList($filters['categoryCode'] ?? []);
$siteIds = $this->readIntList($filters['siteId'] ?? []);
// excludeCategoryCode : EXCLUT les clients ayant ce(s) code(s) de categorie.
// Le module Transport l'utilise pour ecarter les courtiers (COURTIER) de
// ses selects clients.
$excludeCategoryCodes = $this->readStringList($filters['excludeCategoryCode'] ?? []);
// Filtrage delegue au repository (logique partagee avec l'export XLSX).
$qb = $this->repository->createListQueryBuilder(
@@ -78,6 +84,7 @@ final class ClientProvider implements ProviderInterface
$categoryCodes,
$siteIds,
$archivedOnly,
$excludeCategoryCodes,
);
// Echappatoire ?pagination=false : collection complete sans Paginator
@@ -37,6 +37,7 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
array $categoryCodes = [],
array $siteIds = [],
bool $archivedOnly = false,
array $excludeCategoryCodes = [],
): QueryBuilder {
// SELECTION uniquement (filtres + tri) : pas de fetch-join to-many ici.
// L'hydratation des collections affichees (Catégories / Site(s)) est
@@ -57,6 +58,7 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
$this->applySearch($qb, $search);
$this->applyCategoryCodes($qb, $categoryCodes);
$this->applyExcludeCategoryCodes($qb, $excludeCategoryCodes);
$this->applySiteIds($qb, $siteIds);
return $qb;
@@ -151,6 +153,35 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
;
}
/**
* EXCLUT les clients possedant au moins une categorie dont le code figure
* dans la liste (NOT IN). Miroir negatif d'{@see self::applyCategoryCodes()} :
* utilise par le module Transport pour ecarter les courtiers (code COURTIER)
* des selects clients, sans dependre du nombre de categories d'un client (un
* client [COURTIER, DISTRIBUTEUR] est bien exclu). Sous-requete NOT IN pour ne
* pas perturber le DISTINCT / ORDER BY principal.
*
* @param list<string> $excludeCategoryCodes
*/
private function applyExcludeCategoryCodes(QueryBuilder $qb, array $excludeCategoryCodes): void
{
$codes = $this->normalizeStringList($excludeCategoryCodes);
if ([] === $codes) {
return;
}
$sub = $this->getEntityManager()->createQueryBuilder()
->select('c3.id')
->from(Client::class, 'c3')
->join('c3.categories', 'cat3')
->where('cat3.code IN (:excludeCategoryCodes)')
;
$qb->andWhere($qb->expr()->notIn('c.id', $sub->getDQL()))
->setParameter('excludeCategoryCodes', $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
@@ -355,6 +355,29 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
self::assertNotContains('FILTRE SECTEUR CO', $names);
}
/**
* Le module Transport ecarte les courtiers de ses selects clients via
* ?excludeCategoryCode=COURTIER : tout client portant la categorie COURTIER
* est exclu (NOT IN), y compris s'il porte EN PLUS une autre categorie.
*/
public function testListExcludeCategoryCodeRemovesBrokers(): void
{
$client = $this->createAdminClient();
$this->seedClient('Exclu Distrib Co', false, 'DISTRIBUTEUR');
$this->seedClient('Exclu Courtier Co', false, 'COURTIER');
// Client multi-categories DISTRIBUTEUR + COURTIER : doit etre exclu malgre
// sa categorie DISTRIBUTEUR (l'exclusion porte sur « possede COURTIER »).
$mixed = $this->seedClient('Exclu Mixte Co', false, 'DISTRIBUTEUR');
$mixed->addCategory($this->createCategory('COURTIER'));
$this->getEm()->flush();
$names = $this->companyNames($client, '/api/clients?pagination=false&excludeCategoryCode=COURTIER');
self::assertContains('EXCLU DISTRIB CO', $names);
self::assertNotContains('EXCLU COURTIER CO', $names);
self::assertNotContains('EXCLU MIXTE CO', $names);
}
/**
* ERP-62 (drawer) : filtre Sites (?siteId[]=X) — clients ayant >= 1 adresse
* rattachee au site donne.