*/ class DoctrineSupplierRepository extends ServiceEntityRepository implements SupplierRepositoryInterface { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Supplier::class); } public function findById(int $id): ?Supplier { return $this->find($id); } public function save(Supplier $supplier): void { $this->getEntityManager()->persist($supplier); $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). $qb = $this->createQueryBuilder('s') ->andWhere('s.deletedAt IS NULL') ->orderBy('s.companyName', 'ASC') ; // Perimetre d'archivage : archivedOnly prioritaire sur includeArchived. if ($archivedOnly) { $qb->andWhere('s.isArchived = true'); } elseif (!$includeArchived) { $qb->andWhere('s.isArchived = false'); } $this->applySearch($qb, $search); $this->applyCategoryCodes($qb, $categoryCodes); $this->applySiteIds($qb, $siteIds); return $qb; } public function hydrateListCollections(array $suppliers): void { if ([] === $suppliers) { return; } // Ids des fournisseurs deja charges (entites managees). Les requetes // ci-dessous renvoient les MEMES instances Supplier (identity map), dont // les collections sont alors remplies — anti N+1 a la serialisation. $ids = []; foreach ($suppliers as $supplier) { $id = $supplier->getId(); if (null !== $id) { $ids[] = $id; } } if ([] === $ids) { return; } // 1re passe : categories (colonne « Catégories »). Produit s x cat seul. $this->createQueryBuilder('s') ->leftJoin('s.categories', 'cat')->addSelect('cat') ->where('s.id IN (:ids)')->setParameter('ids', $ids) ->getQuery() ->getResult() ; // 2e passe : adresses + sites (colonne « Site(s) », sites portes par les // adresses — RG-2.06). Le join addr -> site reste imbrique mais n'est plus // multiplie par les categories : le cartesien global est casse. $this->createQueryBuilder('s') ->leftJoin('s.addresses', 'addr')->addSelect('addr') ->leftJoin('addr.sites', 'site')->addSelect('site') ->where('s.id IN (:ids)')->setParameter('ids', $ids) ->getQuery() ->getResult() ; } /** * Recherche fuzzy insensible a la casse sur companyName ET sur les contacts * lies (firstName / lastName / email) — decision D1, refonte-contact (§ 4.1). * Les deux criteres sont unis par OR : un fournisseur 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('s2.id') ->from(Supplier::class, 's2') ->join('s2.contacts', 'sc2') ->where('LOWER(sc2.firstName) LIKE :search') ->orWhere('LOWER(sc2.lastName) LIKE :search') ->orWhere('LOWER(sc2.email) LIKE :search') ; $qb->andWhere( $qb->expr()->orX( 'LOWER(s.companyName) LIKE :search', $qb->expr()->in('s.id', $contactSub->getDQL()), ), )->setParameter('search', $pattern); } /** * Restreint aux fournisseurs 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('s3.id') ->from(Supplier::class, 's3') ->join('s3.categories', 'cat3') ->where('cat3.code IN (:categoryCodes)') ; $qb->andWhere($qb->expr()->in('s.id', $sub->getDQL())) ->setParameter('categoryCodes', $codes) ; } /** * Restreint aux fournisseurs ayant au moins une adresse rattachee a l'un des * sites donnes (OR — RG-2.06 : les sites vivent sur les adresses). 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('s4.id') ->from(Supplier::class, 's4') ->join('s4.addresses', 'addr4') ->join('addr4.sites', 'site4') ->where('site4.id IN (:siteIds)') ; $qb->andWhere($qb->expr()->in('s.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 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; } }