Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 276f242b10 | |||
| 97301dcd6c |
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.69'
|
app.version: '0.1.70'
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ interface ClientRepositoryInterface
|
|||||||
* la liste paginee (ClientProvider) et l'export (ClientExportController)
|
* la liste paginee (ClientProvider) et l'export (ClientExportController)
|
||||||
* partagent strictement la meme logique de selection.
|
* partagent strictement la meme logique de selection.
|
||||||
*
|
*
|
||||||
|
* Contrat = SELECTION uniquement (filtres + tri). Aucun fetch-join to-many :
|
||||||
|
* l'hydratation des collections affichees est une decision de l'appelant
|
||||||
|
* (cf. {@see self::hydrateListCollections()}), pour ne pas imposer le cout
|
||||||
|
* d'un produit cartesien a un consommateur qui ne filtrerait/compterait que
|
||||||
|
* (ERP-100).
|
||||||
|
*
|
||||||
* @param list<string> $categoryCodes
|
* @param list<string> $categoryCodes
|
||||||
* @param list<int> $siteIds
|
* @param list<int> $siteIds
|
||||||
*/
|
*/
|
||||||
@@ -43,4 +49,19 @@ interface ClientRepositoryInterface
|
|||||||
array $siteIds = [],
|
array $siteIds = [],
|
||||||
bool $archivedOnly = false,
|
bool $archivedOnly = false,
|
||||||
): QueryBuilder;
|
): QueryBuilder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hydrate en lot les collections affichees par le repertoire (categories,
|
||||||
|
* adresses et leurs sites) sur un jeu de clients DEJA charges, via l'identity
|
||||||
|
* map Doctrine (memes instances). A appeler apres une selection bornee (page
|
||||||
|
* courante ou jeu d'export) pour eviter le N+1 a la serialisation, sans
|
||||||
|
* imposer de fetch-join au QueryBuilder de selection (ERP-100).
|
||||||
|
*
|
||||||
|
* Charge les categories et les adresses/sites en DEUX requetes distinctes
|
||||||
|
* (et non un triple fetch-join) pour ne pas multiplier categories x adresses
|
||||||
|
* x sites en un seul produit cartesien.
|
||||||
|
*
|
||||||
|
* @param list<Client> $clients
|
||||||
|
*/
|
||||||
|
public function hydrateListCollections(array $clients): void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,8 +83,13 @@ final class ClientProvider implements ProviderInterface
|
|||||||
// Echappatoire ?pagination=false : collection complete sans Paginator
|
// Echappatoire ?pagination=false : collection complete sans Paginator
|
||||||
// (cf. convention ERP-72 — utile pour un <select> cote front).
|
// (cf. convention ERP-72 — utile pour un <select> cote front).
|
||||||
if (!$this->pagination->isEnabled($operation, $context)) {
|
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||||
// @var list<Client> $result
|
/** @var list<Client> $clients */
|
||||||
return $qb->getQuery()->getResult();
|
$clients = $qb->getQuery()->getResult();
|
||||||
|
// Hydratation batchee des collections affichees (cf. ERP-100) : evite
|
||||||
|
// le N+1 si la serialisation touche categories/sites, sans cartesien.
|
||||||
|
$this->repository->hydrateListCollections($clients);
|
||||||
|
|
||||||
|
return $clients;
|
||||||
}
|
}
|
||||||
|
|
||||||
$limit = $this->pagination->getLimit($operation, $context);
|
$limit = $this->pagination->getLimit($operation, $context);
|
||||||
@@ -93,9 +98,13 @@ final class ClientProvider implements ProviderInterface
|
|||||||
|
|
||||||
$qb->setFirstResult($offset)->setMaxResults($limit);
|
$qb->setFirstResult($offset)->setMaxResults($limit);
|
||||||
|
|
||||||
// fetchJoinCollection: true pour un COUNT correct des que des JOINs
|
// Le QB de selection ne porte plus de fetch-join to-many (ERP-100) : le
|
||||||
// to-many seront ajoutes (sous-collections embarquees en detail).
|
// COUNT est simple, fetchJoinCollection inutile. On materialise la page
|
||||||
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: true));
|
// puis on hydrate ses collections en lot (memes entites managees).
|
||||||
|
$paginator = new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
|
||||||
|
$this->repository->hydrateListCollections(iterator_to_array($paginator));
|
||||||
|
|
||||||
|
return $paginator;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ final class ClientExportController
|
|||||||
->getResult()
|
->getResult()
|
||||||
;
|
;
|
||||||
|
|
||||||
|
// Hydratation batchee des categories + adresses/sites (ERP-100) : le QB de
|
||||||
|
// selection ne fetch-join plus, on remplit les collections en 2 requetes
|
||||||
|
// IN bornees plutot que d'hydrater un produit cartesien sur tout le jeu.
|
||||||
|
$this->repository->hydrateListCollections($clients);
|
||||||
|
|
||||||
$withSiren = $this->security->isGranted('commercial.clients.accounting.view');
|
$withSiren = $this->security->isGranted('commercial.clients.accounting.view');
|
||||||
|
|
||||||
$binary = $this->exporter->export(
|
$binary = $this->exporter->export(
|
||||||
|
|||||||
@@ -38,16 +38,12 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
|
|||||||
array $siteIds = [],
|
array $siteIds = [],
|
||||||
bool $archivedOnly = false,
|
bool $archivedOnly = false,
|
||||||
): QueryBuilder {
|
): 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')
|
$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')
|
->andWhere('c.deletedAt IS NULL')
|
||||||
->orderBy('c.companyName', 'ASC')
|
->orderBy('c.companyName', 'ASC')
|
||||||
;
|
;
|
||||||
@@ -66,6 +62,46 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
|
|||||||
return $qb;
|
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.
|
* Recherche fuzzy insensible a la casse sur companyName + lastName + email.
|
||||||
* Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester
|
* Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Tests\Module\Commercial\Api;
|
namespace App\Tests\Module\Commercial\Api;
|
||||||
|
|
||||||
|
use App\Module\Commercial\Domain\Entity\ClientAddress;
|
||||||
|
use App\Module\Sites\Domain\Entity\Site;
|
||||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -88,6 +90,39 @@ final class ClientExportControllerTest extends AbstractCommercialApiTestCase
|
|||||||
self::assertNotContains('SECTEUR CO', $names);
|
self::assertNotContains('SECTEUR CO', $names);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ERP-100 : depuis le decouplage hydratation/selection, le QueryBuilder de
|
||||||
|
* liste ne fetch-join plus les collections — l'export les recharge en lot via
|
||||||
|
* hydrateListCollections(). Ce test garde que les colonnes « Catégories » et
|
||||||
|
* « Site(s) » restent peuplees (un oubli d'hydratation les rendrait vides
|
||||||
|
* sans erreur).
|
||||||
|
*/
|
||||||
|
public function testExportPopulatesCategoryAndSiteColumns(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$seed = $this->seedClient('Hydrate 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();
|
||||||
|
|
||||||
|
$flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent()));
|
||||||
|
|
||||||
|
// Colonne « Catégories » : libelle de la categorie du client (getName()).
|
||||||
|
self::assertStringContainsString('test_cli_cat_distributeur', $flat);
|
||||||
|
// Colonne « Site(s) » : site agrege depuis l'adresse (RG-1.10).
|
||||||
|
self::assertStringContainsString((string) $site->getName(), $flat);
|
||||||
|
}
|
||||||
|
|
||||||
public function testSirenColumnPresentWithAccountingView(): void
|
public function testSirenColumnPresentWithAccountingView(): void
|
||||||
{
|
{
|
||||||
// L'admin bypass le RBAC : il a donc accounting.view -> colonne SIREN.
|
// L'admin bypass le RBAC : il a donc accounting.view -> colonne SIREN.
|
||||||
|
|||||||
Reference in New Issue
Block a user