6a01067746
Auto Tag Develop / tag (push) Successful in 7s
## ERP-86 — Entités + Repositories M2 Fournisseurs (étape 2/7) PR **empilée sur ERP-85** (#64) : ne contient que le commit ERP-86. À merger après #64 (la base rebascule automatiquement au fil des merges de la chaîne #63 → #64 → develop). Dépend de #64 (migration BDD). Bloque #87 (Provider + Processor) et suivants. ### Contenu 4 entités jumelles du M1 `Client*`, mapping ORM aligné **exactement** sur la migration ERP-85 (noms, types, longueurs, FK, M2M, index), **sans contact inline** (ERP-106) : - **`Supplier`** — `#[Auditable]` + Timestampable/Blamable. Formulaire principal, onglet Information (+ `volumeForecast`, spécifique fournisseur), onglet Comptabilité (FK référentiels M1 partagés), archivage (`isArchived`/`archivedAt`), soft-delete préparé. Catégories M2M via `CategoryInterface` (règle n°1, pas d'import inter-module). Pas de `distributor`/`broker`. - **`SupplierContact`** — onglet Contacts (RG-2.04 : `firstName` OU `lastName`). - **`SupplierAddress`** — enum `addressType` (`PROSPECT`/`DEPART`/`RENDU` via `Assert\Choice`), `bennes`, `triageProvider` ; M2M sites/contacts/categories. - **`SupplierRib`** — RIB, embed gaté comptable. - **Repositories** : interfaces `Domain/Repository/` + impls `Infrastructure/Doctrine/`. ### Points clés - **Contrat de sérialisation (RETEX M1, 3 maillons posés sur l'entité)** : read-groups sur les propriétés ; getters `isArchived()` / `isTriageProvider()` avec `#[Groups]` + `#[SerializedName('isX')]` (parade piège booléen n°3) ; embed `contacts`/`addresses` (`supplier:item:read`) et `ribs` (`supplier:read:accounting`). `getSites()` agrège/dédoublonne les `Site` des adresses (`name`/`postalCode`, pas de `code`). - **Fetch-joins anti-N+1** dans le **repository de liste** : `hydrateListCollections()` en 2 passes (`categories`, puis `addresses.sites`) — évite le produit cartésien (pattern ERP-100). Filtres : recherche `companyName` + contacts liés (D1), `categoryCode`, `siteId`, archivage. - **Pas d'`#[ApiResource]`** : Provider/Processor (gating accounting, archivage, mode strict) sont au ticket **ERP-87**. L'ajouter ici référencerait des classes inexistantes → boot/tests cassés. Les groupes de lecture/écriture sont déjà en place ; le `normalizationContext` viendra avec #87. - **Validation FR (ERP-107)** : messages FR sur toutes les contraintes ; `Assert\Length(max)` calé sur les colonnes. Garde-fou `EntityConstraintsHaveFrenchMessageTest` étendu : `Assert\Choice` ajouté au mapping ; `addressType` et `postalCode` whitelistés du miroir Length (déjà bornés par Choice / Regex). - Clés i18n `audit.entity.commercial_supplier*` ajoutées (garde-fou `AuditableEntitiesHaveI18nLabelTest`). ### Vérifications - `make test` : **483/483 OK** (1965 assertions). - `make php-cs-fixer-allow-risky` : 0 correction. - `doctrine:schema:validate` : mapping correct (bruit d'index FK cosmétique identique au M1 `client`). --------- Co-authored-by: admin malio <malio@yuno.malio.fr> Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #65 Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr> Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
240 lines
8.1 KiB
PHP
240 lines
8.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Module\Commercial\Infrastructure\Doctrine;
|
|
|
|
use App\Module\Commercial\Domain\Entity\Supplier;
|
|
use App\Module\Commercial\Domain\Repository\SupplierRepositoryInterface;
|
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
|
use Doctrine\ORM\QueryBuilder;
|
|
use Doctrine\Persistence\ManagerRegistry;
|
|
|
|
/**
|
|
* @extends ServiceEntityRepository<Supplier>
|
|
*/
|
|
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<string> $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<int> $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<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;
|
|
}
|
|
}
|