97301dcd6c
Auto Tag Develop / tag (push) Successful in 7s
## Contexte Issu de la review ERP-62 (#44). `DoctrineClientRepository::createListQueryBuilder()` portait 3 `leftJoin+addSelect` to-many imbriqués (`categories × addresses × addresses.sites`) **partagés** entre : - la **liste paginée** (`ClientProvider`) — bornée, OK ; - l'**export XLSX** et **`?pagination=false`** — `getResult()` sans pagination → hydratation du **produit cartésien sur tout le référentiel** (1 client à 5 cat × 4 adr × 3 sites = 60 lignes SQL, × N clients). Défaut d'altitude : un « QueryBuilder de liste » (contrat = filtres) imposait une stratégie d'hydratation à tout appelant. ## Changements - **`createListQueryBuilder()`** redevient **filtres + tri seuls** — conforme au contrat de l'interface. - Nouvelle méthode **`hydrateListCollections(array $clients)`** : recharge les collections en **2 requêtes `WHERE id IN(...)` séparées** (catégories d'un côté, adresses+sites de l'autre) via l'identity map Doctrine. Casse le triple cartésien en `cat + (addr × site)`. - **3 appelants** branchés sur cette stratégie unique : - liste paginée : `fetchJoinCollection: false` (COUNT simple) + hydratation de la page ; - `?pagination=false` : hydratation après `getResult()` ; - export XLSX : hydratation après `getResult()`. ## Tests - `make test` : **465 OK**. - Nouveau test `ClientExportControllerTest::testExportPopulatesCategoryAndSiteColumns` : garde-fou sur les valeurs Catégories/Sites de l'export (qu'un oubli d'hydratation rendrait silencieusement vides). - `php-cs-fixer` : 0 correction. ## Notes - Benchmark « 1000+ clients » non exécuté (pas de jeu de données à cette échelle en dev) ; le cartésien est supprimé structurellement. - `addr × site` reste un join imbriqué (inévitable pour agréger les sites par adresse), désormais non multiplié par les catégories. Closes ERP-100. --------- Co-authored-by: admin malio <malio@yuno.malio.fr> Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #50 Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr> Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
227 lines
7.4 KiB
PHP
227 lines
7.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Module\Commercial\Infrastructure\Doctrine;
|
|
|
|
use App\Module\Commercial\Domain\Entity\Client;
|
|
use App\Module\Commercial\Domain\Repository\ClientRepositoryInterface;
|
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
|
use Doctrine\ORM\QueryBuilder;
|
|
use Doctrine\Persistence\ManagerRegistry;
|
|
|
|
/**
|
|
* @extends ServiceEntityRepository<Client>
|
|
*/
|
|
class DoctrineClientRepository extends ServiceEntityRepository implements ClientRepositoryInterface
|
|
{
|
|
public function __construct(ManagerRegistry $registry)
|
|
{
|
|
parent::__construct($registry, Client::class);
|
|
}
|
|
|
|
public function findById(int $id): ?Client
|
|
{
|
|
return $this->find($id);
|
|
}
|
|
|
|
public function save(Client $client): void
|
|
{
|
|
$this->getEntityManager()->persist($client);
|
|
$this->getEntityManager()->flush();
|
|
}
|
|
|
|
public function createListQueryBuilder(
|
|
bool $includeArchived = false,
|
|
?string $search = null,
|
|
array $categoryCodes = [],
|
|
array $siteIds = [],
|
|
bool $archivedOnly = false,
|
|
): QueryBuilder {
|
|
// SELECTION uniquement (filtres + tri) : pas de fetch-join to-many ici.
|
|
// L'hydratation des collections affichees (Catégories / Site(s)) est
|
|
// deleguee a hydrateListCollections() une fois le jeu borne, pour ne pas
|
|
// imposer un produit cartesien aux chemins non pagines (export,
|
|
// ?pagination=false) — ERP-100.
|
|
$qb = $this->createQueryBuilder('c')
|
|
->andWhere('c.deletedAt IS NULL')
|
|
->orderBy('c.companyName', 'ASC')
|
|
;
|
|
|
|
// 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->applyCategoryCodes($qb, $categoryCodes);
|
|
$this->applySiteIds($qb, $siteIds);
|
|
|
|
return $qb;
|
|
}
|
|
|
|
public function hydrateListCollections(array $clients): void
|
|
{
|
|
if ([] === $clients) {
|
|
return;
|
|
}
|
|
|
|
// Ids des clients deja charges (entites managees). On rehydrate leurs
|
|
// collections via l'identity map : les requetes ci-dessous renvoient les
|
|
// MEMES instances Client, dont les collections sont alors remplies.
|
|
$ids = [];
|
|
foreach ($clients as $client) {
|
|
$id = $client->getId();
|
|
if (null !== $id) {
|
|
$ids[] = $id;
|
|
}
|
|
}
|
|
if ([] === $ids) {
|
|
return;
|
|
}
|
|
|
|
// 1re passe : categories (colonne « Catégories »). Produit c x cat seul.
|
|
$this->createQueryBuilder('c')
|
|
->leftJoin('c.categories', 'cat')->addSelect('cat')
|
|
->where('c.id IN (:ids)')->setParameter('ids', $ids)
|
|
->getQuery()
|
|
->getResult()
|
|
;
|
|
|
|
// 2e passe : adresses + sites (colonne « Site(s) », sites portes par les
|
|
// adresses — RG-1.10). Le join addr -> site reste imbrique mais n'est
|
|
// plus multiplie par les categories : le cartesien global est casse.
|
|
$this->createQueryBuilder('c')
|
|
->leftJoin('c.addresses', 'addr')->addSelect('addr')
|
|
->leftJoin('addr.sites', 'site')->addSelect('site')
|
|
->where('c.id IN (:ids)')->setParameter('ids', $ids)
|
|
->getQuery()
|
|
->getResult()
|
|
;
|
|
}
|
|
|
|
/**
|
|
* Recherche fuzzy insensible a la casse sur companyName + lastName + email.
|
|
* Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester
|
|
* litteraux.
|
|
*/
|
|
private function applySearch(QueryBuilder $qb, ?string $search): void
|
|
{
|
|
if (null === $search || '' === trim($search)) {
|
|
return;
|
|
}
|
|
|
|
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
|
|
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
|
|
|
|
$qb->andWhere(
|
|
'LOWER(c.companyName) LIKE :search '
|
|
.'OR LOWER(c.lastName) LIKE :search '
|
|
.'OR LOWER(c.email) LIKE :search',
|
|
)->setParameter('search', $pattern);
|
|
}
|
|
|
|
/**
|
|
* 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 applyCategoryCodes(QueryBuilder $qb, array $categoryCodes): void
|
|
{
|
|
$codes = $this->normalizeStringList($categoryCodes);
|
|
if ([] === $codes) {
|
|
return;
|
|
}
|
|
|
|
$sub = $this->getEntityManager()->createQueryBuilder()
|
|
->select('c2.id')
|
|
->from(Client::class, 'c2')
|
|
->join('c2.categories', 'cat2')
|
|
->where('cat2.code IN (:categoryCodes)')
|
|
;
|
|
|
|
$qb->andWhere($qb->expr()->in('c.id', $sub->getDQL()))
|
|
->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;
|
|
}
|
|
}
|