feat(commercial) : entités + repositories M2 fournisseurs (ERP-86) (#65)
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>
This commit was merged in pull request #65.
This commit is contained in:
2026-06-08 07:18:30 +00:00
committed by admin malio
parent cd98817b0a
commit 6a01067746
14 changed files with 1713 additions and 1 deletions
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Repository;
use App\Module\Commercial\Domain\Entity\SupplierAddress;
interface SupplierAddressRepositoryInterface
{
public function findById(int $id): ?SupplierAddress;
public function save(SupplierAddress $address): void;
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Repository;
use App\Module\Commercial\Domain\Entity\SupplierContact;
interface SupplierContactRepositoryInterface
{
public function findById(int $id): ?SupplierContact;
public function save(SupplierContact $contact): void;
}
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Repository;
use App\Module\Commercial\Domain\Entity\Supplier;
use Doctrine\ORM\QueryBuilder;
interface SupplierRepositoryInterface
{
public function findById(int $id): ?Supplier;
public function save(Supplier $supplier): void;
/**
* Construit un QueryBuilder de liste pour le repertoire fournisseurs.
* - Exclut toujours les fournisseurs soft-deletes (deleted_at IS NOT NULL, RG-2.17).
* - Archivage (RG-2.17) :
* - $archivedOnly = true -> uniquement les archives (is_archived = true) ;
* - sinon $includeArchived = true -> actifs + archives (echappatoire) ;
* - sinon (defaut) -> uniquement les actifs (is_archived = false).
* $archivedOnly a la priorite sur $includeArchived.
* - Tri par defaut : companyName ASC (RG-2.17).
* - $search : recherche fuzzy insensible a la casse sur companyName + les
* contacts lies (firstName / lastName / email) via sous-requete (D1,
* refonte-contact §4.1). Metacaracteres LIKE echappes. Ignore si null/vide.
* - $categoryCodes : restreint aux fournisseurs possedant au moins une
* categorie dont le code est dans la liste (OR). Liste vide = pas de filtre.
* - $siteIds : restreint aux fournisseurs ayant au moins une adresse rattachee
* a l'un des sites donnes (OR — RG-2.06). Liste vide = pas de filtre.
*
* Filtrage centralise ICI (et non dans le provider/controller) pour que la
* liste paginee (SupplierProvider) et l'export (SupplierExportController)
* partagent strictement la meme logique de selection.
*
* Contrat = SELECTION uniquement (filtres + tri). Aucun fetch-join to-many :
* l'hydratation des collections affichees est deleguee a
* {@see self::hydrateListCollections()} pour ne pas imposer le cout d'un
* produit cartesien aux chemins non pagines (cf. M1/ERP-100).
*
* @param list<string> $categoryCodes
* @param list<int> $siteIds
*/
public function createListQueryBuilder(
bool $includeArchived = false,
?string $search = null,
array $categoryCodes = [],
array $siteIds = [],
bool $archivedOnly = false,
): QueryBuilder;
/**
* Hydrate en lot les collections affichees par le repertoire (categories,
* adresses et leurs sites) sur un jeu de fournisseurs DEJA charges, via
* l'identity map Doctrine (memes instances). A appeler apres une selection
* bornee (page courante ou jeu d'export) pour eviter le N+1 a la
* serialisation, sans imposer de fetch-join au QueryBuilder de selection
* (anti N+1, § 2.12).
*
* Charge les categories et les adresses/sites en DEUX requetes distinctes
* (et non un triple fetch-join) pour ne pas multiplier categories x adresses
* x sites en un seul produit cartesien.
*
* @param list<Supplier> $suppliers
*/
public function hydrateListCollections(array $suppliers): void;
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Repository;
use App\Module\Commercial\Domain\Entity\SupplierRib;
interface SupplierRibRepositoryInterface
{
public function findById(int $id): ?SupplierRib;
public function save(SupplierRib $rib): void;
}