feat(technique) : entités + repositories Provider* (ERP-133)
- 4 entités Provider / ProviderContact / ProviderAddress / ProviderRib (#[Auditable] + Timestampable/Blamable), miroir Supplier* amputé de l'onglet Information et augmenté de provider.sites (M2M direct, RG-3.03). - Contrat de sérialisation à 3 maillons (groupes liste/détail, getter isArchived + SerializedName) ; référentiels comptables consommés en relation ORM partagée, Site/Category via contrats Shared. - DoctrineProviderRepository : createListQueryBuilder (filtres + tri) + hydratation anti-N+1 categories puis sites (relation directe) en requêtes IN bornées séparées. - Mapping ORM du module Technique (doctrine.yaml), catalogue COMMENT des tables provider*, index partiel uq_provider_company_name_active (test-db-setup), libellés audit i18n technique_*, whitelist Length du CP ProviderAddress. ApiResource posé en squelette : ProviderProvider / ProviderProcessor (hydratation effective, gating accounting, cloisonnement site, normalisation, 409, RG-3.07/3.08) relèvent d'ERP-134.
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use App\Module\Technique\Domain\Repository\ProviderRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Provider>
|
||||
*/
|
||||
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<string> $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<int> $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<Provider> $providers
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
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<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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user