*/ class DoctrineProviderRepository extends ServiceEntityRepository implements ProviderRepositoryInterface { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Provider::class); } public function findById(int $id): ?Provider { return $this->find($id); } public function save(Provider $provider): void { $this->getEntityManager()->persist($provider); $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) — § 2.12 (cf. M1/ERP-100, M2). $qb = $this->createQueryBuilder('p') ->andWhere('p.deletedAt IS NULL') ->orderBy('p.companyName', 'ASC') ; // Perimetre d'archivage : archivedOnly prioritaire sur includeArchived. if ($archivedOnly) { $qb->andWhere('p.isArchived = true'); } elseif (!$includeArchived) { $qb->andWhere('p.isArchived = false'); } $this->applySearch($qb, $search); $this->applyCategoryCodes($qb, $categoryCodes); $this->applySiteIds($qb, $siteIds); return $qb; } public function hydrateListCollections(array $providers): void { $ids = $this->collectIds($providers); if ([] === $ids) { return; } // 1re passe : categories (colonne « Catégories »). Produit p x cat seul. $this->createQueryBuilder('p') ->leftJoin('p.categories', 'cat')->addSelect('cat') ->where('p.id IN (:ids)')->setParameter('ids', $ids) ->getQuery() ->getResult() ; // 2e passe : sites (colonne « Site(s) »). SPECIFICITE M3 : les sites sont // portes DIRECTEMENT par le prestataire (provider.sites, RG-3.03), pas via // les adresses comme au M2 — d'ou un simple join p -> site (pas d'imbrication // addr -> site). Separer des categories casse le cartesien cat x site. $this->createQueryBuilder('p') ->leftJoin('p.sites', 'site')->addSelect('site') ->where('p.id IN (:ids)')->setParameter('ids', $ids) ->getQuery() ->getResult() ; } public function hydrateContacts(array $providers): void { $ids = $this->collectIds($providers); if ([] === $ids) { return; } // Une seule requete IN bornee : remplit la collection `contacts` des MEMES // instances Provider (identity map). Tri par position pour que le « contact // principal » (plus petit position) soit deterministe a l'export. $this->createQueryBuilder('p') ->leftJoin('p.contacts', 'pc')->addSelect('pc') ->where('p.id IN (:ids)')->setParameter('ids', $ids) ->orderBy('pc.position', 'ASC') ->getQuery() ->getResult() ; } /** * Recherche fuzzy insensible a la casse sur companyName ET sur les contacts * lies (firstName / lastName / email) — miroir M2. Les deux criteres sont unis * par OR : un prestataire matche si son nom de societe OU l'un de ses contacts * matche. Le critere contact passe par une sous-requete IN (plutot qu'un JOIN * sur la collection) pour ne pas perturber le DISTINCT / ORDER BY / pagination * principal. 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').'%'; $contactSub = $this->getEntityManager()->createQueryBuilder() ->select('p2.id') ->from(Provider::class, 'p2') ->join('p2.contacts', 'pc2') ->where('LOWER(pc2.firstName) LIKE :search') ->orWhere('LOWER(pc2.lastName) LIKE :search') ->orWhere('LOWER(pc2.email) LIKE :search') ; $qb->andWhere( $qb->expr()->orX( 'LOWER(p.companyName) LIKE :search', $qb->expr()->in('p.id', $contactSub->getDQL()), ), )->setParameter('search', $pattern); } /** * Restreint aux prestataires possedant au moins une categorie dont le code * figure dans la liste (OR). Alimente le filtre « Catégories » du drawer. * Sous-requete IN (plutot qu'un JOIN sur la collection M2M) pour ne pas * perturber le DISTINCT / ORDER BY principal. * * @param list $categoryCodes */ private function applyCategoryCodes(QueryBuilder $qb, array $categoryCodes): void { $codes = $this->normalizeStringList($categoryCodes); if ([] === $codes) { return; } $sub = $this->getEntityManager()->createQueryBuilder() ->select('p3.id') ->from(Provider::class, 'p3') ->join('p3.categories', 'cat3') ->where('cat3.code IN (:categoryCodes)') ; $qb->andWhere($qb->expr()->in('p.id', $sub->getDQL())) ->setParameter('categoryCodes', $codes) ; } /** * Restreint aux prestataires rattaches a l'un des sites donnes (OR). SPECIFICITE * M3 : les sites sont portes DIRECTEMENT par le prestataire (provider.sites, * RG-3.03), d'ou une sous-requete sur p.sites (et non sur les adresses comme au * M2). Sous-requete IN pour ne pas perturber le tri/pagination principal. * * @param list $siteIds */ private function applySiteIds(QueryBuilder $qb, array $siteIds): void { $ids = $this->normalizeIntList($siteIds); if ([] === $ids) { return; } $sub = $this->getEntityManager()->createQueryBuilder() ->select('p4.id') ->from(Provider::class, 'p4') ->join('p4.sites', 'site4') ->where('site4.id IN (:siteIds)') ; $qb->andWhere($qb->expr()->in('p.id', $sub->getDQL())) ->setParameter('siteIds', $ids) ; } /** * Extrait les identifiants non nuls d'un jeu de prestataires (entites managees). * Les requetes d'hydratation renvoient les MEMES instances Provider (identity * map), dont les collections sont alors remplies — anti N+1 a la serialisation. * * @param list $providers * * @return list */ private function collectIds(array $providers): array { $ids = []; foreach ($providers as $provider) { $id = $provider->getId(); if (null !== $id) { $ids[] = $id; } } return $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 de normaliser une entree * potentiellement brute (query params). * * @param array $values * * @return list */ 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 $values * * @return list */ private function normalizeIntList(array $values): array { $out = []; foreach ($values as $value) { if (is_numeric($value) && (int) $value > 0) { $out[] = (int) $value; } } return $out; } }