Files
Starseed/src/Module/Technique/Infrastructure/Doctrine/DoctrineProviderRepository.php
T
matthieu 3fe0f676f6
Auto Tag Develop / tag (push) Successful in 11s
test(technique) : couvrir RG-3.x PHPUnit + capturer le contrat JSON (ERP-139) (#100)
Ticket Lesstime #139 (M3 — Répertoire prestataires, position 1.9). DoD back avant le front : suite PHPUnit consolidée sur la matrice § 8.1 + captures JSON réelles dans la spec § 4.0.bis.

## Contenu
- **Fix réfs comptables** : `provider:read:accounting` ajouté sur `TvaMode`/`PaymentDelay`/`PaymentType`/`Bank` — sans ça elles sortaient en IRI nu dans le détail prestataire (réplique du fix ERP-92 du M2, piège #1 § 4.0.bis).
- **`ProviderSerializationContractTest`** (13 tests) : gating RIB/scalaires par omission, réfs compta en objet `{id,code,label}`, `isArchived`, embed categories/sites liste+détail, sous-collections, enveloppe AP4 ; `testDodReferenceJsonShape` dumpe le JSON réel (`PROVIDER_DOD_DUMP=1`).
- **`ProviderAuditTest`** (5 tests) : create/update/archive (`technique.Provider`), iban/bic dans le diff (`technique.ProviderRib`, pas dAuditIgnore), trace M2M `sites`.
- **`ProviderListTest`** étendu : `?pagination=false`, anti-N+1, filtre `?typeCode=PRESTATAIRE`.
- **`ProviderRbacGatingTest`** étendu : restauration en conflit de nom → 409 (RG-3.14).
- **`ProviderFixtures`** (§ 8.4) : démo idempotente (complet VIREMENT+banque+RIB, LCR+RIB, CHEQUE multi-cat, minimal, archivé) répartie sur sites 86/17/82 ; skip en env `test`.
- Helper `seedCompleteProvider` ; spec § 4.0.bis : gabarits remplacés par les captures réelles (liste + détail avec/sans accounting.view).

## Vérifications
- `make php-cs-fixer-allow-risky` → 0 fichier
- `make test` → OK, 677 tests, 3328 assertions (garde-fous globaux verts)

## Notes
- MR stackée sur ERP-138 (base = sa branche).
- Fixtures démo exercées en dev via `make fixtures` (autowiring vérifié).

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #100
2026-06-12 14:44:43 +00:00

288 lines
9.9 KiB
PHP

<?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 applySiteScope(QueryBuilder $qb, int $siteId): void
{
// Cloisonnement par site (RG-3.17, § 2.13) : ne garder que les prestataires
// dont provider.sites contient le site donne. Sous-requete IN (alias p5
// distinct des filtres p2/p3/p4) pour ne pas perturber le tri/pagination du
// QueryBuilder principal — meme parti pris que applyCategoryCodes / applySiteIds.
// Parametre :scopeSiteId distinct de :siteIds (filtre ?siteId du client) pour
// que les deux clauses puissent coexister (intersection) sans collision.
$sub = $this->getEntityManager()->createQueryBuilder()
->select('p5.id')
->from(Provider::class, 'p5')
->join('p5.sites', 'site5')
->where('site5.id = :scopeSiteId')
;
$qb->andWhere($qb->expr()->in('p.id', $sub->getDQL()))
->setParameter('scopeSiteId', $siteId)
;
}
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;
}
}