refactor(commercial) : découpler l'hydratation des collections de la sélection clients (ERP-100) (#50)
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>
This commit was merged in pull request #50.
This commit is contained in:
2026-06-03 09:44:31 +00:00
committed by admin malio
parent daeb8b3003
commit 97301dcd6c
5 changed files with 120 additions and 14 deletions
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\ClientAddress;
use App\Module\Sites\Domain\Entity\Site;
use PhpOffice\PhpSpreadsheet\IOFactory;
/**
@@ -88,6 +90,39 @@ final class ClientExportControllerTest extends AbstractCommercialApiTestCase
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
{
// L'admin bypass le RBAC : il a donc accounting.view -> colonne SIREN.