diff --git a/.gitea/workflows/pull-request.yml b/.gitea/workflows/pull-request.yml
index 56062ab..c981a71 100644
--- a/.gitea/workflows/pull-request.yml
+++ b/.gitea/workflows/pull-request.yml
@@ -75,7 +75,7 @@ jobs:
- name: Bootstrap test database
# Aligne sur la cible `test-db-setup` du makefile : apres
# `schema:update --force`, on RECREE manuellement l'index unique
- # partiel `uq_category_name_type_active` car Doctrine ORM ne sait
+ # partiel `uq_category_name_active` car Doctrine ORM ne sait
# pas exprimer les index fonctionnels partiels (LOWER(name) + WHERE
# deleted_at IS NULL) et `schema:update` les considere comme
# orphelins et les DROP — collisions non detectees, tests d'unicite
@@ -89,7 +89,7 @@ jobs:
php bin/console app:apply-column-comments --env=test --no-interaction
php bin/console doctrine:fixtures:load --env=test --no-interaction
php bin/console app:sync-permissions --env=test --no-interaction
- php bin/console --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL"
+ php bin/console --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_active ON category (LOWER(name)) WHERE deleted_at IS NULL"
- name: Run PHPUnit
run: php -d memory_limit=512M vendor/bin/phpunit
diff --git a/config/packages/test/doctrine.yaml b/config/packages/test/doctrine.yaml
new file mode 100644
index 0000000..26b58a4
--- /dev/null
+++ b/config/packages/test/doctrine.yaml
@@ -0,0 +1,12 @@
+doctrine:
+ dbal:
+ connections:
+ # Force le profiling DBAL en environnement de test independamment de
+ # APP_DEBUG. Sans cela, la CI tourne en APP_DEBUG=0 (prod-like) et le
+ # service `doctrine.debug_data_holder` n'est pas enregistre : le test
+ # anti-N+1 (SupplierListTest::testListQueryCountDoesNotGrowWithRowCount)
+ # qui compte les requetes via ce holder echoue alors en CI alors qu'il
+ # passe en local (APP_DEBUG=1). Activer le profiling ici garde le test
+ # actif precisement la ou il compte (CI), sans impacter la prod.
+ default:
+ profiling: true
diff --git a/config/sidebar.php b/config/sidebar.php
index 3c551b8..f2baf26 100644
--- a/config/sidebar.php
+++ b/config/sidebar.php
@@ -53,10 +53,11 @@ return [
'permission' => 'commercial.clients.view',
],
[
- 'label' => 'sidebar.commercial.suppliers',
- 'to' => '/suppliers',
- 'icon' => 'mdi:account-arrow-left-outline',
- 'module' => 'commercial',
+ 'label' => 'sidebar.commercial.suppliers',
+ 'to' => '/suppliers',
+ 'icon' => 'mdi:account-arrow-left-outline',
+ 'module' => 'commercial',
+ 'permission' => 'commercial.suppliers.view',
],
],
],
diff --git a/config/version.yaml b/config/version.yaml
index c77fdff..3c6fa11 100644
--- a/config/version.yaml
+++ b/config/version.yaml
@@ -1,2 +1,2 @@
parameters:
- app.version: '0.1.83'
+ app.version: '0.1.96'
diff --git a/docs/specs/M2-suppliers/spec-back.md b/docs/specs/M2-suppliers/spec-back.md
index 2194273..5d3e542 100644
--- a/docs/specs/M2-suppliers/spec-back.md
+++ b/docs/specs/M2-suppliers/spec-back.md
@@ -126,7 +126,7 @@ Toutes les entités métier nouvelles implémentent `TimestampableInterface` + `
Notes (miroir M1) :
- **Compta édite uniquement l'onglet Comptabilité** (`accounting.manage`) d'un fournisseur existant. Compta ne peut pas **créer** un fournisseur (pas de `manage` global).
-- **Commerciale** a `view` + `manage` mais **pas** `accounting.view` → l'onglet Comptabilité est masqué (front) et filtré (back, 2 niveaux : `security` API Platform + `SupplierProvider`).
+- **Commerciale** a `view` + `manage` mais **pas** `accounting.view` → l'onglet Comptabilité est masqué (front) et filtré (back). Mécanisme réel (le code fait foi) : le groupe de lecture `supplier:read:accounting` n'est **pas** dans le contexte de sérialisation par défaut ; le `SupplierReadGroupContextBuilder` ne l'**ajoute** dynamiquement que si l'utilisateur porte `commercial.suppliers.accounting.view` (gating **par ajout** de groupe, jamais par retrait). Sans la permission, les champs comptables (et les RIB) ne sont donc jamais sérialisés. La colonne SIREN de l'export XLSX suit la même règle (`accounting.view`).
- **Bureau** : `view` + `manage` (tout sauf Comptabilité).
- **Usine** : aucune permission → item sidebar invisible, accès direct 403.
@@ -159,9 +159,11 @@ final class SupplierFieldNormalizer
Le formatage `XX XX XX XX XX` est fait à l'affichage côté front. Le back stocke `0612345678` (chiffres seuls).
-### 2.12 Liste : embed catégories + sites + fetch-joins (cohérence M1/ERP-62)
+### 2.12 Liste : embed catégories + sites + hydratation anti-N+1 (cohérence M1/ERP-62)
-Décision d'alignement (02/06/2026) : la **liste** `GET /api/suppliers` **embarque** les `categories[]` (avec `code`/`name`) et les `sites[]` (avec `name`/`postalCode` — pas de `code`), comme la liste Clients après ERP-62 — et **non** des champs dérivés aplatis. Conséquence performance : le `DoctrineSupplierRepository` **DOIT** poser des **fetch-joins** (`leftJoin`+`addSelect`) sur `categories` et `addresses.sites` dans la requête de liste pour éviter le N+1. Les `sites` de la liste sont agrégés/dédoublonnés via `Supplier::getSites()` (cf. § 3.3). Le contrat de sérialisation (groupes `category:read` / `site:read` dans le contexte) est posé **une seule fois** sur l'entité — source de vérité unique, le front ne le redéfinit pas.
+Décision d'alignement (02/06/2026) : la **liste** `GET /api/suppliers` **embarque** les `categories[]` (avec `code`/`name`) et les `sites[]` (avec `name`/`postalCode` — pas de `code`), comme la liste Clients après ERP-62 — et **non** des champs dérivés aplatis.
+
+Conséquence performance — **implémentation réelle (le code fait foi)** : le `DoctrineSupplierRepository` **ne fetch-joine PAS** les to-many dans la requête de sélection (`createListQueryBuilder` ne fait que filtres + tri). L'anti-N+1 passe par `hydrateListCollections()` (puis `hydrateContacts()`) : une fois le jeu de fournisseurs borné (page ou export), des requêtes **`IN` bornées séparées** remplissent `categories`, puis `addresses.sites`, puis `contacts` sur les **mêmes** instances `Supplier` (identity map). Ce découpage évite le **produit cartésien** qu'un fetch-join combiné `categories × addresses.sites` imposerait aux chemins non paginés (export, `?pagination=false`). Les `sites` de la liste sont agrégés/dédoublonnés via `Supplier::getSites()` (cf. § 3.3). Le contrat de sérialisation (groupes `category:read` / `site:read` dans le contexte) est posé **une seule fois** sur l'entité — source de vérité unique, le front ne le redéfinit pas.
> Dépendance confirmée sur le JSON réel (#82 mergé) : `Category` expose `code`/`name` sous `category:read` ; `Site` expose `name`/`postalCode`/`city`/`color` sous `site:read` (**pas de `code`**). L'embed est pleinement matérialisé.
@@ -213,6 +215,8 @@ Namespace : **`DoctrineMigrations` (racine `migrations/`)** — fichier `migrati
> **Rappel règle ABSOLUE n°12** : chaque colonne créée ci-dessous DOIT recevoir son `COMMENT ON COLUMN`. Les 4 colonnes Timestampable/Blamable passent par le helper `addStandardTimestampableBlamableComments($schema, '
')`. Le SQL ci-dessous montre la structure ; les `COMMENT ON COLUMN` (un par colonne métier) sont à écrire dans la migration (exemples §3.2.bis).
+> **Types réels de la migration (le code fait foi)** : le SQL ci-dessous est *illustratif*. La migration mergée (`Version20260605130000`) utilise le **style aligné M1** : clés primaires en `INT GENERATED BY DEFAULT AS IDENTITY` (et **non** `SERIAL`) et horodatages en `TIMESTAMP(0) WITHOUT TIME ZONE` (et **non** `TIMESTAMPTZ`, car le `TimestampableBlamableTrait` mappe `datetime_immutable`). Garantit que `schema:update` reste un no-op une fois les entités mappées.
+
```sql
-- =====================================================================
-- Seed taxonomie : nouveau type FOURNISSEUR (référentiels comptables = M1, non recréés)
@@ -422,8 +426,10 @@ use Symfony\Component\Validator\Constraints as Assert;
// Cohérence M1/ERP-62 : la LISTE embarque catégories + sites (pas de
// champ dérivé aplati). Maillon (c) : category:read + site:read dans
// le contexte pour exposer Category(code/name) + Site(name/postalCode).
- // ⚠ Le SupplierRepository DOIT fetch-join categories + addresses.sites
- // pour éviter le N+1 sur la liste (cf. § 2.12).
+ // ⚠ Anti-N+1 : pas de fetch-join dans la requête de liste — le
+ // SupplierRepository hydrate categories/sites/contacts via des requêtes
+ // IN bornées séparées (hydrateListCollections), pour éviter le produit
+ // cartésien sur les chemins non paginés (export) — cf. § 2.12.
normalizationContext: ['groups' => [
'supplier:read',
'category:read',
@@ -442,13 +448,14 @@ use Symfony\Component\Validator\Constraints as Assert;
normalizationContext: ['groups' => [
'supplier:read',
'supplier:item:read', // embed contacts / addresses
- 'supplier:read:accounting', // scalaires compta + embed ribs (filtré par le Provider selon accounting.view)
+ // ⚠ supplier:read:accounting est volontairement ABSENT ici : il est
+ // AJOUTÉ dynamiquement par le SupplierReadGroupContextBuilder quand
+ // l'user porte accounting.view (gating par ajout, pas par retrait —
+ // parade bug #4 M1). Il porte les scalaires compta + l'embed ribs.
'category:read', // embed des Category (id/code/name) — relation imbriquée
'site:read', // embed des Site (id/name/postalCode/city/color, pas de code) — relation imbriquée
'default:read',
]],
- // Le Provider RETIRE supplier:read:accounting du contexte si l'user
- // n'a pas is_granted('commercial.suppliers.accounting.view').
provider: SupplierProvider::class,
),
new Post(
@@ -458,10 +465,13 @@ use Symfony\Component\Validator\Constraints as Assert;
processor: SupplierProcessor::class,
),
new Patch(
- security: "is_granted('commercial.suppliers.manage')",
- // Le SupplierProcessor inspecte les groupes envoyés pour autoriser
- // onglet par onglet (cf. § 2.10 + § 5). Patch des champs comptables
- // exige is_granted('commercial.suppliers.accounting.manage') ;
+ // Security élargie : `manage` OU `accounting.manage` — le rôle Compta
+ // n'a pas `manage` mais doit pouvoir éditer l'onglet Comptabilité d'un
+ // fournisseur existant (§ 2.9). Le SupplierProcessor re-gate ensuite
+ // onglet par onglet (mode strict RG-2.16) :
+ security: "is_granted('commercial.suppliers.manage') or is_granted('commercial.suppliers.accounting.manage')",
+ // Patch des champs comptables exige accounting.manage (guardAccounting) ;
+ // champs main/information exigent manage (guardManage) ;
// patch isArchived exige is_granted('commercial.suppliers.archive').
normalizationContext: ['groups' => ['supplier:read', 'default:read']],
denormalizationContext: ['groups' => [
@@ -711,91 +721,108 @@ Même pattern que les jumelles `Client*` (`#[Auditable]`, `TimestampableBlamable
| Scalaires Comptabilité (siren, refs…) | `supplier:read:accounting` | ✅ (gated) | refs (`tvaMode`…) id+label ∈ `supplier:read:accounting` |
| `ribs[]` (label/bic/iban) | `ribs` ∈ `supplier:read:accounting` | ✅ (gated) | — |
-### 4.0.bis Réponses JSON de référence (DoD — à confirmer sur l'API réelle)
+### 4.0.bis Réponses JSON de référence (DoD — RÉELLES, capturées ERP-92)
-> **Definition of Done de cette spec back (RETEX M1 §3)** : avant d'écrire les tickets front, créer un fournisseur de test et **coller ici les réponses RÉELLES** de `GET /api/suppliers` et `GET /api/suppliers/{id}`. Les containers n'étant pas lancés au moment de la rédaction, le JSON ci-dessous est le **contrat CIBLE** — à valider/remplacer par la réponse réelle (`make start` puis `curl`). Toute donnée affichée par le front DOIT apparaître dans ce JSON.
+> **Definition of Done CLÔTURÉE (ERP-92, 2026-06-05)** : les réponses ci-dessous sont **réelles**, capturées sur l'API de test via PHPUnit (`SupplierSerializationContractTest`, fournisseur complet seedé). Les `id`/timestamps sont illustratifs (run de test). Toute donnée affichée par le front DOIT apparaître dans ce JSON. Front #93→#96 peuvent démarrer.
+>
+> **2 constats validés à la capture** (cf. § 4.0.ter) :
+> 1. 🔧 **Fix ERP-92** : les réfs comptables (`tvaMode`/`paymentDelay`/`paymentType`/`bank`) sortaient en **IRI nu** (les entités partagées ne portaient que `client:read:accounting`, pas `supplier:read:accounting`). Corrigé → objet `{id, code, label}` embarqué (le front consultation/édition affiche le libellé sans fetch).
+> 2. ℹ️ **Liste « riche »** : le groupe `supplier:read` étant partagé liste+détail, la **collection embarque tout le bloc Information** (et, pour un user `accounting.view`, les scalaires compta + `ribs[]`). Comportement identique au M1 (groupe `client:read` partagé) — la datatable n'affiche que Nom/Catégories/Site(s)/MAJ, mais le payload est complet. Le gating `accounting` reste effectif (Commerciale ne voit ni compta ni `ribs` en liste comme en détail).
> **Forme d'enveloppe confirmée sur le M1 réel** (API Platform 4.2) : JSON-LD **sans préfixe `hydra:`** → clés `member` / `totalItems` / `view`, avec `@type: "Collection"` et `view.@type: "PartialCollectionView"`. `Content-Type: application/ld+json; charset=utf-8`. Pagination défaut 10 confirmée. Login réel = `POST /api/login_check` (nginx réécrit vers `/login_check`), réponse `204` + cookie HttpOnly `BEARER`.
-`GET /api/suppliers` (liste, ADMIN) :
+`GET /api/suppliers?search=…` (liste, ADMIN — un membre) :
```json
{
"@context": "/api/contexts/Supplier",
"@id": "/api/suppliers",
"@type": "Collection",
- "totalItems": 13,
+ "totalItems": 1,
"member": [
{
- "@id": "/api/suppliers/1",
+ "@id": "/api/suppliers/85",
"@type": "Supplier",
- "id": 1,
- "companyName": "RECYCLA SAS",
+ "id": 85,
+ "companyName": "DOD59393F 862875",
"categories": [
- {"@id": "/api/categories/12", "id": 12, "code": "NEGOCIANT", "name": "Négociant"}
+ {"@type": "Category", "@id": "/api/categories/2279", "id": 2279, "name": "test_cli_cat_fr_negociant", "code": "NEGOCIANT",
+ "categoryType": {"@id": "/api/category_types/602", "@type": "CategoryType", "id": 602, "code": "FOURNISSEUR", "label": "Fournisseur"},
+ "createdAt": "…", "updatedAt": "…"}
],
+ "description": "Fournisseur de test complet.",
+ "competitors": "Concurrent A, Concurrent B",
+ "foundedAt": "2008-04-01T00:00:00+02:00",
+ "employeesCount": 42,
+ "revenueAmount": "1500000.00",
+ "directorName": "Jean Dupont",
+ "profitAmount": "120000.00",
+ "volumeForecast": 8000,
+ "siren": "123456789",
+ "accountNumber": "F0001",
+ "tvaMode": {"@id": "/api/tva_modes/30", "@type": "TvaMode", "id": 30, "code": "FRANCE_VENTES", "label": "France (ventes)"},
+ "nTva": "FR00123456789",
+ "paymentDelay": {"@id": "/api/payment_delays/11", "@type": "PaymentDelay", "id": 11, "code": "J30", "label": "30 jours"},
+ "paymentType": {"@id": "/api/payment_types/14", "@type": "PaymentType", "id": 14, "code": "LCR", "label": "LCR"},
+ "ribs": [
+ {"@id": "/api/supplier_ribs/27", "@type": "SupplierRib", "id": 27, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606", "createdAt": "…", "updatedAt": "…"}
+ ],
+ "createdAt": "…", "updatedAt": "…",
"sites": [
- {"@id": "/api/sites/1", "id": 1, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"},
- {"@id": "/api/sites/2", "id": 2, "name": "Saint-Jean", "postalCode": "17400", "city": "Fontenet", "color": "#…"}
+ {"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
+ {"@type": "Site", "@id": "/api/sites/88", "id": 88, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "fullAddress": "Z i\n17400 Fontenet"}
],
- "updatedAt": "2026-02-17T09:30:00+00:00",
"isArchived": false
}
],
- "view": {
- "@id": "/api/suppliers?page=1",
- "@type": "PartialCollectionView",
- "first": "/api/suppliers?page=1",
- "last": "/api/suppliers?page=2",
- "next": "/api/suppliers?page=2"
- }
+ "view": {"@id": "/api/suppliers?search=…", "@type": "PartialCollectionView"}
}
```
-> Les fournisseurs archivés sont **exclus** du `totalItems` (sur le M1, 14 clients en base → `totalItems: 13` car 1 archivé filtré par le Provider). `categories[]` (avec `code`/`name`) et `sites[]` (avec `name`/`postalCode` — **pas de `code`**) sont **embarqués** (cohérence M1/ERP-62, § 2.12) ; `sites` est l'agrégat dédoublonné des adresses via `Supplier::getSites()`. Fetch-joins repository obligatoires (anti N+1).
+> Les fournisseurs archivés sont **exclus** du `totalItems` (RG-2.17 — filtré par le Provider). `categories[]` (avec `code`/`name`) et `sites[]` (avec `name`/`postalCode` — **pas de `code`**) sont **embarqués** (cohérence M1/ERP-62, § 2.12) ; `sites` est l'agrégat dédoublonné des adresses via `Supplier::getSites()`. Fetch-joins repository (anti N+1) **vérifiés par test** (`SupplierListTest::testListQueryCountDoesNotGrowWithRowCount` : nombre de requêtes constant entre 2 et 4 fournisseurs). ⚠️ Le membre embarque aussi l'**Information complète** et — pour un user `accounting.view` (ici admin) — les **scalaires compta + `ribs[]`** (groupe `supplier:read` partagé liste/détail). Pour la **Commerciale** (sans `accounting.view`), `siren`/`tvaMode`/`paymentType`/`ribs`… **disparaissent** de chaque membre.
-`GET /api/suppliers/1` (détail — user avec `accounting.view`) :
+`GET /api/suppliers/85` (détail — user avec `accounting.view`) :
```json
{
- "@id": "/api/suppliers/1",
+ "@context": "/api/contexts/Supplier",
+ "@id": "/api/suppliers/85",
"@type": "Supplier",
- "id": 1,
- "companyName": "RECYCLA SAS",
+ "id": 85,
+ "companyName": "DOD59393F 862875",
"categories": [
- {"@id": "/api/categories/12", "id": 12, "code": "NEGOCIANT", "name": "Négociant"}
+ {"@type": "Category", "@id": "/api/categories/2279", "id": 2279, "name": "test_cli_cat_fr_negociant", "code": "NEGOCIANT",
+ "categoryType": {"@id": "/api/category_types/602", "@type": "CategoryType", "id": 602, "code": "FOURNISSEUR", "label": "Fournisseur"}}
],
- "description": "…", "competitors": "…", "foundedAt": "2008-04-01",
- "employeesCount": 42, "revenueAmount": "1500000.00", "directorName": "…",
- "profitAmount": "120000.00", "volumeForecast": 8000,
+ "description": "Fournisseur de test complet.", "competitors": "Concurrent A, Concurrent B",
+ "foundedAt": "2008-04-01T00:00:00+02:00", "employeesCount": 42, "revenueAmount": "1500000.00",
+ "directorName": "Jean Dupont", "profitAmount": "120000.00", "volumeForecast": 8000,
+ "siren": "123456789", "accountNumber": "F0001",
+ "tvaMode": {"@id": "/api/tva_modes/30", "@type": "TvaMode", "id": 30, "code": "FRANCE_VENTES", "label": "France (ventes)"},
+ "nTva": "FR00123456789",
+ "paymentDelay": {"@id": "/api/payment_delays/11", "@type": "PaymentDelay", "id": 11, "code": "J30", "label": "30 jours"},
+ "paymentType": {"@id": "/api/payment_types/14", "@type": "PaymentType", "id": 14, "code": "LCR", "label": "LCR"},
"contacts": [
- {"@id": "/api/supplier_contacts/1", "id": 1, "firstName": "Marie", "lastName": "Martin",
- "jobTitle": "Responsable achats", "phonePrimary": "0612345678", "phoneSecondary": null,
- "email": "marie.martin@recycla.fr"}
+ {"@id": "/api/supplier_contacts/39", "@type": "SupplierContact", "id": 39, "firstName": "Marie", "lastName": "Martin",
+ "jobTitle": "Responsable achats", "phonePrimary": "0612345678", "email": "marie.martin@seed.test"}
],
"addresses": [
- {"@id": "/api/supplier_addresses/1", "id": 1, "addressType": "DEPART",
- "country": "France", "postalCode": "86000", "city": "Poitiers",
- "street": "12 rue des Acacias", "streetComplement": null,
+ {"@id": "/api/supplier_addresses/33", "@type": "SupplierAddress", "id": 33, "addressType": "DEPART",
+ "country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias",
"bennes": 3, "triageProvider": true,
- "sites": [{"@id": "/api/sites/1", "id": 1, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}],
- "categories": [{"@id": "/api/categories/12", "id": 12, "code": "NEGOCIANT", "name": "Négociant"}],
- "contacts": [{"@id": "/api/supplier_contacts/1", "id": 1, "firstName": "Marie", "lastName": "Martin"}]}
+ "sites": [
+ {"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"},
+ {"@type": "Site", "@id": "/api/sites/88", "id": 88, "name": "Saint-Jean", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00"}
+ ],
+ "contacts": [{"@id": "/api/supplier_contacts/39", "@type": "SupplierContact", "id": 39, "firstName": "Marie", "lastName": "Martin"}],
+ "categories": [{"@type": "Category", "@id": "/api/categories/2279", "id": 2279, "name": "test_cli_cat_fr_negociant", "code": "NEGOCIANT"}]}
],
- "siren": "123456789", "accountNumber": "F0001",
- "tvaMode": {"@id": "/api/tva_modes/1", "id": 1, "label": "France (ventes)"},
- "nTva": "FR00123456789",
- "paymentDelay": {"@id": "/api/payment_delays/2", "id": 2, "label": "30 jours"},
- "paymentType": {"@id": "/api/payment_types/2", "id": 2, "code": "LCR", "label": "LCR"},
- "bank": null,
"ribs": [
- {"@id": "/api/supplier_ribs/1", "id": 1, "label": "Compte principal",
- "bic": "SOGEFRPP", "iban": "FR7630003035400005000000123"}
+ {"@id": "/api/supplier_ribs/27", "@type": "SupplierRib", "id": 27, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606"}
],
- "isArchived": false, "archivedAt": null,
- "updatedAt": "2026-02-17T09:30:00+00:00"
+ "isArchived": false
}
```
-> Pour un user **sans** `accounting.view` (ex. Commerciale) : les clés `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType`, `bank`, `ribs` **sont absentes** (pas `null` — réellement non sérialisées car le Provider retire le groupe). Le gating par **omission de clé** est confirmé confortable côté front. Le blame `updatedBy` est sérialisé en **IRI** (`"/api/me"` quand c'est l'user courant) — en tenir compte côté front.
+> Pour un user **sans** `accounting.view` (ex. Commerciale) : les clés `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType`, `bank`, `ribs` **sont absentes** (pas `null` — réellement non sérialisées : le `SupplierReadGroupContextBuilder` n'ajoute pas le groupe). Gating par **omission de clé** confirmé sur le JSON réel (`SupplierSerializationContractTest::testRibsAbsentForCommercialeWithoutAccountingView` + `testAccountingScalarsGatedByOmission`). `bennes`/`triageProvider`/`addressType`/`addresses[].contacts` restent visibles (onglet Adresse non gaté). NB : ici `bank` est absent (paymentType=LCR sans banque) ; avec un VIREMENT, `bank` est embarqué `{id, code, label}` (fix ERP-92).
### 4.0.ter Pièges de sérialisation CONSTATÉS sur le M1 réel → parade M2 (OBLIGATOIRE)
@@ -1046,7 +1073,7 @@ Le M1 a subi un aller-retour (ERP-68) faute de fixtures alignées. Pour le M2, p
- [x] 3 maillons de sérialisation documentés pour chaque champ liste + détail (§ 4.0)
- [x] Décision embed vs GetCollection explicite et câblée (embed détail + sous-ressources write — § 3.3 / § 3.4 / § 4.5), **pas de POST-only**
-- [ ] **Réponses JSON RÉELLES** collées (§ 4.0.bis) — *en attente de `make start` + curl (DoD avant tickets front)*
+- [x] **Réponses JSON RÉELLES** collées (§ 4.0.bis) — capturées via PHPUnit (ERP-92, 2026-06-05) ; fix réfs compta IRI→{id,label} inclus
- [x] Matrice RBAC rôle × onglet + mode strict PATCH (§ 2.9 / RG-2.16)
- [x] Pagination (n°13), COMMENT ON COLUMN (n°12), Timestampable/Blamable, Audit, routes à plat : rappelés
- [x] Réutilisations M1 identifiées (référentiels compta partagés, taxonomie code/type, `usePaginatedList`, blocs, archive, normalisation)
diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json
index cf7bc63..3f20c17 100644
--- a/frontend/i18n/locales/fr.json
+++ b/frontend/i18n/locales/fr.json
@@ -259,7 +259,11 @@
"commercial_client": "Client",
"commercial_clientaddress": "Adresse client",
"commercial_clientcontact": "Contact client",
- "commercial_clientrib": "RIB client"
+ "commercial_clientrib": "RIB client",
+ "commercial_supplier": "Fournisseur",
+ "commercial_supplieraddress": "Adresse fournisseur",
+ "commercial_suppliercontact": "Contact fournisseur",
+ "commercial_supplierrib": "RIB fournisseur"
},
"empty": "Aucune activité enregistrée",
"no_results": "Aucun résultat pour ces filtres",
@@ -416,17 +420,24 @@
"noCategories": "Aucune catégorie pour l'instant.",
"table": {
"name": "Nom",
- "type": "Type"
+ "types": "Types"
+ },
+ "filters": {
+ "title": "Filtres",
+ "search": "Recherche",
+ "types": "Types de catégorie",
+ "apply": "Voir les résultats",
+ "reset": "Réinitialiser"
},
"form": {
"name": "Nom",
- "type": "Type de catégorie",
- "typePlaceholder": "Sélectionner un type"
+ "types": "Types de catégorie",
+ "typesPlaceholder": "Sélectionner un ou plusieurs types"
},
"validation": {
"nameRequired": "Le nom est obligatoire.",
"nameLength": "Le nom doit faire entre 2 et 120 caractères.",
- "typeRequired": "Le type de catégorie est obligatoire."
+ "typesRequired": "Sélectionnez au moins un type de catégorie."
},
"delete": {
"title": "Supprimer la catégorie",
@@ -436,7 +447,7 @@
"created": "Catégorie créée avec succès",
"updated": "Catégorie mise à jour avec succès",
"deleted": "Catégorie supprimée avec succès",
- "duplicate": "Une catégorie nommée « {name} » existe déjà pour ce type.",
+ "duplicate": "Une catégorie nommée « {name} » existe déjà.",
"typesLoadFailed": "Impossible de charger les types de catégorie. Réessayez."
}
}
diff --git a/frontend/modules/catalog/components/CategoryDrawer.vue b/frontend/modules/catalog/components/CategoryDrawer.vue
index 9ada8aa..1a77541 100644
--- a/frontend/modules/catalog/components/CategoryDrawer.vue
+++ b/frontend/modules/catalog/components/CategoryDrawer.vue
@@ -24,16 +24,18 @@
required
/>
-
-
+
diff --git a/frontend/modules/catalog/composables/__tests__/useCategoryForm.spec.ts b/frontend/modules/catalog/composables/__tests__/useCategoryForm.spec.ts
index 5a14387..c46cffe 100644
--- a/frontend/modules/catalog/composables/__tests__/useCategoryForm.spec.ts
+++ b/frontend/modules/catalog/composables/__tests__/useCategoryForm.spec.ts
@@ -39,7 +39,7 @@ const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' }
const CAT: Category = {
id: 42,
name: 'Vis',
- categoryType: TYPE_VENTE,
+ categoryTypes: [TYPE_VENTE],
deletedAt: null,
createdAt: '2026-01-01T10:00:00+00:00',
updatedAt: '2026-01-01T10:00:00+00:00',
@@ -58,25 +58,25 @@ describe('useCategoryForm', () => {
})
describe('loadFrom', () => {
- it('pre-remplit le formulaire depuis une categorie existante', () => {
+ it('pre-remplit le formulaire depuis une categorie existante (multi-types)', () => {
const form = useCategoryForm()
- form.loadFrom(CAT)
+ form.loadFrom({ ...CAT, categoryTypes: [TYPE_VENTE, TYPE_ACHAT] })
expect(form.name.value).toBe('Vis')
- expect(form.categoryTypeId.value).toBe(1)
+ expect(form.categoryTypeIds.value).toEqual([1, 2])
expect(form.errors).toEqual({})
})
it('vide le formulaire en mode creation (null)', () => {
const form = useCategoryForm()
form.name.value = 'old'
- form.categoryTypeId.value = 99
+ form.categoryTypeIds.value = [99]
form.loadFrom(null)
expect(form.name.value).toBe('')
- expect(form.categoryTypeId.value).toBeNull()
+ expect(form.categoryTypeIds.value).toEqual([])
})
it('reinitialise le snapshot initial → isDirty=false juste apres', () => {
@@ -98,13 +98,32 @@ describe('useCategoryForm', () => {
expect(form.isDirty.value).toBe(true)
})
+
+ it('passe a true quand on ajoute un type (selection multi)', () => {
+ const form = useCategoryForm()
+ form.loadFrom(CAT)
+ expect(form.isDirty.value).toBe(false)
+
+ form.categoryTypeIds.value = [1, 2]
+
+ expect(form.isDirty.value).toBe(true)
+ })
+
+ it('reste false si la selection est identique dans un autre ordre', () => {
+ const form = useCategoryForm()
+ form.loadFrom({ ...CAT, categoryTypes: [TYPE_VENTE, TYPE_ACHAT] })
+
+ form.categoryTypeIds.value = [2, 1]
+
+ expect(form.isDirty.value).toBe(false)
+ })
})
describe('validate', () => {
it('signale une erreur si name est vide (RG-1.02)', () => {
const form = useCategoryForm()
form.name.value = ''
- form.categoryTypeId.value = 1
+ form.categoryTypeIds.value = [1]
const ok = form.validate()
@@ -115,7 +134,7 @@ describe('useCategoryForm', () => {
it('signale erreur si name est whitespace-only (trim → vide)', () => {
const form = useCategoryForm()
form.name.value = ' '
- form.categoryTypeId.value = 1
+ form.categoryTypeIds.value = [1]
const ok = form.validate()
@@ -126,7 +145,7 @@ describe('useCategoryForm', () => {
it('signale erreur si name fait 1 caractere (< 2, RG-1.04)', () => {
const form = useCategoryForm()
form.name.value = 'A'
- form.categoryTypeId.value = 1
+ form.categoryTypeIds.value = [1]
const ok = form.validate()
@@ -137,7 +156,7 @@ describe('useCategoryForm', () => {
it('signale erreur si name fait 121 caracteres (> 120, RG-1.04)', () => {
const form = useCategoryForm()
form.name.value = 'A'.repeat(121)
- form.categoryTypeId.value = 1
+ form.categoryTypeIds.value = [1]
const ok = form.validate()
@@ -145,21 +164,21 @@ describe('useCategoryForm', () => {
expect(form.errors.name).toBe('admin.categories.validation.nameLength')
})
- it('signale erreur si categoryTypeId est null (RG-1.05)', () => {
+ it('signale erreur si aucun type selectionne (RG-1.05)', () => {
const form = useCategoryForm()
form.name.value = 'Vis'
- form.categoryTypeId.value = null
+ form.categoryTypeIds.value = []
const ok = form.validate()
expect(ok).toBe(false)
- expect(form.errors.categoryType).toBe('admin.categories.validation.typeRequired')
+ expect(form.errors.categoryTypes).toBe('admin.categories.validation.typesRequired')
})
- it('passe quand name et categoryType sont valides', () => {
+ it('passe quand name et au moins un type sont valides', () => {
const form = useCategoryForm()
form.name.value = 'Vis'
- form.categoryTypeId.value = 1
+ form.categoryTypeIds.value = [1, 2]
const ok = form.validate()
@@ -171,7 +190,7 @@ describe('useCategoryForm', () => {
const form = useCategoryForm()
// Erreur prealable : une validation en echec peuple errors.name.
form.name.value = ''
- form.categoryTypeId.value = 1
+ form.categoryTypeIds.value = [1]
form.validate()
expect(form.errors.name).toBeTruthy()
@@ -184,17 +203,17 @@ describe('useCategoryForm', () => {
})
describe('submitCreate', () => {
- it('appelle POST /categories avec body { name trimme, categoryType en IRI }', async () => {
+ it('appelle POST /categories avec body { name trimme, categoryTypes en IRI[] }', async () => {
mockPost.mockResolvedValueOnce(CAT)
const form = useCategoryForm()
form.name.value = ' Vis '
- form.categoryTypeId.value = 1
+ form.categoryTypeIds.value = [1, 2]
const result = await form.submitCreate()
expect(mockPost).toHaveBeenCalledWith(
'/categories',
- { name: 'Vis', categoryType: '/api/category_types/1' },
+ { name: 'Vis', categoryTypes: ['/api/category_types/1', '/api/category_types/2'] },
{ toast: false },
)
expect(result).toEqual(CAT)
@@ -203,7 +222,7 @@ describe('useCategoryForm', () => {
it('ne declenche aucun appel API si la validation client echoue', async () => {
const form = useCategoryForm()
form.name.value = ''
- form.categoryTypeId.value = 1
+ form.categoryTypeIds.value = [1]
const result = await form.submitCreate()
@@ -215,7 +234,7 @@ describe('useCategoryForm', () => {
mockPost.mockResolvedValueOnce(CAT)
const form = useCategoryForm()
form.name.value = 'Vis'
- form.categoryTypeId.value = 1
+ form.categoryTypeIds.value = [1]
await form.submitCreate()
@@ -231,7 +250,7 @@ describe('useCategoryForm', () => {
})
const form = useCategoryForm()
form.name.value = 'Vis'
- form.categoryTypeId.value = 1
+ form.categoryTypeIds.value = [1]
const result = await form.submitCreate()
@@ -258,7 +277,7 @@ describe('useCategoryForm', () => {
})
const form = useCategoryForm()
form.name.value = 'Vis'
- form.categoryTypeId.value = 1
+ form.categoryTypeIds.value = [1]
const result = await form.submitCreate()
@@ -269,24 +288,24 @@ describe('useCategoryForm', () => {
expect(mockToastError).not.toHaveBeenCalled()
})
- it('mappe aussi hydra:violations (negociation de format alternative)', async () => {
+ it('mappe une violation sur categoryTypes (hydra:violations alternative)', async () => {
mockPost.mockRejectedValueOnce({
response: {
status: 422,
_data: {
'hydra:violations': [
- { propertyPath: 'categoryType', message: 'Type invalide.' },
+ { propertyPath: 'categoryTypes', message: 'Sélectionnez au moins un type de catégorie.' },
],
},
},
})
const form = useCategoryForm()
form.name.value = 'Vis'
- form.categoryTypeId.value = 1
+ form.categoryTypeIds.value = [1]
await form.submitCreate()
- expect(form.errors.categoryType).toBe('Type invalide.')
+ expect(form.errors.categoryTypes).toBe('Sélectionnez au moins un type de catégorie.')
})
it('fallback en toast generique si le status n est ni 409 ni 422', async () => {
@@ -295,7 +314,7 @@ describe('useCategoryForm', () => {
})
const form = useCategoryForm()
form.name.value = 'Vis'
- form.categoryTypeId.value = 1
+ form.categoryTypeIds.value = [1]
await form.submitCreate()
@@ -314,7 +333,7 @@ describe('useCategoryForm', () => {
)
const form = useCategoryForm()
form.name.value = 'Vis'
- form.categoryTypeId.value = 1
+ form.categoryTypeIds.value = [1]
const pending = form.submitCreate()
expect(form.submitting.value).toBe(true)
@@ -331,28 +350,28 @@ describe('useCategoryForm', () => {
mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' })
const form = useCategoryForm()
form.loadFrom(CAT)
- form.name.value = 'Vis V2' // categoryTypeId inchange
+ form.name.value = 'Vis V2' // types inchanges
await form.submitUpdate(42)
expect(mockPatch).toHaveBeenCalledWith(
'/categories/42',
- { name: 'Vis V2' }, // pas de categoryType car non modifie
+ { name: 'Vis V2' }, // pas de categoryTypes car non modifies
{ toast: false },
)
})
- it('envoie categoryType en IRI quand seul le type a change', async () => {
- mockPatch.mockResolvedValueOnce({ ...CAT, categoryType: TYPE_ACHAT })
+ it('envoie categoryTypes en IRI[] quand on ajoute un type', async () => {
+ mockPatch.mockResolvedValueOnce({ ...CAT, categoryTypes: [TYPE_VENTE, TYPE_ACHAT] })
const form = useCategoryForm()
form.loadFrom(CAT)
- form.categoryTypeId.value = 2
+ form.categoryTypeIds.value = [1, 2]
await form.submitUpdate(42)
expect(mockPatch).toHaveBeenCalledWith(
'/categories/42',
- { categoryType: '/api/category_types/2' },
+ { categoryTypes: ['/api/category_types/1', '/api/category_types/2'] },
{ toast: false },
)
})
@@ -438,7 +457,7 @@ describe('useCategoryForm', () => {
form.reset()
expect(form.name.value).toBe('')
- expect(form.categoryTypeId.value).toBeNull()
+ expect(form.categoryTypeIds.value).toEqual([])
expect(form.errors).toEqual({})
expect(form.submitting.value).toBe(false)
})
diff --git a/frontend/modules/catalog/composables/useCategoryForm.ts b/frontend/modules/catalog/composables/useCategoryForm.ts
index 689b9f3..2fadf5e 100644
--- a/frontend/modules/catalog/composables/useCategoryForm.ts
+++ b/frontend/modules/catalog/composables/useCategoryForm.ts
@@ -13,9 +13,10 @@
* revalide toujours (defense en profondeur).
*
* Erreurs par champ : delegue a `useFormErrors` (convention ERP-101). Les
- * violations 422 sont mappees par `propertyPath` (`name`, `categoryType`) ;
+ * violations 422 sont mappees par `propertyPath` (`name`, `categoryTypes`) ;
* l'erreur globale (status != 422 exploitable) part en toast. Le 409 (doublon
- * RG-1.07) reste un cas metier specifique : erreur inline sur `name` + toast.
+ * de nom GLOBAL, RG-1.07) reste un cas metier specifique : erreur inline sur
+ * `name` + toast.
*/
import { computed, ref } from 'vue'
import type { Category } from '~/modules/catalog/types/category'
@@ -42,20 +43,29 @@ export function useCategoryForm() {
// State local du formulaire — pas singleton, chaque appel a useCategoryForm
// cree son propre state (cohérent avec le pattern « un drawer = un form »).
const name = ref('')
- const categoryTypeId = ref(null)
+ const categoryTypeIds = ref([])
// Snapshot des valeurs initiales : sert a calculer `isDirty` pour le
// pattern view → edit du drawer (le bouton Enregistrer reste masque tant
// que rien n'a change en mode consultation).
const initialName = ref('')
- const initialCategoryTypeId = ref(null)
+ const initialCategoryTypeIds = ref([])
const submitting = ref(false)
+ // Compare deux listes d'ids sans tenir compte de l'ordre (la selection
+ // multi-types n'est pas ordonnee).
+ function sameIds(a: number[], b: number[]): boolean {
+ if (a.length !== b.length) return false
+ const sortedA = [...a].sort((x, y) => x - y)
+ const sortedB = [...b].sort((x, y) => x - y)
+ return sortedA.every((v, i) => v === sortedB[i])
+ }
+
const isDirty = computed(
() =>
name.value !== initialName.value
- || categoryTypeId.value !== initialCategoryTypeId.value,
+ || !sameIds(categoryTypeIds.value, initialCategoryTypeIds.value),
)
/**
@@ -66,15 +76,16 @@ export function useCategoryForm() {
function loadFrom(category: Category | null): void {
formErrors.clearErrors()
if (category) {
+ const ids = category.categoryTypes.map(t => t.id)
name.value = category.name
- categoryTypeId.value = category.categoryType.id
+ categoryTypeIds.value = [...ids]
initialName.value = category.name
- initialCategoryTypeId.value = category.categoryType.id
+ initialCategoryTypeIds.value = [...ids]
} else {
name.value = ''
- categoryTypeId.value = null
+ categoryTypeIds.value = []
initialName.value = ''
- initialCategoryTypeId.value = null
+ initialCategoryTypeIds.value = []
}
}
@@ -95,23 +106,23 @@ export function useCategoryForm() {
formErrors.setError('name', t('admin.categories.validation.nameLength'))
}
- // RG-1.05 — categoryType obligatoire.
- if (categoryTypeId.value === null) {
- formErrors.setError('categoryType', t('admin.categories.validation.typeRequired'))
+ // RG-1.05 — au moins un type obligatoire.
+ if (categoryTypeIds.value.length === 0) {
+ formErrors.setError('categoryTypes', t('admin.categories.validation.typesRequired'))
}
- return !formErrors.errors.name && !formErrors.errors.categoryType
+ return !formErrors.errors.name && !formErrors.errors.categoryTypes
}
/**
- * Construit le payload POST a partir du state. Le `categoryType` est
- * envoye en IRI Hydra (`/api/category_types/{id}`) — convention API
- * Platform pour referencer une ressource liee.
+ * Construit le payload POST a partir du state. Les `categoryTypes` sont
+ * envoyes en tableau d'IRI Hydra (`/api/category_types/{id}`) — convention
+ * API Platform pour referencer une collection de ressources liees.
*/
function buildCreatePayload(): Record {
return {
name: name.value.trim(),
- categoryType: `/api/category_types/${categoryTypeId.value}`,
+ categoryTypes: categoryTypeIds.value.map(id => `/api/category_types/${id}`),
}
}
@@ -174,8 +185,8 @@ export function useCategoryForm() {
if (name.value !== initialName.value) {
payload.name = name.value.trim()
}
- if (categoryTypeId.value !== initialCategoryTypeId.value) {
- payload.categoryType = `/api/category_types/${categoryTypeId.value}`
+ if (!sameIds(categoryTypeIds.value, initialCategoryTypeIds.value)) {
+ payload.categoryTypes = categoryTypeIds.value.map(id => `/api/category_types/${id}`)
}
// Garde-fou : un PATCH sans changement ne sert a rien. Theoriquement
// empeche par le drawer (bouton Enregistrer masque si !isDirty) mais
@@ -233,9 +244,9 @@ export function useCategoryForm() {
*/
function reset(): void {
name.value = ''
- categoryTypeId.value = null
+ categoryTypeIds.value = []
initialName.value = ''
- initialCategoryTypeId.value = null
+ initialCategoryTypeIds.value = []
formErrors.clearErrors()
submitting.value = false
}
@@ -243,7 +254,7 @@ export function useCategoryForm() {
return {
// State
name,
- categoryTypeId,
+ categoryTypeIds,
errors: formErrors.errors,
submitting,
isDirty,
diff --git a/frontend/modules/catalog/pages/admin/categories.vue b/frontend/modules/catalog/pages/admin/categories.vue
index 6d4104e..784b16e 100644
--- a/frontend/modules/catalog/pages/admin/categories.vue
+++ b/frontend/modules/catalog/pages/admin/categories.vue
@@ -3,13 +3,28 @@
{{ t('admin.categories.title') }}
-
+
+
+
+
+
+
@@ -47,6 +62,60 @@
:loading="deleting"
@confirm="handleDelete"
/>
+
+
+
+
+ {{ t('admin.categories.filters.title') }}
+
+
+
+
+
+
+
+
+
+
+
+ toggleType(opt.value, val)"
+ />
+
+
+
+
+
+
+
+
+
@@ -55,7 +124,7 @@ import type { Category } from '~/modules/catalog/types/category'
const { t } = useI18n()
const { can } = usePermissions()
-const { fetchTypes } = useCategoriesAdmin()
+const { types, fetchTypes } = useCategoriesAdmin()
const { submitDelete } = useCategoryForm()
useHead({ title: t('admin.categories.title') })
@@ -74,6 +143,7 @@ const {
fetch: fetchCategories,
goToPage,
setItemsPerPage,
+ setFilters,
} = usePaginatedList({ url: '/categories' })
const drawerOpen = ref(false)
@@ -82,21 +152,96 @@ const deleteModalOpen = ref(false)
const categoryToDelete = ref(null)
const deleting = ref(false)
-// Colonnes du datatable. Le type est embarque cote API (cf. spec-back § 3.4) —
-// on aplatit en label lisible pour l'affichage.
+// Colonnes du datatable. Les types sont embarques cote API (ManyToMany) — on
+// aplatit en libelles joints par une virgule pour l'affichage.
const columns = [
{ key: 'name', label: t('admin.categories.table.name') },
- { key: 'typeLabel', label: t('admin.categories.table.type') },
+ { key: 'typesLabel', label: t('admin.categories.table.types') },
]
const categoryItems = computed(() =>
categories.value.map(cat => ({
id: cat.id,
name: cat.name,
- typeLabel: cat.categoryType?.label ?? '',
+ typesLabel: (cat.categoryTypes ?? []).map(ct => ct.label).join(', '),
})),
)
+// ── Filtres (drawer) ────────────────────────────────────────────────────────
+// Deux niveaux d'etat (pattern Repertoire Clients) :
+// - APPLIED : pilote la liste + le compteur du bouton. Modifie uniquement au
+// clic « Appliquer » / « Réinitialiser ».
+// - DRAFT : edite librement dans le drawer ; recopie vers applied a la validation.
+const filterDrawerOpen = ref(false)
+
+const draftSearch = ref('')
+const draftTypeIds = ref([])
+
+const appliedSearch = ref('')
+const appliedTypeIds = ref([])
+
+// Options du filtre Type(s), derivees du referentiel deja charge (fetchTypes).
+const typeFilterOptions = computed(() =>
+ types.value.map(ct => ({ value: ct.id, label: ct.label })),
+)
+
+const activeFilterCount = computed(() => {
+ let count = 0
+ if (appliedSearch.value.trim() !== '') count++
+ if (appliedTypeIds.value.length > 0) count++
+ return count
+})
+
+const filterButtonLabel = computed(() => {
+ const base = t('admin.categories.filters.title')
+ return activeFilterCount.value > 0 ? `${base} (${activeFilterCount.value})` : base
+})
+
+// Recopie l'etat applique vers le brouillon puis ouvre le drawer.
+function openFilters(): void {
+ draftSearch.value = appliedSearch.value
+ draftTypeIds.value = [...appliedTypeIds.value]
+ filterDrawerOpen.value = true
+}
+
+function toggleType(id: number, selected: boolean): void {
+ draftTypeIds.value = selected
+ ? [...draftTypeIds.value, id]
+ : draftTypeIds.value.filter(t => t !== id)
+}
+
+/**
+ * Construit le payload de filtres serveur a partir de l'etat applique. Cle
+ * `typeId[]` pour que PHP la parse en tableau (OR cote back). Filtres vides omis.
+ */
+function buildFilterPayload(): Record {
+ const payload: Record = {}
+ if (appliedSearch.value.trim() !== '') payload.name = appliedSearch.value.trim()
+ if (appliedTypeIds.value.length > 0) payload['typeId[]'] = appliedTypeIds.value.map(String)
+ return payload
+}
+
+// « Appliquer » : recopie brouillon → applied, pousse les filtres (retombe en
+// page 1 via usePaginatedList) et ferme le drawer.
+function applyFilters(): void {
+ appliedSearch.value = draftSearch.value.trim()
+ appliedTypeIds.value = [...draftTypeIds.value]
+
+ setFilters(buildFilterPayload(), { replace: true })
+ filterDrawerOpen.value = false
+}
+
+// « Réinitialiser » : vide brouillon ET applied, recharge la liste complete.
+// Le drawer reste ouvert pour montrer le formulaire vide.
+function resetFilters(): void {
+ draftSearch.value = ''
+ draftTypeIds.value = []
+ appliedSearch.value = ''
+ appliedTypeIds.value = []
+
+ setFilters({}, { replace: true })
+}
+
function getCategoryById(id: number): Category | undefined {
return categories.value.find(c => c.id === id)
}
diff --git a/frontend/modules/catalog/types/category.ts b/frontend/modules/catalog/types/category.ts
index acb154d..4cc9947 100644
--- a/frontend/modules/catalog/types/category.ts
+++ b/frontend/modules/catalog/types/category.ts
@@ -4,15 +4,15 @@
* Contrats API consommes :
* - GET /api/categories → HydraCollection
* - GET /api/categories/{id} → Category
- * - POST /api/categories → body { name, categoryType: IRI }
- * - PATCH /api/categories/{id} → body partiel { name?, categoryType?: IRI }
+ * - POST /api/categories → body { name, categoryTypes: IRI[] }
+ * - PATCH /api/categories/{id} → body partiel { name?, categoryTypes?: IRI[] }
* - DELETE /api/categories/{id} → 204 (soft delete via CategoryProcessor)
* - GET /api/category_types → HydraCollection
*
* Notes :
- * - Les IRI sont envoyes en POST/PATCH (ex. "/api/category_types/3").
- * - `categoryType` est embarque (groupe Serializer `category:read` sur les
- * proprietes de CategoryType, cf. spec-back § 3.4).
+ * - Les IRI sont envoyes en POST/PATCH (ex. ["/api/category_types/3"]).
+ * - `categoryTypes` est embarque (groupe Serializer `category:read` sur les
+ * proprietes de CategoryType) : tableau d'objets type en lecture.
* - `createdBy` / `updatedBy` peuvent etre `null` (hors contexte HTTP,
* ON DELETE SET NULL en BDD). Affichage : libelle "Systeme" si null.
*/
@@ -43,7 +43,8 @@ export interface CategoryType {
export interface Category {
id: number
name: string
- categoryType: CategoryType
+ /** Types de la categorie (>= 1, ManyToMany embarque en lecture). */
+ categoryTypes: CategoryType[]
/** Soft delete : null = active, valeur = supprimee logiquement le {date}. */
deletedAt: string | null
createdAt: string
@@ -53,12 +54,12 @@ export interface Category {
}
/**
- * Payload accepte en POST /api/categories. `categoryType` est envoye en
- * IRI Hydra (ex. `/api/category_types/3`).
+ * Payload accepte en POST /api/categories. `categoryTypes` est un tableau
+ * d'IRI Hydra (ex. `['/api/category_types/3', '/api/category_types/5']`).
*/
export interface CategoryCreateInput {
name: string
- categoryType: string
+ categoryTypes: string[]
}
/**
@@ -67,5 +68,5 @@ export interface CategoryCreateInput {
*/
export interface CategoryUpdateInput {
name?: string
- categoryType?: string
+ categoryTypes?: string[]
}
diff --git a/frontend/tests/e2e/_fixtures/personas.ts b/frontend/tests/e2e/_fixtures/personas.ts
index c610608..73afbd4 100644
--- a/frontend/tests/e2e/_fixtures/personas.ts
+++ b/frontend/tests/e2e/_fixtures/personas.ts
@@ -75,6 +75,15 @@ export const personas: Record = {
'commercial.clients.accounting.view',
'commercial.clients.accounting.manage',
'commercial.clients.archive',
+ // Commercial — Repertoire fournisseurs (M2, ERP-90). Meme logique que
+ // les clients : mappe sur le persona "tout", pas de nouveau persona
+ // (regle ABSOLUE n°7). commercial.suppliers.view n'ajoute pas de lien
+ // dans la section Administration, donc expectedAdminLinks reste inchange.
+ 'commercial.suppliers.view',
+ 'commercial.suppliers.manage',
+ 'commercial.suppliers.accounting.view',
+ 'commercial.suppliers.accounting.manage',
+ 'commercial.suppliers.archive',
],
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
},
diff --git a/makefile b/makefile
index 9977884..b749e4b 100644
--- a/makefile
+++ b/makefile
@@ -207,7 +207,8 @@ migration-migrate:
# orphelins du mapping ORM. Les index partiels (LOWER + WHERE) ne sont pas
# exprimables via les attributs Doctrine ORM (fonctionnel + partiel), donc
# ils disparaissent apres schema:update. On les recree par dbal:run-sql :
-# - `uq_category_name_type_active` (M0 Catalog) : tests RG-1.07.
+# - `uq_category_name_active` (M0 Catalog) : unicite GLOBALE du nom parmi
+# les actifs (M:N categorie<->type), tests RG-1.07.
# - `uq_category_code` (Catalog ERP-78) : unicite du code categorie parmi
# les actifs (slug du nom), pilote RG-1.03/1.29.
# - `uq_client_company_name_active` (M1 Commercial) : unicite nom societe
@@ -226,9 +227,10 @@ test-db-setup:
$(SYMFONY_CONSOLE) --env=test --no-interaction doctrine:fixtures:load
$(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions
$(SYMFONY_CONSOLE) --env=test --no-interaction app:seed-rbac
- $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL"
+ $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_active ON category (LOWER(name)) WHERE deleted_at IS NULL"
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_code ON category (code) WHERE deleted_at IS NULL"
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_client_company_name_active ON client (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
+ $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_company_name_active ON supplier (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
fixtures:
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
diff --git a/migrations/Version20260605120000.php b/migrations/Version20260605120000.php
new file mode 100644
index 0000000..6814005
--- /dev/null
+++ b/migrations/Version20260605120000.php
@@ -0,0 +1,102 @@
+ pas de `COMMENT ON COLUMN` (regle ABSOLUE n°12).
+ *
+ * Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire
+ * Catalog : avec plusieurs migrations_paths, Doctrine Migrations 3.x trie par
+ * FQCN alphabetique -> une migration `App\Module\Catalog\...` passerait avant les
+ * `DoctrineMigrations\...` sur base vide, donc avant la creation de la table
+ * `category_type`. Le namespace racine garantit l'ordre par timestamp.
+ *
+ * Idempotence : `INSERT ... ON CONFLICT (code) DO NOTHING` pour le type,
+ * `INSERT ... SELECT ... WHERE NOT EXISTS` pour chaque categorie (aligne sur le
+ * pattern ERP-78 etape 4). En prod la table `category` est vide (aucune fixture
+ * metier). En dev/test, le purger Doctrine vide `category`/`category_type` avant
+ * les fixtures qui reproduisent le meme etat final (CategoryTypeFixtures /
+ * CategoryFixtures etendus a FOURNISSEUR).
+ */
+final class Version20260605120000 extends AbstractMigration
+{
+ /**
+ * Categories de demonstration du type FOURNISSEUR : nom => code stable. Le
+ * code est la cle metier (slug MAJUSCULE du nom, miroir du
+ * CategoryCodeGenerator) et reste unique parmi les actifs (uq_category_code,
+ * partage avec les codes CLIENT — aucune collision ici).
+ */
+ private const array SUPPLIER_CATEGORIES = [
+ 'Négociant' => 'NEGOCIANT',
+ 'Coopérative' => 'COOPERATIVE',
+ 'Producteur' => 'PRODUCTEUR',
+ 'Grossiste' => 'GROSSISTE',
+ 'Importateur' => 'IMPORTATEUR',
+ ];
+
+ public function getDescription(): string
+ {
+ return 'ERP-84 : recree le CategoryType FOURNISSEUR + seed des categories fournisseurs (Negociant, Cooperative...).';
+ }
+
+ public function up(Schema $schema): void
+ {
+ // 1. Type FOURNISSEUR (idempotent via l'index unique uq_category_type_code).
+ $this->addSql(<<<'SQL'
+ INSERT INTO category_type (code, label) VALUES ('FOURNISSEUR', 'Fournisseur')
+ ON CONFLICT (code) DO NOTHING
+ SQL);
+
+ // 2. Categories de demonstration sous FOURNISSEUR (si le code est libre
+ // parmi les actifs). created_at/updated_at NOT NULL -> NOW() ; le blame
+ // reste null (seed hors contexte HTTP, libelle « Systeme » cote front).
+ foreach (self::SUPPLIER_CATEGORIES as $name => $code) {
+ $this->addSql(<<<'SQL'
+ INSERT INTO category (name, code, category_type_id, created_at, updated_at)
+ SELECT :name, :code, ct.id, NOW(), NOW()
+ FROM category_type ct
+ WHERE ct.code = 'FOURNISSEUR'
+ AND NOT EXISTS (
+ SELECT 1 FROM category c WHERE c.code = :code AND c.deleted_at IS NULL
+ )
+ SQL, ['name' => $name, 'code' => $code]);
+ }
+ }
+
+ public function down(Schema $schema): void
+ {
+ // Best-effort : on retire d'abord les categories seedees (par code), puis
+ // le type s'il n'est plus reference (guard NOT EXISTS sur la FK RESTRICT).
+ $this->addSql(
+ 'DELETE FROM category WHERE code IN (:codes) '
+ ."AND category_type_id = (SELECT id FROM category_type WHERE code = 'FOURNISSEUR')",
+ ['codes' => array_values(self::SUPPLIER_CATEGORIES)],
+ ['codes' => \Doctrine\DBAL\ArrayParameterType::STRING],
+ );
+
+ $this->addSql(<<<'SQL'
+ DELETE FROM category_type
+ WHERE code = 'FOURNISSEUR'
+ AND NOT EXISTS (
+ SELECT 1 FROM category c WHERE c.category_type_id = category_type.id
+ )
+ SQL);
+ }
+}
diff --git a/migrations/Version20260605130000.php b/migrations/Version20260605130000.php
new file mode 100644
index 0000000..70bdb48
--- /dev/null
+++ b/migrations/Version20260605130000.php
@@ -0,0 +1,438 @@
+ echec des FK. Le namespace racine garantit l'ordre par timestamp.
+ *
+ * Style DDL aligne sur le M1 (Version20260601000000) : `INT GENERATED BY DEFAULT
+ * AS IDENTITY` (et non SERIAL), `TIMESTAMP(0) WITHOUT TIME ZONE` (et non
+ * TIMESTAMPTZ, car le TimestampableBlamableTrait mappe `datetime_immutable`).
+ * Garantit que `schema:update` restera un no-op quand les entites arriveront
+ * (ticket ERP-86).
+ *
+ * Decision unicite (Matthieu 02/06, alignee Q4 du M1) : unicite metier sur le
+ * NOM DE SOCIETE uniquement (uq_supplier_company_name_active, partiel). Pas
+ * d'index unique sur siren ni email.
+ *
+ * Chaque colonne porte un `COMMENT ON COLUMN` (regle ABSOLUE n°12, garde-fou
+ * ColumnsHaveSqlCommentTest). Les tables n'etant pas encore mappees par l'ORM
+ * (entites a ERP-86), ces commentaires survivent au `schema:update --force` du
+ * setup de test (additif, ne drop pas les tables non mappees).
+ */
+final class Version20260605130000 extends AbstractMigration
+{
+ public function getDescription(): string
+ {
+ return 'ERP-85 (M2) : tables supplier + sous-collections + jointures M2M (referentiels comptables et CategoryType FOURNISSEUR reutilises).';
+ }
+
+ public function up(Schema $schema): void
+ {
+ $this->createSupplierTable();
+ $this->createSupplierCategory();
+ $this->createSupplierContact();
+ $this->createSupplierAddress();
+ $this->createSupplierAddressJoinTables();
+ $this->createSupplierRib();
+ }
+
+ public function down(Schema $schema): void
+ {
+ // Ordre inverse des dependances FK : jointures et sous-collections
+ // d'abord, puis supplier. Les referentiels comptables et le
+ // CategoryType FOURNISSEUR ne sont pas touches (crees ailleurs).
+ $this->addSql('DROP TABLE IF EXISTS supplier_address_category');
+ $this->addSql('DROP TABLE IF EXISTS supplier_address_contact');
+ $this->addSql('DROP TABLE IF EXISTS supplier_address_site');
+ $this->addSql('DROP TABLE IF EXISTS supplier_rib');
+ $this->addSql('DROP TABLE IF EXISTS supplier_address');
+ $this->addSql('DROP TABLE IF EXISTS supplier_contact');
+ $this->addSql('DROP TABLE IF EXISTS supplier_category');
+ $this->addSql('DROP TABLE IF EXISTS supplier');
+ }
+
+ // =================================================================
+ // Table principale `supplier`
+ // =================================================================
+
+ private function createSupplierTable(): void
+ {
+ $this->addSql(<<<'SQL'
+ CREATE TABLE supplier (
+ id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
+ company_name VARCHAR(180) NOT NULL,
+ description TEXT DEFAULT NULL,
+ competitors VARCHAR(255) DEFAULT NULL,
+ founded_at DATE DEFAULT NULL,
+ employees_count INT DEFAULT NULL,
+ revenue_amount NUMERIC(15, 2) DEFAULT NULL,
+ director_name VARCHAR(120) DEFAULT NULL,
+ profit_amount NUMERIC(15, 2) DEFAULT NULL,
+ volume_forecast INT DEFAULT NULL,
+ siren VARCHAR(20) DEFAULT NULL,
+ account_number VARCHAR(40) DEFAULT NULL,
+ tva_mode_id INT DEFAULT NULL,
+ n_tva VARCHAR(40) DEFAULT NULL,
+ payment_delay_id INT DEFAULT NULL,
+ payment_type_id INT DEFAULT NULL,
+ bank_id INT DEFAULT NULL,
+ is_archived BOOLEAN DEFAULT FALSE NOT NULL,
+ archived_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
+ deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
+ created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
+ updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
+ created_by INT DEFAULT NULL,
+ updated_by INT DEFAULT NULL,
+ PRIMARY KEY (id),
+ CONSTRAINT fk_supplier_tva_mode
+ FOREIGN KEY (tva_mode_id) REFERENCES tva_mode (id) ON DELETE RESTRICT,
+ CONSTRAINT fk_supplier_payment_delay
+ FOREIGN KEY (payment_delay_id) REFERENCES payment_delay (id) ON DELETE RESTRICT,
+ CONSTRAINT fk_supplier_payment_type
+ FOREIGN KEY (payment_type_id) REFERENCES payment_type (id) ON DELETE RESTRICT,
+ CONSTRAINT fk_supplier_bank
+ FOREIGN KEY (bank_id) REFERENCES bank (id) ON DELETE RESTRICT,
+ CONSTRAINT fk_supplier_created_by
+ FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
+ CONSTRAINT fk_supplier_updated_by
+ FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
+ )
+ SQL);
+
+ $this->addSql('CREATE INDEX idx_supplier_is_archived ON supplier (is_archived)');
+ $this->addSql('CREATE INDEX idx_supplier_deleted_at ON supplier (deleted_at)');
+ $this->addSql('CREATE INDEX idx_supplier_created_by ON supplier (created_by)');
+ $this->addSql('CREATE INDEX idx_supplier_updated_by ON supplier (updated_by)');
+
+ // Index sur les FK des referentiels comptables (Postgres n'indexe pas
+ // automatiquement les colonnes portant une FOREIGN KEY).
+ $this->addSql('CREATE INDEX idx_supplier_tva_mode_id ON supplier (tva_mode_id)');
+ $this->addSql('CREATE INDEX idx_supplier_payment_delay_id ON supplier (payment_delay_id)');
+ $this->addSql('CREATE INDEX idx_supplier_payment_type_id ON supplier (payment_type_id)');
+ $this->addSql('CREATE INDEX idx_supplier_bank_id ON supplier (bank_id)');
+
+ // Unicite metier partielle : nom de societe insensible a la casse, parmi
+ // les non-archives ET non soft-deletes uniquement (spec § 2.6). Pas
+ // d'index unique sur siren ni email.
+ $this->addSql(<<<'SQL'
+ CREATE UNIQUE INDEX uq_supplier_company_name_active
+ ON supplier (LOWER(company_name))
+ WHERE is_archived = FALSE AND deleted_at IS NULL
+ SQL);
+
+ $this->comment('supplier', '_table', 'Repertoire fournisseurs (M2 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M3).');
+ $this->comment('supplier', 'id', 'Identifiant interne auto-incremente.');
+ $this->comment('supplier', 'company_name', 'Raison sociale du fournisseur (stockee en MAJUSCULES). Unique case-insensitive parmi les actifs non archives/non supprimes (uq_supplier_company_name_active, § 2.6).');
+ $this->comment('supplier', 'description', 'Onglet Information : description libre. Obligatoire pour le role Commerciale (RG-2.03), optionnel sinon.');
+ $this->comment('supplier', 'competitors', 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-2.03).');
+ $this->comment('supplier', 'founded_at', 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-2.03).');
+ $this->comment('supplier', 'employees_count', 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-2.03).');
+ $this->comment('supplier', 'revenue_amount', 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-2.03).');
+ $this->comment('supplier', 'director_name', 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-2.03).');
+ $this->comment('supplier', 'profit_amount', 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-2.03).');
+ $this->comment('supplier', 'volume_forecast', 'Onglet Information : volume previsionnel (entier >= 0) — specifique fournisseur. Obligatoire role Commerciale (RG-2.03).');
+ $this->comment('supplier', 'siren', 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (§ 2.6).');
+ $this->comment('supplier', 'account_number', 'Onglet Comptabilite : numero de compte comptable du fournisseur.');
+ $this->comment('supplier', 'tva_mode_id', 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id (referentiel partage M1), ON DELETE RESTRICT.');
+ $this->comment('supplier', 'n_tva', 'Onglet Comptabilite : numero de TVA intracommunautaire.');
+ $this->comment('supplier', 'payment_delay_id', 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id (M1), ON DELETE RESTRICT.');
+ $this->comment('supplier', 'payment_type_id', 'Onglet Comptabilite : type de reglement — FK -> payment_type.id (M1), ON DELETE RESTRICT. Pilote RG-2.07 (Banque si VIREMENT) et RG-2.08 (RIB).');
+ $this->comment('supplier', 'bank_id', 'Onglet Comptabilite : banque — FK -> bank.id (M1), ON DELETE RESTRICT. Obligatoire ssi payment_type = VIREMENT (RG-2.07), null sinon.');
+ $this->comment('supplier', 'is_archived', 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission commercial.suppliers.archive.');
+ $this->comment('supplier', 'archived_at', 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration.');
+ $this->comment('supplier', 'deleted_at', 'Horodatage du soft-delete technique (HP M3) — non expose par l API au M2. Null = ligne active.');
+ $this->addTimestampableBlamableComments('supplier');
+ }
+
+ // =================================================================
+ // M2M supplier <-> category (type FOURNISSEUR — RG-2.10)
+ // =================================================================
+
+ private function createSupplierCategory(): void
+ {
+ $this->addSql(<<<'SQL'
+ CREATE TABLE supplier_category (
+ supplier_id INT NOT NULL,
+ category_id INT NOT NULL,
+ PRIMARY KEY (supplier_id, category_id),
+ CONSTRAINT fk_supplier_category_supplier
+ FOREIGN KEY (supplier_id) REFERENCES supplier (id) ON DELETE CASCADE,
+ CONSTRAINT fk_supplier_category_category
+ FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT
+ )
+ SQL);
+ $this->addSql('CREATE INDEX idx_supplier_category_category ON supplier_category (category_id)');
+
+ $this->comment('supplier_category', '_table', 'Jointure M2M supplier <-> category (Catalog) — categories de type FOURNISSEUR du fournisseur, au moins une obligatoire (RG-2.10).');
+ $this->comment('supplier_category', 'supplier_id', 'FK -> supplier.id, ON DELETE CASCADE — fournisseur porteur de la categorie.');
+ $this->comment('supplier_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie de type FOURNISSEUR rattachee au fournisseur (RG-2.10).');
+ }
+
+ // =================================================================
+ // Sous-collection : contacts (1:n)
+ // =================================================================
+
+ private function createSupplierContact(): void
+ {
+ $this->addSql(<<<'SQL'
+ CREATE TABLE supplier_contact (
+ id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
+ supplier_id INT NOT NULL,
+ first_name VARCHAR(120) DEFAULT NULL,
+ last_name VARCHAR(120) DEFAULT NULL,
+ job_title VARCHAR(120) DEFAULT NULL,
+ phone_primary VARCHAR(20) DEFAULT NULL,
+ phone_secondary VARCHAR(20) DEFAULT NULL,
+ email VARCHAR(180) DEFAULT NULL,
+ position INT DEFAULT 0 NOT NULL,
+ created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
+ updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
+ created_by INT DEFAULT NULL,
+ updated_by INT DEFAULT NULL,
+ PRIMARY KEY (id),
+ CONSTRAINT chk_supplier_contact_name
+ CHECK (first_name IS NOT NULL OR last_name IS NOT NULL),
+ CONSTRAINT fk_supplier_contact_supplier
+ FOREIGN KEY (supplier_id) REFERENCES supplier (id) ON DELETE CASCADE,
+ CONSTRAINT fk_supplier_contact_created_by
+ FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
+ CONSTRAINT fk_supplier_contact_updated_by
+ FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
+ )
+ SQL);
+ $this->addSql('CREATE INDEX idx_supplier_contact_supplier ON supplier_contact (supplier_id)');
+
+ $this->comment('supplier_contact', '_table', 'Contacts d un fournisseur (1:n) — au moins firstName OU lastName par contact (RG-2.04).');
+ $this->comment('supplier_contact', 'id', 'Identifiant interne auto-incremente.');
+ $this->comment('supplier_contact', 'supplier_id', 'FK -> supplier.id, ON DELETE CASCADE — fournisseur proprietaire du contact.');
+ $this->comment('supplier_contact', 'first_name', 'Prenom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-2.04, chk_supplier_contact_name).');
+ $this->comment('supplier_contact', 'last_name', 'Nom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-2.04, chk_supplier_contact_name).');
+ $this->comment('supplier_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres).');
+ $this->comment('supplier_contact', 'phone_primary', 'Telephone principal du contact — chiffres uniquement (normalisation serveur).');
+ $this->comment('supplier_contact', 'phone_secondary', 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).');
+ $this->comment('supplier_contact', 'email', 'Email du contact (lowercase serveur).');
+ $this->comment('supplier_contact', 'position', 'Ordre d affichage du contact dans la liste du fournisseur (croissant).');
+ $this->addTimestampableBlamableComments('supplier_contact');
+ }
+
+ // =================================================================
+ // Sous-collection : adresses (1:n)
+ // =================================================================
+
+ private function createSupplierAddress(): void
+ {
+ $this->addSql(<<<'SQL'
+ CREATE TABLE supplier_address (
+ id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
+ supplier_id INT NOT NULL,
+ address_type VARCHAR(20) NOT NULL,
+ country VARCHAR(80) DEFAULT 'France' NOT NULL,
+ postal_code VARCHAR(20) NOT NULL,
+ city VARCHAR(120) NOT NULL,
+ street VARCHAR(255) NOT NULL,
+ street_complement VARCHAR(255) DEFAULT NULL,
+ bennes INT DEFAULT NULL,
+ triage_provider BOOLEAN DEFAULT FALSE NOT NULL,
+ position INT DEFAULT 0 NOT NULL,
+ created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
+ updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
+ created_by INT DEFAULT NULL,
+ updated_by INT DEFAULT NULL,
+ PRIMARY KEY (id),
+ CONSTRAINT chk_supplier_address_type
+ CHECK (address_type IN ('PROSPECT', 'DEPART', 'RENDU')),
+ CONSTRAINT fk_supplier_address_supplier
+ FOREIGN KEY (supplier_id) REFERENCES supplier (id) ON DELETE CASCADE,
+ CONSTRAINT fk_supplier_address_created_by
+ FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
+ CONSTRAINT fk_supplier_address_updated_by
+ FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
+ )
+ SQL);
+ $this->addSql('CREATE INDEX idx_supplier_address_supplier ON supplier_address (supplier_id)');
+
+ $this->comment('supplier_address', '_table', 'Adresses d un fournisseur (1:n) — type PROSPECT/DEPART/RENDU exclusif (RG-2.09), >= 1 site rattache (RG-2.06).');
+ $this->comment('supplier_address', 'id', 'Identifiant interne auto-incremente.');
+ $this->comment('supplier_address', 'supplier_id', 'FK -> supplier.id, ON DELETE CASCADE — fournisseur proprietaire de l adresse.');
+ $this->comment('supplier_address', 'address_type', 'Type d adresse : PROSPECT | DEPART | RENDU (radio exclusif par construction — RG-2.09, chk_supplier_address_type).');
+ $this->comment('supplier_address', 'country', 'Pays de l adresse — defaut France.');
+ $this->comment('supplier_address', 'postal_code', 'Code postal (4-5 chiffres attendus).');
+ $this->comment('supplier_address', 'city', 'Ville — preremplie depuis le code postal via API BAN cote front.');
+ $this->comment('supplier_address', 'street', 'Numero et voie de l adresse.');
+ $this->comment('supplier_address', 'street_complement', 'Complement d adresse (etage, batiment...) — optionnel.');
+ $this->comment('supplier_address', 'bennes', 'Nombre de bennes sur le site fournisseur (entier nullable) — specifique fournisseur.');
+ $this->comment('supplier_address', 'triage_provider', 'Le fournisseur est prestataire de triage sur cette adresse. Faux par defaut.');
+ $this->comment('supplier_address', 'position', 'Ordre d affichage de l adresse dans la liste du fournisseur (croissant).');
+ $this->addTimestampableBlamableComments('supplier_address');
+ }
+
+ // =================================================================
+ // Jointures de supplier_address (M2M)
+ // =================================================================
+
+ private function createSupplierAddressJoinTables(): void
+ {
+ $this->addSql(<<<'SQL'
+ CREATE TABLE supplier_address_site (
+ supplier_address_id INT NOT NULL,
+ site_id INT NOT NULL,
+ PRIMARY KEY (supplier_address_id, site_id),
+ CONSTRAINT fk_supplier_address_site_address
+ FOREIGN KEY (supplier_address_id) REFERENCES supplier_address (id) ON DELETE CASCADE,
+ CONSTRAINT fk_supplier_address_site_site
+ FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT
+ )
+ SQL);
+ $this->comment('supplier_address_site', '_table', 'Jointure M2M supplier_address <-> site (Sites) — sites rattaches a l adresse (>= 1 obligatoire, RG-2.06).');
+ $this->comment('supplier_address_site', 'supplier_address_id', 'FK -> supplier_address.id, ON DELETE CASCADE — adresse concernee.');
+ $this->comment('supplier_address_site', 'site_id', 'FK -> site.id, ON DELETE RESTRICT — site rattache a l adresse.');
+
+ $this->addSql(<<<'SQL'
+ CREATE TABLE supplier_address_contact (
+ supplier_address_id INT NOT NULL,
+ supplier_contact_id INT NOT NULL,
+ PRIMARY KEY (supplier_address_id, supplier_contact_id),
+ CONSTRAINT fk_supplier_address_contact_address
+ FOREIGN KEY (supplier_address_id) REFERENCES supplier_address (id) ON DELETE CASCADE,
+ CONSTRAINT fk_supplier_address_contact_contact
+ FOREIGN KEY (supplier_contact_id) REFERENCES supplier_contact (id) ON DELETE CASCADE
+ )
+ SQL);
+ $this->comment('supplier_address_contact', '_table', 'Jointure M2M supplier_address <-> supplier_contact — contacts associes a une adresse.');
+ $this->comment('supplier_address_contact', 'supplier_address_id', 'FK -> supplier_address.id, ON DELETE CASCADE — adresse concernee.');
+ $this->comment('supplier_address_contact', 'supplier_contact_id', 'FK -> supplier_contact.id, ON DELETE CASCADE — contact associe a l adresse.');
+
+ $this->addSql(<<<'SQL'
+ CREATE TABLE supplier_address_category (
+ supplier_address_id INT NOT NULL,
+ category_id INT NOT NULL,
+ PRIMARY KEY (supplier_address_id, category_id),
+ CONSTRAINT fk_supplier_address_category_address
+ FOREIGN KEY (supplier_address_id) REFERENCES supplier_address (id) ON DELETE CASCADE,
+ CONSTRAINT fk_supplier_address_category_category
+ FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT
+ )
+ SQL);
+ $this->comment('supplier_address_category', '_table', 'Jointure M2M supplier_address <-> category — categories d adresse de type FOURNISSEUR (RG-2.10).');
+ $this->comment('supplier_address_category', 'supplier_address_id', 'FK -> supplier_address.id, ON DELETE CASCADE — adresse concernee.');
+ $this->comment('supplier_address_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse de type FOURNISSEUR (RG-2.10).');
+ }
+
+ // =================================================================
+ // Sous-collection : RIB (1:n)
+ // =================================================================
+
+ private function createSupplierRib(): void
+ {
+ $this->addSql(<<<'SQL'
+ CREATE TABLE supplier_rib (
+ id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
+ supplier_id INT NOT NULL,
+ label VARCHAR(120) NOT NULL,
+ bic VARCHAR(20) NOT NULL,
+ iban VARCHAR(34) NOT NULL,
+ position INT DEFAULT 0 NOT NULL,
+ created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
+ updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
+ created_by INT DEFAULT NULL,
+ updated_by INT DEFAULT NULL,
+ PRIMARY KEY (id),
+ CONSTRAINT fk_supplier_rib_supplier
+ FOREIGN KEY (supplier_id) REFERENCES supplier (id) ON DELETE CASCADE,
+ CONSTRAINT fk_supplier_rib_created_by
+ FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
+ CONSTRAINT fk_supplier_rib_updated_by
+ FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
+ )
+ SQL);
+ $this->addSql('CREATE INDEX idx_supplier_rib_supplier ON supplier_rib (supplier_id)');
+
+ $this->comment('supplier_rib', '_table', 'Coordonnees bancaires d un fournisseur (1:n) — >= 1 RIB attendu selon le type de reglement (RG-2.08). Tous les champs audites (pas d AuditIgnore).');
+ $this->comment('supplier_rib', 'id', 'Identifiant interne auto-incremente.');
+ $this->comment('supplier_rib', 'supplier_id', 'FK -> supplier.id, ON DELETE CASCADE — fournisseur proprietaire du RIB.');
+ $this->comment('supplier_rib', 'label', 'Libelle du RIB (ex: compte principal).');
+ $this->comment('supplier_rib', 'bic', 'Code BIC/SWIFT de la banque (8 ou 11 caracteres).');
+ $this->comment('supplier_rib', 'iban', 'IBAN du compte (≤ 34 caracteres).');
+ $this->comment('supplier_rib', 'position', 'Ordre d affichage du RIB dans la liste du fournisseur (croissant).');
+ $this->addTimestampableBlamableComments('supplier_rib');
+ }
+
+ // =================================================================
+ // Helpers
+ // =================================================================
+
+ /**
+ * Pose les 4 commentaires standardises Timestampable/Blamable sur une table,
+ * en reutilisant le catalogue partage (source unique, cf. ERP-67).
+ */
+ private function addTimestampableBlamableComments(string $table): void
+ {
+ foreach (ColumnCommentsCatalog::timestampableBlamableComments() as $column => $description) {
+ $this->comment($table, $column, $description);
+ }
+ }
+
+ /**
+ * Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou
+ * `COMMENT ON COLUMN` en dollar-quoting Postgres ($_$...$_$) pour eviter
+ * tout echappement d apostrophe.
+ */
+ private function comment(string $table, string $column, string $description): void
+ {
+ $quotedTable = '"'.str_replace('"', '""', $table).'"';
+
+ if ('_table' === $column) {
+ $this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
+
+ return;
+ }
+
+ $this->addSql(sprintf(
+ 'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
+ $quotedTable,
+ '"'.str_replace('"', '""', $column).'"',
+ $description,
+ ));
+ }
+}
diff --git a/migrations/Version20260608120000.php b/migrations/Version20260608120000.php
new file mode 100644
index 0000000..d93db8f
--- /dev/null
+++ b/migrations/Version20260608120000.php
@@ -0,0 +1,149 @@
+ CategoryType
+ * de ManyToOne a ManyToMany.
+ *
+ * Ordre critique :
+ * 1. Creation de la table de jonction `category_category_type` (FK category ON
+ * DELETE CASCADE, FK category_type ON DELETE RESTRICT — conserve le garde-fou
+ * « on ne supprime pas un type encore reference »).
+ * 2. Backfill : chaque categorie existante recoit une ligne de jonction vers son
+ * ancien `category_type_id` (avant de dropper la colonne).
+ * 3. Drop de l'index unique (LOWER(name), category_type_id), de l'index FK et de
+ * la colonne `category.category_type_id` (Postgres drope la FK dependante).
+ * 4. Nouvel index unique GLOBAL sur le nom : LOWER(name) WHERE deleted_at IS NULL
+ * (l'unicite n'est plus liee au type — RG-1.07 reformulee).
+ *
+ * Sur base fraiche, les categories seedees CLIENT (Distributeur/Courtier/Secteur/
+ * Autre) et FOURNISSEUR (Negociant/Cooperative/...) n'ont aucun nom en collision
+ * -> l'index unique global passe sans conflit.
+ *
+ * Migration placee au namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) :
+ * Doctrine Migrations 3.x trie par FQCN puis version ; le namespace racine garantit
+ * l'ordre par timestamp apres les migrations d'init des tables.
+ */
+final class Version20260608120000 extends AbstractMigration
+{
+ public function getDescription(): string
+ {
+ return 'Catalog : Category <-> CategoryType en ManyToMany (jonction category_category_type), unicite du nom globalisee.';
+ }
+
+ public function up(Schema $schema): void
+ {
+ // 1. Table de jonction.
+ $this->addSql(<<<'SQL'
+ CREATE TABLE category_category_type (
+ category_id INT NOT NULL,
+ category_type_id INT NOT NULL,
+ PRIMARY KEY (category_id, category_type_id),
+ CONSTRAINT fk_category_category_type_category
+ FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE CASCADE,
+ CONSTRAINT fk_category_category_type_type
+ FOREIGN KEY (category_type_id) REFERENCES category_type (id) ON DELETE RESTRICT
+ )
+ SQL);
+ $this->addSql('CREATE INDEX idx_cat_cat_type_type ON category_category_type (category_type_id)');
+
+ $this->comment('category_category_type', '_table', 'Jointure M2M category <-> category_type (Catalog) — types portes par la categorie, au moins un obligatoire (RG-1.05).');
+ $this->comment('category_category_type', 'category_id', 'FK -> category.id, ON DELETE CASCADE — categorie portant le type.');
+ $this->comment('category_category_type', 'category_type_id', 'FK -> category_type.id, ON DELETE RESTRICT — type rattache (un type ne peut etre supprime tant qu il reste reference).');
+
+ // 2. Backfill depuis l'ancienne colonne ManyToOne (chaque categorie -> 1 type).
+ $this->addSql(<<<'SQL'
+ INSERT INTO category_category_type (category_id, category_type_id)
+ SELECT id, category_type_id FROM category
+ SQL);
+
+ // 3. Suppression de l'ancien modele : index unique par type, index FK, colonne.
+ $this->addSql('DROP INDEX uq_category_name_type_active');
+ $this->addSql('DROP INDEX idx_category_type_id');
+ // DROP COLUMN drope automatiquement la FK fk_category_type qui en depend.
+ $this->addSql('ALTER TABLE category DROP COLUMN category_type_id');
+
+ // 4. Unicite du nom desormais GLOBALE parmi les actifs (RG-1.07 reformulee).
+ $this->addSql(<<<'SQL'
+ CREATE UNIQUE INDEX uq_category_name_active
+ ON category (LOWER(name))
+ WHERE deleted_at IS NULL
+ SQL);
+
+ // Realignement de la doc SQL de `category` (le type n'est plus une colonne).
+ $this->comment('category', '_table', 'Categories — referentiel multi-types via la jonction category_category_type, soft-delete via deleted_at, unicite LOWER(name) GLOBALE parmi les actifs (uq_category_name_active).');
+ $this->comment('category', 'name', 'Libelle de la categorie (≤ 120 caracteres) — unique GLOBALEMENT parmi les actifs (RG-1.07, uq_category_name_active).');
+ }
+
+ public function down(Schema $schema): void
+ {
+ // Restauration best-effort de l'ancien modele ManyToOne (1 type par categorie).
+ $this->addSql('DROP INDEX IF EXISTS uq_category_name_active');
+
+ $this->addSql('ALTER TABLE category ADD COLUMN category_type_id INT DEFAULT NULL');
+
+ // Reprend le premier type de chaque categorie (l'ordre des types perdus
+ // au-dela du premier est best-effort : le modele cible n'en gardait qu'un).
+ $this->addSql(<<<'SQL'
+ UPDATE category c
+ SET category_type_id = (
+ SELECT cct.category_type_id
+ FROM category_category_type cct
+ WHERE cct.category_id = c.id
+ ORDER BY cct.category_type_id ASC
+ LIMIT 1
+ )
+ SQL);
+
+ // Categories sans aucun type (theorique) : on les rattache a defaut au
+ // premier type existant pour pouvoir reposer le NOT NULL.
+ $this->addSql(<<<'SQL'
+ UPDATE category
+ SET category_type_id = (SELECT id FROM category_type ORDER BY id ASC LIMIT 1)
+ WHERE category_type_id IS NULL
+ SQL);
+
+ $this->addSql('ALTER TABLE category ALTER COLUMN category_type_id SET NOT NULL');
+ $this->addSql(<<<'SQL'
+ ALTER TABLE category
+ ADD CONSTRAINT fk_category_type
+ FOREIGN KEY (category_type_id) REFERENCES category_type (id) ON DELETE RESTRICT
+ SQL);
+ $this->addSql('CREATE INDEX idx_category_type_id ON category (category_type_id)');
+ $this->addSql(<<<'SQL'
+ CREATE UNIQUE INDEX uq_category_name_type_active
+ ON category (LOWER(name), category_type_id)
+ WHERE deleted_at IS NULL
+ SQL);
+
+ $this->addSql('DROP TABLE category_category_type');
+ }
+
+ /**
+ * Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou `COMMENT ON COLUMN`
+ * en dollar-quoting Postgres ($_$...$_$) pour eviter tout echappement.
+ */
+ private function comment(string $table, string $column, string $description): void
+ {
+ $quotedTable = '"'.str_replace('"', '""', $table).'"';
+
+ if ('_table' === $column) {
+ $this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
+
+ return;
+ }
+
+ $this->addSql(sprintf(
+ 'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
+ $quotedTable,
+ '"'.str_replace('"', '""', $column).'"',
+ $description,
+ ));
+ }
+}
diff --git a/src/Module/Catalog/Domain/Entity/Category.php b/src/Module/Catalog/Domain/Entity/Category.php
index be04b81..03dc0d7 100644
--- a/src/Module/Catalog/Domain/Entity/Category.php
+++ b/src/Module/Catalog/Domain/Entity/Category.php
@@ -19,14 +19,18 @@ use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Categorie : referentiel metier classifiant les futurs tiers (clients,
- * fournisseurs, prestataires). Porte un `name` libre et un `categoryType`
- * (FK vers le referentiel statique CategoryType).
+ * fournisseurs, prestataires). Porte un `name` libre et un ou plusieurs
+ * `categoryTypes` (ManyToMany vers le referentiel statique CategoryType,
+ * table de jonction `category_category_type`). Une categorie peut appartenir
+ * a plusieurs types simultanement (>= 1 obligatoire, RG-1.05).
*
* - Soft delete via `deletedAt` (pas de hard delete) : la liste exclut par
* defaut les categories supprimees (cf. CategoryProvider, ticket 0.3).
@@ -81,12 +85,11 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: DoctrineCategoryRepository::class)]
#[ORM\Table(name: 'category')]
// Index nommes pour matcher la migration (cf. Role/Permission/Site). Les index
-// uniques partiels `uq_category_name_type_active` (LOWER(name), category_type_id
-// WHERE deleted_at IS NULL) et `uq_category_code` (code WHERE deleted_at IS NULL)
-// restent possedes par la seule migration : Doctrine ORM ne sait pas exprimer un
-// index partiel via attribut.
+// uniques partiels `uq_category_name_active` (LOWER(name) WHERE deleted_at IS
+// NULL — unicite GLOBALE du nom parmi les actifs) et `uq_category_code` (code
+// WHERE deleted_at IS NULL) restent possedes par la seule migration : Doctrine
+// ORM ne sait pas exprimer un index partiel via attribut.
#[ORM\Index(name: 'idx_category_deleted_at', columns: ['deleted_at'])]
-#[ORM\Index(name: 'idx_category_type_id', columns: ['category_type_id'])]
#[ORM\Index(name: 'idx_category_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_category_updated_by', columns: ['updated_by'])]
#[Auditable]
@@ -126,11 +129,21 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt
#[Groups(['category:read'])]
private ?string $code = null;
- #[ORM\ManyToOne(targetEntity: CategoryType::class)]
- #[ORM\JoinColumn(name: 'category_type_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
- #[Assert\NotNull(message: 'Type de catégorie obligatoire.')]
+ /**
+ * Types de la categorie (>= 1 obligatoire, RG-1.05). ManyToMany vers le
+ * referentiel statique CategoryType via la jonction `category_category_type`.
+ * Cote inverse (category_type) en ON DELETE RESTRICT : un type ne peut etre
+ * supprime tant qu'il reste reference par une categorie.
+ *
+ * @var Collection
+ */
+ #[ORM\ManyToMany(targetEntity: CategoryType::class)]
+ #[ORM\JoinTable(name: 'category_category_type')]
+ #[ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
+ #[ORM\InverseJoinColumn(name: 'category_type_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
+ #[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un type de catégorie.')]
#[Groups(['category:read', 'category:write'])]
- private ?CategoryType $categoryType = null;
+ private Collection $categoryTypes;
/**
* Soft delete : null = active, valeur = supprimee logiquement le {date}.
@@ -141,6 +154,11 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt
#[Groups(['category:read'])]
private ?DateTimeImmutable $deletedAt = null;
+ public function __construct()
+ {
+ $this->categoryTypes = new ArrayCollection();
+ }
+
public function getId(): ?int
{
return $this->id;
@@ -173,26 +191,42 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt
return $this;
}
- public function getCategoryType(): ?CategoryType
+ /**
+ * @return Collection
+ */
+ public function getCategoryTypes(): Collection
{
- return $this->categoryType;
+ return $this->categoryTypes;
}
- public function setCategoryType(?CategoryType $categoryType): static
+ public function addCategoryType(CategoryType $categoryType): static
{
- $this->categoryType = $categoryType;
+ if (!$this->categoryTypes->contains($categoryType)) {
+ $this->categoryTypes->add($categoryType);
+ }
+
+ return $this;
+ }
+
+ public function removeCategoryType(CategoryType $categoryType): static
+ {
+ $this->categoryTypes->removeElement($categoryType);
return $this;
}
/**
- * Implemente CategoryInterface : code du type rattache (ou null). Permet
- * aux modules tiers de filtrer/valider par type metier sans dependre de
- * Catalog.
+ * Implemente CategoryInterface : liste des codes de types rattaches a la
+ * categorie. Permet aux modules tiers de filtrer/valider par type metier
+ * (ex: RG-2.10 « contient FOURNISSEUR ») sans dependre de Catalog.
+ *
+ * @return list
*/
- public function getCategoryTypeCode(): ?string
+ public function getCategoryTypeCodes(): array
{
- return $this->categoryType?->getCode();
+ return array_values(array_filter(
+ $this->categoryTypes->map(static fn (CategoryType $t): ?string => $t->getCode())->toArray(),
+ ));
}
public function getDeletedAt(): ?DateTimeImmutable
diff --git a/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php b/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php
index 5a43d5c..17c1ba8 100644
--- a/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php
+++ b/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php
@@ -23,7 +23,26 @@ interface CategoryRepositoryInterface
/**
* Construit un QueryBuilder de liste avec filtre soft-delete et tri par defaut.
* - $includeDeleted = false : exclut les categories soft-deleted (RG-1.08)
+ * - $typeCode non null : ne garde que les categories PORTANT ce code de type
+ * (filtre `?typeCode=`, ex. FOURNISSEUR / CLIENT). Sert au multi-select
+ * Categorie du fournisseur (M2, RG-2.10).
+ * - $nameSearch non null : recherche partielle case-insensitive sur le nom
+ * (filtre `?name=` de la liste admin).
+ * - $typeIds non vide : ne garde que les categories portant AU MOINS UN des
+ * types (OR, filtre `?typeId[]=` de la liste admin).
* - Tri : name ASC (RG-1.10).
+ *
+ * Les categories etant en ManyToMany avec leurs types, la collection
+ * `categoryTypes` est eager-loadee (addSelect) pour eviter un N+1 a la
+ * serialisation, et `distinct` est applique des qu'un filtre type joint la
+ * table de jonction (evite les lignes dupliquees).
+ *
+ * @param list $typeIds
*/
- public function createListQueryBuilder(bool $includeDeleted = false): QueryBuilder;
+ public function createListQueryBuilder(
+ bool $includeDeleted = false,
+ ?string $typeCode = null,
+ ?string $nameSearch = null,
+ array $typeIds = [],
+ ): QueryBuilder;
}
diff --git a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/CategoryProcessor.php b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/CategoryProcessor.php
index c97ef7c..8f34334 100644
--- a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/CategoryProcessor.php
+++ b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/CategoryProcessor.php
@@ -22,8 +22,8 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
* via CategoryCodeGenerator ; puis delegation au persist_processor Doctrine
* ORM. Le code est FIGE a la creation (jamais recalcule sur PATCH). Toute
* UniqueConstraintViolationException remontee par Postgres (collision sur
- * l'index partiel uq_category_name_type_active) est traduite en HTTP 409 avec
- * le message attendu par la spec (RG-1.07).
+ * l'index partiel uq_category_name_active — unicite GLOBALE du nom parmi les
+ * actifs) est traduite en HTTP 409 avec le message attendu par la spec (RG-1.07).
* - DELETE : soft delete (RG-1.12). On NE delegue PAS au remove_processor ;
* on pose deletedAt = now() puis on delegue au persist_processor pour que
* le UPDATE Doctrine parte et que le TimestampableBlamableSubscriber mette
@@ -78,10 +78,12 @@ final class CategoryProcessor implements ProcessorInterface
try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} catch (UniqueConstraintViolationException $e) {
- // RG-1.07 : doublon (LOWER(name), category_type_id) parmi les non-soft-deleted.
+ // RG-1.07 : doublon de nom GLOBAL (LOWER(name)) parmi les non-soft-deleted
+ // (uq_category_name_active). L'unicite n'est plus liee au type depuis le
+ // passage en ManyToMany.
throw new HttpException(
409,
- sprintf('Une catégorie nommée "%s" existe déjà pour ce type.', $data->getName() ?? ''),
+ sprintf('Une catégorie nommée "%s" existe déjà.', $data->getName() ?? ''),
$e,
);
}
diff --git a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/CategoryProvider.php b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/CategoryProvider.php
index bc7f4c7..a0cc167 100644
--- a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/CategoryProvider.php
+++ b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/CategoryProvider.php
@@ -40,7 +40,12 @@ final class CategoryProvider implements ProviderInterface
$includeDeleted = $this->readIncludeDeleted($context);
if ($operation instanceof CollectionOperationInterface) {
- $qb = $this->repository->createListQueryBuilder($includeDeleted);
+ $qb = $this->repository->createListQueryBuilder(
+ $includeDeleted,
+ $this->readTypeCode($context),
+ $this->readNameSearch($context),
+ $this->readTypeIds($context),
+ );
// Echappatoire ?pagination=false : retourne la collection complete sans Paginator.
// Utile pour les drawers Role/Permission/Site/CategoryType qui alimentent un .
@@ -97,4 +102,66 @@ final class CategoryProvider implements ProviderInterface
return false;
}
+
+ /**
+ * Lit le filtre `?typeCode=` depuis les filtres API Platform. Renvoie le code
+ * normalise (trim) ou null si absent / vide. Ne contraint pas la casse : la
+ * comparaison SQL se fait sur le code exact stocke (ex. FOURNISSEUR, CLIENT).
+ */
+ private function readTypeCode(array $context): ?string
+ {
+ $raw = $context['filters']['typeCode'] ?? null;
+
+ if (!is_string($raw)) {
+ return null;
+ }
+
+ $raw = trim($raw);
+
+ return '' === $raw ? null : $raw;
+ }
+
+ /**
+ * Lit le filtre `?name=` (recherche partielle sur le nom, liste admin).
+ * Renvoie la valeur trimmee ou null si absente / vide.
+ */
+ private function readNameSearch(array $context): ?string
+ {
+ $raw = $context['filters']['name'] ?? null;
+
+ if (!is_string($raw)) {
+ return null;
+ }
+
+ $raw = trim($raw);
+
+ return '' === $raw ? null : $raw;
+ }
+
+ /**
+ * Lit le filtre `?typeId[]=` (liste admin) : ids des types coches (OR).
+ * Tolere une valeur scalaire unique (`?typeId=3`) ou un tableau. Ignore
+ * les entrees non numeriques.
+ *
+ * @return list
+ */
+ private function readTypeIds(array $context): array
+ {
+ $raw = $context['filters']['typeId'] ?? null;
+
+ if (null === $raw) {
+ return [];
+ }
+
+ $values = is_array($raw) ? $raw : [$raw];
+
+ $ids = [];
+ foreach ($values as $value) {
+ if (is_int($value) || (is_string($value) && ctype_digit($value))) {
+ $ids[] = (int) $value;
+ }
+ }
+
+ return array_values(array_unique($ids));
+ }
}
diff --git a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php
index d8247a4..760cf89 100644
--- a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php
+++ b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php
@@ -14,14 +14,16 @@ use RuntimeException;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
- * Fixtures dev/test du module Catalog : ~11 categories de demonstration, toutes
- * rattachees au type unique CLIENT (refonte taxonomie ERP-78). Chaque categorie
- * porte un `code` stable. Alimente le repertoire clients (ClientFixtures, module
- * Commercial) avec des donnees realistes couvrant RG-1.03 (codes DISTRIBUTEUR /
- * COURTIER) et RG-1.29 (codes interdits sur adresse).
+ * Fixtures dev/test du module Catalog : categories de demonstration rattachees
+ * a leur CategoryType. Le type CLIENT porte ~11 categories clients (refonte
+ * taxonomie ERP-78) ; le type FOURNISSEUR porte les categories fournisseurs
+ * (ERP-84 : Negociant, Cooperative...). Chaque categorie porte un `code` stable.
+ * Alimente le repertoire clients (ClientFixtures, module Commercial) avec des
+ * donnees realistes couvrant RG-1.03 (codes DISTRIBUTEUR / COURTIER) et RG-1.29
+ * (codes interdits sur adresse), et le multi-select Categorie fournisseur (M2).
*
- * Depend de CategoryTypeFixtures : le type CLIENT doit etre seede avant de
- * pouvoir y rattacher des Category.
+ * Depend de CategoryTypeFixtures : les types CLIENT et FOURNISSEUR doivent etre
+ * seedes avant de pouvoir y rattacher des Category.
*
* Idempotence : lookup par `code` parmi les categories non supprimees (deletedAt
* null), coherent avec l'index unique partiel uq_category_code (code WHERE
@@ -39,28 +41,36 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
*/
class CategoryFixtures extends Fixture implements DependentFixtureInterface
{
- /** Code du type unique (cf. CategoryTypeFixtures, migration ERP-78). */
- private const string CLIENT_TYPE_CODE = 'CLIENT';
-
/**
- * Source unique des categories de demonstration : nom => code stable. Les 4
- * premieres (Distributeur / Courtier / Secteur / Autre) sont les categories
+ * Categories de demonstration par code de type. Les 4 premieres categories
+ * CLIENT (Distributeur / Courtier / Secteur / Autre) sont les categories
* « systeme » reportees des anciens types ; leurs codes pilotent les RG.
+ * Les categories FOURNISSEUR (ERP-84) miroir de la migration
+ * Version20260605120000. Chaque valeur : nom => code stable.
*
- * @var array
+ * @var array>
*/
- private const CATEGORIES = [
- 'Distributeur' => 'DISTRIBUTEUR',
- 'Courtier' => 'COURTIER',
- 'Secteur' => 'SECTEUR',
- 'Autre' => 'AUTRE',
- 'BTP' => 'BTP',
- 'Industrie' => 'INDUSTRIE',
- 'Agro-alimentaire' => 'AGRO_ALIMENTAIRE',
- 'Transport/Logistique' => 'TRANSPORT_LOGISTIQUE',
- 'Services' => 'SERVICES',
- 'Association' => 'ASSOCIATION',
- 'Indépendant' => 'INDEPENDANT',
+ private const CATEGORIES_BY_TYPE = [
+ 'CLIENT' => [
+ 'Distributeur' => 'DISTRIBUTEUR',
+ 'Courtier' => 'COURTIER',
+ 'Secteur' => 'SECTEUR',
+ 'Autre' => 'AUTRE',
+ 'BTP' => 'BTP',
+ 'Industrie' => 'INDUSTRIE',
+ 'Agro-alimentaire' => 'AGRO_ALIMENTAIRE',
+ 'Transport/Logistique' => 'TRANSPORT_LOGISTIQUE',
+ 'Services' => 'SERVICES',
+ 'Association' => 'ASSOCIATION',
+ 'Indépendant' => 'INDEPENDANT',
+ ],
+ 'FOURNISSEUR' => [
+ 'Négociant' => 'NEGOCIANT',
+ 'Coopérative' => 'COOPERATIVE',
+ 'Producteur' => 'PRODUCTEUR',
+ 'Grossiste' => 'GROSSISTE',
+ 'Importateur' => 'IMPORTATEUR',
+ ],
];
public function __construct(
@@ -84,31 +94,33 @@ class CategoryFixtures extends Fixture implements DependentFixtureInterface
return;
}
- $clientType = null;
+ // Index des types presents par code, pour rattacher chaque categorie.
+ $typesByCode = [];
foreach ($this->categoryTypeRepository->findAllOrderedByLabel() as $type) {
- if (self::CLIENT_TYPE_CODE === $type->getCode()) {
- $clientType = $type;
+ $typesByCode[$type->getCode()] = $type;
+ }
- break;
+ foreach (self::CATEGORIES_BY_TYPE as $typeCode => $categories) {
+ $type = $typesByCode[$typeCode] ?? null;
+
+ if (!$type instanceof CategoryType) {
+ // Misconfiguration : CategoryTypeFixtures n'a pas seede ce type.
+ throw new RuntimeException(sprintf(
+ 'CategoryTypeFixtures doit avoir seede le type "%s" avant CategoryFixtures.',
+ $typeCode,
+ ));
}
- }
- if (!$clientType instanceof CategoryType) {
- // Misconfiguration : CategoryTypeFixtures n'a pas tourne avant.
- throw new RuntimeException(
- 'CategoryTypeFixtures doit avoir seede le type "CLIENT" avant CategoryFixtures.',
- );
- }
-
- foreach (self::CATEGORIES as $name => $code) {
- $this->ensureCategory($manager, $name, $code, $clientType);
+ foreach ($categories as $name => $code) {
+ $this->ensureCategory($manager, $name, $code, $type);
+ }
}
$manager->flush();
}
/**
- * Cree la categorie (name, code) sous le type CLIENT si son code n'existe pas
+ * Cree la categorie (name, code) sous le type fourni si son code n'existe pas
* encore parmi les categories actives, sinon la laisse en place. Lookup
* aligne sur l'index unique partiel uq_category_code.
*/
@@ -126,7 +138,7 @@ class CategoryFixtures extends Fixture implements DependentFixtureInterface
$category = new Category();
$category->setName($name);
$category->setCode($code);
- $category->setCategoryType($type);
+ $category->addCategoryType($type);
$manager->persist($category);
}
}
diff --git a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php
index 2a2d5b4..b329414 100644
--- a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php
+++ b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php
@@ -12,10 +12,14 @@ use Doctrine\Persistence\ObjectManager;
/**
* Fixtures du module Catalog : seed du type de categorie (M1).
*
- * Refonte taxonomie ERP-78 : le modele n'a plus qu'UN SEUL `category_type`,
- * CLIENT (code CLIENT, label « Client »). Distributeur / Courtier / Secteur /
- * Autre (et les categories metier fines) sont desormais des `Category` codees
- * rattachees a ce type (cf. CategoryFixtures + migration Version20260602100000).
+ * Refonte taxonomie ERP-78 : le type CLIENT (code CLIENT, label « Client »)
+ * porte les categories clients ; Distributeur / Courtier / Secteur / Autre (et
+ * les categories metier fines) sont des `Category` codees rattachees a ce type
+ * (cf. CategoryFixtures + migration Version20260602100000).
+ *
+ * ERP-84 : ajout du type FOURNISSEUR (code FOURNISSEUR, label « Fournisseur »),
+ * taxonomie distincte des fournisseurs (Negociant, Cooperative...). Mirroir de
+ * la migration Version20260605120000.
*
* Pourquoi une fixture EN PLUS du seed de la migration : `category_type` est une
* entite managee par l ORM, donc le purger Doctrine la vide avant chaque
@@ -31,11 +35,13 @@ use Doctrine\Persistence\ObjectManager;
class CategoryTypeFixtures extends Fixture
{
/**
- * Source unique du type : code technique => libelle FR. Doit rester aligne
- * sur le seed de la migration Version20260602100000 (type unique CLIENT).
+ * Source unique des types : code technique => libelle FR. Doit rester aligne
+ * sur le seed des migrations Version20260602100000 (CLIENT) et
+ * Version20260605120000 (FOURNISSEUR).
*/
private const TYPES = [
- 'CLIENT' => 'Client',
+ 'CLIENT' => 'Client',
+ 'FOURNISSEUR' => 'Fournisseur',
];
public function __construct(
diff --git a/src/Module/Catalog/Infrastructure/Doctrine/DoctrineCategoryRepository.php b/src/Module/Catalog/Infrastructure/Doctrine/DoctrineCategoryRepository.php
index aad21d5..4a1699b 100644
--- a/src/Module/Catalog/Infrastructure/Doctrine/DoctrineCategoryRepository.php
+++ b/src/Module/Catalog/Infrastructure/Doctrine/DoctrineCategoryRepository.php
@@ -48,9 +48,19 @@ class DoctrineCategoryRepository extends ServiceEntityRepository implements Cate
return [] !== $qb->getQuery()->getResult();
}
- public function createListQueryBuilder(bool $includeDeleted = false): QueryBuilder
- {
+ public function createListQueryBuilder(
+ bool $includeDeleted = false,
+ ?string $typeCode = null,
+ ?string $nameSearch = null,
+ array $typeIds = [],
+ ): QueryBuilder {
+ // Eager-load de la collection categoryTypes (ManyToMany) : embarquee a la
+ // serialisation -> on la fetch-joine pour eviter un N+1 par categorie. Le
+ // provider enveloppe la requete dans un Paginator(fetchJoinCollection: true),
+ // compatible avec ce fetch-join to-many.
$qb = $this->createQueryBuilder('c')
+ ->leftJoin('c.categoryTypes', 'cte')
+ ->addSelect('cte')
->orderBy('c.name', 'ASC')
;
@@ -58,6 +68,45 @@ class DoctrineCategoryRepository extends ServiceEntityRepository implements Cate
$qb->andWhere('c.deletedAt IS NULL');
}
+ // Filtre `?typeCode=` : la categorie doit PORTER ce code de type (RG-2.10,
+ // multi-select fournisseur). Sous-requete EXISTS correlee pour ne PAS
+ // restreindre la collection eager-loadee `cte` (sinon les autres types de
+ // la categorie disparaitraient du JSON) et eviter les lignes dupliquees.
+ if (null !== $typeCode) {
+ $sub = $this->getEntityManager()->createQueryBuilder()
+ ->select('1')
+ ->from(Category::class, 'c_tc')
+ ->join('c_tc.categoryTypes', 'ct_tc')
+ ->where('c_tc = c')
+ ->andWhere('ct_tc.code = :typeCode')
+ ;
+ $qb->andWhere($qb->expr()->exists($sub->getDQL()))
+ ->setParameter('typeCode', $typeCode)
+ ;
+ }
+
+ // Filtre `?typeId[]=` (liste admin) : la categorie porte AU MOINS UN des
+ // types coches (OR). Meme strategie EXISTS correlee que `typeCode`.
+ if ([] !== $typeIds) {
+ $sub = $this->getEntityManager()->createQueryBuilder()
+ ->select('1')
+ ->from(Category::class, 'c_ti')
+ ->join('c_ti.categoryTypes', 'ct_ti')
+ ->where('c_ti = c')
+ ->andWhere('ct_ti.id IN (:typeIds)')
+ ;
+ $qb->andWhere($qb->expr()->exists($sub->getDQL()))
+ ->setParameter('typeIds', $typeIds)
+ ;
+ }
+
+ // Filtre `?name=` (liste admin) : recherche partielle case-insensitive.
+ if (null !== $nameSearch && '' !== $nameSearch) {
+ $qb->andWhere('LOWER(c.name) LIKE :nameSearch')
+ ->setParameter('nameSearch', '%'.mb_strtolower($nameSearch).'%')
+ ;
+ }
+
return $qb;
}
}
diff --git a/src/Module/Commercial/Application/Service/SupplierFieldNormalizer.php b/src/Module/Commercial/Application/Service/SupplierFieldNormalizer.php
new file mode 100644
index 0000000..9b49405
--- /dev/null
+++ b/src/Module/Commercial/Application/Service/SupplierFieldNormalizer.php
@@ -0,0 +1,82 @@
+ "0612345678" (RG-2.12).
+ * Le formatage d'affichage "XX XX XX XX XX" est de la responsabilite du front.
+ * - email : lowercase integral (RG-2.12)
+ *
+ * Toutes les methodes sont null-safe et trim-ent l'entree ; une chaine vide
+ * apres trim devient null (evite de persister "" dans des colonnes nullable).
+ */
+final class SupplierFieldNormalizer
+{
+ /**
+ * Nom de societe en majuscules (RG-2.12). Conserve null tel quel ; une
+ * chaine non vide est trim + upper. Une chaine vide reste "" (champ
+ * obligatoire : c'est l'Assert\NotBlank qui rejette, pas le normalizer).
+ */
+ public function normalizeCompanyName(?string $value): ?string
+ {
+ if (null === $value) {
+ return null;
+ }
+
+ return mb_strtoupper(trim($value), 'UTF-8');
+ }
+
+ /**
+ * Nom/prenom de personne en Title Case (RG-2.12) : "JEAN dupont" ->
+ * "Jean Dupont". Une chaine vide apres trim devient null.
+ */
+ public function normalizePersonName(?string $value): ?string
+ {
+ if (null === $value) {
+ return null;
+ }
+
+ $value = trim($value);
+
+ return '' === $value ? null : mb_convert_case($value, MB_CASE_TITLE, 'UTF-8');
+ }
+
+ /**
+ * Email en minuscules (RG-2.12). Une chaine vide apres trim devient null.
+ */
+ public function normalizeEmail(?string $value): ?string
+ {
+ if (null === $value) {
+ return null;
+ }
+
+ $value = trim($value);
+
+ return '' === $value ? null : mb_strtolower($value, 'UTF-8');
+ }
+
+ /**
+ * Telephone reduit aux chiffres (RG-2.12) : "06.12.34.56.78" ->
+ * "0612345678". Une valeur sans aucun chiffre devient null.
+ */
+ public function normalizePhone(?string $value): ?string
+ {
+ if (null === $value) {
+ return null;
+ }
+
+ $digits = preg_replace('/\D+/', '', $value) ?? '';
+
+ return '' === $digits ? null : $digits;
+ }
+}
diff --git a/src/Module/Commercial/Application/Validator/SupplierInformationCompletenessValidator.php b/src/Module/Commercial/Application/Validator/SupplierInformationCompletenessValidator.php
new file mode 100644
index 0000000..d6d4f03
--- /dev/null
+++ b/src/Module/Commercial/Application/Validator/SupplierInformationCompletenessValidator.php
@@ -0,0 +1,82 @@
+ valeur courante de l'onglet Information.
+ $fields = [
+ 'description' => $supplier->getDescription(),
+ 'competitors' => $supplier->getCompetitors(),
+ 'foundedAt' => $supplier->getFoundedAt(),
+ 'employeesCount' => $supplier->getEmployeesCount(),
+ 'revenueAmount' => $supplier->getRevenueAmount(),
+ 'directorName' => $supplier->getDirectorName(),
+ 'profitAmount' => $supplier->getProfitAmount(),
+ 'volumeForecast' => $supplier->getVolumeForecast(),
+ ];
+
+ $violations = new ConstraintViolationList();
+
+ foreach ($fields as $property => $value) {
+ if ($this->isMissing($value)) {
+ $violations->add(new ConstraintViolation(
+ // Pas de nom de champ technique dans le message : la violation est
+ // deja rattachee au bon champ via son propertyPath (mappe inline
+ // cote front par useFormErrors).
+ 'Ce champ est obligatoire pour le rôle Commerciale.',
+ null,
+ [],
+ $supplier,
+ $property,
+ $value,
+ ));
+ }
+ }
+
+ if (count($violations) > 0) {
+ throw new ValidationException($violations);
+ }
+ }
+
+ /**
+ * Une valeur est manquante si null ou, pour une chaine, vide apres trim. Les
+ * zeros numeriques (employeesCount = 0, profitAmount = "0.00",
+ * volumeForecast = 0) sont des valeurs valides : on ne les considere pas
+ * manquants.
+ */
+ private function isMissing(mixed $value): bool
+ {
+ if (null === $value) {
+ return true;
+ }
+
+ return is_string($value) && '' === trim($value);
+ }
+}
diff --git a/src/Module/Commercial/CommercialModule.php b/src/Module/Commercial/CommercialModule.php
index f4fac9c..71157c9 100644
--- a/src/Module/Commercial/CommercialModule.php
+++ b/src/Module/Commercial/CommercialModule.php
@@ -39,6 +39,11 @@ final class CommercialModule
['code' => 'commercial.clients.accounting.view', 'label' => 'Voir l\'onglet Comptabilité d\'un client'],
['code' => 'commercial.clients.accounting.manage', 'label' => 'Modifier l\'onglet Comptabilité d\'un client'],
['code' => 'commercial.clients.archive', 'label' => 'Archiver / restaurer un client'],
+ ['code' => 'commercial.suppliers.view', 'label' => 'Voir les fournisseurs'],
+ ['code' => 'commercial.suppliers.manage', 'label' => 'Créer / modifier les fournisseurs (hors onglet Comptabilité)'],
+ ['code' => 'commercial.suppliers.accounting.view', 'label' => 'Voir l\'onglet Comptabilité d\'un fournisseur'],
+ ['code' => 'commercial.suppliers.accounting.manage', 'label' => 'Modifier l\'onglet Comptabilité d\'un fournisseur'],
+ ['code' => 'commercial.suppliers.archive', 'label' => 'Archiver / restaurer un fournisseur'],
];
}
}
diff --git a/src/Module/Commercial/Domain/Entity/Bank.php b/src/Module/Commercial/Domain/Entity/Bank.php
index e690504..1ee4542 100644
--- a/src/Module/Commercial/Domain/Entity/Bank.php
+++ b/src/Module/Commercial/Domain/Entity/Bank.php
@@ -20,12 +20,13 @@ use Symfony\Component\Serializer\Attribute\Groups;
* permission commercial.clients.view ; POST/PATCH/DELETE -> 405. Pas de
* Timestampable/Blamable (referentiel statique whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
- * `client:read:accounting` permet l'embarquement dans la reponse Client.
+ * `client:read:accounting` permet l'embarquement dans la reponse Client ;
+ * `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0).
*/
#[ApiResource(
operations: [
new GetCollection(
- security: "is_granted('commercial.clients.view')",
+ security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
normalizationContext: ['groups' => ['bank:read']],
// Tri par defaut spec M1 § 4.7 : position ASC puis label ASC.
order: ['position' => 'ASC', 'label' => 'ASC'],
@@ -33,11 +34,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
paginationClientEnabled: true,
),
new Get(
- security: "is_granted('commercial.clients.view')",
+ security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
normalizationContext: ['groups' => ['bank:read']],
),
],
- security: "is_granted('commercial.clients.view')",
+ security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
)]
#[ORM\Entity(repositoryClass: DoctrineBankRepository::class)]
#[ORM\Table(name: 'bank')]
@@ -47,15 +48,15 @@ class Bank
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
- #[Groups(['bank:read', 'client:read:accounting'])]
+ #[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?int $id = null;
#[ORM\Column(length: 30)]
- #[Groups(['bank:read', 'client:read:accounting'])]
+ #[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
- #[Groups(['bank:read', 'client:read:accounting'])]
+ #[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?string $label = null;
#[ORM\Column(options: ['default' => 0])]
diff --git a/src/Module/Commercial/Domain/Entity/Client.php b/src/Module/Commercial/Domain/Entity/Client.php
index 69e6b39..387f94f 100644
--- a/src/Module/Commercial/Domain/Entity/Client.php
+++ b/src/Module/Commercial/Domain/Entity/Client.php
@@ -188,7 +188,7 @@ class Client implements TimestampableInterface, BlamableInterface
private ?string $description = null;
#[ORM\Column(length: 255, nullable: true)]
- #[Assert\Length(max: 255, maxMessage: 'Ce champ ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Assert\Length(max: 255, maxMessage: 'La liste des concurrents ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client:read', 'client:write:information'])]
private ?string $competitors = null;
diff --git a/src/Module/Commercial/Domain/Entity/PaymentDelay.php b/src/Module/Commercial/Domain/Entity/PaymentDelay.php
index f45f225..5e8be75 100644
--- a/src/Module/Commercial/Domain/Entity/PaymentDelay.php
+++ b/src/Module/Commercial/Domain/Entity/PaymentDelay.php
@@ -20,12 +20,13 @@ use Symfony\Component\Serializer\Attribute\Groups;
* permission commercial.clients.view ; POST/PATCH/DELETE -> 405. Pas de
* Timestampable/Blamable (referentiel statique whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
- * `client:read:accounting` permet l'embarquement dans la reponse Client.
+ * `client:read:accounting` permet l'embarquement dans la reponse Client ;
+ * `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0).
*/
#[ApiResource(
operations: [
new GetCollection(
- security: "is_granted('commercial.clients.view')",
+ security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
normalizationContext: ['groups' => ['payment_delay:read']],
// Tri par defaut spec M1 § 4.7 : position ASC puis label ASC.
order: ['position' => 'ASC', 'label' => 'ASC'],
@@ -33,11 +34,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
paginationClientEnabled: true,
),
new Get(
- security: "is_granted('commercial.clients.view')",
+ security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
normalizationContext: ['groups' => ['payment_delay:read']],
),
],
- security: "is_granted('commercial.clients.view')",
+ security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
)]
#[ORM\Entity(repositoryClass: DoctrinePaymentDelayRepository::class)]
#[ORM\Table(name: 'payment_delay')]
@@ -47,15 +48,15 @@ class PaymentDelay
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
- #[Groups(['payment_delay:read', 'client:read:accounting'])]
+ #[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?int $id = null;
#[ORM\Column(length: 30)]
- #[Groups(['payment_delay:read', 'client:read:accounting'])]
+ #[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
- #[Groups(['payment_delay:read', 'client:read:accounting'])]
+ #[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?string $label = null;
#[ORM\Column(options: ['default' => 0])]
diff --git a/src/Module/Commercial/Domain/Entity/PaymentType.php b/src/Module/Commercial/Domain/Entity/PaymentType.php
index 5402b68..af045c9 100644
--- a/src/Module/Commercial/Domain/Entity/PaymentType.php
+++ b/src/Module/Commercial/Domain/Entity/PaymentType.php
@@ -23,12 +23,13 @@ use Symfony\Component\Serializer\Attribute\Groups;
* permission commercial.clients.view ; POST/PATCH/DELETE -> 405. Pas de
* Timestampable/Blamable (referentiel statique whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
- * `client:read:accounting` permet l'embarquement dans la reponse Client.
+ * `client:read:accounting` permet l'embarquement dans la reponse Client ;
+ * `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0).
*/
#[ApiResource(
operations: [
new GetCollection(
- security: "is_granted('commercial.clients.view')",
+ security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
normalizationContext: ['groups' => ['payment_type:read']],
// Tri par defaut spec M1 § 4.7 : position ASC puis label ASC.
order: ['position' => 'ASC', 'label' => 'ASC'],
@@ -36,11 +37,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
paginationClientEnabled: true,
),
new Get(
- security: "is_granted('commercial.clients.view')",
+ security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
normalizationContext: ['groups' => ['payment_type:read']],
),
],
- security: "is_granted('commercial.clients.view')",
+ security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
)]
#[ORM\Entity(repositoryClass: DoctrinePaymentTypeRepository::class)]
#[ORM\Table(name: 'payment_type')]
@@ -50,15 +51,15 @@ class PaymentType
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
- #[Groups(['payment_type:read', 'client:read:accounting'])]
+ #[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?int $id = null;
#[ORM\Column(length: 30)]
- #[Groups(['payment_type:read', 'client:read:accounting'])]
+ #[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
- #[Groups(['payment_type:read', 'client:read:accounting'])]
+ #[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?string $label = null;
#[ORM\Column(options: ['default' => 0])]
diff --git a/src/Module/Commercial/Domain/Entity/Supplier.php b/src/Module/Commercial/Domain/Entity/Supplier.php
new file mode 100644
index 0000000..7c7d7f1
--- /dev/null
+++ b/src/Module/Commercial/Domain/Entity/Supplier.php
@@ -0,0 +1,728 @@
+ ['supplier:read', 'default:read', 'category:read', 'site:read']],
+ provider: SupplierProvider::class,
+ ),
+ new Get(
+ security: "is_granted('commercial.suppliers.view')",
+ // Detail : fournisseur + sous-collections embarquees (contacts /
+ // adresses + leurs sites/categories/contacts).
+ // - supplier:read:accounting est ajoute par SupplierReadGroupContextBuilder
+ // selon la permission (gate les scalaires comptables ET les RIB
+ // embarques), donc volontairement ABSENT ici (parade bug #4 M1).
+ // - category:read / site:read indispensables pour embarquer le
+ // code/name des categories et le name/postalCode des sites (sinon
+ // stub IRI nu — bugs #1/#2 M1).
+ normalizationContext: ['groups' => [
+ 'supplier:read',
+ 'supplier:item:read',
+ 'category:read',
+ 'site:read',
+ 'default:read',
+ ]],
+ provider: SupplierProvider::class,
+ ),
+ new Post(
+ security: "is_granted('commercial.suppliers.manage')",
+ normalizationContext: ['groups' => ['supplier:read', 'default:read', 'category:read', 'site:read']],
+ denormalizationContext: ['groups' => ['supplier:write:main']],
+ processor: SupplierProcessor::class,
+ ),
+ new Patch(
+ // Security elargie : `manage` OU `accounting.manage`. Le role Compta
+ // n'a pas `manage` mais doit pouvoir editer l'onglet Comptabilite
+ // d'un fournisseur existant (§ 2.9). Le SupplierProcessor re-gate
+ // ensuite onglet par onglet (mode strict RG-2.16) :
+ // - champs accounting -> accounting.manage (guardAccounting) ;
+ // - champs main/information -> manage (guardManage : empeche Compta
+ // d'editer les autres onglets) ;
+ // - isArchived -> archive (guardArchive, RG-2.14).
+ security: "is_granted('commercial.suppliers.manage') or is_granted('commercial.suppliers.accounting.manage')",
+ normalizationContext: ['groups' => ['supplier:read', 'default:read', 'category:read', 'site:read']],
+ denormalizationContext: ['groups' => [
+ 'supplier:write:main',
+ 'supplier:write:information',
+ 'supplier:write:accounting',
+ 'supplier:write:archive',
+ ]],
+ provider: SupplierProvider::class,
+ processor: SupplierProcessor::class,
+ ),
+ // Pas de Delete au M2 (HP-M3-1). Archivage via PATCH { isArchived: true }.
+ ],
+)]
+#[ORM\Entity(repositoryClass: DoctrineSupplierRepository::class)]
+#[ORM\Table(name: 'supplier')]
+// Index nommes pour matcher la migration (Version20260605130000). L'index unique
+// partiel uq_supplier_company_name_active reste possede par la migration :
+// Doctrine ORM ne sait pas exprimer un index fonctionnel (LOWER) + partiel
+// (WHERE) via attribut. Pas de #[ORM\UniqueConstraint] (§ 2.6).
+#[ORM\Index(name: 'idx_supplier_is_archived', columns: ['is_archived'])]
+#[ORM\Index(name: 'idx_supplier_deleted_at', columns: ['deleted_at'])]
+#[ORM\Index(name: 'idx_supplier_created_by', columns: ['created_by'])]
+#[ORM\Index(name: 'idx_supplier_updated_by', columns: ['updated_by'])]
+#[Auditable]
+class Supplier implements TimestampableInterface, BlamableInterface
+{
+ use TimestampableBlamableTrait;
+
+ /**
+ * RG-2.10 : seules les categories PORTANT ce type sont autorisees sur le
+ * fournisseur (entite principale). Miroir de SupplierAddress (ERP-88).
+ * S'appuie sur CategoryInterface::getCategoryTypeCodes() (pas d'import du
+ * module Catalog — regle ABSOLUE n°1).
+ */
+ private const string REQUIRED_CATEGORY_TYPE_CODE = 'FOURNISSEUR';
+
+ /** RG-2.07 : code du type de reglement imposant une banque. */
+ private const string PAYMENT_TYPE_VIREMENT = 'VIREMENT';
+
+ /** RG-2.08 : code du type de reglement imposant au moins un RIB. */
+ private const string PAYMENT_TYPE_LCR = 'LCR';
+
+ #[ORM\Id]
+ #[ORM\GeneratedValue]
+ #[ORM\Column]
+ #[Groups(['supplier:read'])]
+ private ?int $id = null;
+
+ // === Formulaire principal ===
+ #[ORM\Column(length: 180)]
+ #[Assert\NotBlank(message: 'Le nom du fournisseur est obligatoire.', normalizer: 'trim')]
+ #[Assert\Length(min: 2, max: 180, minMessage: 'Le nom du fournisseur doit comporter au moins {{ limit }} caractères.', maxMessage: 'Le nom du fournisseur ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Groups(['supplier:read', 'supplier:write:main'])]
+ private ?string $companyName = null;
+
+ // RG : au moins une categorie (Count min 1), de type FOURNISSEUR (RG-2.10,
+ // verifiee au Processor/Validator a ERP-89). M2M vers Category via le contrat
+ // CategoryInterface (resolve_target_entities -> Category). Embarquee en LISTE
+ // ET DETAIL (coherence M1/ERP-62) ; maillon (c) : le contexte inclut
+ // 'category:read' pour exposer id/code/name.
+ /** @var Collection */
+ #[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
+ #[ORM\JoinTable(name: 'supplier_category')]
+ #[ORM\JoinColumn(name: 'supplier_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
+ #[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
+ #[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')]
+ #[Groups(['supplier:read', 'supplier:write:main'])]
+ private Collection $categories;
+
+ // === Onglet Information ===
+ #[ORM\Column(type: 'text', nullable: true)]
+ #[Groups(['supplier:read', 'supplier:write:information'])]
+ private ?string $description = null;
+
+ #[ORM\Column(length: 255, nullable: true)]
+ #[Assert\Length(max: 255, maxMessage: 'La liste des concurrents ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Groups(['supplier:read', 'supplier:write:information'])]
+ private ?string $competitors = null;
+
+ #[ORM\Column(type: 'date_immutable', nullable: true)]
+ #[Groups(['supplier:read', 'supplier:write:information'])]
+ private ?DateTimeImmutable $foundedAt = null;
+
+ #[ORM\Column(nullable: true)]
+ #[Assert\PositiveOrZero(message: 'L\'effectif doit être un nombre positif ou nul.')]
+ #[Groups(['supplier:read', 'supplier:write:information'])]
+ private ?int $employeesCount = null;
+
+ #[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)]
+ #[Groups(['supplier:read', 'supplier:write:information'])]
+ private ?string $revenueAmount = null;
+
+ #[ORM\Column(length: 120, nullable: true)]
+ #[Assert\Length(max: 120, maxMessage: 'Le nom du dirigeant ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Groups(['supplier:read', 'supplier:write:information'])]
+ private ?string $directorName = null;
+
+ #[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)]
+ #[Groups(['supplier:read', 'supplier:write:information'])]
+ private ?string $profitAmount = null;
+
+ // NEW vs Client : Volume previsionnel (entier).
+ #[ORM\Column(nullable: true)]
+ #[Assert\PositiveOrZero(message: 'Le volume prévisionnel doit être un nombre positif ou nul.')]
+ #[Groups(['supplier:read', 'supplier:write:information'])]
+ private ?int $volumeForecast = null;
+
+ // === Onglet Comptabilite ===
+ // Lecture conditionnee via le groupe `supplier:read:accounting` (ajoute au
+ // contexte par le SupplierReadGroupContextBuilder si l'user a accounting.view,
+ // ERP-87 — un Provider ne peut pas influencer les groupes de serialisation).
+ // Ecriture via `supplier:write:accounting` (le Processor exige accounting.manage).
+ #[ORM\Column(length: 20, nullable: true)]
+ #[Assert\Length(max: 20, maxMessage: 'Le SIREN ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
+ private ?string $siren = null;
+
+ #[ORM\Column(length: 40, nullable: true)]
+ #[Assert\Length(max: 40, maxMessage: 'Le numéro de compte ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
+ private ?string $accountNumber = null;
+
+ #[ORM\ManyToOne(targetEntity: TvaMode::class)]
+ #[ORM\JoinColumn(name: 'tva_mode_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
+ #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
+ private ?TvaMode $tvaMode = null;
+
+ #[ORM\Column(length: 40, nullable: true)]
+ #[Assert\Length(max: 40, maxMessage: 'Le numéro de TVA ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
+ private ?string $nTva = null;
+
+ #[ORM\ManyToOne(targetEntity: PaymentDelay::class)]
+ #[ORM\JoinColumn(name: 'payment_delay_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
+ #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
+ private ?PaymentDelay $paymentDelay = null;
+
+ #[ORM\ManyToOne(targetEntity: PaymentType::class)]
+ #[ORM\JoinColumn(name: 'payment_type_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
+ #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
+ private ?PaymentType $paymentType = null;
+
+ #[ORM\ManyToOne(targetEntity: Bank::class)]
+ #[ORM\JoinColumn(name: 'bank_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
+ #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
+ private ?Bank $bank = null;
+
+ // === Sous-collections — EMBARQUEES dans le DETAIL (RETEX M1 §2) ===
+ // Maillon (a) : le read-group est porte par le GETTER (getContacts / getAddresses
+ // / getRibs) — sans #[Groups], jamais serialisees. Edition via sous-ressources
+ // POST/PATCH/DELETE (ERP-88).
+ /** @var Collection */
+ #[ORM\OneToMany(mappedBy: 'supplier', targetEntity: SupplierContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ private Collection $contacts;
+
+ /** @var Collection */
+ #[ORM\OneToMany(mappedBy: 'supplier', targetEntity: SupplierAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ private Collection $addresses;
+
+ /** @var Collection */
+ #[ORM\OneToMany(mappedBy: 'supplier', targetEntity: SupplierRib::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ private Collection $ribs;
+
+ // === Archive / Soft delete ===
+ // Groupe d'ECRITURE uniquement sur la propriete (denormalisation PATCH archive).
+ // Le groupe de LECTURE est declare sur le getter isArchived() avec
+ // SerializedName('isArchived') : sans cela, Symfony strip le prefixe "is" et
+ // exposerait la cle JSON "archived" — en pratique la cle est totalement
+ // DROPPEE (piege n°3 du M1). Pattern corrige : Groups + SerializedName sur le getter.
+ #[ORM\Column(name: 'is_archived', options: ['default' => false])]
+ #[Groups(['supplier:write:archive'])]
+ private bool $isArchived = false;
+
+ #[ORM\Column(type: 'datetime_immutable', nullable: true)]
+ #[Groups(['supplier:read'])]
+ private ?DateTimeImmutable $archivedAt = null;
+
+ // Soft delete technique (HP M3) : non expose en lecture/ecriture au M2.
+ #[ORM\Column(type: 'datetime_immutable', nullable: true)]
+ private ?DateTimeImmutable $deletedAt = null;
+
+ public function __construct()
+ {
+ $this->categories = new ArrayCollection();
+ $this->contacts = new ArrayCollection();
+ $this->addresses = new ArrayCollection();
+ $this->ribs = new ArrayCollection();
+ }
+
+ /**
+ * RG-2.10 : toute categorie posee sur le fournisseur doit etre de type
+ * FOURNISSEUR -> sinon 422 avec violation sur le champ `categories`
+ * (propertyPath aligne ERP-101, message FR ERP-107). Miroir de
+ * SupplierAddress::validateCategoryType (ERP-88). S'appuie sur
+ * CategoryInterface::getCategoryTypeCodes() (multi-type — la categorie est
+ * acceptee des qu'elle PORTE le type FOURNISSEUR ; pas d'import du module
+ * Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API
+ * Platform, sur POST (categories ∈ supplier:write:main) comme sur PATCH.
+ */
+ #[Assert\Callback]
+ public function validateCategoryType(ExecutionContextInterface $context): void
+ {
+ foreach ($this->categories as $category) {
+ if ($category instanceof CategoryInterface
+ && !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) {
+ $context->buildViolation('Type de catégorie non autorisé (FOURNISSEUR attendu).')
+ ->atPath('categories')
+ ->addViolation()
+ ;
+
+ return;
+ }
+ }
+ }
+
+ /**
+ * RG-2.07 / RG-2.08 : coherence du type de reglement comptable. Decision
+ * figee ERP-89 : ces RG inter-champs passent par une contrainte d'entite
+ * (Assert\Callback + ->atPath()) et NON par le SupplierProcessor, afin que
+ * chaque 422 porte un propertyPath exploitable par extractApiViolations
+ * (mapping inline sous le champ, pas un toast — convention ERP-101).
+ * - RG-2.07 : paymentType = VIREMENT impose une banque -> violation sur `bank`.
+ * - RG-2.08 : paymentType = LCR impose au moins un RIB -> violation sur `ribs`
+ * (le 409 sur DELETE du dernier RIB en LCR est porte par ERP-88).
+ *
+ * Ces champs vivant dans le groupe d'ecriture comptable (absent du POST, qui
+ * n'expose que supplier:write:main), la contrainte ne mord en pratique que
+ * sur le PATCH de l'onglet Comptabilite.
+ */
+ #[Assert\Callback]
+ public function validatePaymentTypeConsistency(ExecutionContextInterface $context): void
+ {
+ $paymentCode = $this->paymentType?->getCode();
+
+ if (self::PAYMENT_TYPE_VIREMENT === $paymentCode && null === $this->bank) {
+ $context->buildViolation('La banque est obligatoire pour le type de règlement Virement.')
+ ->atPath('bank')
+ ->addViolation()
+ ;
+ }
+
+ if (self::PAYMENT_TYPE_LCR === $paymentCode && $this->ribs->isEmpty()) {
+ $context->buildViolation('Au moins un RIB est obligatoire pour le type de règlement LCR.')
+ ->atPath('ribs')
+ ->addViolation()
+ ;
+ }
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getCompanyName(): ?string
+ {
+ return $this->companyName;
+ }
+
+ public function setCompanyName(string $companyName): static
+ {
+ $this->companyName = $companyName;
+
+ return $this;
+ }
+
+ /** @return Collection */
+ public function getCategories(): Collection
+ {
+ return $this->categories;
+ }
+
+ public function addCategory(CategoryInterface $category): static
+ {
+ if (!$this->categories->contains($category)) {
+ $this->categories->add($category);
+ }
+
+ return $this;
+ }
+
+ public function removeCategory(CategoryInterface $category): static
+ {
+ $this->categories->removeElement($category);
+
+ return $this;
+ }
+
+ public function getDescription(): ?string
+ {
+ return $this->description;
+ }
+
+ public function setDescription(?string $description): static
+ {
+ $this->description = $description;
+
+ return $this;
+ }
+
+ public function getCompetitors(): ?string
+ {
+ return $this->competitors;
+ }
+
+ public function setCompetitors(?string $competitors): static
+ {
+ $this->competitors = $competitors;
+
+ return $this;
+ }
+
+ public function getFoundedAt(): ?DateTimeImmutable
+ {
+ return $this->foundedAt;
+ }
+
+ public function setFoundedAt(?DateTimeImmutable $foundedAt): static
+ {
+ $this->foundedAt = $foundedAt;
+
+ return $this;
+ }
+
+ public function getEmployeesCount(): ?int
+ {
+ return $this->employeesCount;
+ }
+
+ public function setEmployeesCount(?int $employeesCount): static
+ {
+ $this->employeesCount = $employeesCount;
+
+ return $this;
+ }
+
+ public function getRevenueAmount(): ?string
+ {
+ return $this->revenueAmount;
+ }
+
+ public function setRevenueAmount(?string $revenueAmount): static
+ {
+ $this->revenueAmount = $revenueAmount;
+
+ return $this;
+ }
+
+ public function getDirectorName(): ?string
+ {
+ return $this->directorName;
+ }
+
+ public function setDirectorName(?string $directorName): static
+ {
+ $this->directorName = $directorName;
+
+ return $this;
+ }
+
+ public function getProfitAmount(): ?string
+ {
+ return $this->profitAmount;
+ }
+
+ public function setProfitAmount(?string $profitAmount): static
+ {
+ $this->profitAmount = $profitAmount;
+
+ return $this;
+ }
+
+ public function getVolumeForecast(): ?int
+ {
+ return $this->volumeForecast;
+ }
+
+ public function setVolumeForecast(?int $volumeForecast): static
+ {
+ $this->volumeForecast = $volumeForecast;
+
+ return $this;
+ }
+
+ public function getSiren(): ?string
+ {
+ return $this->siren;
+ }
+
+ public function setSiren(?string $siren): static
+ {
+ $this->siren = $siren;
+
+ return $this;
+ }
+
+ public function getAccountNumber(): ?string
+ {
+ return $this->accountNumber;
+ }
+
+ public function setAccountNumber(?string $accountNumber): static
+ {
+ $this->accountNumber = $accountNumber;
+
+ return $this;
+ }
+
+ public function getTvaMode(): ?TvaMode
+ {
+ return $this->tvaMode;
+ }
+
+ public function setTvaMode(?TvaMode $tvaMode): static
+ {
+ $this->tvaMode = $tvaMode;
+
+ return $this;
+ }
+
+ public function getNTva(): ?string
+ {
+ return $this->nTva;
+ }
+
+ public function setNTva(?string $nTva): static
+ {
+ $this->nTva = $nTva;
+
+ return $this;
+ }
+
+ public function getPaymentDelay(): ?PaymentDelay
+ {
+ return $this->paymentDelay;
+ }
+
+ public function setPaymentDelay(?PaymentDelay $paymentDelay): static
+ {
+ $this->paymentDelay = $paymentDelay;
+
+ return $this;
+ }
+
+ public function getPaymentType(): ?PaymentType
+ {
+ return $this->paymentType;
+ }
+
+ public function setPaymentType(?PaymentType $paymentType): static
+ {
+ $this->paymentType = $paymentType;
+
+ return $this;
+ }
+
+ public function getBank(): ?Bank
+ {
+ return $this->bank;
+ }
+
+ public function setBank(?Bank $bank): static
+ {
+ $this->bank = $bank;
+
+ return $this;
+ }
+
+ /** @return Collection */
+ #[Groups(['supplier:item:read'])]
+ public function getContacts(): Collection
+ {
+ return $this->contacts;
+ }
+
+ public function addContact(SupplierContact $contact): static
+ {
+ if (!$this->contacts->contains($contact)) {
+ $this->contacts->add($contact);
+ $contact->setSupplier($this);
+ }
+
+ return $this;
+ }
+
+ public function removeContact(SupplierContact $contact): static
+ {
+ if ($this->contacts->removeElement($contact) && $contact->getSupplier() === $this) {
+ $contact->setSupplier(null);
+ }
+
+ return $this;
+ }
+
+ /** @return Collection */
+ #[Groups(['supplier:item:read'])]
+ public function getAddresses(): Collection
+ {
+ return $this->addresses;
+ }
+
+ public function addAddress(SupplierAddress $address): static
+ {
+ if (!$this->addresses->contains($address)) {
+ $this->addresses->add($address);
+ $address->setSupplier($this);
+ }
+
+ return $this;
+ }
+
+ public function removeAddress(SupplierAddress $address): static
+ {
+ if ($this->addresses->removeElement($address) && $address->getSupplier() === $this) {
+ $address->setSupplier(null);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Sites distincts rattaches a au moins une adresse du fournisseur (RG-2.06).
+ * Le fournisseur ne porte pas de sites en propre : ils vivent sur les
+ * adresses. Agrege en lecture seule pour la colonne « Site(s) » du Repertoire
+ * (badges colores) — expose en LISTE via le groupe supplier:read (les adresses
+ * completes restent reservees au detail, supplier:item:read). Site n'a pas de
+ * champ `code` : libelle = `name`, prefixe = `postalCode` (§ 2.4 / § 4.0.ter).
+ *
+ * Fetch-join obligatoire (addresses.sites) cote repository pour eviter le N+1
+ * a la serialisation de la liste (cf. DoctrineSupplierRepository, § 2.12).
+ *
+ * @return list
+ */
+ #[Groups(['supplier:read'])]
+ public function getSites(): array
+ {
+ $sites = [];
+ foreach ($this->addresses as $address) {
+ foreach ($address->getSites() as $site) {
+ // Deduplication par identite d'objet : un meme site peut etre
+ // rattache a plusieurs adresses du fournisseur.
+ $sites[spl_object_id($site)] = $site;
+ }
+ }
+
+ return array_values($sites);
+ }
+
+ // Embed gate sur le groupe COMPTABLE (et non supplier:item:read comme contacts/
+ // adresses) : supplier:read:accounting n'est ajoute au contexte que si l'user a
+ // accounting.view (SupplierReadGroupContextBuilder, ERP-87). Resultat : la cle `ribs` est
+ // TOTALEMENT ABSENTE du detail pour un user sans accounting.view (ex. Commerciale),
+ // au meme titre que les scalaires comptables — evite la fuite IBAN/BIC (piege n°4 M1).
+ /** @return Collection */
+ #[Groups(['supplier:read:accounting'])]
+ public function getRibs(): Collection
+ {
+ return $this->ribs;
+ }
+
+ public function addRib(SupplierRib $rib): static
+ {
+ if (!$this->ribs->contains($rib)) {
+ $this->ribs->add($rib);
+ $rib->setSupplier($this);
+ }
+
+ return $this;
+ }
+
+ public function removeRib(SupplierRib $rib): static
+ {
+ if ($this->ribs->removeElement($rib) && $rib->getSupplier() === $this) {
+ $rib->setSupplier(null);
+ }
+
+ return $this;
+ }
+
+ // Groupe de lecture + nom serialise explicite : sans SerializedName, Symfony
+ // exposerait la cle "archived" (strip du prefixe "is" sur les getters) et
+ // droppait silencieusement la cle du JSON (piege n°3 du M1).
+ #[Groups(['supplier:read'])]
+ #[SerializedName('isArchived')]
+ public function isArchived(): bool
+ {
+ return $this->isArchived;
+ }
+
+ public function setIsArchived(bool $isArchived): static
+ {
+ $this->isArchived = $isArchived;
+
+ return $this;
+ }
+
+ public function getArchivedAt(): ?DateTimeImmutable
+ {
+ return $this->archivedAt;
+ }
+
+ public function setArchivedAt(?DateTimeImmutable $archivedAt): static
+ {
+ $this->archivedAt = $archivedAt;
+
+ return $this;
+ }
+
+ public function getDeletedAt(): ?DateTimeImmutable
+ {
+ return $this->deletedAt;
+ }
+
+ public function setDeletedAt(?DateTimeImmutable $deletedAt): static
+ {
+ $this->deletedAt = $deletedAt;
+
+ return $this;
+ }
+}
diff --git a/src/Module/Commercial/Domain/Entity/SupplierAddress.php b/src/Module/Commercial/Domain/Entity/SupplierAddress.php
new file mode 100644
index 0000000..6667675
--- /dev/null
+++ b/src/Module/Commercial/Domain/Entity/SupplierAddress.php
@@ -0,0 +1,438 @@
+ ['supplier:item:read', 'site:read', 'category:read', 'default:read']],
+ ),
+ new Post(
+ uriTemplate: '/suppliers/{supplierId}/addresses',
+ uriVariables: [
+ 'supplierId' => new Link(fromClass: Supplier::class, toProperty: 'supplier'),
+ ],
+ // read:false : pas de stade lecture du parent. Le Link toProperty
+ // resoudrait l'enfant (SELECT SupplierAddress ... WHERE supplier = :id)
+ // et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
+ // manuellement par SupplierAddressProcessor::linkParent (404 si absent).
+ read: false,
+ security: "is_granted('commercial.suppliers.manage')",
+ normalizationContext: ['groups' => ['supplier:item:read', 'site:read', 'category:read', 'default:read']],
+ denormalizationContext: ['groups' => ['supplier:write:addresses']],
+ processor: SupplierAddressProcessor::class,
+ ),
+ new Patch(
+ security: "is_granted('commercial.suppliers.manage')",
+ normalizationContext: ['groups' => ['supplier:item:read', 'site:read', 'category:read', 'default:read']],
+ denormalizationContext: ['groups' => ['supplier:write:addresses']],
+ processor: SupplierAddressProcessor::class,
+ ),
+ new Delete(
+ security: "is_granted('commercial.suppliers.manage')",
+ processor: SupplierAddressProcessor::class,
+ ),
+ ],
+)]
+#[ORM\Entity(repositoryClass: DoctrineSupplierAddressRepository::class)]
+#[ORM\Table(name: 'supplier_address')]
+#[ORM\Index(name: 'idx_supplier_address_supplier', columns: ['supplier_id'])]
+#[Auditable]
+class SupplierAddress implements TimestampableInterface, BlamableInterface
+{
+ use TimestampableBlamableTrait;
+
+ /**
+ * Valeurs autorisees de address_type (RG-2.09). Miroir applicatif du CHECK BDD
+ * chk_supplier_address_type : alimente l'Assert\Choice (422 propre rattachee
+ * au champ avant la base) et reste la source des options cote front.
+ */
+ public const array ADDRESS_TYPES = ['PROSPECT', 'DEPART', 'RENDU'];
+
+ /**
+ * RG-2.10 : seules les categories PORTANT ce type sont autorisees sur une
+ * adresse fournisseur. S'appuie sur CategoryInterface::getCategoryTypeCodes()
+ * (pas d'import du module Catalog — regle ABSOLUE n°1).
+ */
+ private const string REQUIRED_CATEGORY_TYPE_CODE = 'FOURNISSEUR';
+
+ #[ORM\Id]
+ #[ORM\GeneratedValue]
+ #[ORM\Column]
+ #[Groups(['supplier:item:read'])]
+ private ?int $id = null;
+
+ #[ORM\ManyToOne(targetEntity: Supplier::class, inversedBy: 'addresses')]
+ #[ORM\JoinColumn(name: 'supplier_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
+ private ?Supplier $supplier = null;
+
+ // RG-2.09 : enum exclusif. La valeur est bornee par Assert\Choice (longueur de
+ // fait <= 8), d'ou la whitelist du miroir Assert\Length == ORM length (ERP-107,
+ // EntityConstraintsHaveFrenchMessageTest::EXCLUDED_LENGTH_MIRROR).
+ #[ORM\Column(length: 20)]
+ #[Assert\NotBlank(message: 'Le type d\'adresse est obligatoire.', normalizer: 'trim')]
+ #[Assert\Choice(choices: self::ADDRESS_TYPES, message: 'Le type d\'adresse doit être Prospect, Départ ou Rendu.')]
+ #[Groups(['supplier:item:read', 'supplier:write:addresses'])]
+ private ?string $addressType = null;
+
+ #[ORM\Column(length: 80, options: ['default' => 'France'])]
+ #[Assert\Length(max: 80, maxMessage: 'Le pays ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Groups(['supplier:item:read', 'supplier:write:addresses'])]
+ private string $country = 'France';
+
+ // RG-2.05 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur).
+ // Le Regex borne deja la longueur (<= 5) : pas de Length redondant (whitelist).
+ #[ORM\Column(length: 20)]
+ #[Assert\NotBlank(message: 'Le code postal est obligatoire.', normalizer: 'trim')]
+ #[Assert\Regex(pattern: '/^[0-9]{4,5}$/', message: 'Le code postal doit comporter 4 ou 5 chiffres.')]
+ #[Groups(['supplier:item:read', 'supplier:write:addresses'])]
+ private ?string $postalCode = null;
+
+ #[ORM\Column(length: 120)]
+ #[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')]
+ #[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Groups(['supplier:item:read', 'supplier:write:addresses'])]
+ private ?string $city = null;
+
+ #[ORM\Column(length: 255)]
+ #[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')]
+ #[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Groups(['supplier:item:read', 'supplier:write:addresses'])]
+ private ?string $street = null;
+
+ #[ORM\Column(length: 255, nullable: true)]
+ #[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Groups(['supplier:item:read', 'supplier:write:addresses'])]
+ private ?string $streetComplement = null;
+
+ // Specifique fournisseur : nombre de bennes sur le site.
+ #[ORM\Column(nullable: true)]
+ #[Assert\PositiveOrZero(message: 'Le nombre de bennes doit être un nombre positif ou nul.')]
+ #[Groups(['supplier:item:read', 'supplier:write:addresses'])]
+ private ?int $bennes = null;
+
+ // Specifique fournisseur : prestataire de triage sur cette adresse. Groupe
+ // d'ECRITURE uniquement sur la propriete ; le groupe de LECTURE est porte par
+ // le getter isTriageProvider() avec SerializedName('triageProvider') — sinon
+ // Symfony strip le prefixe "is" et droppe la cle (piege n°3 du M1).
+ #[ORM\Column(name: 'triage_provider', options: ['default' => false])]
+ #[Groups(['supplier:write:addresses'])]
+ private bool $triageProvider = false;
+
+ // Ordre d'affichage de l'adresse (gere serveur, non expose au M2).
+ #[ORM\Column(options: ['default' => 0])]
+ private int $position = 0;
+
+ // RG-2.06 : au moins un site rattache a chaque adresse.
+ /** @var Collection */
+ #[ORM\ManyToMany(targetEntity: SiteInterface::class)]
+ #[ORM\JoinTable(name: 'supplier_address_site')]
+ #[ORM\JoinColumn(name: 'supplier_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
+ #[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
+ #[Assert\Count(min: 1, minMessage: 'Au moins un site est obligatoire.')]
+ #[Groups(['supplier:item:read', 'supplier:write:addresses'])]
+ private Collection $sites;
+
+ /** @var Collection */
+ #[ORM\ManyToMany(targetEntity: SupplierContact::class)]
+ #[ORM\JoinTable(name: 'supplier_address_contact')]
+ #[ORM\JoinColumn(name: 'supplier_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
+ #[ORM\InverseJoinColumn(name: 'supplier_contact_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
+ #[Groups(['supplier:item:read', 'supplier:write:addresses'])]
+ private Collection $contacts;
+
+ // RG-2.10 : categories d'adresse de type FOURNISSEUR (controle au Processor).
+ /** @var Collection */
+ #[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
+ #[ORM\JoinTable(name: 'supplier_address_category')]
+ #[ORM\JoinColumn(name: 'supplier_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
+ #[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
+ #[Groups(['supplier:item:read', 'supplier:write:addresses'])]
+ private Collection $categories;
+
+ public function __construct()
+ {
+ $this->sites = new ArrayCollection();
+ $this->contacts = new ArrayCollection();
+ $this->categories = new ArrayCollection();
+ }
+
+ /**
+ * RG-2.10 : toute categorie posee sur une adresse fournisseur doit etre de
+ * type FOURNISSEUR -> sinon 422 avec violation sur le champ `categories`
+ * (propertyPath aligne ERP-101, message FR ERP-107). S'appuie sur
+ * CategoryInterface::getCategoryTypeCodes() (multi-type — la categorie est
+ * acceptee des qu'elle PORTE le type FOURNISSEUR ; pas d'import du module
+ * Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API Platform.
+ */
+ #[Assert\Callback]
+ public function validateCategoryType(ExecutionContextInterface $context): void
+ {
+ foreach ($this->categories as $category) {
+ if ($category instanceof CategoryInterface
+ && !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) {
+ $context->buildViolation('Type de catégorie non autorisé (FOURNISSEUR attendu).')
+ ->atPath('categories')
+ ->addViolation()
+ ;
+
+ return;
+ }
+ }
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getSupplier(): ?Supplier
+ {
+ return $this->supplier;
+ }
+
+ public function setSupplier(?Supplier $supplier): static
+ {
+ $this->supplier = $supplier;
+
+ return $this;
+ }
+
+ public function getAddressType(): ?string
+ {
+ return $this->addressType;
+ }
+
+ public function setAddressType(?string $addressType): static
+ {
+ $this->addressType = $addressType;
+
+ return $this;
+ }
+
+ public function getCountry(): string
+ {
+ return $this->country;
+ }
+
+ public function setCountry(string $country): static
+ {
+ $this->country = $country;
+
+ return $this;
+ }
+
+ public function getPostalCode(): ?string
+ {
+ return $this->postalCode;
+ }
+
+ public function setPostalCode(?string $postalCode): static
+ {
+ $this->postalCode = $postalCode;
+
+ return $this;
+ }
+
+ public function getCity(): ?string
+ {
+ return $this->city;
+ }
+
+ public function setCity(?string $city): static
+ {
+ $this->city = $city;
+
+ return $this;
+ }
+
+ public function getStreet(): ?string
+ {
+ return $this->street;
+ }
+
+ public function setStreet(?string $street): static
+ {
+ $this->street = $street;
+
+ return $this;
+ }
+
+ public function getStreetComplement(): ?string
+ {
+ return $this->streetComplement;
+ }
+
+ public function setStreetComplement(?string $streetComplement): static
+ {
+ $this->streetComplement = $streetComplement;
+
+ return $this;
+ }
+
+ public function getBennes(): ?int
+ {
+ return $this->bennes;
+ }
+
+ public function setBennes(?int $bennes): static
+ {
+ $this->bennes = $bennes;
+
+ return $this;
+ }
+
+ // Groupe de lecture + nom serialise explicite (cf. note sur la propriete) :
+ // sans SerializedName, Symfony exposerait la cle "triage" (strip du prefixe
+ // "is") et, le groupe etant sur la propriete `triageProvider`, droppait
+ // silencieusement la cle du JSON.
+ #[Groups(['supplier:item:read'])]
+ #[SerializedName('triageProvider')]
+ public function isTriageProvider(): bool
+ {
+ return $this->triageProvider;
+ }
+
+ public function setTriageProvider(bool $triageProvider): static
+ {
+ $this->triageProvider = $triageProvider;
+
+ return $this;
+ }
+
+ public function getPosition(): int
+ {
+ return $this->position;
+ }
+
+ public function setPosition(int $position): static
+ {
+ $this->position = $position;
+
+ return $this;
+ }
+
+ /** @return Collection */
+ public function getSites(): Collection
+ {
+ return $this->sites;
+ }
+
+ public function addSite(SiteInterface $site): static
+ {
+ if (!$this->sites->contains($site)) {
+ $this->sites->add($site);
+ }
+
+ return $this;
+ }
+
+ public function removeSite(SiteInterface $site): static
+ {
+ $this->sites->removeElement($site);
+
+ return $this;
+ }
+
+ /** @return Collection */
+ public function getContacts(): Collection
+ {
+ return $this->contacts;
+ }
+
+ public function addContact(SupplierContact $contact): static
+ {
+ if (!$this->contacts->contains($contact)) {
+ $this->contacts->add($contact);
+ }
+
+ return $this;
+ }
+
+ public function removeContact(SupplierContact $contact): static
+ {
+ $this->contacts->removeElement($contact);
+
+ return $this;
+ }
+
+ /** @return Collection */
+ public function getCategories(): Collection
+ {
+ return $this->categories;
+ }
+
+ public function addCategory(CategoryInterface $category): static
+ {
+ if (!$this->categories->contains($category)) {
+ $this->categories->add($category);
+ }
+
+ return $this;
+ }
+
+ public function removeCategory(CategoryInterface $category): static
+ {
+ $this->categories->removeElement($category);
+
+ return $this;
+ }
+}
diff --git a/src/Module/Commercial/Domain/Entity/SupplierContact.php b/src/Module/Commercial/Domain/Entity/SupplierContact.php
new file mode 100644
index 0000000..de48188
--- /dev/null
+++ b/src/Module/Commercial/Domain/Entity/SupplierContact.php
@@ -0,0 +1,238 @@
+ ['supplier:item:read']],
+ ),
+ new Post(
+ uriTemplate: '/suppliers/{supplierId}/contacts',
+ uriVariables: [
+ 'supplierId' => new Link(fromClass: Supplier::class, toProperty: 'supplier'),
+ ],
+ // read:false : pas de stade lecture du parent. Le Link toProperty
+ // resoudrait l'enfant (SELECT SupplierContact ... WHERE supplier = :id)
+ // et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
+ // manuellement par SupplierContactProcessor::linkParent (404 si absent).
+ read: false,
+ security: "is_granted('commercial.suppliers.manage')",
+ normalizationContext: ['groups' => ['supplier:item:read']],
+ denormalizationContext: ['groups' => ['supplier:write:contacts']],
+ processor: SupplierContactProcessor::class,
+ ),
+ new Patch(
+ security: "is_granted('commercial.suppliers.manage')",
+ normalizationContext: ['groups' => ['supplier:item:read']],
+ denormalizationContext: ['groups' => ['supplier:write:contacts']],
+ processor: SupplierContactProcessor::class,
+ ),
+ new Delete(
+ security: "is_granted('commercial.suppliers.manage')",
+ processor: SupplierContactProcessor::class,
+ ),
+ ],
+)]
+#[ORM\Entity(repositoryClass: DoctrineSupplierContactRepository::class)]
+#[ORM\Table(name: 'supplier_contact')]
+#[ORM\Index(name: 'idx_supplier_contact_supplier', columns: ['supplier_id'])]
+#[Auditable]
+class SupplierContact implements TimestampableInterface, BlamableInterface
+{
+ use TimestampableBlamableTrait;
+
+ #[ORM\Id]
+ #[ORM\GeneratedValue]
+ #[ORM\Column]
+ #[Groups(['supplier:item:read'])]
+ private ?int $id = null;
+
+ #[ORM\ManyToOne(targetEntity: Supplier::class, inversedBy: 'contacts')]
+ #[ORM\JoinColumn(name: 'supplier_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
+ private ?Supplier $supplier = null;
+
+ // RG-2.04 : firstName OU lastName obligatoire (CHECK BDD + Processor). Les
+ // deux restent nullable au niveau ORM.
+ #[ORM\Column(length: 120, nullable: true)]
+ #[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Groups(['supplier:item:read', 'supplier:write:contacts'])]
+ private ?string $firstName = null;
+
+ #[ORM\Column(length: 120, nullable: true)]
+ #[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Groups(['supplier:item:read', 'supplier:write:contacts'])]
+ private ?string $lastName = null;
+
+ #[ORM\Column(length: 120, nullable: true)]
+ #[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Groups(['supplier:item:read', 'supplier:write:contacts'])]
+ private ?string $jobTitle = null;
+
+ // RG : pas de validation de format telephone (saisie libre), mais une
+ // Assert\Length calee sur la colonne VARCHAR(20) evite l'erreur Postgres
+ // (500 non rattachee au champ) au profit d'une 422 propre (ERP-107).
+ #[ORM\Column(length: 20, nullable: true)]
+ #[Assert\Length(max: 20, maxMessage: 'Le téléphone ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Groups(['supplier:item:read', 'supplier:write:contacts'])]
+ private ?string $phonePrimary = null;
+
+ #[ORM\Column(length: 20, nullable: true)]
+ #[Assert\Length(max: 20, maxMessage: 'Le téléphone secondaire ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Groups(['supplier:item:read', 'supplier:write:contacts'])]
+ private ?string $phoneSecondary = null;
+
+ #[ORM\Column(length: 180, nullable: true)]
+ #[Assert\Email(message: 'L\'adresse email n\'est pas valide.')]
+ #[Assert\Length(max: 180, maxMessage: 'L\'email ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Groups(['supplier:item:read', 'supplier:write:contacts'])]
+ private ?string $email = null;
+
+ // Ordre d'affichage du contact (gere serveur, non expose au M2).
+ #[ORM\Column(options: ['default' => 0])]
+ private int $position = 0;
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getSupplier(): ?Supplier
+ {
+ return $this->supplier;
+ }
+
+ public function setSupplier(?Supplier $supplier): static
+ {
+ $this->supplier = $supplier;
+
+ return $this;
+ }
+
+ public function getFirstName(): ?string
+ {
+ return $this->firstName;
+ }
+
+ public function setFirstName(?string $firstName): static
+ {
+ $this->firstName = $firstName;
+
+ return $this;
+ }
+
+ public function getLastName(): ?string
+ {
+ return $this->lastName;
+ }
+
+ public function setLastName(?string $lastName): static
+ {
+ $this->lastName = $lastName;
+
+ return $this;
+ }
+
+ public function getJobTitle(): ?string
+ {
+ return $this->jobTitle;
+ }
+
+ public function setJobTitle(?string $jobTitle): static
+ {
+ $this->jobTitle = $jobTitle;
+
+ return $this;
+ }
+
+ public function getPhonePrimary(): ?string
+ {
+ return $this->phonePrimary;
+ }
+
+ public function setPhonePrimary(?string $phonePrimary): static
+ {
+ $this->phonePrimary = $phonePrimary;
+
+ return $this;
+ }
+
+ public function getPhoneSecondary(): ?string
+ {
+ return $this->phoneSecondary;
+ }
+
+ public function setPhoneSecondary(?string $phoneSecondary): static
+ {
+ $this->phoneSecondary = $phoneSecondary;
+
+ return $this;
+ }
+
+ public function getEmail(): ?string
+ {
+ return $this->email;
+ }
+
+ public function setEmail(?string $email): static
+ {
+ $this->email = $email;
+
+ return $this;
+ }
+
+ public function getPosition(): int
+ {
+ return $this->position;
+ }
+
+ public function setPosition(int $position): static
+ {
+ $this->position = $position;
+
+ return $this;
+ }
+}
diff --git a/src/Module/Commercial/Domain/Entity/SupplierRib.php b/src/Module/Commercial/Domain/Entity/SupplierRib.php
new file mode 100644
index 0000000..7ef1a2f
--- /dev/null
+++ b/src/Module/Commercial/Domain/Entity/SupplierRib.php
@@ -0,0 +1,188 @@
+ ['supplier:read:accounting']],
+ ),
+ new Post(
+ uriTemplate: '/suppliers/{supplierId}/ribs',
+ uriVariables: [
+ 'supplierId' => new Link(fromClass: Supplier::class, toProperty: 'supplier'),
+ ],
+ // read:false : pas de stade lecture du parent. Le Link toProperty
+ // resoudrait l'enfant (SELECT SupplierRib ... WHERE supplier = :id) et
+ // casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
+ // manuellement par SupplierRibProcessor::linkParent (404 si absent).
+ read: false,
+ security: "is_granted('commercial.suppliers.accounting.manage')",
+ normalizationContext: ['groups' => ['supplier:read:accounting']],
+ denormalizationContext: ['groups' => ['supplier:write:accounting']],
+ processor: SupplierRibProcessor::class,
+ ),
+ new Patch(
+ security: "is_granted('commercial.suppliers.accounting.manage')",
+ normalizationContext: ['groups' => ['supplier:read:accounting']],
+ denormalizationContext: ['groups' => ['supplier:write:accounting']],
+ processor: SupplierRibProcessor::class,
+ ),
+ new Delete(
+ security: "is_granted('commercial.suppliers.accounting.manage')",
+ processor: SupplierRibProcessor::class,
+ ),
+ ],
+)]
+#[ORM\Entity(repositoryClass: DoctrineSupplierRibRepository::class)]
+#[ORM\Table(name: 'supplier_rib')]
+#[ORM\Index(name: 'idx_supplier_rib_supplier', columns: ['supplier_id'])]
+#[Auditable]
+class SupplierRib implements TimestampableInterface, BlamableInterface
+{
+ use TimestampableBlamableTrait;
+
+ #[ORM\Id]
+ #[ORM\GeneratedValue]
+ #[ORM\Column]
+ #[Groups(['supplier:read:accounting'])]
+ private ?int $id = null;
+
+ #[ORM\ManyToOne(targetEntity: Supplier::class, inversedBy: 'ribs')]
+ #[ORM\JoinColumn(name: 'supplier_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
+ private ?Supplier $supplier = null;
+
+ #[ORM\Column(length: 120)]
+ #[Assert\NotBlank(message: 'Le libellé du RIB est obligatoire.', normalizer: 'trim')]
+ #[Assert\Length(max: 120, maxMessage: 'Le libellé ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
+ private ?string $label = null;
+
+ // Bic/Iban bornent deja le format (et donc la longueur) : pas de Length
+ // redondant calee sur la colonne (auto-exempte du miroir ERP-107).
+ #[ORM\Column(length: 20)]
+ #[Assert\NotBlank(message: 'Le BIC est obligatoire.', normalizer: 'trim')]
+ #[Assert\Bic(message: 'Le BIC n\'est pas valide.')]
+ #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
+ private ?string $bic = null;
+
+ #[ORM\Column(length: 34)]
+ #[Assert\NotBlank(message: 'L\'IBAN est obligatoire.', normalizer: 'trim')]
+ #[Assert\Iban(message: 'L\'IBAN n\'est pas valide.')]
+ #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
+ private ?string $iban = null;
+
+ // Ordre d'affichage du RIB (gere serveur, non expose au M2).
+ #[ORM\Column(options: ['default' => 0])]
+ private int $position = 0;
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getSupplier(): ?Supplier
+ {
+ return $this->supplier;
+ }
+
+ public function setSupplier(?Supplier $supplier): static
+ {
+ $this->supplier = $supplier;
+
+ return $this;
+ }
+
+ public function getLabel(): ?string
+ {
+ return $this->label;
+ }
+
+ public function setLabel(string $label): static
+ {
+ $this->label = $label;
+
+ return $this;
+ }
+
+ public function getBic(): ?string
+ {
+ return $this->bic;
+ }
+
+ public function setBic(string $bic): static
+ {
+ $this->bic = $bic;
+
+ return $this;
+ }
+
+ public function getIban(): ?string
+ {
+ return $this->iban;
+ }
+
+ public function setIban(string $iban): static
+ {
+ $this->iban = $iban;
+
+ return $this;
+ }
+
+ public function getPosition(): int
+ {
+ return $this->position;
+ }
+
+ public function setPosition(int $position): static
+ {
+ $this->position = $position;
+
+ return $this;
+ }
+}
diff --git a/src/Module/Commercial/Domain/Entity/TvaMode.php b/src/Module/Commercial/Domain/Entity/TvaMode.php
index 989fb02..e28072f 100644
--- a/src/Module/Commercial/Domain/Entity/TvaMode.php
+++ b/src/Module/Commercial/Domain/Entity/TvaMode.php
@@ -17,18 +17,20 @@ use Symfony\Component\Serializer\Attribute\Groups;
* re-seede en dev/test par CommercialReferentialFixtures.
*
* Lecture seule au M1 (HP-M2-2) : seules GetCollection et Get sont exposees
- * (ERP-56), sous la permission commercial.clients.view ; aucune ecriture
+ * (ERP-56), sous la permission commercial.clients.view (elargie aux roles
+ * fournisseurs au M2 via commercial.suppliers.view, ERP-90) ; aucune ecriture
* declaree -> POST/PATCH/DELETE renvoient 405.
*
* Referentiel statique : pas de Timestampable/Blamable (whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED, comme CategoryType). Le
* groupe `client:read:accounting` permet d'embarquer le mode dans la reponse
- * d'un Client (onglet Comptabilite) au lieu d'un IRI.
+ * d'un Client (onglet Comptabilite) au lieu d'un IRI ; `supplier:read:accounting`
+ * fait de meme dans la reponse Fournisseur (M2, ERP-92 — sinon IRI nu, § 4.0).
*/
#[ApiResource(
operations: [
new GetCollection(
- security: "is_granted('commercial.clients.view')",
+ security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
normalizationContext: ['groups' => ['tva_mode:read']],
// Tri par defaut spec M1 § 4.7 : position ASC puis label ASC
// (ordre des selecteurs comptables) — provider Doctrine par defaut.
@@ -39,11 +41,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
paginationClientEnabled: true,
),
new Get(
- security: "is_granted('commercial.clients.view')",
+ security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
normalizationContext: ['groups' => ['tva_mode:read']],
),
],
- security: "is_granted('commercial.clients.view')",
+ security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
)]
#[ORM\Entity(repositoryClass: DoctrineTvaModeRepository::class)]
#[ORM\Table(name: 'tva_mode')]
@@ -53,15 +55,15 @@ class TvaMode
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
- #[Groups(['tva_mode:read', 'client:read:accounting'])]
+ #[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?int $id = null;
#[ORM\Column(length: 30)]
- #[Groups(['tva_mode:read', 'client:read:accounting'])]
+ #[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
- #[Groups(['tva_mode:read', 'client:read:accounting'])]
+ #[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?string $label = null;
#[ORM\Column(options: ['default' => 0])]
diff --git a/src/Module/Commercial/Domain/Repository/SupplierAddressRepositoryInterface.php b/src/Module/Commercial/Domain/Repository/SupplierAddressRepositoryInterface.php
new file mode 100644
index 0000000..ff3ed5d
--- /dev/null
+++ b/src/Module/Commercial/Domain/Repository/SupplierAddressRepositoryInterface.php
@@ -0,0 +1,14 @@
+ 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 $categoryCodes
+ * @param list $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 $suppliers
+ */
+ public function hydrateListCollections(array $suppliers): void;
+
+ /**
+ * Hydrate en lot la collection `contacts` sur un jeu de fournisseurs DEJA
+ * charges (memes instances via l'identity map). Reservee a l'export XLSX
+ * (§ 4.6) qui a besoin du contact principal : la LISTE paginee n'embarque
+ * pas les contacts (§ 2.12), d'ou une methode dediee plutot qu'une passe
+ * supplementaire dans {@see self::hydrateListCollections()} — on n'impose pas
+ * le cout du chargement des contacts au chemin liste.
+ *
+ * @param list $suppliers
+ */
+ public function hydrateContacts(array $suppliers): void;
+}
diff --git a/src/Module/Commercial/Domain/Repository/SupplierRibRepositoryInterface.php b/src/Module/Commercial/Domain/Repository/SupplierRibRepositoryInterface.php
new file mode 100644
index 0000000..4f9c36b
--- /dev/null
+++ b/src/Module/Commercial/Domain/Repository/SupplierRibRepositoryInterface.php
@@ -0,0 +1,14 @@
+decorated->createFromRequest($request, $normalization, $extractedAttributes);
+
+ // Uniquement en lecture, sur la ressource Supplier, avec la permission.
+ if (!$normalization) {
+ return $context;
+ }
+
+ if (Supplier::class !== ($context['resource_class'] ?? null)) {
+ return $context;
+ }
+
+ if (!$this->security->isGranted('commercial.suppliers.accounting.view')) {
+ return $context;
+ }
+
+ $groups = $context['groups'] ?? [];
+ if (!in_array('supplier:read:accounting', $groups, true)) {
+ $groups[] = 'supplier:read:accounting';
+ }
+ $context['groups'] = $groups;
+
+ return $context;
+ }
+}
diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php
index 8967c5b..5b2053b 100644
--- a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php
+++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php
@@ -117,6 +117,12 @@ final class ClientProcessor implements ProcessorInterface
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
+ // Reinitialisation de la memoisation du payload en debut de traitement :
+ // le service est partage (stateful), on repart du corps de LA requete
+ // courante et on n'herite jamais des cles decodees d'une requete passee.
+ $this->decodedContent = null;
+ $this->decodedPayloadKeys = [];
+
$writableKeys = $this->writablePayloadKeys();
$isArchiveRequest = $this->guardArchive($data, $writableKeys);
diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierAddressProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierAddressProcessor.php
new file mode 100644
index 0000000..43f0d2b
--- /dev/null
+++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierAddressProcessor.php
@@ -0,0 +1,90 @@
+= 1 site, Assert\Count), RG-2.09 (type d'adresse, Assert\Choice +
+ * CHECK BDD), RG-2.10 (categorie de type FOURNISSEUR, Assert\Callback
+ * SupplierAddress::validateCategoryType).
+ * - DELETE : aucune regle metier specifique (suppression physique directe).
+ *
+ * La security de l'operation (commercial.suppliers.manage) est appliquee par API
+ * Platform en amont, de meme que la validation Symfony des contraintes d'attribut.
+ *
+ * @implements ProcessorInterface
+ */
+final class SupplierAddressProcessor implements ProcessorInterface
+{
+ public function __construct(
+ #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
+ private readonly ProcessorInterface $persistProcessor,
+ #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
+ private readonly ProcessorInterface $removeProcessor,
+ private readonly EntityManagerInterface $em,
+ ) {}
+
+ public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
+ {
+ if (!$data instanceof SupplierAddress) {
+ return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
+ }
+
+ if ($operation instanceof DeleteOperationInterface) {
+ return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
+ }
+
+ $this->linkParent($data, $uriVariables);
+
+ return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
+ }
+
+ /**
+ * Rattache l'adresse au fournisseur parent de la sous-ressource POST
+ * (/suppliers/{supplierId}/addresses) : la relation n'est pas peuplee
+ * automatiquement par le Link sur une ecriture. Sur PATCH, no-op.
+ */
+ private function linkParent(SupplierAddress $address, array $uriVariables): void
+ {
+ if (null !== $address->getSupplier()) {
+ return;
+ }
+
+ $supplierId = $uriVariables['supplierId'] ?? null;
+ if (null === $supplierId) {
+ return;
+ }
+
+ $supplier = $supplierId instanceof Supplier
+ ? $supplierId
+ : $this->em->getRepository(Supplier::class)->find($supplierId);
+
+ // read:false sur le POST : sans stade lecture, un parent introuvable n'est
+ // plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
+ // contrainte supplier_id NOT NULL).
+ if (!$supplier instanceof Supplier) {
+ throw new NotFoundHttpException('Fournisseur introuvable.');
+ }
+
+ $address->setSupplier($supplier);
+ }
+}
diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierContactProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierContactProcessor.php
new file mode 100644
index 0000000..3d5f3a8
--- /dev/null
+++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierContactProcessor.php
@@ -0,0 +1,135 @@
+
+ */
+final class SupplierContactProcessor implements ProcessorInterface
+{
+ public function __construct(
+ #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
+ private readonly ProcessorInterface $persistProcessor,
+ #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
+ private readonly ProcessorInterface $removeProcessor,
+ private readonly SupplierFieldNormalizer $normalizer,
+ private readonly EntityManagerInterface $em,
+ ) {}
+
+ public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
+ {
+ if (!$data instanceof SupplierContact) {
+ return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
+ }
+
+ if ($operation instanceof DeleteOperationInterface) {
+ return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
+ }
+
+ $this->linkParent($data, $uriVariables);
+ $this->normalize($data);
+ $this->validateName($data);
+
+ return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
+ }
+
+ /**
+ * Rattache le contact au fournisseur parent de la sous-ressource POST
+ * (/suppliers/{supplierId}/contacts). La relation n'est pas peuplee
+ * automatiquement par le Link sur une operation d'ecriture : on resout le
+ * parent depuis l'uri variable. Sur PATCH (entite existante), le fournisseur
+ * est deja present -> no-op.
+ */
+ private function linkParent(SupplierContact $contact, array $uriVariables): void
+ {
+ if (null !== $contact->getSupplier()) {
+ return;
+ }
+
+ $supplierId = $uriVariables['supplierId'] ?? null;
+ if (null === $supplierId) {
+ return;
+ }
+
+ $supplier = $supplierId instanceof Supplier
+ ? $supplierId
+ : $this->em->getRepository(Supplier::class)->find($supplierId);
+
+ // read:false sur le POST : sans stade lecture, un parent introuvable n'est
+ // plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
+ // contrainte supplier_id NOT NULL).
+ if (!$supplier instanceof Supplier) {
+ throw new NotFoundHttpException('Fournisseur introuvable.');
+ }
+
+ $contact->setSupplier($supplier);
+ }
+
+ /**
+ * Normalisation serveur (RG-2.12). Toutes les methodes du normalizer sont
+ * null-safe : une chaine vide apres trim devient null.
+ */
+ private function normalize(SupplierContact $contact): void
+ {
+ $contact->setFirstName($this->normalizer->normalizePersonName($contact->getFirstName()));
+ $contact->setLastName($this->normalizer->normalizePersonName($contact->getLastName()));
+ $contact->setPhonePrimary($this->normalizer->normalizePhone($contact->getPhonePrimary()));
+ $contact->setPhoneSecondary($this->normalizer->normalizePhone($contact->getPhoneSecondary()));
+ $contact->setEmail($this->normalizer->normalizeEmail($contact->getEmail()));
+ }
+
+ /**
+ * RG-2.04 : au moins le prenom OU le nom est obligatoire (double garde avec le
+ * CHECK BDD chk_supplier_contact_name — leve une 422 propre rattachee au champ
+ * `firstName` plutot qu'une 500 SQL). Joue apres normalisation, donc les
+ * chaines vides sont deja ramenees a null.
+ */
+ private function validateName(SupplierContact $contact): void
+ {
+ if (null === $contact->getFirstName() && null === $contact->getLastName()) {
+ $violations = new ConstraintViolationList();
+ $violations->add(new ConstraintViolation(
+ 'Le prénom ou le nom du contact est obligatoire.',
+ null,
+ [],
+ $contact,
+ 'firstName',
+ null,
+ ));
+
+ throw new ValidationException($violations);
+ }
+ }
+}
diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierProcessor.php
new file mode 100644
index 0000000..e66eb3c
--- /dev/null
+++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierProcessor.php
@@ -0,0 +1,533 @@
+ exige accounting.manage (403) ;
+ * - champ main/information modifie -> exige manage (guardManage, 403) :
+ * empeche Compta d'editer un autre onglet que la Comptabilite (§ 2.9) ;
+ * - champ isArchived dans le payload -> exige archive (RG-2.14, 403) et
+ * interdit toute autre modification dans la meme requete (RG-2.14, 422).
+ * 2. Normalisation serveur (RG-2.12) via SupplierFieldNormalizer.
+ * 3. Pose / retrait de archivedAt (RG-2.14 true=now, RG-2.15 false=null).
+ * 4. Persistance via le persist_processor Doctrine, avec traduction des
+ * collisions d'unicite en 409 (RG-2.11 doublon de nom ; RG-2.15 conflit de
+ * restauration).
+ *
+ * Validators metier (ERP-89). Decision figee : ce processor ne porte QUE
+ * RG-2.03 (completude Information exigee pour le role Commerciale — detection du
+ * role cote back, non exprimable en contrainte d'entite). Les RG inter-champs
+ * RG-2.07 (Virement -> banque), RG-2.08 (LCR -> >= 1 RIB) et RG-2.10 (categorie
+ * de type FOURNISSEUR) sont portees par des Assert\Callback + ->atPath() sur
+ * l'entite Supplier (jouees par API Platform AVANT ce processor), pour que
+ * chaque 422 porte un propertyPath consommable par extractApiViolations
+ * (mapping inline, pas un toast — convention ERP-101).
+ *
+ * Note : la validation Symfony (Assert\NotBlank, Assert\Count sur categories,
+ * les Callback RG-2.07/2.08/2.10...) est jouee par API Platform AVANT ce
+ * processor ; on n'y traite donc que les regles non exprimables en simples
+ * contraintes d'entite (RG-2.03, qui depend du role de l'utilisateur courant).
+ *
+ * @implements ProcessorInterface
+ */
+final class SupplierProcessor implements ProcessorInterface
+{
+ /** Champs de l'onglet principal (groupe supplier:write:main). */
+ private const array MAIN_FIELDS = [
+ 'companyName', 'categories',
+ ];
+
+ /** Champs de l'onglet Information (groupe supplier:write:information). */
+ private const array INFORMATION_FIELDS = [
+ 'description', 'competitors', 'foundedAt', 'employeesCount',
+ 'revenueAmount', 'directorName', 'profitAmount', 'volumeForecast',
+ ];
+
+ /** Champs de l'onglet Comptabilite (groupe supplier:write:accounting). */
+ private const array ACCOUNTING_FIELDS = [
+ 'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay',
+ 'paymentType', 'bank',
+ ];
+
+ /** Champ d'archivage (groupe supplier:write:archive). */
+ private const string ARCHIVE_FIELD = 'isArchived';
+
+ private const string PERM_MANAGE = 'commercial.suppliers.manage';
+ private const string PERM_ACCOUNTING_MANAGE = 'commercial.suppliers.accounting.manage';
+ private const string PERM_ARCHIVE = 'commercial.suppliers.archive';
+
+ /**
+ * Memoisation du dernier corps de requete decode, clos par le contenu brut.
+ * payloadKeys() est appele plusieurs fois par requete (writablePayloadKeys,
+ * categoriesChanged...) : on evite de rejouer json_decode a chaque appel. La
+ * cle etant le contenu lui-meme et le calcul une fonction pure de ce contenu,
+ * aucune fuite n'est possible entre requetes sur ce service partage (un meme
+ * corps redonne les memes cles).
+ */
+ private ?string $decodedContent = null;
+
+ /** @var list Cles de premier niveau correspondant au corps memoise. */
+ private array $decodedPayloadKeys = [];
+
+ public function __construct(
+ #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
+ private readonly ProcessorInterface $persistProcessor,
+ private readonly SupplierFieldNormalizer $normalizer,
+ private readonly SupplierInformationCompletenessValidator $informationValidator,
+ private readonly Security $security,
+ private readonly RequestStack $requestStack,
+ private readonly EntityManagerInterface $em,
+ ) {}
+
+ public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
+ {
+ if (!$data instanceof Supplier) {
+ return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
+ }
+
+ // Reinitialisation de la memoisation du payload en debut de traitement :
+ // le service est partage (stateful), on repart du corps de LA requete
+ // courante et on n'herite jamais des cles decodees d'une requete passee.
+ $this->decodedContent = null;
+ $this->decodedPayloadKeys = [];
+
+ $writableKeys = $this->writablePayloadKeys();
+
+ $isArchiveRequest = $this->guardArchive($data, $writableKeys);
+ $this->guardAccounting($data);
+
+ $this->normalize($data);
+
+ // guardManage apres normalize : la comparaison « change vs etat
+ // persiste » des champs texte (companyName...) se fait sur des valeurs
+ // normalisees des deux cotes (l'etat persiste l'a deja ete).
+ $this->guardManage($data);
+
+ $this->validateInformationCompleteness($data);
+
+ try {
+ return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
+ } catch (UniqueConstraintViolationException $e) {
+ // Le seul index unique partiel est uq_supplier_company_name_active
+ // (LOWER(company_name) parmi non-archives/non-deletes — § 2.6).
+ if ($isArchiveRequest && false === $data->isArchived()) {
+ // RG-2.15 : restauration en conflit avec un homonyme actif.
+ throw new ConflictHttpException(
+ 'Restauration impossible : un autre fournisseur a pris le nom entre-temps.',
+ $e,
+ );
+ }
+
+ // RG-2.11 : doublon de nom de societe.
+ throw new ConflictHttpException(
+ sprintf('Un fournisseur nommé "%s" existe déjà.', (string) $data->getCompanyName()),
+ $e,
+ );
+ }
+ }
+
+ /**
+ * RG-2.14 / RG-2.15 : si le payload bascule reellement isArchived, exige la
+ * permission archive (403), interdit toute autre modification (422) et
+ * pose/retire archivedAt. Retourne true si la requete est une requete
+ * d'archivage.
+ *
+ * Le gating est restreint a la mise a jour d'un fournisseur existant ET au
+ * seul cas ou isArchived change vraiment : un POST (entite non encore geree
+ * par l'ORM) ou un PATCH « representation complete » renvoyant isArchived
+ * inchange ne doit declencher ni 403 ni 422 parasite.
+ *
+ * @param list $writableKeys cles ecrivables du payload (hors @* et champs inconnus)
+ */
+ private function guardArchive(Supplier $data, array $writableKeys): bool
+ {
+ // POST / entite non geree : l'archivage est une action de mise a jour.
+ if (!$this->em->contains($data)) {
+ return false;
+ }
+
+ // isArchived inchange par rapport a l'etat persiste : pas une requete
+ // d'archivage (cas du PATCH representation complete).
+ if (!$this->fieldChanged($data, 'isArchived', $data->isArchived())) {
+ return false;
+ }
+
+ if (!$this->security->isGranted(self::PERM_ARCHIVE)) {
+ throw new AccessDeniedHttpException(sprintf(
+ 'Le champ "%s" requiert la permission "%s".',
+ self::ARCHIVE_FIELD,
+ self::PERM_ARCHIVE,
+ ));
+ }
+
+ // RG-2.14 : une requete d'archivage ne modifie aucun autre champ ecrivable.
+ if ([] !== array_diff($writableKeys, [self::ARCHIVE_FIELD])) {
+ throw new UnprocessableEntityHttpException(
+ 'Une requête d\'archivage ne peut modifier aucun autre champ que "isArchived".',
+ );
+ }
+
+ // RG-2.14 (true -> now) / RG-2.15 (false -> null).
+ $data->setArchivedAt($data->isArchived() ? new DateTimeImmutable() : null);
+
+ return true;
+ }
+
+ /**
+ * RG-2.16 : la modification effective d'un champ comptable exige
+ * accounting.manage, sinon 403 sur l'ensemble du payload (mode strict, pas
+ * de filtrage silencieux). On ne gate que si un champ change reellement par
+ * rapport a l'etat persiste : un POST/PATCH renvoyant des champs comptables
+ * inchanges (ou null en creation) ne declenche pas de 403 parasite. Le
+ * message precise le premier champ fautif.
+ */
+ private function guardAccounting(Supplier $data): void
+ {
+ $changed = $this->changedAccountingFields($data);
+
+ if ([] === $changed) {
+ return;
+ }
+
+ if (!$this->security->isGranted(self::PERM_ACCOUNTING_MANAGE)) {
+ throw new AccessDeniedHttpException(sprintf(
+ 'Le champ "%s" requiert la permission "%s".',
+ $changed[0],
+ self::PERM_ACCOUNTING_MANAGE,
+ ));
+ }
+ }
+
+ /**
+ * § 2.9 / RG-2.16 : la modification effective d'un champ « metier » (onglets
+ * principal ou Information) exige `commercial.suppliers.manage`. Sans cette
+ * permission -> 403 sur l'ensemble du payload (mode strict, miroir de
+ * guardAccounting). C'est ce qui empeche le role Compta — qui entre dans le
+ * PATCH via `accounting.manage` (security d'operation elargie) — d'editer
+ * autre chose que l'onglet Comptabilite.
+ *
+ * Ne s'applique qu'aux mises a jour (entite geree) : la creation (POST) est
+ * deja gardee par la security d'operation `manage`, donc inutile de la
+ * re-gater ici (et un POST par un porteur de `manage` passerait de toute
+ * facon).
+ */
+ private function guardManage(Supplier $data): void
+ {
+ if (!$this->em->contains($data)) {
+ return;
+ }
+
+ $changed = $this->changedBusinessFields($data);
+
+ if ([] === $changed) {
+ return;
+ }
+
+ if (!$this->security->isGranted(self::PERM_MANAGE)) {
+ throw new AccessDeniedHttpException(sprintf(
+ 'Le champ "%s" requiert la permission "%s".',
+ $changed[0],
+ self::PERM_MANAGE,
+ ));
+ }
+ }
+
+ /**
+ * RG-2.03 : si l'utilisateur porte le role metier Commerciale, TOUS les
+ * champs de l'onglet Information sont obligatoires sur POST comme sur TOUT
+ * PATCH — independamment des champs reellement envoyes. Garantit qu'un
+ * fournisseur cree/edite par une Commerciale ne reste jamais avec un onglet
+ * Information incomplet. Pour les autres roles, ces champs restent optionnels.
+ *
+ * Consequence (cf. spec § 7, miroir RG-1.04) : le POST n'exposant que
+ * supplier:write:main, une Commerciale obtient 422 sur tout POST tant que
+ * l'Information n'est pas complete -> la completude se fait via les PATCH
+ * supplier:write:information.
+ */
+ private function validateInformationCompleteness(Supplier $data): void
+ {
+ if ($this->currentUserIsCommerciale()) {
+ $this->informationValidator->validate($data);
+ }
+ }
+
+ /**
+ * Detection du role metier Commerciale cote back (jamais front), via le
+ * contrat BusinessRoleAwareInterface (pas d'import de User — regle ABSOLUE
+ * n°1). Identique au ClientProcessor (M1).
+ */
+ private function currentUserIsCommerciale(): bool
+ {
+ $user = $this->security->getUser();
+
+ return $user instanceof BusinessRoleAwareInterface
+ && $user->hasBusinessRole(BusinessRoles::COMMERCIALE);
+ }
+
+ /**
+ * Champs « metier » (onglets principal + Information, hors comptabilite et
+ * archivage) dont la valeur courante differe de l'etat persiste. Memes
+ * regles de comparaison que changedAccountingFields (scalaires par valeur).
+ *
+ * Cas particulier `categories` (M2M) : non trace par getOriginalEntityData,
+ * compare par valeur via le snapshot de la PersistentCollection (cf.
+ * categoriesChanged) — la simple presence dans le payload ne suffit pas, sous
+ * peine de 403 parasite sur un PATCH representation complete reincluant des
+ * categories inchangees.
+ *
+ * @return list
+ */
+ private function changedBusinessFields(Supplier $data): array
+ {
+ $newValues = [
+ 'companyName' => $data->getCompanyName(),
+ 'description' => $data->getDescription(),
+ 'competitors' => $data->getCompetitors(),
+ 'foundedAt' => $data->getFoundedAt(),
+ 'employeesCount' => $data->getEmployeesCount(),
+ 'revenueAmount' => $data->getRevenueAmount(),
+ 'directorName' => $data->getDirectorName(),
+ 'profitAmount' => $data->getProfitAmount(),
+ 'volumeForecast' => $data->getVolumeForecast(),
+ ];
+
+ $changed = [];
+ foreach ($newValues as $field => $newValue) {
+ if ($this->fieldChanged($data, $field, $newValue)) {
+ $changed[] = $field;
+ }
+ }
+
+ if ($this->categoriesChanged($data)) {
+ $changed[] = 'categories';
+ }
+
+ return $changed;
+ }
+
+ /**
+ * Vrai si l'ensemble des categories (M2M) differe reellement de l'etat
+ * persiste. La collection n'etant pas tracee par getOriginalEntityData, on
+ * compare par identifiants (independamment de l'ordre) le snapshot de la
+ * PersistentCollection (etat charge depuis la base) a l'etat courant (apres
+ * application du payload). Symetrique de changedAccountingFields : seul un
+ * changement effectif compte, pas la simple presence dans le payload.
+ *
+ * - POST / entite non geree : fournir des categories est un acte metier
+ * (branche defensive, guardManage ne s'execute de toute facon que sur
+ * entite geree).
+ * - categories absent du payload (PATCH partiel) : aucun changement.
+ */
+ private function categoriesChanged(Supplier $data): bool
+ {
+ if (!$this->em->contains($data)) {
+ return true;
+ }
+
+ if (!in_array('categories', $this->payloadKeys(), true)) {
+ return false;
+ }
+
+ $collection = $data->getCategories();
+
+ // Hors PersistentCollection (cas limite hors flux PATCH reel) : faute
+ // d'etat persiste comparable, on se rabat sur la presence payload.
+ if (!$collection instanceof PersistentCollection) {
+ return true;
+ }
+
+ return $this->categoryIdSet($collection->toArray())
+ !== $this->categoryIdSet($collection->getSnapshot());
+ }
+
+ /**
+ * Ensemble trie des identifiants d'une liste de categories — pour une
+ * comparaison par valeur independante de l'ordre.
+ *
+ * @param array $categories
+ *
+ * @return list
+ */
+ private function categoryIdSet(array $categories): array
+ {
+ $ids = array_map(
+ static fn (object $category): mixed => method_exists($category, 'getId')
+ ? $category->getId()
+ : spl_object_id($category),
+ array_values($categories),
+ );
+ sort($ids);
+
+ return $ids;
+ }
+
+ /**
+ * Champs comptables dont la valeur courante differe de l'etat persiste. Les
+ * relations (tvaMode, paymentDelay, paymentType, bank) sont comparees par
+ * identite d'objet : l'identity map Doctrine renvoie la meme instance tant
+ * que la reference est inchangee.
+ *
+ * @return list
+ */
+ private function changedAccountingFields(Supplier $data): array
+ {
+ $changed = [];
+
+ foreach (self::ACCOUNTING_FIELDS as $field) {
+ $newValue = match ($field) {
+ 'siren' => $data->getSiren(),
+ 'accountNumber' => $data->getAccountNumber(),
+ 'tvaMode' => $data->getTvaMode(),
+ 'nTva' => $data->getNTva(),
+ 'paymentDelay' => $data->getPaymentDelay(),
+ 'paymentType' => $data->getPaymentType(),
+ 'bank' => $data->getBank(),
+ };
+
+ if ($this->fieldChanged($data, $field, $newValue)) {
+ $changed[] = $field;
+ }
+ }
+
+ return $changed;
+ }
+
+ /**
+ * Vrai si la valeur courante d'un champ differe de l'etat persiste. Pour une
+ * entite non geree (creation/POST), l'etat persiste est vide : toute valeur
+ * non-null est alors un changement.
+ */
+ private function fieldChanged(Supplier $data, string $field, mixed $newValue): bool
+ {
+ $original = $this->originalData($data);
+
+ return $newValue !== ($original[$field] ?? null);
+ }
+
+ /**
+ * Snapshot des valeurs persistees de l'entite (telles que chargees, avant
+ * application du payload). Vide pour une entite non geree (POST).
+ *
+ * @return array
+ */
+ private function originalData(Supplier $data): array
+ {
+ if (!$this->em->contains($data)) {
+ return [];
+ }
+
+ return $this->em->getUnitOfWork()->getOriginalEntityData($data);
+ }
+
+ /**
+ * Normalisation serveur du formulaire principal (RG-2.12). Seul companyName
+ * subsiste cote Supplier (le contact inline a ete retire en V1 — les champs
+ * de contact sont normalises par SupplierContactProcessor, ERP-88). Le setter
+ * non-nullable n'est touche que si une valeur est presente, pour ne jamais
+ * ecraser l'existant lors d'un PATCH partiel.
+ */
+ private function normalize(Supplier $data): void
+ {
+ if (null !== $data->getCompanyName()) {
+ $data->setCompanyName((string) $this->normalizer->normalizeCompanyName($data->getCompanyName()));
+ }
+ }
+
+ /**
+ * Cles ecrivables effectivement presentes dans le payload : on retire les
+ * cles JSON-LD (@id, @context, @var...) et tout champ non rattache a un
+ * groupe d'ecriture connu. C'est la base du 422 d'archivage (RG-2.14) —
+ * sans elles, un PATCH « representation complete » porteur de @id ferait
+ * croire a une modification multi-onglets.
+ *
+ * @return list
+ */
+ private function writablePayloadKeys(): array
+ {
+ $writable = array_merge(
+ self::MAIN_FIELDS,
+ self::INFORMATION_FIELDS,
+ self::ACCOUNTING_FIELDS,
+ [self::ARCHIVE_FIELD],
+ );
+
+ return array_values(array_intersect($this->payloadKeys(), $writable));
+ }
+
+ /**
+ * Cles de premier niveau effectivement envoyees par le client (payload JSON
+ * brut), filtrage compris. Pour un PATCH merge-patch+json, ce sont les seuls
+ * champs modifies.
+ *
+ * @return list
+ */
+ private function payloadKeys(): array
+ {
+ $request = $this->requestStack->getCurrentRequest();
+ if (null === $request) {
+ return [];
+ }
+
+ $content = $request->getContent();
+
+ // Cache hit : meme corps brut que le dernier decodage -> memes cles.
+ if ($content === $this->decodedContent) {
+ return $this->decodedPayloadKeys;
+ }
+
+ $this->decodedContent = $content;
+ $this->decodedPayloadKeys = $this->extractPayloadKeys($content);
+
+ return $this->decodedPayloadKeys;
+ }
+
+ /**
+ * Decode le corps brut et en extrait les cles de premier niveau (chaines).
+ * Corps vide ou JSON invalide -> aucune cle.
+ *
+ * @return list
+ */
+ private function extractPayloadKeys(string $content): array
+ {
+ if ('' === $content) {
+ return [];
+ }
+
+ try {
+ $decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
+ } catch (JsonException) {
+ return [];
+ }
+
+ return is_array($decoded) ? array_values(array_filter(array_keys($decoded), 'is_string')) : [];
+ }
+}
diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierRibProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierRibProcessor.php
new file mode 100644
index 0000000..7eeda22
--- /dev/null
+++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierRibProcessor.php
@@ -0,0 +1,114 @@
+
+ */
+final class SupplierRibProcessor implements ProcessorInterface
+{
+ public function __construct(
+ #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
+ private readonly ProcessorInterface $persistProcessor,
+ #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
+ private readonly ProcessorInterface $removeProcessor,
+ private readonly EntityManagerInterface $em,
+ ) {}
+
+ public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
+ {
+ if (!$data instanceof SupplierRib) {
+ return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
+ }
+
+ if ($operation instanceof DeleteOperationInterface) {
+ $this->guardLastRibDeletionUnderLcr($data);
+
+ return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
+ }
+
+ $this->linkParent($data, $uriVariables);
+
+ return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
+ }
+
+ /**
+ * Rattache le RIB au fournisseur parent de la sous-ressource POST
+ * (/suppliers/{supplierId}/ribs) : la relation n'est pas peuplee
+ * automatiquement par le Link sur une ecriture. Sur PATCH, no-op.
+ */
+ private function linkParent(SupplierRib $rib, array $uriVariables): void
+ {
+ if (null !== $rib->getSupplier()) {
+ return;
+ }
+
+ $supplierId = $uriVariables['supplierId'] ?? null;
+ if (null === $supplierId) {
+ return;
+ }
+
+ $supplier = $supplierId instanceof Supplier
+ ? $supplierId
+ : $this->em->getRepository(Supplier::class)->find($supplierId);
+
+ // read:false sur le POST : sans stade lecture, un parent introuvable n'est
+ // plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
+ // contrainte supplier_id NOT NULL).
+ if (!$supplier instanceof Supplier) {
+ throw new NotFoundHttpException('Fournisseur introuvable.');
+ }
+
+ $rib->setSupplier($supplier);
+ }
+
+ /**
+ * RG-2.08 : un fournisseur dont le type de reglement est LCR doit conserver au
+ * moins un RIB. La collection inclut le RIB en cours de suppression : un
+ * effectif <= 1 signifie qu'il ne resterait aucun RIB -> 409. Pour tout autre
+ * type de reglement, les RIBs sont optionnels (suppression libre).
+ */
+ private function guardLastRibDeletionUnderLcr(SupplierRib $rib): void
+ {
+ $supplier = $rib->getSupplier();
+ if (null === $supplier) {
+ return;
+ }
+
+ if ('LCR' === $supplier->getPaymentType()?->getCode() && $supplier->getRibs()->count() <= 1) {
+ throw new ConflictHttpException(
+ 'Impossible de supprimer le dernier RIB : le type de règlement LCR exige au moins un RIB.',
+ );
+ }
+ }
+}
diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Provider/SupplierProvider.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Provider/SupplierProvider.php
new file mode 100644
index 0000000..d61ed46
--- /dev/null
+++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Provider/SupplierProvider.php
@@ -0,0 +1,191 @@
+ (fournisseurs
+ * ayant >= 1 categorie de ce code, repetable) et ?siteId= (fournisseurs
+ * ayant >= 1 adresse rattachee a ce site, repetable) ;
+ * - pagination obligatoire (regle ABSOLUE n°13) : Paginator ORM ; echappatoire
+ * ?pagination=false pour alimenter un sans pagination.
+ *
+ * Item (GET /api/suppliers/{id} + provider de PATCH) :
+ * - 404 si introuvable OU soft-delete (deleted_at non null, jamais expose au
+ * M2) ; les archives restent consultables/restaurables en detail.
+ *
+ * Le filtrage des champs comptables en lecture (groupe supplier:read:accounting)
+ * n'est PAS fait ici mais dans SupplierReadGroupContextBuilder : un Provider
+ * retourne des donnees mais ne peut pas influencer les groupes de serialisation.
+ * Le contexte de normalisation est construit par le SerializerContextBuilder, en
+ * amont du serializer — c'est le point d'extension idiomatique d'API Platform
+ * pour conditionner le groupe accounting selon la permission de l'utilisateur.
+ *
+ * @implements ProviderInterface
+ */
+final class SupplierProvider implements ProviderInterface
+{
+ public function __construct(
+ #[Autowire(service: 'App\Module\Commercial\Infrastructure\Doctrine\DoctrineSupplierRepository')]
+ private readonly SupplierRepositoryInterface $repository,
+ private readonly Pagination $pagination,
+ ) {}
+
+ public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Supplier|null
+ {
+ if ($operation instanceof CollectionOperationInterface) {
+ return $this->provideCollection($operation, $context);
+ }
+
+ return $this->provideItem($uriVariables);
+ }
+
+ /**
+ * @param array $context
+ *
+ * @return list|Paginator
+ */
+ private function provideCollection(Operation $operation, array $context): array|Paginator
+ {
+ $filters = $context['filters'] ?? [];
+ $includeArchived = $this->readBool($filters['includeArchived'] ?? false);
+ $archivedOnly = $this->readBool($filters['archivedOnly'] ?? false);
+ $search = $filters['search'] ?? null;
+ // categoryCode accepte un code unique (?categoryCode=NEGOCIANT, selects)
+ // OU une liste (?categoryCode[]=A&categoryCode[]=B, drawer multi).
+ $categoryCodes = $this->readStringList($filters['categoryCode'] ?? []);
+ $siteIds = $this->readIntList($filters['siteId'] ?? []);
+
+ // Filtrage delegue au repository (logique partagee avec l'export XLSX).
+ $qb = $this->repository->createListQueryBuilder(
+ $includeArchived,
+ is_string($search) ? $search : null,
+ $categoryCodes,
+ $siteIds,
+ $archivedOnly,
+ );
+
+ // Echappatoire ?pagination=false : collection complete sans Paginator
+ // (regle n°13 — utile pour un cote front).
+ if (!$this->pagination->isEnabled($operation, $context)) {
+ /** @var list $suppliers */
+ $suppliers = $qb->getQuery()->getResult();
+ // Hydratation batchee des collections affichees (§ 2.12) : evite le
+ // N+1 si la serialisation touche categories/sites, sans cartesien.
+ $this->repository->hydrateListCollections($suppliers);
+
+ return $suppliers;
+ }
+
+ $limit = $this->pagination->getLimit($operation, $context);
+ $page = max(1, $this->pagination->getPage($context));
+ $offset = ($page - 1) * $limit;
+
+ $qb->setFirstResult($offset)->setMaxResults($limit);
+
+ // Le QB de selection ne porte pas de fetch-join to-many (§ 2.12) : le
+ // COUNT est simple, fetchJoinCollection inutile. On materialise la page
+ // puis on hydrate ses collections en lot (memes entites managees).
+ $paginator = new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
+ $this->repository->hydrateListCollections(iterator_to_array($paginator));
+
+ return $paginator;
+ }
+
+ /**
+ * @param array $uriVariables
+ */
+ private function provideItem(array $uriVariables): ?Supplier
+ {
+ $id = $uriVariables['id'] ?? null;
+ if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
+ return null;
+ }
+
+ $supplier = $this->repository->findById((int) $id);
+ if (null === $supplier) {
+ return null;
+ }
+
+ // Soft-delete : jamais expose au M2 (HP-M3-1) — 404 via retour null.
+ // Les archives restent visibles en detail (consultation + restauration).
+ if (null !== $supplier->getDeletedAt()) {
+ return null;
+ }
+
+ return $supplier;
+ }
+
+ /**
+ * Lit un flag booleen issu des query params. Accepte true / "true" / "1".
+ */
+ private function readBool(mixed $raw): bool
+ {
+ if (is_bool($raw)) {
+ return $raw;
+ }
+
+ return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
+ }
+
+ /**
+ * Normalise un filtre en liste de chaines. Tolere un code unique (string)
+ * ou une liste (?key[]=a&key[]=b). Trim + retrait des vides.
+ *
+ * @return list
+ */
+ private function readStringList(mixed $raw): array
+ {
+ $values = is_array($raw) ? $raw : [$raw];
+
+ $out = [];
+ foreach ($values as $value) {
+ if (is_string($value) && '' !== trim($value)) {
+ $out[] = trim($value);
+ }
+ }
+
+ return $out;
+ }
+
+ /**
+ * Normalise un filtre en liste d'identifiants entiers positifs. Tolere une
+ * valeur unique ou une liste (?key[]=1&key[]=2).
+ *
+ * @return list
+ */
+ private function readIntList(mixed $raw): array
+ {
+ $values = is_array($raw) ? $raw : [$raw];
+
+ $out = [];
+ foreach ($values as $value) {
+ if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) {
+ $out[] = (int) $value;
+ }
+ }
+
+ return $out;
+ }
+}
diff --git a/src/Module/Commercial/Infrastructure/Controller/SupplierExportController.php b/src/Module/Commercial/Infrastructure/Controller/SupplierExportController.php
new file mode 100644
index 0000000..78f8edb
--- /dev/null
+++ b/src/Module/Commercial/Infrastructure/Controller/SupplierExportController.php
@@ -0,0 +1,296 @@
+readBool($request->query->get('includeArchived'));
+ $archivedOnly = $this->readBool($request->query->get('archivedOnly'));
+ $search = $request->query->getString('search') ?: null;
+
+ // Memes filtres que la vue liste : categoryCode/siteId tolerent une valeur
+ // unique ou une liste (?categoryCode[]=A&siteId[]=1). On lit via all() pour
+ // ne pas lever d'exception sur une valeur scalaire.
+ $query = $request->query->all();
+ $categoryCodes = $this->readStringList($query['categoryCode'] ?? []);
+ $siteIds = $this->readIntList($query['siteId'] ?? []);
+
+ /** @var list $suppliers */
+ $suppliers = $this->repository
+ ->createListQueryBuilder($includeArchived, $search, $categoryCodes, $siteIds, $archivedOnly)
+ ->getQuery()
+ ->getResult()
+ ;
+
+ // Hydratation batchee des collections affichees (§ 2.12) : le QB de
+ // selection ne fetch-join pas les to-many. On remplit categories + sites en
+ // lot (colonnes « Catégories » / « Sites »), puis les contacts (colonnes du
+ // contact principal) — chacune en requetes IN bornees, anti N+1.
+ $this->repository->hydrateListCollections($suppliers);
+ $this->repository->hydrateContacts($suppliers);
+
+ $withSiren = $this->security->isGranted('commercial.suppliers.accounting.view');
+
+ $binary = $this->exporter->export(
+ 'Répertoire fournisseurs',
+ $this->buildHeaders($withSiren),
+ $this->buildRows($suppliers, $withSiren),
+ );
+
+ return $this->buildResponse($binary);
+ }
+
+ /**
+ * Colonnes de l'export (spec § 4.6). SIREN inseree avant la date de creation,
+ * uniquement si l'utilisateur a accounting.view.
+ *
+ * @return list
+ */
+ private function buildHeaders(bool $withSiren): array
+ {
+ $headers = [
+ 'Nom fournisseur',
+ 'Contact principal',
+ 'Téléphone principal',
+ 'Téléphone secondaire',
+ 'Email',
+ 'Catégories',
+ 'Sites',
+ ];
+
+ if ($withSiren) {
+ $headers[] = 'SIREN';
+ }
+
+ $headers[] = 'Date de création';
+
+ return $headers;
+ }
+
+ /**
+ * @param list $suppliers
+ *
+ * @return iterable>
+ */
+ private function buildRows(array $suppliers, bool $withSiren): iterable
+ {
+ foreach ($suppliers as $supplier) {
+ $contact = $this->principalContact($supplier);
+
+ $row = [
+ $supplier->getCompanyName(),
+ null !== $contact ? $this->formatContactName($contact) : '',
+ $contact?->getPhonePrimary() ?? '',
+ $contact?->getPhoneSecondary() ?? '',
+ $contact?->getEmail() ?? '',
+ $this->formatCategories($supplier),
+ $this->formatSites($supplier),
+ ];
+
+ if ($withSiren) {
+ $row[] = $supplier->getSiren();
+ }
+
+ $row[] = $supplier->getCreatedAt()?->format('d/m/Y');
+
+ yield $row;
+ }
+ }
+
+ /**
+ * Contact principal du fournisseur : le SupplierContact de plus petit
+ * `position` (decision D2, spec § 4.6). Null si le fournisseur n'a aucun
+ * contact (les colonnes contact restent vides).
+ */
+ private function principalContact(Supplier $supplier): ?SupplierContact
+ {
+ $contacts = $supplier->getContacts()->toArray();
+ if ([] === $contacts) {
+ return null;
+ }
+
+ usort(
+ $contacts,
+ static fn (SupplierContact $a, SupplierContact $b): int => $a->getPosition() <=> $b->getPosition(),
+ );
+
+ return $contacts[0];
+ }
+
+ /**
+ * Libelle du contact principal « Nom Prénom » (spec § 4.6). Les deux parties
+ * sont optionnelles (RG-2.04 : au moins l'une des deux), d'ou le trim final.
+ */
+ private function formatContactName(SupplierContact $contact): string
+ {
+ return trim(sprintf('%s %s', $contact->getLastName() ?? '', $contact->getFirstName() ?? ''));
+ }
+
+ /**
+ * Libelles des categories du fournisseur, dedupliques, tries, joints par
+ * virgule.
+ */
+ private function formatCategories(Supplier $supplier): string
+ {
+ $names = [];
+ foreach ($supplier->getCategories() as $category) {
+ // @var CategoryInterface $category
+ $name = $category->getName();
+ if (null !== $name && '' !== $name) {
+ $names[$name] = true;
+ }
+ }
+
+ return $this->joinSorted($names);
+ }
+
+ /**
+ * Le fournisseur ne porte pas de sites en propre : ils sont rattaches aux
+ * adresses (RG-2.06). La colonne « Sites » agrege donc l'union distincte des
+ * sites de toutes les adresses du fournisseur.
+ */
+ private function formatSites(Supplier $supplier): string
+ {
+ $names = [];
+ foreach ($supplier->getAddresses() as $address) {
+ foreach ($address->getSites() as $site) {
+ // @var SiteInterface $site
+ $name = $site->getName();
+ if (null !== $name && '' !== $name) {
+ $names[$name] = true;
+ }
+ }
+ }
+
+ return $this->joinSorted($names);
+ }
+
+ /**
+ * @param array $names ensemble de libelles (cles)
+ */
+ private function joinSorted(array $names): string
+ {
+ $list = array_keys($names);
+ sort($list);
+
+ return implode(', ', $list);
+ }
+
+ private function buildResponse(string $binary): Response
+ {
+ $filename = sprintf('repertoire-fournisseurs-%s.xlsx', new DateTimeImmutable()->format('Ymd'));
+
+ $response = new Response($binary);
+ $response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
+ $response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
+
+ return $response;
+ }
+
+ /**
+ * Lit un flag booleen issu des query params. Accepte true / "true" / "1".
+ * Aligne sur SupplierProvider pour un comportement identique a la liste.
+ */
+ private function readBool(mixed $raw): bool
+ {
+ return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
+ }
+
+ /**
+ * Normalise un filtre en liste de chaines (valeur unique ou liste).
+ * Aligne sur SupplierProvider pour un comportement identique a la liste.
+ *
+ * @return list
+ */
+ private function readStringList(mixed $raw): array
+ {
+ $values = is_array($raw) ? $raw : [$raw];
+
+ $out = [];
+ foreach ($values as $value) {
+ if (is_string($value) && '' !== trim($value)) {
+ $out[] = trim($value);
+ }
+ }
+
+ return $out;
+ }
+
+ /**
+ * Normalise un filtre en liste d'identifiants entiers positifs (valeur unique
+ * ou liste). Aligne sur SupplierProvider.
+ *
+ * @return list
+ */
+ private function readIntList(mixed $raw): array
+ {
+ $values = is_array($raw) ? $raw : [$raw];
+
+ $out = [];
+ foreach ($values as $value) {
+ if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) {
+ $out[] = (int) $value;
+ }
+ }
+
+ return $out;
+ }
+}
diff --git a/src/Module/Commercial/Infrastructure/DataFixtures/SupplierFixtures.php b/src/Module/Commercial/Infrastructure/DataFixtures/SupplierFixtures.php
new file mode 100644
index 0000000..a7908c4
--- /dev/null
+++ b/src/Module/Commercial/Infrastructure/DataFixtures/SupplierFixtures.php
@@ -0,0 +1,525 @@
+ Category) ;
+ * - sites resolus via le contrat Shared SiteProviderInterface.
+ *
+ * Normalisation : les valeurs sont fournies BRUTES (casse libre, telephones
+ * formates) et normalisees par SupplierFieldNormalizer avant persist, exactement
+ * comme le ferait le SupplierProcessor via l'API (companyName UPPERCASE,
+ * first/last Capitalize, telephones chiffres seuls, emails lowercase).
+ *
+ * Coherence gating comptable (RG-2.16) : les scalaires comptables (siren,
+ * tvaMode, paymentType, bank...) et les RIB ne sont visibles qu'avec
+ * accounting.view. Les donnees sont posees pour que les roles SANS cette
+ * permission (ex. Commerciale) ne voient pas de compta — support des tests
+ * ERP-92 et du golden path front.
+ *
+ * Idempotence : lookup par companyName normalise (coherent avec l'index unique
+ * partiel uq_supplier_company_name_active). Un fournisseur deja present n'est pas
+ * reconstruit (ses sous-collections ne sont pas redupliquees). Rejouable sans
+ * doublon meme si le purger Doctrine est desactive.
+ *
+ * Audit / Blamable : persist hors contexte HTTP -> created_by / updated_by
+ * restent null (« Systeme » cote front), c'est attendu. Les donnees respectent
+ * les CHECK BDD (chk_supplier_contact_name : firstName OU lastName ;
+ * chk_supplier_address_type : PROSPECT | DEPART | RENDU) ET la coherence des
+ * validators d'entite (RG-2.07/2.08 : VIREMENT => banque, LCR => >= 1 RIB).
+ *
+ * Depend de CategoryFixtures (categories FOURNISSEUR), SitesFixtures (sites) et
+ * CommercialReferentialFixtures (referentiels comptables — REUTILISES de M1,
+ * aucune nouvelle table).
+ *
+ * Portee : DONNEES DE DEMONSTRATION (dev uniquement). En environnement `test`,
+ * la fixture ne charge rien : les tests seedent et nettoient leurs propres
+ * fournisseurs et comptent sur une table `supplier` vierge — y injecter 13
+ * fournisseurs de demo casserait les comptages de liste et les cleanups. Meme
+ * garde-fou que ClientFixtures / CategoryFixtures.
+ */
+class SupplierFixtures extends Fixture implements DependentFixtureInterface
+{
+ /**
+ * Type de categorie exige pour un fournisseur et ses adresses (RG-2.10).
+ * Miroir de Supplier::REQUIRED_CATEGORY_TYPE_CODE (non importable — regle n°1).
+ */
+ private const string SUPPLIER_CATEGORY_TYPE_CODE = 'FOURNISSEUR';
+
+ /** Cache des categories resolues par nom (evite des requetes repetees). */
+ private array $categoryCache = [];
+
+ /** Cache des sites resolus par nom. */
+ private array $siteCache = [];
+
+ /** ObjectManager courant, capture en debut de load (resolution categories). */
+ private ObjectManager $manager;
+
+ public function __construct(
+ private readonly SupplierFieldNormalizer $normalizer,
+ private readonly SiteProviderInterface $siteProvider,
+ #[Autowire('%kernel.environment%')]
+ private readonly string $environment,
+ ) {}
+
+ /**
+ * @return array
+ */
+ public function getDependencies(): array
+ {
+ return [
+ CategoryFixtures::class,
+ SitesFixtures::class,
+ CommercialReferentialFixtures::class,
+ ];
+ }
+
+ public function load(ObjectManager $manager): void
+ {
+ // Donnees de demo : dev uniquement. En test, on laisse la table vierge.
+ if ('test' === $this->environment) {
+ return;
+ }
+
+ $this->manager = $manager;
+
+ // === Fournisseur basique — VIREMENT + banque (RG-2.07), compta complete ===
+ [$negoce, $isNew] = $this->ensureSupplier($manager, 'Négoce Métaux Atlantique', ['Négociant']);
+ if ($isNew) {
+ $negoce->setSiren('841611054');
+ $negoce->setAccountNumber('F0001');
+ $negoce->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
+ $negoce->setNTva('FR12841611054');
+ $negoce->setPaymentDelay($this->paymentDelay($manager, 'J30'));
+ $negoce->setPaymentType($this->paymentType($manager, 'VIREMENT'));
+ $negoce->setBank($this->bank($manager, 'SG'));
+ $this->addContact($negoce, 'Jean', 'Dubois', 'Responsable achats', '05 49 00 00 01', null, 'jean.dubois@negoce-metaux.fr');
+ $this->addAddress($negoce, 'DEPART', ['Chatellerault'], '86100', 'Châtellerault', '12 rue de la Ferraille', bennes: 4, triageProvider: true, categoryNames: ['Négociant']);
+ }
+
+ // === LCR avec 1 RIB (RG-2.08) + 2 contacts ===
+ [$coop, $isNew] = $this->ensureSupplier($manager, 'Coopérative Agricole du Sud-Ouest', ['Coopérative']);
+ if ($isNew) {
+ $coop->setSiren('775680459');
+ $coop->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
+ $coop->setPaymentDelay($this->paymentDelay($manager, 'J15'));
+ $coop->setPaymentType($this->paymentType($manager, 'LCR'));
+ $this->addContact($coop, 'Sophie', 'Marchand', 'Directrice', '05 56 10 20 30', '06 11 22 33 44', 'sophie.marchand@coop-so.fr', 0);
+ $this->addContact($coop, 'Marc', 'Girard', 'Acheteur', '05 56 10 20 31', null, 'marc.girard@coop-so.fr', 1);
+ $this->addAddress($coop, 'RENDU', ['Pommevic'], '82400', 'Pommevic', '8 route des Cooperateurs', bennes: 2);
+ $this->addRib($coop, 'Compte principal', 'BNPAFRPPXXX', 'FR1420041010050500013M02606', 0);
+ }
+
+ // === Prospect seul (adresse PROSPECT), compta minimale ===
+ [$producteur, $isNew] = $this->ensureSupplier($manager, 'Producteur Bio Charente', ['Producteur']);
+ if ($isNew) {
+ $this->addContact($producteur, 'Claire', 'Moreau', 'Gérante', '05 49 21 22 23', null, 'claire.moreau@bio-charente.fr');
+ $this->addAddress($producteur, 'PROSPECT', ['Saint-Jean'], '17400', 'Fontenet', '1 chemin des Producteurs');
+ }
+
+ // === Multi-categories M2M + LCR avec 2 RIB + 3 contacts ===
+ [$grossiste, $isNew] = $this->ensureSupplier($manager, 'Grossiste Multi-Métaux', ['Grossiste', 'Négociant']);
+ if ($isNew) {
+ $grossiste->setSiren('552081317');
+ $grossiste->setAccountNumber('F0004');
+ $grossiste->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
+ $grossiste->setNTva('FR45552081317');
+ $grossiste->setPaymentDelay($this->paymentDelay($manager, 'J30'));
+ $grossiste->setPaymentType($this->paymentType($manager, 'LCR'));
+ $this->addContact($grossiste, 'Thomas', 'Petit', 'Directeur des achats', '05 56 31 32 33', '06 01 02 03 04', 'thomas.petit@grossiste-mm.fr', 0);
+ $this->addContact($grossiste, 'Julie', 'Roux', 'Assistante commerciale', '05 56 31 32 34', null, 'julie.roux@grossiste-mm.fr', 1);
+ $this->addContact($grossiste, 'Hélène', 'Faure', 'Logistique', '05 56 31 32 35', null, 'helene.faure@grossiste-mm.fr', 2);
+ $this->addAddress($grossiste, 'DEPART', ['Chatellerault'], '86100', 'Châtellerault', '20 zone des Activités', streetComplement: 'Bâtiment C', bennes: 6, triageProvider: true, categoryNames: ['Grossiste', 'Négociant']);
+ $this->addRib($grossiste, 'Compte principal', 'BNPAFRPPXXX', 'FR7630006000011234567890189', 0);
+ $this->addRib($grossiste, 'Compte secondaire', 'SOGEFRPPXXX', 'FR7630001007941234567890185', 1);
+ }
+
+ // === VIREMENT + banque, TVA intracom (importateur), multi-sites sur l'adresse ===
+ [$import, $isNew] = $this->ensureSupplier($manager, 'Import Recyclage International', ['Importateur']);
+ if ($isNew) {
+ $import->setSiren('409512012');
+ $import->setTvaMode($this->tvaMode($manager, 'INTRACOM_VENTES'));
+ $import->setNTva('FR90409512012');
+ $import->setPaymentDelay($this->paymentDelay($manager, 'J30'));
+ $import->setPaymentType($this->paymentType($manager, 'VIREMENT'));
+ $import->setBank($this->bank($manager, 'CIC'));
+ $this->addContact($import, 'Paul', 'Garnier', 'Import manager', '05 56 44 55 66', null, 'paul.garnier@import-recyclage.fr', 0);
+ $this->addContact($import, null, 'Bernard', 'Douanes', '05 56 44 55 67', null, 'douanes@import-recyclage.fr', 1);
+ $this->addAddress($import, 'RENDU', ['Pommevic', 'Saint-Jean'], '82400', 'Pommevic', '3 quai des Importateurs', bennes: 8);
+ }
+
+ // === Multi-adresses PROSPECT / DEPART / RENDU (RG-2.09) + VIREMENT/banque ===
+ [$ferrailleur, $isNew] = $this->ensureSupplier($manager, 'Ferrailleur Grand Ouest', ['Négociant']);
+ if ($isNew) {
+ $ferrailleur->setSiren('732829320');
+ $ferrailleur->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
+ $ferrailleur->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION'));
+ $ferrailleur->setPaymentType($this->paymentType($manager, 'VIREMENT'));
+ $ferrailleur->setBank($this->bank($manager, 'CA'));
+ $this->addContact($ferrailleur, 'Olivier', 'Renard', 'Responsable site', '05 49 61 62 63', null, 'olivier.renard@ferrailleur-go.fr', 0);
+ $this->addContact($ferrailleur, 'Nadia', 'Benali', 'Pesée', '05 49 61 62 64', null, 'nadia.benali@ferrailleur-go.fr', 1);
+ // Prospect (site en cours de demarchage).
+ $this->addAddress($ferrailleur, 'PROSPECT', ['Chatellerault'], '86100', 'Châtellerault', '5 avenue de la Prospection', position: 0);
+ // Depart (collecte) multi-sites avec bennes + triage.
+ $this->addAddress($ferrailleur, 'DEPART', ['Saint-Jean', 'Pommevic'], '17400', 'Fontenet', '4 rue de la Collecte', bennes: 5, triageProvider: true, categoryNames: ['Négociant'], position: 1);
+ // Rendu (livraison).
+ $this->addAddress($ferrailleur, 'RENDU', ['Pommevic'], '82400', 'Pommevic', '7 boulevard du Rendu', bennes: 3, position: 2);
+ }
+
+ // === Onglet Information complet (dont volumeForecast) + VIREMENT/banque ===
+ [$holding, $isNew] = $this->ensureSupplier($manager, 'Holding Recyclage Premium', ['Importateur']);
+ if ($isNew) {
+ $holding->setDescription('Holding de recyclage diversifiée, présente sur le Grand Sud-Ouest.');
+ $holding->setCompetitors('Groupe Atlantique Recyclage, Sud Métaux');
+ $holding->setFoundedAt(new DateTimeImmutable('2008-09-01'));
+ $holding->setEmployeesCount(180);
+ $holding->setRevenueAmount('24500000.00');
+ $holding->setDirectorName('Antoine Lefèvre');
+ $holding->setProfitAmount('1850000.00');
+ $holding->setVolumeForecast(120000);
+ $holding->setSiren('318471925');
+ $holding->setAccountNumber('F0007');
+ $holding->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
+ $holding->setNTva('FR33318471925');
+ $holding->setPaymentDelay($this->paymentDelay($manager, 'J30'));
+ $holding->setPaymentType($this->paymentType($manager, 'VIREMENT'));
+ $holding->setBank($this->bank($manager, 'SG'));
+ $this->addContact($holding, 'Antoine', 'Lefèvre', 'PDG', '05 56 51 52 53', null, 'antoine.lefevre@holding-recyclage.fr');
+ $this->addAddress($holding, 'DEPART', ['Chatellerault'], '86100', 'Châtellerault', '1 allée des Investisseurs', bennes: 5, triageProvider: true, categoryNames: ['Importateur']);
+ }
+
+ // === Coop minimale — contact par le seul nom (RG-2.04), sans compta ===
+ [$coopMin, $isNew] = $this->ensureSupplier($manager, 'Coop Métaux Réunis', ['Coopérative']);
+ if ($isNew) {
+ $this->addContact($coopMin, null, 'Caron', 'Président', '05 49 81 82 83', null, 'president@coop-metaux-reunis.fr');
+ $this->addAddress($coopMin, 'DEPART', ['Saint-Jean'], '17400', 'Fontenet', '6 chemin du Village');
+ }
+
+ // === Reglement CHEQUE (sans banque ni RIB requis) ===
+ [$petit, $isNew] = $this->ensureSupplier($manager, 'Petit Négoce Local', ['Négociant']);
+ if ($isNew) {
+ $petit->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
+ $petit->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION'));
+ $petit->setPaymentType($this->paymentType($manager, 'CHEQUE'));
+ $this->addContact($petit, 'Luc', 'Martin', 'Gérant', '05 56 71 72 73', null, 'luc.martin@petit-negoce.fr');
+ $this->addAddress($petit, 'RENDU', ['Chatellerault'], '86100', 'Châtellerault', '15 rue du Commerce');
+ }
+
+ // === Reglement NON_SOUMISE + adresse multi-sites avec triage ===
+ [$recup, $isNew] = $this->ensureSupplier($manager, 'Récupération Métaux Express', ['Grossiste']);
+ if ($isNew) {
+ $recup->setSiren('490212019');
+ $recup->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
+ $recup->setPaymentDelay($this->paymentDelay($manager, 'J15'));
+ $recup->setPaymentType($this->paymentType($manager, 'NON_SOUMISE'));
+ $this->addContact($recup, 'Marie', 'Lemoine', 'Responsable', '05 49 77 88 99', null, 'marie.lemoine@recup-express.fr', 0);
+ $this->addContact($recup, 'Pierre', 'Durand', 'Chauffeur', '05 49 77 88 98', null, 'pierre.durand@recup-express.fr', 1);
+ $this->addAddress($recup, 'DEPART', ['Saint-Jean', 'Chatellerault'], '17400', 'Fontenet', '10 zone industrielle', bennes: 7, triageProvider: true, categoryNames: ['Grossiste']);
+ }
+
+ // === Centre de tri — focus bennes/triage + multi-categories ===
+ [$centre, $isNew] = $this->ensureSupplier($manager, 'Centre de Tri Sud', ['Producteur', 'Coopérative']);
+ if ($isNew) {
+ $centre->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION'));
+ $this->addContact($centre, 'Camille', 'Faure', 'Chef de centre', '05 56 91 92 93', null, 'camille.faure@centre-tri-sud.fr');
+ $this->addAddress($centre, 'DEPART', ['Pommevic'], '82400', 'Pommevic', '2 route du Tri', bennes: 12, triageProvider: true, categoryNames: ['Producteur']);
+ }
+
+ // === Fournisseur archive #1 (RG-2.17) ===
+ [$ancien, $isNew] = $this->ensureSupplier($manager, 'Ancien Fournisseur Fermé', ['Producteur'], isArchived: true);
+ if ($isNew) {
+ $this->addContact($ancien, null, 'Lambert', 'Ancien contact', '05 49 99 99 99', null, 'contact@ancien-fournisseur.fr');
+ $this->addAddress($ancien, 'DEPART', ['Chatellerault'], '86100', 'Châtellerault', '99 rue Fermée');
+ }
+
+ // === Fournisseur archive #2 (RG-2.17) ===
+ [$disparu, $isNew] = $this->ensureSupplier($manager, 'Négoce Disparu', ['Grossiste'], isArchived: true);
+ if ($isNew) {
+ $this->addContact($disparu, 'Gérard', 'Blanc', 'Ex-gérant', '05 56 00 00 00', null, 'gerard.blanc@negoce-disparu.fr');
+ $this->addAddress($disparu, 'RENDU', ['Saint-Jean'], '17400', 'Fontenet', '0 impasse Oubliée');
+ }
+
+ $manager->flush();
+ }
+
+ /**
+ * Cree un fournisseur (base normalisee + categories de type FOURNISSEUR)
+ * s'il n'existe pas encore, sinon retourne l'existant. Retourne
+ * [Supplier, isNew] : isNew=false bloque la reconstruction des
+ * sous-collections (idempotence sans doublon).
+ *
+ * @param list $categoryNames categories de type FOURNISSEUR (RG-2.10)
+ *
+ * @return array{0: Supplier, 1: bool}
+ */
+ private function ensureSupplier(
+ ObjectManager $manager,
+ string $companyName,
+ array $categoryNames,
+ bool $isArchived = false,
+ ): array {
+ $normalizedName = (string) $this->normalizer->normalizeCompanyName($companyName);
+
+ $existing = $manager->getRepository(Supplier::class)->findOneBy(['companyName' => $normalizedName]);
+ if ($existing instanceof Supplier) {
+ return [$existing, false];
+ }
+
+ $supplier = new Supplier();
+ $supplier->setCompanyName($normalizedName);
+
+ foreach ($categoryNames as $categoryName) {
+ $supplier->addCategory($this->category($manager, $categoryName));
+ }
+
+ if ($isArchived) {
+ $supplier->setIsArchived(true);
+ $supplier->setArchivedAt(new DateTimeImmutable());
+ }
+
+ $manager->persist($supplier);
+
+ return [$supplier, true];
+ }
+
+ /**
+ * Ajoute un contact normalise au fournisseur (cascade persist via
+ * Supplier.contacts). Au moins firstName OU lastName est toujours fourni
+ * (RG-2.04, chk_supplier_contact_name).
+ */
+ private function addContact(
+ Supplier $supplier,
+ ?string $firstName,
+ ?string $lastName,
+ ?string $jobTitle,
+ ?string $phonePrimary,
+ ?string $phoneSecondary,
+ ?string $email,
+ int $position = 0,
+ ): void {
+ $contact = new SupplierContact();
+ $contact->setFirstName($this->normalizer->normalizePersonName($firstName));
+ $contact->setLastName($this->normalizer->normalizePersonName($lastName));
+ $contact->setJobTitle($jobTitle);
+ $contact->setPhonePrimary($this->normalizer->normalizePhone($phonePrimary));
+ $contact->setPhoneSecondary($this->normalizer->normalizePhone($phoneSecondary));
+ $contact->setEmail($this->normalizer->normalizeEmail($email));
+ $contact->setPosition($position);
+
+ $supplier->addContact($contact);
+ }
+
+ /**
+ * Ajoute une adresse au fournisseur (cascade persist via Supplier.addresses).
+ * Le type d'adresse est exclusif (PROSPECT | DEPART | RENDU — RG-2.09,
+ * chk_supplier_address_type) ; au moins un site est rattache (RG-2.06) ; les
+ * categories d'adresse sont de type FOURNISSEUR (RG-2.10).
+ *
+ * @param list $siteNames au moins un site (RG-2.06)
+ * @param list $categoryNames categories de type FOURNISSEUR (RG-2.10)
+ */
+ private function addAddress(
+ Supplier $supplier,
+ string $addressType,
+ array $siteNames,
+ string $postalCode,
+ string $city,
+ string $street,
+ ?string $streetComplement = null,
+ ?int $bennes = null,
+ bool $triageProvider = false,
+ array $categoryNames = [],
+ int $position = 0,
+ ): void {
+ $address = new SupplierAddress();
+ $address->setAddressType($addressType);
+ $address->setPostalCode($postalCode);
+ $address->setCity($city);
+ $address->setStreet($street);
+ $address->setStreetComplement($streetComplement);
+ $address->setBennes($bennes);
+ $address->setTriageProvider($triageProvider);
+ $address->setPosition($position);
+
+ foreach ($siteNames as $siteName) {
+ $address->addSite($this->site($siteName));
+ }
+
+ foreach ($categoryNames as $categoryName) {
+ $address->addCategory($this->category($this->manager, $categoryName));
+ }
+
+ $supplier->addAddress($address);
+ }
+
+ /**
+ * Ajoute un RIB au fournisseur (cascade persist via Supplier.ribs). IBAN/BIC
+ * valides (Assert\Iban/Bic non rejouee sur persist direct mais donnees
+ * coherentes pour le golden path / les tests).
+ */
+ private function addRib(Supplier $supplier, string $label, string $bic, string $iban, int $position = 0): void
+ {
+ $rib = new SupplierRib();
+ $rib->setLabel($label);
+ $rib->setBic($bic);
+ $rib->setIban($iban);
+ $rib->setPosition($position);
+
+ $supplier->addRib($rib);
+ }
+
+ /**
+ * Resout une categorie par son nom via le contrat Shared CategoryInterface
+ * (resolve_target_entities -> Category), sans importer le module Catalog
+ * (regle n°1). Mise en cache par nom.
+ */
+ private function category(ObjectManager $manager, string $name): CategoryInterface
+ {
+ if (isset($this->categoryCache[$name])) {
+ return $this->categoryCache[$name];
+ }
+
+ // RG-2.10 : on garde la categorie des qu'elle PORTE le type FOURNISSEUR
+ // (multi-type depuis le passage en ManyToMany). Le nom etant desormais
+ // unique GLOBALEMENT parmi les actifs, le lookup par `name` renvoie au
+ // plus une categorie, mais on conserve la verification du type pour
+ // ecarter un homonyme qui ne porterait pas FOURNISSEUR. Le filtre type
+ // est porte cote PHP (findBy ne sait pas filtrer la collection categoryTypes).
+ $candidates = $manager->getRepository(CategoryInterface::class)->findBy([
+ 'name' => $name,
+ 'deletedAt' => null,
+ ]);
+
+ foreach ($candidates as $candidate) {
+ if ($candidate instanceof CategoryInterface
+ && in_array(self::SUPPLIER_CATEGORY_TYPE_CODE, $candidate->getCategoryTypeCodes(), true)) {
+ return $this->categoryCache[$name] = $candidate;
+ }
+ }
+
+ throw new RuntimeException(sprintf(
+ 'Categorie FOURNISSEUR "%s" introuvable : CategoryFixtures doit tourner avant SupplierFixtures.',
+ $name,
+ ));
+ }
+
+ /**
+ * Resout un site par son nom via le contrat Shared SiteProviderInterface,
+ * sans importer le module Sites (regle n°1). Mise en cache par nom.
+ */
+ private function site(string $name): SiteInterface
+ {
+ if (isset($this->siteCache[$name])) {
+ return $this->siteCache[$name];
+ }
+
+ $site = $this->siteProvider->findByName($name);
+
+ if (!$site instanceof SiteInterface) {
+ throw new RuntimeException(sprintf(
+ 'Site "%s" introuvable : SitesFixtures doit tourner avant SupplierFixtures.',
+ $name,
+ ));
+ }
+
+ return $this->siteCache[$name] = $site;
+ }
+
+ private function tvaMode(ObjectManager $manager, string $code): TvaMode
+ {
+ $mode = $manager->getRepository(TvaMode::class)->findOneBy(['code' => $code]);
+
+ if (!$mode instanceof TvaMode) {
+ throw new RuntimeException(sprintf(
+ 'TvaMode "%s" introuvable : CommercialReferentialFixtures doit tourner avant SupplierFixtures.',
+ $code,
+ ));
+ }
+
+ return $mode;
+ }
+
+ private function paymentDelay(ObjectManager $manager, string $code): PaymentDelay
+ {
+ $delay = $manager->getRepository(PaymentDelay::class)->findOneBy(['code' => $code]);
+
+ if (!$delay instanceof PaymentDelay) {
+ throw new RuntimeException(sprintf(
+ 'PaymentDelay "%s" introuvable : CommercialReferentialFixtures doit tourner avant SupplierFixtures.',
+ $code,
+ ));
+ }
+
+ return $delay;
+ }
+
+ private function paymentType(ObjectManager $manager, string $code): PaymentType
+ {
+ $type = $manager->getRepository(PaymentType::class)->findOneBy(['code' => $code]);
+
+ if (!$type instanceof PaymentType) {
+ throw new RuntimeException(sprintf(
+ 'PaymentType "%s" introuvable : CommercialReferentialFixtures doit tourner avant SupplierFixtures.',
+ $code,
+ ));
+ }
+
+ return $type;
+ }
+
+ private function bank(ObjectManager $manager, string $code): Bank
+ {
+ $bank = $manager->getRepository(Bank::class)->findOneBy(['code' => $code]);
+
+ if (!$bank instanceof Bank) {
+ throw new RuntimeException(sprintf(
+ 'Bank "%s" introuvable : CommercialReferentialFixtures doit tourner avant SupplierFixtures.',
+ $code,
+ ));
+ }
+
+ return $bank;
+ }
+}
diff --git a/src/Module/Commercial/Infrastructure/Doctrine/DoctrineSupplierAddressRepository.php b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineSupplierAddressRepository.php
new file mode 100644
index 0000000..8730bdf
--- /dev/null
+++ b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineSupplierAddressRepository.php
@@ -0,0 +1,32 @@
+
+ */
+class DoctrineSupplierAddressRepository extends ServiceEntityRepository implements SupplierAddressRepositoryInterface
+{
+ public function __construct(ManagerRegistry $registry)
+ {
+ parent::__construct($registry, SupplierAddress::class);
+ }
+
+ public function findById(int $id): ?SupplierAddress
+ {
+ return $this->find($id);
+ }
+
+ public function save(SupplierAddress $address): void
+ {
+ $this->getEntityManager()->persist($address);
+ $this->getEntityManager()->flush();
+ }
+}
diff --git a/src/Module/Commercial/Infrastructure/Doctrine/DoctrineSupplierContactRepository.php b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineSupplierContactRepository.php
new file mode 100644
index 0000000..72ffb95
--- /dev/null
+++ b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineSupplierContactRepository.php
@@ -0,0 +1,32 @@
+
+ */
+class DoctrineSupplierContactRepository extends ServiceEntityRepository implements SupplierContactRepositoryInterface
+{
+ public function __construct(ManagerRegistry $registry)
+ {
+ parent::__construct($registry, SupplierContact::class);
+ }
+
+ public function findById(int $id): ?SupplierContact
+ {
+ return $this->find($id);
+ }
+
+ public function save(SupplierContact $contact): void
+ {
+ $this->getEntityManager()->persist($contact);
+ $this->getEntityManager()->flush();
+ }
+}
diff --git a/src/Module/Commercial/Infrastructure/Doctrine/DoctrineSupplierRepository.php b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineSupplierRepository.php
new file mode 100644
index 0000000..05fefe0
--- /dev/null
+++ b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineSupplierRepository.php
@@ -0,0 +1,264 @@
+
+ */
+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()
+ ;
+ }
+
+ public function hydrateContacts(array $suppliers): void
+ {
+ $ids = [];
+ foreach ($suppliers as $supplier) {
+ $id = $supplier->getId();
+ if (null !== $id) {
+ $ids[] = $id;
+ }
+ }
+ if ([] === $ids) {
+ return;
+ }
+
+ // Une seule requete IN bornee : remplit la collection `contacts` des MEMES
+ // instances Supplier (identity map). Tri par position pour que le « contact
+ // principal » (plus petit position) soit deterministe a l'export (§ 4.6).
+ $this->createQueryBuilder('s')
+ ->leftJoin('s.contacts', 'sc')->addSelect('sc')
+ ->where('s.id IN (:ids)')->setParameter('ids', $ids)
+ ->orderBy('sc.position', 'ASC')
+ ->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;
+ }
+}
diff --git a/src/Module/Commercial/Infrastructure/Doctrine/DoctrineSupplierRibRepository.php b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineSupplierRibRepository.php
new file mode 100644
index 0000000..8c5869d
--- /dev/null
+++ b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineSupplierRibRepository.php
@@ -0,0 +1,32 @@
+
+ */
+class DoctrineSupplierRibRepository extends ServiceEntityRepository implements SupplierRibRepositoryInterface
+{
+ public function __construct(ManagerRegistry $registry)
+ {
+ parent::__construct($registry, SupplierRib::class);
+ }
+
+ public function findById(int $id): ?SupplierRib
+ {
+ return $this->find($id);
+ }
+
+ public function save(SupplierRib $rib): void
+ {
+ $this->getEntityManager()->persist($rib);
+ $this->getEntityManager()->flush();
+ }
+}
diff --git a/src/Module/Core/Application/Rbac/RbacSeeder.php b/src/Module/Core/Application/Rbac/RbacSeeder.php
index ebbc0ca..20669e9 100644
--- a/src/Module/Core/Application/Rbac/RbacSeeder.php
+++ b/src/Module/Core/Application/Rbac/RbacSeeder.php
@@ -51,8 +51,9 @@ final class RbacSeeder
* Definition unique des 4 roles + matrice § 2.7. La cle est le code du role,
* `label` le libelle FR affichable, `permissions` la liste des codes RBAC a
* attacher (vide pour usine : aucun acces ; admin n'apparait pas car il
- * bypass tout via isAdmin ; `commercial.clients.archive` n'est attache a
- * aucun role metier — admin seul).
+ * bypass tout via isAdmin ; `commercial.clients.archive` et
+ * `commercial.suppliers.archive` ne sont attaches a aucun role metier —
+ * admin seul).
*
* @var array}>
*/
@@ -62,6 +63,9 @@ final class RbacSeeder
'permissions' => [
'commercial.clients.view',
'commercial.clients.manage',
+ // Fournisseurs (M2 § 2.9, ERP-90) : view + manage (hors Comptabilite).
+ 'commercial.suppliers.view',
+ 'commercial.suppliers.manage',
// Lecture des referentiels transverses pour les selects client (ERP-102).
'catalog.categories.read_ref',
'sites.read_ref',
@@ -73,6 +77,11 @@ final class RbacSeeder
'commercial.clients.view',
'commercial.clients.accounting.view',
'commercial.clients.accounting.manage',
+ // Fournisseurs (M2 § 2.9, ERP-90) : view + onglet Comptabilite uniquement
+ // (pas de manage global -> ne peut pas creer un fournisseur).
+ 'commercial.suppliers.view',
+ 'commercial.suppliers.accounting.view',
+ 'commercial.suppliers.accounting.manage',
// Lecture des referentiels transverses pour les selects client (ERP-102).
'catalog.categories.read_ref',
'sites.read_ref',
@@ -83,6 +92,10 @@ final class RbacSeeder
'permissions' => [
'commercial.clients.view',
'commercial.clients.manage',
+ // Fournisseurs (M2 § 2.9, ERP-90) : view + manage, sans accounting
+ // (onglet Comptabilite masque/filtre pour la Commerciale).
+ 'commercial.suppliers.view',
+ 'commercial.suppliers.manage',
// Lecture des referentiels transverses pour les selects client (ERP-102).
'catalog.categories.read_ref',
'sites.read_ref',
diff --git a/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php b/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php
index 9a59237..7e7545b 100644
--- a/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php
+++ b/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php
@@ -195,6 +195,14 @@ final class SeedE2ECommand extends Command
'commercial.clients.accounting.view',
'commercial.clients.accounting.manage',
'commercial.clients.archive',
+ // Commercial — Repertoire fournisseurs (M2, ERP-90). Meme
+ // logique que les clients : mappe sur le persona "tout".
+ // Miroir de frontend/tests/e2e/_fixtures/personas.ts.
+ 'commercial.suppliers.view',
+ 'commercial.suppliers.manage',
+ 'commercial.suppliers.accounting.view',
+ 'commercial.suppliers.accounting.manage',
+ 'commercial.suppliers.archive',
],
],
[
diff --git a/src/Shared/Domain/Contract/CategoryInterface.php b/src/Shared/Domain/Contract/CategoryInterface.php
index 2aef070..21ff609 100644
--- a/src/Shared/Domain/Contract/CategoryInterface.php
+++ b/src/Shared/Domain/Contract/CategoryInterface.php
@@ -35,10 +35,14 @@ interface CategoryInterface
public function getCode(): ?string;
/**
- * Code du type de categorie rattache (CategoryType::code), ou null si la
- * categorie n'a pas de type. Depuis ERP-78, le modele n'a plus qu'un seul
- * type (CLIENT) : le filtrage metier passe desormais par getCode() ci-dessus.
- * Conserve pour l'affichage / la retrocompatibilite.
+ * Codes des types de categorie rattaches (CategoryType::code), tableau vide
+ * si aucun. Depuis le passage en ManyToMany, une categorie peut porter
+ * plusieurs types : un module tiers teste l'appartenance via
+ * `in_array($code, $category->getCategoryTypeCodes(), true)`. Pilote, cote
+ * M2 Commercial, la RG-2.10 (une categorie de fournisseur doit etre de type
+ * FOURNISSEUR).
+ *
+ * @return list
*/
- public function getCategoryTypeCode(): ?string;
+ public function getCategoryTypeCodes(): array;
}
diff --git a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php
index 70a32e2..0c85547 100644
--- a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php
+++ b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php
@@ -50,12 +50,11 @@ final class ColumnCommentsCatalog
],
'category' => [
- '_table' => 'Categories M0 — referentiel type par category_type, soft-delete via deleted_at, unicite (LOWER(name), category_type_id) parmi les actifs.',
- 'id' => 'Identifiant interne auto-incremente.',
- 'name' => 'Libelle de la categorie (≤ 120 caracteres) — unique par type parmi les actifs (RG-1.06).',
- 'code' => 'Code technique stable (slug MAJUSCULE du nom, ≤ 50) — unique parmi les actifs (uq_category_code). Fige a la creation. DISTRIBUTEUR/COURTIER pilotent RG-1.03/1.29.',
- 'category_type_id' => 'Reference au type de la categorie — FK -> category_type.id, ON DELETE RESTRICT (un type ne peut etre supprime tant qu il a des categories).',
- 'deleted_at' => 'Horodatage UTC du soft-delete (archivage logique) — null si la categorie est active.',
+ '_table' => 'Categories — referentiel multi-types via la jonction category_category_type, soft-delete via deleted_at, unicite LOWER(name) GLOBALE parmi les actifs (uq_category_name_active).',
+ 'id' => 'Identifiant interne auto-incremente.',
+ 'name' => 'Libelle de la categorie (≤ 120 caracteres) — unique GLOBALEMENT parmi les actifs (RG-1.07, uq_category_name_active).',
+ 'code' => 'Code technique stable (slug MAJUSCULE du nom, ≤ 50) — unique parmi les actifs (uq_category_code). Fige a la creation. DISTRIBUTEUR/COURTIER pilotent RG-1.03/1.29.',
+ 'deleted_at' => 'Horodatage UTC du soft-delete (archivage logique) — null si la categorie est active.',
] + self::timestampableBlamableComments(),
'category_type' => [
@@ -65,6 +64,12 @@ final class ColumnCommentsCatalog
'label' => 'Libelle affichable du type (FR, ≤ 120 caracteres).',
],
+ 'category_category_type' => [
+ '_table' => 'Jointure M2M category <-> category_type (Catalog) — types portes par la categorie, au moins un obligatoire (RG-1.05).',
+ 'category_id' => 'FK -> category.id, ON DELETE CASCADE — categorie portant le type.',
+ 'category_type_id' => 'FK -> category_type.id, ON DELETE RESTRICT — type rattache (un type ne peut etre supprime tant qu il reste reference).',
+ ],
+
'permission' => [
'_table' => 'Referentiel des permissions RBAC — codes au format module.resource[.subresource].action, synchronise par app:sync-permissions.',
'id' => 'Identifiant interne auto-incremente.',
@@ -256,6 +261,95 @@ final class ColumnCommentsCatalog
'iban' => 'IBAN du compte (≤ 34 caracteres).',
'position' => 'Ordre d affichage du RIB dans la liste du client (croissant).',
] + self::timestampableBlamableComments(),
+
+ // === M2 Commercial (ERP-85) — miroir des COMMENT de la migration
+ // Version20260605130000 pour le chemin schema:update (dev/test). ===
+
+ 'supplier' => [
+ '_table' => 'Repertoire fournisseurs (M2 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M3).',
+ 'id' => 'Identifiant interne auto-incremente.',
+ 'company_name' => 'Raison sociale du fournisseur (stockee en MAJUSCULES). Unique case-insensitive parmi les actifs non archives/non supprimes (uq_supplier_company_name_active, § 2.6).',
+ 'description' => 'Onglet Information : description libre. Obligatoire pour le role Commerciale (RG-2.03), optionnel sinon.',
+ 'competitors' => 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-2.03).',
+ 'founded_at' => 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-2.03).',
+ 'employees_count' => 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-2.03).',
+ 'revenue_amount' => 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-2.03).',
+ 'director_name' => 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-2.03).',
+ 'profit_amount' => 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-2.03).',
+ 'volume_forecast' => 'Onglet Information : volume previsionnel (entier >= 0) — specifique fournisseur. Obligatoire role Commerciale (RG-2.03).',
+ 'siren' => 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (§ 2.6).',
+ 'account_number' => 'Onglet Comptabilite : numero de compte comptable du fournisseur.',
+ 'tva_mode_id' => 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id (referentiel partage M1), ON DELETE RESTRICT.',
+ 'n_tva' => 'Onglet Comptabilite : numero de TVA intracommunautaire.',
+ 'payment_delay_id' => 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id (M1), ON DELETE RESTRICT.',
+ 'payment_type_id' => 'Onglet Comptabilite : type de reglement — FK -> payment_type.id (M1), ON DELETE RESTRICT. Pilote RG-2.07 (Banque si VIREMENT) et RG-2.08 (RIB).',
+ 'bank_id' => 'Onglet Comptabilite : banque — FK -> bank.id (M1), ON DELETE RESTRICT. Obligatoire ssi payment_type = VIREMENT (RG-2.07), null sinon.',
+ 'is_archived' => 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission commercial.suppliers.archive.',
+ 'archived_at' => 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration.',
+ 'deleted_at' => 'Horodatage du soft-delete technique (HP M3) — non expose par l API au M2. Null = ligne active.',
+ ] + self::timestampableBlamableComments(),
+
+ 'supplier_category' => [
+ '_table' => 'Jointure M2M supplier <-> category (Catalog) — categories de type FOURNISSEUR du fournisseur, au moins une obligatoire (RG-2.10).',
+ 'supplier_id' => 'FK -> supplier.id, ON DELETE CASCADE — fournisseur porteur de la categorie.',
+ 'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie de type FOURNISSEUR rattachee au fournisseur (RG-2.10).',
+ ],
+
+ 'supplier_contact' => [
+ '_table' => 'Contacts d un fournisseur (1:n) — au moins firstName OU lastName par contact (RG-2.04).',
+ 'id' => 'Identifiant interne auto-incremente.',
+ 'supplier_id' => 'FK -> supplier.id, ON DELETE CASCADE — fournisseur proprietaire du contact.',
+ 'first_name' => 'Prenom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-2.04, chk_supplier_contact_name).',
+ 'last_name' => 'Nom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-2.04, chk_supplier_contact_name).',
+ 'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).',
+ 'phone_primary' => 'Telephone principal du contact — chiffres uniquement (normalisation serveur).',
+ 'phone_secondary' => 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).',
+ 'email' => 'Email du contact (lowercase serveur).',
+ 'position' => 'Ordre d affichage du contact dans la liste du fournisseur (croissant).',
+ ] + self::timestampableBlamableComments(),
+
+ 'supplier_address' => [
+ '_table' => 'Adresses d un fournisseur (1:n) — type PROSPECT/DEPART/RENDU exclusif (RG-2.09), >= 1 site rattache (RG-2.06).',
+ 'id' => 'Identifiant interne auto-incremente.',
+ 'supplier_id' => 'FK -> supplier.id, ON DELETE CASCADE — fournisseur proprietaire de l adresse.',
+ 'address_type' => 'Type d adresse : PROSPECT | DEPART | RENDU (radio exclusif par construction — RG-2.09, chk_supplier_address_type).',
+ 'country' => 'Pays de l adresse — defaut France.',
+ 'postal_code' => 'Code postal (4-5 chiffres attendus).',
+ 'city' => 'Ville — preremplie depuis le code postal via API BAN cote front.',
+ 'street' => 'Numero et voie de l adresse.',
+ 'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.',
+ 'bennes' => 'Nombre de bennes sur le site fournisseur (entier nullable) — specifique fournisseur.',
+ 'triage_provider' => 'Le fournisseur est prestataire de triage sur cette adresse. Faux par defaut.',
+ 'position' => 'Ordre d affichage de l adresse dans la liste du fournisseur (croissant).',
+ ] + self::timestampableBlamableComments(),
+
+ 'supplier_address_site' => [
+ '_table' => 'Jointure M2M supplier_address <-> site (Sites) — sites rattaches a l adresse (>= 1 obligatoire, RG-2.06).',
+ 'supplier_address_id' => 'FK -> supplier_address.id, ON DELETE CASCADE — adresse concernee.',
+ 'site_id' => 'FK -> site.id, ON DELETE RESTRICT — site rattache a l adresse.',
+ ],
+
+ 'supplier_address_contact' => [
+ '_table' => 'Jointure M2M supplier_address <-> supplier_contact — contacts associes a une adresse.',
+ 'supplier_address_id' => 'FK -> supplier_address.id, ON DELETE CASCADE — adresse concernee.',
+ 'supplier_contact_id' => 'FK -> supplier_contact.id, ON DELETE CASCADE — contact associe a l adresse.',
+ ],
+
+ 'supplier_address_category' => [
+ '_table' => 'Jointure M2M supplier_address <-> category — categories d adresse de type FOURNISSEUR (RG-2.10).',
+ 'supplier_address_id' => 'FK -> supplier_address.id, ON DELETE CASCADE — adresse concernee.',
+ 'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse de type FOURNISSEUR (RG-2.10).',
+ ],
+
+ 'supplier_rib' => [
+ '_table' => 'Coordonnees bancaires d un fournisseur (1:n) — >= 1 RIB attendu selon le type de reglement (RG-2.08). Tous les champs audites (pas d AuditIgnore).',
+ 'id' => 'Identifiant interne auto-incremente.',
+ 'supplier_id' => 'FK -> supplier.id, ON DELETE CASCADE — fournisseur proprietaire du RIB.',
+ 'label' => 'Libelle du RIB (ex: compte principal).',
+ 'bic' => 'Code BIC/SWIFT de la banque (8 ou 11 caracteres).',
+ 'iban' => 'IBAN du compte (≤ 34 caracteres).',
+ 'position' => 'Ordre d affichage du RIB dans la liste du fournisseur (croissant).',
+ ] + self::timestampableBlamableComments(),
];
}
diff --git a/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php b/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php
index 9673d5b..fb5d338 100644
--- a/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php
+++ b/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php
@@ -52,6 +52,10 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
private const array EXCLUDED_LENGTH_MIRROR = [
// Le Regex /^[0-9]{4,5}$/ borne deja la longueur a 5 caracteres (< 20).
'ClientAddress::postalCode' => 'Regex {4,5} borne deja la longueur.',
+ // Idem cote fournisseur (meme Regex CP).
+ 'SupplierAddress::postalCode' => 'Regex {4,5} borne deja la longueur.',
+ // Le Choice {PROSPECT,DEPART,RENDU} borne les valeurs (<= 8 < 20).
+ 'SupplierAddress::addressType' => 'Choice {PROSPECT,DEPART,RENDU} borne deja les valeurs.',
// Le Regex /^#[0-9A-Fa-f]{6}$/ borne la longueur a exactement 7 caracteres.
'Site::color' => 'Regex code hex #RRGGBB borne deja la longueur.',
];
@@ -70,6 +74,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
Assert\NotBlank::class,
Assert\NotNull::class,
Assert\Email::class,
+ Assert\Choice::class,
Assert\Regex::class,
Assert\Bic::class,
Assert\Iban::class,
diff --git a/tests/Module/Catalog/Api/AbstractCatalogApiTestCase.php b/tests/Module/Catalog/Api/AbstractCatalogApiTestCase.php
index beeb62e..fee412a 100644
--- a/tests/Module/Catalog/Api/AbstractCatalogApiTestCase.php
+++ b/tests/Module/Catalog/Api/AbstractCatalogApiTestCase.php
@@ -70,11 +70,17 @@ abstract class AbstractCatalogApiTestCase extends AbstractApiTestCase
* cleanup. Si aucun type n'est fourni, un nouveau CategoryType est cree.
* Le flag $deletedAt permet de seeder directement une categorie
* soft-deleted (pour les tests RG-1.08 / RG-1.11).
+ *
+ * Multi-types (ManyToMany) : `$type` est le type principal (cree si null) ;
+ * `$additionalTypes` permet d'attacher d'autres types pour les cas multi.
+ *
+ * @param list $additionalTypes
*/
protected function createCategory(
?string $name = null,
?CategoryType $type = null,
?DateTimeImmutable $deletedAt = null,
+ array $additionalTypes = [],
): Category {
$em = $this->getEm();
@@ -86,7 +92,10 @@ abstract class AbstractCatalogApiTestCase extends AbstractApiTestCase
// ERP-78 : code NOT NULL + unique parmi les actifs (uq_category_code).
// Nonce aleatoire -> unicite garantie entre seeds successifs du test.
$category->setCode('TEST_'.strtoupper($suffix));
- $category->setCategoryType($type);
+ $category->addCategoryType($type);
+ foreach ($additionalTypes as $additionalType) {
+ $category->addCategoryType($additionalType);
+ }
if (null !== $deletedAt) {
$category->setDeletedAt($deletedAt);
}
diff --git a/tests/Module/Catalog/Api/CategoryAuditTest.php b/tests/Module/Catalog/Api/CategoryAuditTest.php
index e9b4749..637be5e 100644
--- a/tests/Module/Catalog/Api/CategoryAuditTest.php
+++ b/tests/Module/Catalog/Api/CategoryAuditTest.php
@@ -57,7 +57,7 @@ final class CategoryAuditTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'audit_create',
- 'categoryType' => '/api/category_types/'.$type->getId(),
+ 'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
self::assertSame(201, $response->getStatusCode());
@@ -139,7 +139,7 @@ final class CategoryAuditTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'audit_manager',
- 'categoryType' => '/api/category_types/'.$type->getId(),
+ 'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
self::assertSame(201, $response->getStatusCode());
diff --git a/tests/Module/Catalog/Api/CategoryCodeTest.php b/tests/Module/Catalog/Api/CategoryCodeTest.php
index 6846f4c..fa60fda 100644
--- a/tests/Module/Catalog/Api/CategoryCodeTest.php
+++ b/tests/Module/Catalog/Api/CategoryCodeTest.php
@@ -26,7 +26,7 @@ final class CategoryCodeTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'Agro-alimentaire',
- 'categoryType' => '/api/category_types/'.$type->getId(),
+ 'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
self::assertResponseStatusCodeSame(201);
@@ -48,7 +48,7 @@ final class CategoryCodeTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'readonly',
- 'categoryType' => '/api/category_types/'.$type->getId(),
+ 'categoryTypes' => ['/api/category_types/'.$type->getId()],
// Le client tente d'imposer un code : doit etre ignore.
'code' => 'CLIENT_FORGED',
],
@@ -65,13 +65,13 @@ final class CategoryCodeTest extends AbstractCatalogApiTestCase
$type = $this->createCategoryType();
$client = $this->createAdminClient();
- // Deux noms differents (donc autorises par uq_category_name_type_active)
+ // Deux noms differents (donc autorises par uq_category_name_active)
// mais qui produisent le meme slug -> codes distincts (suffixe `_2`).
$first = $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'Agro Plus',
- 'categoryType' => '/api/category_types/'.$type->getId(),
+ 'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
])->toArray();
@@ -79,7 +79,7 @@ final class CategoryCodeTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'Agro-Plus',
- 'categoryType' => '/api/category_types/'.$type->getId(),
+ 'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
])->toArray();
diff --git a/tests/Module/Catalog/Api/CategoryFilterTest.php b/tests/Module/Catalog/Api/CategoryFilterTest.php
new file mode 100644
index 0000000..d8c6010
--- /dev/null
+++ b/tests/Module/Catalog/Api/CategoryFilterTest.php
@@ -0,0 +1,137 @@
+> $members
+ *
+ * @return list
+ */
+ private function testNames(array $members): array
+ {
+ $names = array_map(static fn (array $m): string => $m['name'], $members);
+ $names = array_values(array_filter(
+ $names,
+ fn (string $n): bool => str_starts_with($n, self::TEST_CATEGORY_PREFIX),
+ ));
+ sort($names);
+
+ return $names;
+ }
+
+ public function testNameFilterIsPartialAndCaseInsensitive(): void
+ {
+ $type = $this->createCategoryType();
+ $this->createCategory(self::TEST_CATEGORY_PREFIX.'Acier inox', $type);
+ $this->createCategory(self::TEST_CATEGORY_PREFIX.'Aluminium', $type);
+
+ $client = $this->createAdminClient();
+ $response = $client->request('GET', '/api/categories?name=ACIER&pagination=false');
+ self::assertSame(200, $response->getStatusCode());
+
+ self::assertSame(
+ [self::TEST_CATEGORY_PREFIX.'Acier inox'],
+ $this->testNames($response->toArray()['member']),
+ 'Le filtre ?name= doit etre partiel et insensible a la casse.',
+ );
+ }
+
+ public function testTypeIdFilterReturnsCategoriesWithAtLeastOneType(): void
+ {
+ $typeA = $this->createCategoryType();
+ $typeB = $this->createCategoryType();
+ $typeC = $this->createCategoryType();
+
+ $this->createCategory(self::TEST_CATEGORY_PREFIX.'only_a', $typeA);
+ $this->createCategory(self::TEST_CATEGORY_PREFIX.'only_b', $typeB);
+ $this->createCategory(self::TEST_CATEGORY_PREFIX.'only_c', $typeC);
+
+ $client = $this->createAdminClient();
+ $response = $client->request(
+ 'GET',
+ sprintf('/api/categories?typeId[]=%d&typeId[]=%d&pagination=false', $typeA->getId(), $typeB->getId()),
+ );
+ self::assertSame(200, $response->getStatusCode());
+
+ self::assertSame(
+ [
+ self::TEST_CATEGORY_PREFIX.'only_a',
+ self::TEST_CATEGORY_PREFIX.'only_b',
+ ],
+ $this->testNames($response->toArray()['member']),
+ 'Le filtre ?typeId[]= doit remonter les categories portant AU MOINS UN des types (OR).',
+ );
+ }
+
+ public function testMultiTypeCategoryAppearsOnceWhenFilteredByOneType(): void
+ {
+ // Une categorie portant deux types ne doit pas etre dupliquee quand on
+ // filtre sur l'un de ses types (la sous-requete EXISTS evite les doublons).
+ $typeA = $this->createCategoryType();
+ $typeB = $this->createCategoryType();
+
+ $this->createCategory(
+ self::TEST_CATEGORY_PREFIX.'multi',
+ $typeA,
+ null,
+ [$typeB],
+ );
+
+ $client = $this->createAdminClient();
+ $response = $client->request(
+ 'GET',
+ sprintf('/api/categories?typeId[]=%d&pagination=false', $typeA->getId()),
+ );
+ self::assertSame(200, $response->getStatusCode());
+
+ $members = $response->toArray()['member'];
+ self::assertSame(
+ [self::TEST_CATEGORY_PREFIX.'multi'],
+ $this->testNames($members),
+ 'La categorie multi-types ne doit apparaitre qu une seule fois.',
+ );
+
+ // Les deux types restent embarques (la collection n'est pas tronquee).
+ $multi = array_values(array_filter(
+ $members,
+ fn (array $m): bool => $m['name'] === self::TEST_CATEGORY_PREFIX.'multi',
+ ))[0];
+ self::assertCount(2, $multi['categoryTypes'], 'Les 2 types doivent rester embarques malgre le filtre.');
+ }
+
+ public function testNameAndTypeIdFiltersCombine(): void
+ {
+ $typeA = $this->createCategoryType();
+ $typeB = $this->createCategoryType();
+
+ $this->createCategory(self::TEST_CATEGORY_PREFIX.'steel_a', $typeA);
+ $this->createCategory(self::TEST_CATEGORY_PREFIX.'steel_b', $typeB);
+ $this->createCategory(self::TEST_CATEGORY_PREFIX.'wood_a', $typeA);
+
+ $client = $this->createAdminClient();
+ $response = $client->request(
+ 'GET',
+ sprintf('/api/categories?name=steel&typeId[]=%d&pagination=false', $typeA->getId()),
+ );
+ self::assertSame(200, $response->getStatusCode());
+
+ self::assertSame(
+ [self::TEST_CATEGORY_PREFIX.'steel_a'],
+ $this->testNames($response->toArray()['member']),
+ 'Les filtres ?name= et ?typeId[]= doivent se combiner (ET).',
+ );
+ }
+}
diff --git a/tests/Module/Catalog/Api/CategoryPermissionsTest.php b/tests/Module/Catalog/Api/CategoryPermissionsTest.php
index a18f677..184d3a5 100644
--- a/tests/Module/Catalog/Api/CategoryPermissionsTest.php
+++ b/tests/Module/Catalog/Api/CategoryPermissionsTest.php
@@ -66,7 +66,7 @@ final class CategoryPermissionsTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'forbidden',
- 'categoryType' => '/api/category_types/'.$type->getId(),
+ 'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
@@ -81,7 +81,7 @@ final class CategoryPermissionsTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'anon',
- 'categoryType' => '/api/category_types/'.$type->getId(),
+ 'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
@@ -96,7 +96,7 @@ final class CategoryPermissionsTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'admin_create',
- 'categoryType' => '/api/category_types/'.$type->getId(),
+ 'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
@@ -112,7 +112,7 @@ final class CategoryPermissionsTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'view_only',
- 'categoryType' => '/api/category_types/'.$type->getId(),
+ 'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
diff --git a/tests/Module/Catalog/Api/CategoryTimestampableBlamableTest.php b/tests/Module/Catalog/Api/CategoryTimestampableBlamableTest.php
index e7886d2..1671540 100644
--- a/tests/Module/Catalog/Api/CategoryTimestampableBlamableTest.php
+++ b/tests/Module/Catalog/Api/CategoryTimestampableBlamableTest.php
@@ -69,7 +69,7 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'tsb_admin',
- 'categoryType' => '/api/category_types/'.$type->getId(),
+ 'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
self::assertSame(201, $response->getStatusCode());
@@ -140,7 +140,7 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'tsb_patch',
- 'categoryType' => '/api/category_types/'.$type->getId(),
+ 'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
self::assertSame(201, $response->getStatusCode());
@@ -220,7 +220,7 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'tsb_delete',
- 'categoryType' => '/api/category_types/'.$type->getId(),
+ 'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
self::assertSame(201, $response->getStatusCode());
diff --git a/tests/Module/Catalog/Api/CategoryTypeCodeFilterTest.php b/tests/Module/Catalog/Api/CategoryTypeCodeFilterTest.php
new file mode 100644
index 0000000..4ac0cf6
--- /dev/null
+++ b/tests/Module/Catalog/Api/CategoryTypeCodeFilterTest.php
@@ -0,0 +1,87 @@
+createCategoryType('TEST_CLIENT');
+ $supplierType = $this->createCategoryType('TEST_FOURNISSEUR');
+
+ $this->createCategory(self::TEST_CATEGORY_PREFIX.'client_one', $clientType);
+ $this->createCategory(self::TEST_CATEGORY_PREFIX.'supplier_one', $supplierType);
+ $this->createCategory(self::TEST_CATEGORY_PREFIX.'supplier_two', $supplierType);
+
+ $client = $this->createAdminClient();
+ $response = $client->request('GET', '/api/categories?typeCode=TEST_FOURNISSEUR&pagination=false');
+ self::assertSame(200, $response->getStatusCode());
+
+ $members = $response->toArray()['member'];
+ $names = array_map(fn (array $m): string => $m['name'], $members);
+ $testOnly = array_values(array_filter(
+ $names,
+ fn (string $n): bool => str_starts_with($n, self::TEST_CATEGORY_PREFIX),
+ ));
+
+ sort($testOnly);
+ self::assertSame(
+ [
+ self::TEST_CATEGORY_PREFIX.'supplier_one',
+ self::TEST_CATEGORY_PREFIX.'supplier_two',
+ ],
+ $testOnly,
+ 'Le filtre ?typeCode= doit ne renvoyer QUE les categories du type demande.',
+ );
+
+ // Chaque categorie remontee doit PORTER le type filtre (multi-types :
+ // la collection categoryTypes embarquee contient le code demande).
+ foreach ($members as $member) {
+ self::assertContains('TEST_FOURNISSEUR', array_column($member['categoryTypes'], 'code'));
+ }
+ }
+
+ public function testTypeCodeFilterWorksWithPagination(): void
+ {
+ $supplierType = $this->createCategoryType('TEST_FOURNISSEUR');
+ $this->createCategory(self::TEST_CATEGORY_PREFIX.'paginated', $supplierType);
+
+ $client = $this->createAdminClient();
+ // Sans ?pagination=false : on doit obtenir l'enveloppe Hydra paginee.
+ $response = $client->request('GET', '/api/categories?typeCode=TEST_FOURNISSEUR');
+ self::assertSame(200, $response->getStatusCode());
+
+ $data = $response->toArray();
+ self::assertArrayHasKey('totalItems', $data, 'Le filtre ne doit pas casser la pagination Hydra.');
+ self::assertArrayHasKey('member', $data);
+
+ foreach ($data['member'] as $member) {
+ self::assertContains('TEST_FOURNISSEUR', array_column($member['categoryTypes'], 'code'));
+ }
+ }
+
+ public function testUnknownTypeCodeReturnsEmptyList(): void
+ {
+ $type = $this->createCategoryType('TEST_CLIENT');
+ $this->createCategory(self::TEST_CATEGORY_PREFIX.'lonely', $type);
+
+ $client = $this->createAdminClient();
+ $response = $client->request('GET', '/api/categories?typeCode=TEST_DOES_NOT_EXIST&pagination=false');
+ self::assertSame(200, $response->getStatusCode());
+
+ self::assertSame([], $response->toArray()['member']);
+ }
+}
diff --git a/tests/Module/Catalog/Api/CategoryUniqueTest.php b/tests/Module/Catalog/Api/CategoryUniqueTest.php
index 1b2cf98..2a99147 100644
--- a/tests/Module/Catalog/Api/CategoryUniqueTest.php
+++ b/tests/Module/Catalog/Api/CategoryUniqueTest.php
@@ -5,22 +5,22 @@ declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
/**
- * Tests RG-1.07 : unicite case-insensitive de (LOWER(name), category_type_id)
- * parmi les categories non soft-deleted. L'index Postgres partiel
- * `uq_category_name_type_active` est traduit en 409 Conflict par le
- * CategoryProcessor.
+ * Tests RG-1.07 : unicite case-insensitive du nom GLOBALEMENT (LOWER(name))
+ * parmi les categories non soft-deleted. Depuis le passage en ManyToMany,
+ * l'unicite n'est plus liee au type. L'index Postgres partiel
+ * `uq_category_name_active` est traduit en 409 Conflict par le CategoryProcessor.
*
* Cas couverts :
- * - doublon strict (meme name + meme type) → 409 ;
- * - doublon case-insensitive (Vis / vis sur meme type) → 409 ;
- * - meme name sur 2 types differents → les deux passent (pas de doublon) ;
- * - recreation apres soft delete → 201 (l'index partiel libere le couple).
+ * - doublon strict (meme name) → 409 ;
+ * - doublon case-insensitive (Vis / VIS) → 409 ;
+ * - meme name avec des types differents → 409 (unicite GLOBALE) ;
+ * - recreation apres soft delete → 201 (l'index partiel libere le nom).
*
* @internal
*/
final class CategoryUniqueTest extends AbstractCatalogApiTestCase
{
- public function testDuplicateNameSameTypeReturns409(): void
+ public function testDuplicateNameReturns409(): void
{
$type = $this->createCategoryType();
$client = $this->createAdminClient();
@@ -29,29 +29,29 @@ final class CategoryUniqueTest extends AbstractCatalogApiTestCase
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
- 'name' => self::TEST_CATEGORY_PREFIX.'unique',
- 'categoryType' => '/api/category_types/'.$type->getId(),
+ 'name' => self::TEST_CATEGORY_PREFIX.'unique',
+ 'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
self::assertResponseStatusCodeSame(201);
- // 2eme POST : meme name + meme type → doublon strict.
+ // 2eme POST : meme name → doublon (unicite globale).
$response = $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
- 'name' => self::TEST_CATEGORY_PREFIX.'unique',
- 'categoryType' => '/api/category_types/'.$type->getId(),
+ 'name' => self::TEST_CATEGORY_PREFIX.'unique',
+ 'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
self::assertSame(409, $response->getStatusCode());
- // Message attendu par la spec RG-1.07.
+ // Message attendu par la spec RG-1.07 (reformulee, sans "pour ce type").
$payload = $response->toArray(false);
$description = $payload['description'] ?? $payload['detail'] ?? $payload['hydra:description'] ?? '';
self::assertStringContainsString(
- 'existe déjà pour ce type',
+ 'existe déjà',
$description,
- 'Le message d\'erreur 409 doit citer la spec ("existe deja pour ce type").',
+ 'Le message d\'erreur 409 doit citer la spec ("existe deja").',
);
}
@@ -64,8 +64,8 @@ final class CategoryUniqueTest extends AbstractCatalogApiTestCase
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
- 'name' => self::TEST_CATEGORY_PREFIX.'Vis',
- 'categoryType' => '/api/category_types/'.$type->getId(),
+ 'name' => self::TEST_CATEGORY_PREFIX.'Vis',
+ 'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
self::assertResponseStatusCodeSame(201);
@@ -74,17 +74,17 @@ final class CategoryUniqueTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
// Meme prefix mais variation de casse → meme LOWER → collision.
- 'name' => self::TEST_CATEGORY_PREFIX.'VIS',
- 'categoryType' => '/api/category_types/'.$type->getId(),
+ 'name' => self::TEST_CATEGORY_PREFIX.'VIS',
+ 'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
self::assertSame(409, $response->getStatusCode());
}
- public function testSameNameDifferentTypeAllowed(): void
+ public function testSameNameDifferentTypeReturns409(): void
{
- // RG-1.07 : la contrainte est SUR (name, type), pas sur name seul.
- // Le meme nom doit etre acceptable sur deux types differents.
+ // RG-1.07 (reformulee) : l'unicite du nom est desormais GLOBALE — le
+ // meme nom sur deux types differents est un doublon.
$type1 = $this->createCategoryType();
$type2 = $this->createCategoryType();
$client = $this->createAdminClient();
@@ -92,27 +92,27 @@ final class CategoryUniqueTest extends AbstractCatalogApiTestCase
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
- 'name' => self::TEST_CATEGORY_PREFIX.'shared',
- 'categoryType' => '/api/category_types/'.$type1->getId(),
+ 'name' => self::TEST_CATEGORY_PREFIX.'shared',
+ 'categoryTypes' => ['/api/category_types/'.$type1->getId()],
],
]);
self::assertResponseStatusCodeSame(201);
- $client->request('POST', '/api/categories', [
+ $response = $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
- 'name' => self::TEST_CATEGORY_PREFIX.'shared',
- 'categoryType' => '/api/category_types/'.$type2->getId(),
+ 'name' => self::TEST_CATEGORY_PREFIX.'shared',
+ 'categoryTypes' => ['/api/category_types/'.$type2->getId()],
],
]);
- self::assertResponseStatusCodeSame(201);
+ self::assertSame(409, $response->getStatusCode());
}
public function testRecreateAfterSoftDeleteAllowed(): void
{
// RG-1.07 : l'index Postgres est partiel (WHERE deleted_at IS NULL).
- // Apres un soft delete, le couple (name, type) est libere et un
- // nouveau POST identique doit reussir.
+ // Apres un soft delete, le nom est libere et un nouveau POST identique
+ // doit reussir.
$type = $this->createCategoryType();
$client = $this->createAdminClient();
@@ -120,8 +120,8 @@ final class CategoryUniqueTest extends AbstractCatalogApiTestCase
$response = $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
- 'name' => self::TEST_CATEGORY_PREFIX.'recreate',
- 'categoryType' => '/api/category_types/'.$type->getId(),
+ 'name' => self::TEST_CATEGORY_PREFIX.'recreate',
+ 'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
self::assertSame(201, $response->getStatusCode());
@@ -131,12 +131,12 @@ final class CategoryUniqueTest extends AbstractCatalogApiTestCase
$client->request('DELETE', '/api/categories/'.$created['id']);
self::assertResponseStatusCodeSame(204);
- // 3) recreation : meme name + meme type → autorise (couple libere).
+ // 3) recreation : meme name → autorise (nom libere par l'archivage).
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
- 'name' => self::TEST_CATEGORY_PREFIX.'recreate',
- 'categoryType' => '/api/category_types/'.$type->getId(),
+ 'name' => self::TEST_CATEGORY_PREFIX.'recreate',
+ 'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
self::assertResponseStatusCodeSame(201);
diff --git a/tests/Module/Catalog/Api/CategoryValidationTest.php b/tests/Module/Catalog/Api/CategoryValidationTest.php
index aa9baf5..78a788a 100644
--- a/tests/Module/Catalog/Api/CategoryValidationTest.php
+++ b/tests/Module/Catalog/Api/CategoryValidationTest.php
@@ -11,8 +11,8 @@ use App\Module\Catalog\Domain\Entity\Category;
* - RG-1.02 : `name` obligatoire (NotBlank) ;
* - RG-1.03 : `name` trim cote serveur via CategoryProcessor ;
* - RG-1.04 : `name` longueur 2..120 (Length) ;
- * - RG-1.05 : `categoryType` obligatoire ;
- * - RG-1.06 : `categoryType` doit pointer un type existant.
+ * - RG-1.05 : `categoryTypes` — au moins un type (Count min 1) ;
+ * - RG-1.06 : chaque IRI de `categoryTypes` doit pointer un type existant.
*
* @internal
*/
@@ -27,7 +27,7 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
- 'categoryType' => '/api/category_types/'.$type->getId(),
+ 'categoryTypes' => ['/api/category_types/'.$type->getId()],
// name absent
],
]);
@@ -42,8 +42,8 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
- 'name' => '',
- 'categoryType' => '/api/category_types/'.$type->getId(),
+ 'name' => '',
+ 'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
@@ -59,8 +59,8 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
- 'name' => ' ',
- 'categoryType' => '/api/category_types/'.$type->getId(),
+ 'name' => ' ',
+ 'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
@@ -79,8 +79,8 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
- 'name' => $payloadName,
- 'categoryType' => '/api/category_types/'.$type->getId(),
+ 'name' => $payloadName,
+ 'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
@@ -103,8 +103,8 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
- 'name' => 'A',
- 'categoryType' => '/api/category_types/'.$type->getId(),
+ 'name' => 'A',
+ 'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
@@ -118,8 +118,8 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
- 'name' => str_repeat('a', 121),
- 'categoryType' => '/api/category_types/'.$type->getId(),
+ 'name' => str_repeat('a', 121),
+ 'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
@@ -140,71 +140,74 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
- 'name' => $name,
- 'categoryType' => '/api/category_types/'.$type->getId(),
+ 'name' => $name,
+ 'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
self::assertResponseStatusCodeSame(201);
}
- // ============ RG-1.05 — categoryType obligatoire ============
+ // ============ RG-1.05 — au moins un type (Count min 1) ============
- public function testCategoryTypeRequiredReturns422(): void
+ public function testCategoryTypesRequiredReturns422(): void
{
$client = $this->createAdminClient();
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'no_type',
- // categoryType absent
+ // categoryTypes absent -> collection vide -> Count(min:1) viole.
],
]);
self::assertResponseStatusCodeSame(422);
}
- public function testCategoryTypeNullIsRejected(): void
+ public function testCategoryTypesEmptyReturns422(): void
{
- // `categoryType: null` echoue a la deserialization IRI (API Platform
- // renvoie 400) bien avant la validation Assert\NotNull. La spec § 4.3
- // accepte les deux : on assert le contrat fort "ne passe pas en BDD".
+ // Tableau vide explicite : Assert\Count(min: 1) doit declencher 422 avec
+ // une violation sur le propertyPath `categoryTypes` (consommable inline).
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
- 'name' => self::TEST_CATEGORY_PREFIX.'null_type',
- 'categoryType' => null,
+ 'name' => self::TEST_CATEGORY_PREFIX.'empty_types',
+ 'categoryTypes' => [],
],
]);
+ self::assertSame(422, $response->getStatusCode());
+ $payload = $response->toArray(false);
+ $violations = $payload['violations'] ?? $payload['hydra:violations'] ?? [];
+ $paths = array_column($violations, 'propertyPath');
self::assertContains(
- $response->getStatusCode(),
- [400, 422],
- 'categoryType=null doit etre rejete (400 deserialization ou 422 validation).',
+ 'categoryTypes',
+ $paths,
+ 'La violation Count doit porter le propertyPath `categoryTypes`.',
);
}
- // ============ RG-1.06 — categoryType doit exister ============
+ // ============ RG-1.06 — chaque type doit exister ============
public function testCategoryTypeMustExistReturns4xx(): void
{
// IRI vers un id qui n'existe pas. API Platform peut renvoyer 400
- // (resolution IRI echouee) ou 422 (validation NotNull declenchee).
- // La spec § 4.3 accepte les deux : on assert le contrat "ne passe pas".
+ // (resolution IRI echouee) ou 422 (validation declenchee). La spec § 4.3
+ // accepte les deux : on assert le contrat "ne passe pas".
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
- 'name' => self::TEST_CATEGORY_PREFIX.'ghost_type',
- 'categoryType' => '/api/category_types/9999999',
+ 'name' => self::TEST_CATEGORY_PREFIX.'ghost_type',
+ 'categoryTypes' => ['/api/category_types/9999999'],
],
]);
self::assertContains(
$response->getStatusCode(),
[400, 404, 422],
- 'IRI categoryType inexistante doit etre rejetee (400/404/422 selon API Platform).',
+ 'IRI categoryTypes inexistante doit etre rejetee (400/404/422 selon API Platform).',
);
}
}
diff --git a/tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php b/tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php
index 4b2f704..d5f5241 100644
--- a/tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php
+++ b/tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php
@@ -107,7 +107,7 @@ abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase
$category = new Category();
$category->setName($name);
$category->setCode($effectiveCode);
- $category->setCategoryType($this->clientCategoryType());
+ $category->addCategoryType($this->clientCategoryType());
$em->persist($category);
$em->flush();
diff --git a/tests/Module/Commercial/Api/AbstractSupplierApiTestCase.php b/tests/Module/Commercial/Api/AbstractSupplierApiTestCase.php
new file mode 100644
index 0000000..c2c5f9e
--- /dev/null
+++ b/tests/Module/Commercial/Api/AbstractSupplierApiTestCase.php
@@ -0,0 +1,339 @@
+getEm()->createQuery('DELETE FROM '.Supplier::class)->execute();
+ parent::tearDown();
+ }
+
+ /**
+ * Fetch-or-create une categorie de type FOURNISSEUR par code (defaut
+ * Negociant). Type FOURNISSEUR exige par RG-2.10 : un POST fournisseur portant
+ * cette categorie passe la validation. Idempotent (lookup par code, aligne sur
+ * l'index unique partiel uq_category_code) et auto-suffisant : ne depend pas du
+ * seed CategoryFixtures (que d'autres tests de la suite peuvent purger). Une
+ * categorie creee ici porte le prefixe de nom de test -> purgee par le parent.
+ */
+ protected function supplierCategory(string $code = 'NEGOCIANT'): Category
+ {
+ $em = $this->getEm();
+ $existing = $em->getRepository(Category::class)->findOneBy(['code' => $code, 'deletedAt' => null]);
+ if (null !== $existing) {
+ return $existing;
+ }
+
+ $category = new Category();
+ $category->setName(self::TEST_CATEGORY_PREFIX.'fr_'.strtolower($code));
+ $category->setCode($code);
+ $category->addCategoryType($this->supplierCategoryType());
+ $em->persist($category);
+ $em->flush();
+
+ return $category;
+ }
+
+ /**
+ * Recupere (ou cree) le type FOURNISSEUR. Idempotent : la contrainte d'unicite
+ * sur category_type.code interdit les doublons.
+ */
+ protected function supplierCategoryType(): CategoryType
+ {
+ $em = $this->getEm();
+ $existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'FOURNISSEUR']);
+ if (null !== $existing) {
+ return $existing;
+ }
+
+ $type = new CategoryType();
+ $type->setCode('FOURNISSEUR');
+ $type->setLabel('Fournisseur');
+ $em->persist($type);
+ $em->flush();
+
+ return $type;
+ }
+
+ /**
+ * Seede directement un Supplier minimal (sans passer par l'API), pour les
+ * tests de liste / archivage / serialisation. Nom stocke en MAJUSCULES pour
+ * refleter l'etat normalise (RG-2.12) qu'aurait produit le SupplierProcessor.
+ * Porte une categorie FOURNISSEUR (defaut Negociant).
+ */
+ protected function seedSupplier(string $companyName, bool $isArchived = false, string $categoryCode = 'NEGOCIANT'): Supplier
+ {
+ $em = $this->getEm();
+ $supplier = new Supplier();
+ $supplier->setCompanyName(mb_strtoupper($companyName, 'UTF-8'));
+ $supplier->addCategory($this->supplierCategory($categoryCode));
+ $supplier->setIsArchived($isArchived);
+ if ($isArchived) {
+ $supplier->setArchivedAt(new DateTimeImmutable());
+ }
+ $em->persist($supplier);
+ $em->flush();
+
+ return $supplier;
+ }
+
+ /**
+ * Seede un fournisseur COMPLET (sans passer par l'API — validations
+ * applicatives non rejouees mais CHECK BDD respectes) : onglet Information
+ * rempli, bloc comptable non nul (SIREN + refs), >= 1 RIB, >= 1 adresse
+ * multi-sites (>= 2 sites, triageProvider=true) avec >= 1 categorie
+ * FOURNISSEUR, >= 1 contact, >= 1 categorie sur le fournisseur. Sert de socle
+ * au contrat de serialisation et a la DoD (§ 4.0.bis).
+ *
+ * @param string $paymentTypeCode code du type de reglement a poser (defaut LCR,
+ * coherent avec le RIB seede ; RG-2.08)
+ */
+ protected function seedCompleteSupplier(string $companyName, string $paymentTypeCode = 'LCR'): Supplier
+ {
+ $em = $this->getEm();
+
+ // Nom unique parmi les actifs (index partiel uq_supplier_company_name_active).
+ $suffix = substr(bin2hex(random_bytes(3)), 0, 6);
+
+ $supplier = new Supplier();
+ $supplier->setCompanyName(mb_strtoupper($companyName.' '.$suffix, 'UTF-8'));
+ $supplier->addCategory($this->supplierCategory('NEGOCIANT'));
+
+ // Onglet Information complet (RG-2.03 : exige pour la Commerciale).
+ $supplier->setDescription('Fournisseur de test complet.');
+ $supplier->setCompetitors('Concurrent A, Concurrent B');
+ $supplier->setFoundedAt(new DateTimeImmutable('2008-04-01'));
+ $supplier->setEmployeesCount(42);
+ $supplier->setRevenueAmount('1500000.00');
+ $supplier->setDirectorName('Jean Dupont');
+ $supplier->setProfitAmount('120000.00');
+ $supplier->setVolumeForecast(8000);
+
+ // Bloc comptable non nul (gating par omission cote Commerciale).
+ $supplier->setSiren('123456789');
+ $supplier->setAccountNumber('F0001');
+ $supplier->setNTva('FR00123456789');
+ $supplier->setTvaMode($this->tvaMode('FRANCE_VENTES'));
+ $supplier->setPaymentDelay($this->paymentDelay('J30'));
+ $supplier->setPaymentType($this->paymentType($paymentTypeCode));
+ if ('VIREMENT' === $paymentTypeCode) {
+ $supplier->setBank($this->bank('SG'));
+ }
+ $em->persist($supplier);
+
+ // >= 2 sites fixtures pour une adresse multi-sites (RG-2.06).
+ $sites = $em->getRepository(Site::class)->findBy([], null, 2);
+ self::assertGreaterThanOrEqual(2, count($sites), 'Au moins 2 sites fixtures requis (SitesFixtures).');
+
+ $contact = new SupplierContact();
+ $contact->setSupplier($supplier);
+ $contact->setFirstName('Marie');
+ $contact->setLastName('Martin');
+ $contact->setJobTitle('Responsable achats');
+ $contact->setPhonePrimary('0612345678');
+ $contact->setEmail('marie.martin@seed.test');
+ $supplier->addContact($contact);
+ $em->persist($contact);
+
+ $address = new SupplierAddress();
+ $address->setSupplier($supplier);
+ $address->setAddressType('DEPART');
+ $address->setPostalCode('86000');
+ $address->setCity('Poitiers');
+ $address->setStreet('12 rue des Acacias');
+ $address->setBennes(3);
+ // triageProvider=true : prouve qu'un booleen `true` est bien serialise
+ // (piege n°3 du M1 — la cle etait droppee).
+ $address->setTriageProvider(true);
+ foreach ($sites as $site) {
+ $address->addSite($site);
+ }
+ $address->addCategory($this->supplierCategory('NEGOCIANT'));
+ $address->addContact($contact);
+ $supplier->addAddress($address);
+ $em->persist($address);
+
+ $rib = new SupplierRib();
+ $rib->setSupplier($supplier);
+ $rib->setLabel('Compte principal');
+ $rib->setBic(self::VALID_BIC);
+ $rib->setIban(self::VALID_IBAN);
+ $supplier->addRib($rib);
+ $em->persist($rib);
+
+ $em->flush();
+
+ return $supplier;
+ }
+
+ /**
+ * Ajoute un contact a un fournisseur deja persiste (seed direct).
+ */
+ protected function addContact(
+ Supplier $supplier,
+ ?string $firstName = 'Marie',
+ ?string $lastName = 'Martin',
+ ?string $phonePrimary = null,
+ ?string $email = null,
+ int $position = 0,
+ ): SupplierContact {
+ $contact = new SupplierContact();
+ $contact->setSupplier($supplier);
+ $contact->setFirstName($firstName);
+ $contact->setLastName($lastName);
+ $contact->setPhonePrimary($phonePrimary);
+ $contact->setEmail($email);
+ $contact->setPosition($position);
+ $supplier->addContact($contact);
+ $this->getEm()->persist($contact);
+ $this->getEm()->flush();
+
+ return $contact;
+ }
+
+ /**
+ * Ajoute un RIB a un fournisseur deja persiste (seed direct).
+ */
+ protected function addRib(Supplier $supplier, string $label = 'Compte principal'): SupplierRib
+ {
+ $rib = new SupplierRib();
+ $rib->setSupplier($supplier);
+ $rib->setLabel($label);
+ $rib->setBic(self::VALID_BIC);
+ $rib->setIban(self::VALID_IBAN);
+ $supplier->addRib($rib);
+ $this->getEm()->persist($rib);
+ $this->getEm()->flush();
+
+ return $rib;
+ }
+
+ /**
+ * Payload minimal valide de l'onglet principal (companyName + 1 categorie
+ * FOURNISSEUR). Si $categoryId est null, la categorie Negociant seedee est
+ * utilisee.
+ *
+ * @return array
+ */
+ protected function validMainPayload(string $companyName, ?int $categoryId = null): array
+ {
+ $categoryId ??= $this->supplierCategory('NEGOCIANT')->getId();
+
+ return [
+ 'companyName' => $companyName,
+ 'categories' => ['/api/categories/'.$categoryId],
+ ];
+ }
+
+ protected function paymentType(string $code): PaymentType
+ {
+ return $this->referential(PaymentType::class, $code);
+ }
+
+ protected function paymentDelay(string $code): PaymentDelay
+ {
+ return $this->referential(PaymentDelay::class, $code);
+ }
+
+ protected function tvaMode(string $code): TvaMode
+ {
+ return $this->referential(TvaMode::class, $code);
+ }
+
+ protected function bank(string $code): Bank
+ {
+ return $this->referential(Bank::class, $code);
+ }
+
+ /**
+ * Recupere un referentiel comptable seede (CommercialReferentialFixtures) par
+ * code. Echoue explicitement si absent (fixtures non chargees).
+ *
+ * @template T of object
+ *
+ * @param class-string $entityClass
+ *
+ * @return T
+ */
+ private function referential(string $entityClass, string $code): object
+ {
+ $entity = $this->getEm()->getRepository($entityClass)->findOneBy(['code' => $code]);
+
+ self::assertNotNull(
+ $entity,
+ sprintf('Referentiel %s "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $entityClass, $code),
+ );
+
+ return $entity;
+ }
+
+ /**
+ * Indexe les violations d'un corps de reponse 422 par propertyPath. Permet
+ * d'asserter qu'un 422 porte bien sur le champ attendu (et n'est pas un 422
+ * orthogonal) : un test qui se contente du code 422 passerait meme si la RG
+ * visee etait cassee pour une autre raison.
+ *
+ * @param array $body corps decode de la reponse (toArray(false))
+ *
+ * @return array propertyPath => message
+ */
+ protected function violationsByPath(array $body): array
+ {
+ $byPath = [];
+ foreach ($body['violations'] ?? [] as $v) {
+ $byPath[$v['propertyPath']] = $v['message'];
+ }
+
+ return $byPath;
+ }
+}
diff --git a/tests/Module/Commercial/Api/SupplierAccountingApiTest.php b/tests/Module/Commercial/Api/SupplierAccountingApiTest.php
new file mode 100644
index 0000000..0819aee
--- /dev/null
+++ b/tests/Module/Commercial/Api/SupplierAccountingApiTest.php
@@ -0,0 +1,81 @@
+createAdminClient();
+ $seed = $this->seedSupplier('Virement No Bank');
+
+ $response = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
+ 'headers' => ['Content-Type' => self::MERGE, 'Accept' => self::LD],
+ 'json' => ['paymentType' => '/api/payment_types/'.$this->paymentType('VIREMENT')->getId()],
+ ]);
+
+ self::assertResponseStatusCodeSame(422);
+ self::assertArrayHasKey('bank', $this->violationsByPath($response->toArray(false)));
+ }
+
+ public function testVirementWithBankReturns200(): void
+ {
+ $client = $this->createAdminClient();
+ $seed = $this->seedSupplier('Virement With Bank');
+
+ $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
+ 'headers' => ['Content-Type' => self::MERGE],
+ 'json' => [
+ 'paymentType' => '/api/payment_types/'.$this->paymentType('VIREMENT')->getId(),
+ 'bank' => '/api/banks/'.$this->bank('SG')->getId(),
+ ],
+ ]);
+
+ self::assertResponseStatusCodeSame(200);
+ }
+
+ // === RG-2.08 : LCR impose au moins un RIB ===
+
+ public function testLcrWithoutRibReturns422OnRibsPath(): void
+ {
+ $client = $this->createAdminClient();
+ $seed = $this->seedSupplier('Lcr No Rib');
+
+ $response = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
+ 'headers' => ['Content-Type' => self::MERGE, 'Accept' => self::LD],
+ 'json' => ['paymentType' => '/api/payment_types/'.$this->paymentType('LCR')->getId()],
+ ]);
+
+ self::assertResponseStatusCodeSame(422);
+ self::assertArrayHasKey('ribs', $this->violationsByPath($response->toArray(false)));
+ }
+
+ public function testLcrWithRibReturns200(): void
+ {
+ $client = $this->createAdminClient();
+ $seed = $this->seedSupplier('Lcr With Rib');
+ $this->addRib($seed);
+
+ $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
+ 'headers' => ['Content-Type' => self::MERGE],
+ 'json' => ['paymentType' => '/api/payment_types/'.$this->paymentType('LCR')->getId()],
+ ]);
+
+ self::assertResponseStatusCodeSame(200);
+ }
+
+ // violationsByPath() : helper mutualise dans AbstractSupplierApiTestCase.
+}
diff --git a/tests/Module/Commercial/Api/SupplierApiTest.php b/tests/Module/Commercial/Api/SupplierApiTest.php
new file mode 100644
index 0000000..44f418d
--- /dev/null
+++ b/tests/Module/Commercial/Api/SupplierApiTest.php
@@ -0,0 +1,180 @@
+createAdminClient();
+ $cat = $this->supplierCategory('NEGOCIANT');
+
+ $data = $client->request('POST', '/api/suppliers', [
+ 'headers' => ['Content-Type' => self::LD],
+ 'json' => [
+ 'companyName' => 'recycla sas',
+ 'categories' => ['/api/categories/'.$cat->getId()],
+ ],
+ ])->toArray();
+
+ self::assertResponseStatusCodeSame(201);
+ // RG-2.12 : companyName normalise en MAJUSCULES sur la valeur RENVOYEE.
+ self::assertSame('RECYCLA SAS', $data['companyName']);
+ // Embed categorie : code/name presents (category:read dans le contexte).
+ self::assertSame('NEGOCIANT', $data['categories'][0]['code']);
+ }
+
+ public function testPostMainFormHasNoInlineContactFields(): void
+ {
+ // refonte-contact V0.2 : plus aucun champ de contact inline au POST.
+ $client = $this->createAdminClient();
+ $cat = $this->supplierCategory('NEGOCIANT');
+
+ $data = $client->request('POST', '/api/suppliers', [
+ 'headers' => ['Content-Type' => self::LD],
+ 'json' => [
+ 'companyName' => 'No Inline Co',
+ // Champs historiques : ignores par le denormaliseur.
+ 'firstName' => 'Ignored',
+ 'lastName' => 'Ignored',
+ 'phonePrimary' => '0612345678',
+ 'email' => 'ignored@test.fr',
+ 'categories' => ['/api/categories/'.$cat->getId()],
+ ],
+ ])->toArray();
+
+ self::assertResponseStatusCodeSame(201);
+ foreach (['firstName', 'lastName', 'phonePrimary', 'phoneSecondary', 'email'] as $key) {
+ self::assertArrayNotHasKey($key, $data);
+ }
+ }
+
+ // === RG-2.10 : categorie de type FOURNISSEUR ===
+
+ public function testPostWithNonFournisseurCategoryReturns422OnCategoriesPath(): void
+ {
+ $client = $this->createAdminClient();
+ // createCategory() (parent) cree une categorie de type CLIENT -> interdite.
+ $clientTypedCategory = $this->createCategory('SECTEUR');
+
+ $response = $client->request('POST', '/api/suppliers', [
+ 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
+ 'json' => [
+ 'companyName' => 'Wrong Cat Type',
+ 'categories' => ['/api/categories/'.$clientTypedCategory->getId()],
+ ],
+ ]);
+
+ self::assertResponseStatusCodeSame(422);
+ $byPath = [];
+ foreach ($response->toArray(false)['violations'] ?? [] as $v) {
+ $byPath[$v['propertyPath']] = $v['message'];
+ }
+ // ERP-101 : la violation porte propertyPath=categories (mapping inline front).
+ self::assertArrayHasKey('categories', $byPath);
+ self::assertSame('Type de catégorie non autorisé (FOURNISSEUR attendu).', $byPath['categories']);
+ }
+
+ // === RG-2.11 : unicite du nom de societe ===
+
+ public function testPostDuplicateCompanyNameReturns409(): void
+ {
+ $client = $this->createAdminClient();
+ $this->seedSupplier('Dup Name Co');
+
+ $client->request('POST', '/api/suppliers', [
+ 'headers' => ['Content-Type' => self::LD],
+ 'json' => $this->validMainPayload('Dup Name Co'),
+ ]);
+
+ // RG-2.11 : doublon parmi les actifs -> 409 (index uq_supplier_company_name_active).
+ self::assertResponseStatusCodeSame(409);
+ }
+
+ public function testPostSameNameAfterArchivingPreviousReturns201(): void
+ {
+ $client = $this->createAdminClient();
+ // L'homonyme est archive -> hors index partiel : le nom redevient disponible.
+ $this->seedSupplier('Reuse After Archive', true);
+
+ $client->request('POST', '/api/suppliers', [
+ 'headers' => ['Content-Type' => self::LD],
+ 'json' => $this->validMainPayload('Reuse After Archive'),
+ ]);
+
+ self::assertResponseStatusCodeSame(201);
+ }
+
+ // === RG-2.14 : archivage (admin) ===
+
+ public function testAdminArchiveSetsArchivedAt(): void
+ {
+ $client = $this->createAdminClient();
+ $seed = $this->seedSupplier('Archive Me');
+
+ $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
+ 'headers' => ['Content-Type' => self::MERGE],
+ 'json' => ['isArchived' => true],
+ ]);
+
+ self::assertResponseStatusCodeSame(200);
+
+ $em = $this->getEm();
+ $em->clear();
+ $reloaded = $em->getRepository(Supplier::class)->find($seed->getId());
+ self::assertNotNull($reloaded);
+ self::assertTrue($reloaded->isArchived());
+ self::assertNotNull($reloaded->getArchivedAt(), 'RG-2.14 : archivedAt doit etre rempli a l\'archivage.');
+ }
+
+ public function testArchiveWithOtherFieldReturns422(): void
+ {
+ $client = $this->createAdminClient();
+ $seed = $this->seedSupplier('Archive Plus Field');
+
+ // RG-2.14 : une requete d'archivage ne modifie aucun autre champ.
+ $response = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
+ 'headers' => ['Content-Type' => self::MERGE],
+ 'json' => ['isArchived' => true, 'companyName' => 'Renamed While Archiving'],
+ ]);
+
+ self::assertResponseStatusCodeSame(422);
+ // Le 422 doit etre celui de RG-2.14 (archivage exclusif) et non un 422
+ // orthogonal : on verifie le message porte par l'exception.
+ self::assertStringContainsString('archivage', $response->getContent(false));
+ }
+
+ public function testRestoreSetsArchivedAtNull(): void
+ {
+ $client = $this->createAdminClient();
+ $seed = $this->seedSupplier('Restore Me', true);
+
+ $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
+ 'headers' => ['Content-Type' => self::MERGE],
+ 'json' => ['isArchived' => false],
+ ]);
+
+ self::assertResponseStatusCodeSame(200);
+
+ $em = $this->getEm();
+ $em->clear();
+ $reloaded = $em->getRepository(Supplier::class)->find($seed->getId());
+ self::assertNotNull($reloaded);
+ self::assertFalse($reloaded->isArchived());
+ self::assertNull($reloaded->getArchivedAt(), 'RG-2.15 : archivedAt repasse a null a la restauration.');
+ }
+}
diff --git a/tests/Module/Commercial/Api/SupplierArchiveTest.php b/tests/Module/Commercial/Api/SupplierArchiveTest.php
new file mode 100644
index 0000000..c736671
--- /dev/null
+++ b/tests/Module/Commercial/Api/SupplierArchiveTest.php
@@ -0,0 +1,36 @@
+createAdminClient();
+
+ $archived = $this->seedSupplier('Acme Conflict', true);
+ $this->seedSupplier('Acme Conflict', false);
+
+ $client->request('PATCH', '/api/suppliers/'.$archived->getId(), [
+ 'headers' => ['Content-Type' => self::MERGE],
+ 'json' => ['isArchived' => false],
+ ]);
+
+ self::assertResponseStatusCodeSame(409);
+ }
+}
diff --git a/tests/Module/Commercial/Api/SupplierAuditTest.php b/tests/Module/Commercial/Api/SupplierAuditTest.php
new file mode 100644
index 0000000..bf86e03
--- /dev/null
+++ b/tests/Module/Commercial/Api/SupplierAuditTest.php
@@ -0,0 +1,140 @@
+ ligne audit_log entity_type='commercial.Supplier'
+ * avec l'action et le diff attendus ;
+ * - RIB : `#[Auditable]` SANS `#[AuditIgnore]` sur iban/bic -> ces champs sensibles
+ * DOIVENT apparaitre dans le diff audite (decision § 2.7, miroir M1).
+ *
+ * @internal
+ */
+final class SupplierAuditTest extends AbstractSupplierApiTestCase
+{
+ private const string SUPPLIER_TYPE = 'commercial.Supplier';
+ private const string RIB_TYPE = 'commercial.SupplierRib';
+
+ private ?Connection $auditConnection = null;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+ self::bootKernel();
+
+ /** @var Connection $conn */
+ $conn = self::getContainer()->get('doctrine.dbal.audit_connection');
+ $this->auditConnection = $conn;
+ }
+
+ protected function tearDown(): void
+ {
+ if (null !== $this->auditConnection) {
+ $this->auditConnection->close();
+ }
+ parent::tearDown();
+ }
+
+ public function testPostSupplierIsAudited(): void
+ {
+ $admin = $this->createAdminClient();
+ $cat = $this->supplierCategory('NEGOCIANT');
+
+ $created = $admin->request('POST', '/api/suppliers', [
+ 'headers' => ['Content-Type' => self::LD],
+ 'json' => [
+ 'companyName' => 'Audit Created Co',
+ 'categories' => ['/api/categories/'.$cat->getId()],
+ ],
+ ])->toArray();
+ self::assertResponseStatusCodeSame(201);
+
+ self::assertGreaterThanOrEqual(
+ 1,
+ $this->countAudit(self::SUPPLIER_TYPE, (string) $created['id'], 'create'),
+ 'Un audit_log "create" doit etre genere pour le fournisseur.',
+ );
+ }
+
+ public function testPatchSupplierIsAudited(): void
+ {
+ $admin = $this->createAdminClient();
+ $seed = $this->seedSupplier('Audit Patch Co');
+
+ $admin->request('PATCH', '/api/suppliers/'.$seed->getId(), [
+ 'headers' => ['Content-Type' => self::MERGE],
+ 'json' => ['companyName' => 'Audit Patch Renamed'],
+ ]);
+ self::assertResponseStatusCodeSame(200);
+
+ self::assertGreaterThanOrEqual(
+ 1,
+ $this->countAudit(self::SUPPLIER_TYPE, (string) $seed->getId(), 'update'),
+ 'Un audit_log "update" doit etre genere pour le PATCH.',
+ );
+ }
+
+ public function testArchiveSupplierIsAudited(): void
+ {
+ $admin = $this->createAdminClient();
+ $seed = $this->seedSupplier('Audit Archive Co');
+
+ $admin->request('PATCH', '/api/suppliers/'.$seed->getId(), [
+ 'headers' => ['Content-Type' => self::MERGE],
+ 'json' => ['isArchived' => true],
+ ]);
+ self::assertResponseStatusCodeSame(200);
+
+ $rows = $this->auditConnection->fetchAllAssociative(
+ 'SELECT changes FROM audit_log WHERE entity_type = :type AND entity_id = :id AND action = :action ORDER BY performed_at DESC',
+ ['type' => self::SUPPLIER_TYPE, 'id' => (string) $seed->getId(), 'action' => 'update'],
+ );
+ self::assertGreaterThanOrEqual(1, count($rows));
+
+ /** @var array $changes */
+ $changes = json_decode((string) $rows[0]['changes'], true, flags: JSON_THROW_ON_ERROR);
+ self::assertArrayHasKey('isArchived', $changes, 'Le diff d\'archivage doit tracer isArchived.');
+ }
+
+ public function testRibCreateAuditIncludesIbanAndBic(): void
+ {
+ $admin = $this->createAdminClient();
+ $seed = $this->seedSupplier('Rib Audit Host');
+
+ $rib = $admin->request('POST', '/api/suppliers/'.$seed->getId().'/ribs', [
+ 'headers' => ['Content-Type' => self::LD],
+ 'json' => [
+ 'label' => 'Compte audite',
+ 'bic' => self::VALID_BIC,
+ 'iban' => self::VALID_IBAN,
+ ],
+ ])->toArray();
+ self::assertResponseStatusCodeSame(201);
+
+ $rows = $this->auditConnection->fetchAllAssociative(
+ 'SELECT changes FROM audit_log WHERE entity_type = :type AND entity_id = :id AND action = :action ORDER BY performed_at DESC',
+ ['type' => self::RIB_TYPE, 'id' => (string) $rib['id'], 'action' => 'create'],
+ );
+ self::assertGreaterThanOrEqual(1, count($rows), 'Un audit_log "create" doit etre genere pour le RIB.');
+
+ /** @var array $changes */
+ $changes = json_decode((string) $rows[0]['changes'], true, flags: JSON_THROW_ON_ERROR);
+ self::assertArrayHasKey('iban', $changes, 'iban doit figurer dans le diff audite (pas d\'AuditIgnore).');
+ self::assertArrayHasKey('bic', $changes, 'bic doit figurer dans le diff audite (pas d\'AuditIgnore).');
+ self::assertSame(self::VALID_IBAN, $changes['iban']);
+ self::assertSame(self::VALID_BIC, $changes['bic']);
+ }
+
+ private function countAudit(string $type, string $id, string $action): int
+ {
+ return (int) $this->auditConnection->fetchOne(
+ 'SELECT COUNT(*) FROM audit_log WHERE entity_type = :type AND entity_id = :id AND action = :action',
+ ['type' => $type, 'id' => $id, 'action' => $action],
+ );
+ }
+}
diff --git a/tests/Module/Commercial/Api/SupplierExportControllerTest.php b/tests/Module/Commercial/Api/SupplierExportControllerTest.php
new file mode 100644
index 0000000..f39f741
--- /dev/null
+++ b/tests/Module/Commercial/Api/SupplierExportControllerTest.php
@@ -0,0 +1,313 @@
+createAdminClient();
+ $this->seedSupplier('Export Alpha');
+
+ $response = $client->request('GET', self::EXPORT_URL);
+
+ self::assertResponseIsSuccessful();
+ $headers = $response->getHeaders(false);
+ self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? '');
+
+ $disposition = $headers['content-disposition'][0] ?? '';
+ self::assertStringContainsString('attachment; filename="repertoire-fournisseurs-', $disposition);
+ self::assertMatchesRegularExpression(
+ '/filename="repertoire-fournisseurs-\d{8}\.xlsx"/',
+ $disposition,
+ );
+
+ // Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes.
+ $grid = $this->gridFromResponse($response->getContent());
+ $headers = $grid[0];
+ self::assertSame('Nom fournisseur', $headers[0]);
+ self::assertContains('Contact principal', $headers);
+ self::assertContains('Téléphone principal', $headers);
+ self::assertContains('Téléphone secondaire', $headers);
+ self::assertContains('Email', $headers);
+ self::assertContains('Catégories', $headers);
+ self::assertContains('Sites', $headers);
+ self::assertContains('Date de création', $headers);
+ }
+
+ public function testExportExcludesArchivedByDefault(): void
+ {
+ $client = $this->createAdminClient();
+ $this->seedSupplier('Active One');
+ $this->seedSupplier('Archived One', true);
+
+ $names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent());
+
+ self::assertContains('ACTIVE ONE', $names);
+ self::assertNotContains('ARCHIVED ONE', $names);
+ }
+
+ public function testExportRespectsSearchFilter(): void
+ {
+ $client = $this->createAdminClient();
+ $this->seedSupplier('Searchable Alpha');
+ $this->seedSupplier('Other Beta');
+
+ $names = $this->companyNames(
+ $client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(),
+ );
+
+ self::assertContains('SEARCHABLE ALPHA', $names);
+ self::assertNotContains('OTHER BETA', $names);
+ }
+
+ /**
+ * Les colonnes contact sont alimentees par le CONTACT PRINCIPAL : le contact
+ * de plus petit `position` (decision D2, § 4.6). On seede deux contacts en
+ * ordre de position inverse pour garantir que c'est bien le principal (et non
+ * le premier insere) qui alimente la ligne.
+ */
+ public function testExportUsesPrincipalContactColumns(): void
+ {
+ $client = $this->createAdminClient();
+ $supplier = $this->seedSupplier('Contact Co');
+
+ // position 1 (secondaire) insere en premier...
+ $this->addContact($supplier, 'Bob', 'Secondaire', '0600000001', 'bob@contact.co', 1);
+ // ...position 0 (principal) insere ensuite : c'est lui qui doit gagner.
+ $principal = $this->addContact($supplier, 'Alice', 'Principal', '0612345678', 'alice@contact.co', 0);
+ // Le telephone secondaire n'est pas porte par le helper de base : on le pose
+ // directement sur le contact principal pour alimenter la colonne dediee.
+ $principal->setPhoneSecondary('0698765432');
+ $this->getEm()->flush();
+
+ $row = $this->rowFor($client->request('GET', self::EXPORT_URL)->getContent(), 'CONTACT CO');
+
+ self::assertNotNull($row, 'Ligne « CONTACT CO » introuvable dans l\'export.');
+ self::assertSame('Principal Alice', $row[1]);
+ self::assertSame('0612345678', $row[2]);
+ self::assertSame('0698765432', $row[3]);
+ self::assertSame('alice@contact.co', $row[4]);
+ }
+
+ /**
+ * Colonnes « Catégories » et « Sites » : un oubli d'hydratation les rendrait
+ * vides sans erreur (cf. ERP-100 cote client). Le site est porte par l'adresse
+ * (RG-2.06).
+ */
+ public function testExportPopulatesCategoryAndSiteColumns(): void
+ {
+ $client = $this->createAdminClient();
+ $supplier = $this->seedSupplier('Hydrate Co', false, 'NEGOCIANT');
+
+ $em = $this->getEm();
+ $site = $em->getRepository(Site::class)->findOneBy([]);
+ self::assertNotNull($site, 'Aucun site seede : impossible de tester la colonne Sites.');
+
+ $address = new SupplierAddress();
+ $address->setSupplier($supplier);
+ $address->setAddressType('DEPART');
+ $address->setPostalCode('86100');
+ $address->setCity('Châtellerault');
+ $address->setStreet('1 rue du Test');
+ $address->addSite($site);
+ $em->persist($address);
+ $em->flush();
+
+ $flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent()));
+
+ // Colonne « Catégories » : libelle de la categorie FOURNISSEUR du fournisseur
+ // (getName()). On le derive du helper de base (idempotent) plutot que de
+ // hardcoder le prefixe de nom de test.
+ self::assertStringContainsString((string) $this->supplierCategory('NEGOCIANT')->getName(), $flat);
+ // Colonne « Sites » : site agrege depuis l'adresse (RG-2.06).
+ self::assertStringContainsString((string) $site->getName(), $flat);
+ }
+
+ public function testSirenColumnPresentWithAccountingView(): void
+ {
+ // L'admin bypass le RBAC : il a donc accounting.view -> colonne SIREN.
+ $client = $this->createAdminClient();
+ $supplier = $this->seedSupplier('Siren Co');
+ $em = $this->getEm();
+ $supplier->setSiren('123456789');
+ $em->flush();
+
+ $grid = $this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent());
+
+ self::assertContains('SIREN', $grid[0]);
+ self::assertStringContainsString('123456789', $this->flatten($grid));
+ }
+
+ public function testSirenColumnAbsentWithoutAccountingView(): void
+ {
+ // Seed via admin, puis relecture par un user qui n'a QUE suppliers.view.
+ $admin = $this->createAdminClient();
+ $supplier = $this->seedSupplier('No Siren Co');
+ $em = $this->getEm();
+ $supplier->setSiren('987654321');
+ $em->flush();
+
+ $creds = $this->createUserWithPermission('commercial.suppliers.view');
+ $viewer = $this->authenticatedClient($creds['username'], $creds['password']);
+
+ $grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent());
+
+ self::assertNotContains('SIREN', $grid[0]);
+ self::assertStringNotContainsString('987654321', $this->flatten($grid));
+ }
+
+ /**
+ * Gating SIREN prouve via une permission EXPLICITE (et non le bypass admin) :
+ * un user minimal portant uniquement commercial.suppliers.view +
+ * commercial.suppliers.accounting.view voit bien la colonne SIREN et sa
+ * valeur. Complement de testSirenColumnPresentWithAccountingView (admin), qui
+ * ne prouve pas que accounting.view SEULE suffit (l'admin bypasse le RBAC).
+ * Le pendant negatif (sans accounting.view -> colonne absente) est couvert par
+ * testSirenColumnAbsentWithoutAccountingView.
+ */
+ public function testSirenColumnPresentForMinimalUserWithAccountingView(): void
+ {
+ // Seed via admin, puis relecture par un user non-admin a 2 permissions.
+ $this->createAdminClient();
+ $supplier = $this->seedSupplier('Gated Siren Co');
+ $em = $this->getEm();
+ $supplier->setSiren('456789123');
+ $em->flush();
+
+ $creds = $this->createUserWithPermissions([
+ 'commercial.suppliers.view',
+ 'commercial.suppliers.accounting.view',
+ ]);
+ $viewer = $this->authenticatedClient($creds['username'], $creds['password']);
+
+ $grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent());
+
+ self::assertContains('SIREN', $grid[0]);
+ self::assertStringContainsString('456789123', $this->flatten($grid));
+ }
+
+ /**
+ * Dedup F3 : un fournisseur portant >= 2 categories FOURNISSEUR est multiplie
+ * par la jointure (selection/hydratation des collections) ; l'export doit le
+ * rendre sur UNE SEULE ligne. On seede un fournisseur a 2 categories et on
+ * assert qu'il n'apparait qu'une fois dans la colonne « Nom fournisseur ».
+ */
+ public function testExportDeduplicatesSupplierWithMultipleCategories(): void
+ {
+ $client = $this->createAdminClient();
+ $supplier = $this->seedSupplier('Multi Cat Co', false, 'NEGOCIANT');
+ // 2e categorie FOURNISSEUR sur le meme fournisseur (RG-2.10).
+ $supplier->addCategory($this->supplierCategory('GROSSISTE'));
+ $this->getEm()->flush();
+
+ $names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent());
+
+ $occurrences = count(array_filter($names, static fn (string $name): bool => 'MULTI CAT CO' === $name));
+ self::assertSame(
+ 1,
+ $occurrences,
+ 'Un fournisseur multi-categories doit apparaitre sur une seule ligne (dedup F3).',
+ );
+ }
+
+ public function testForbiddenWithoutSuppliersViewPermission(): void
+ {
+ $creds = $this->createUserWithPermission('core.users.view');
+ $client = $this->authenticatedClient($creds['username'], $creds['password']);
+
+ $client->request('GET', self::EXPORT_URL);
+
+ self::assertResponseStatusCodeSame(403);
+ }
+
+ public function testUnauthorizedWhenAnonymous(): void
+ {
+ $client = self::createClient();
+ $client->request('GET', self::EXPORT_URL);
+
+ self::assertResponseStatusCodeSame(401);
+ }
+
+ /**
+ * Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
+ *
+ * @return array>
+ */
+ private function gridFromResponse(string $binary): array
+ {
+ $tmp = tempnam(sys_get_temp_dir(), 'xlsx_export_test_');
+ self::assertIsString($tmp);
+ file_put_contents($tmp, $binary);
+
+ try {
+ return IOFactory::load($tmp)->getActiveSheet()->toArray();
+ } finally {
+ @unlink($tmp);
+ }
+ }
+
+ /**
+ * Extrait la colonne « Nom fournisseur » (1re colonne) des lignes de donnees.
+ *
+ * @return list
+ */
+ private function companyNames(string $binary): array
+ {
+ $grid = $this->gridFromResponse($binary);
+ $rows = array_slice($grid, 1); // saute l'en-tete
+
+ return array_values(array_map(static fn (array $row): string => (string) ($row[0] ?? ''), $rows));
+ }
+
+ /**
+ * Renvoie la ligne de donnees dont la 1re colonne (nom) vaut $companyName.
+ *
+ * @return null|array
+ */
+ private function rowFor(string $binary, string $companyName): ?array
+ {
+ foreach (array_slice($this->gridFromResponse($binary), 1) as $row) {
+ if ((string) ($row[0] ?? '') === $companyName) {
+ return $row;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Aplatit toute la grille en une chaine, pour les assertions de presence.
+ *
+ * @param array> $grid
+ */
+ private function flatten(array $grid): string
+ {
+ return implode('|', array_map(
+ static fn (array $row): string => implode('|', array_map(static fn ($cell): string => (string) $cell, $row)),
+ $grid,
+ ));
+ }
+}
diff --git a/tests/Module/Commercial/Api/SupplierListTest.php b/tests/Module/Commercial/Api/SupplierListTest.php
new file mode 100644
index 0000000..493e556
--- /dev/null
+++ b/tests/Module/Commercial/Api/SupplierListTest.php
@@ -0,0 +1,118 @@
+createAdminClient();
+ $token = $this->token();
+
+ $this->seedSupplier($token.' Active');
+ $this->seedSupplier($token.' Archived', true);
+
+ $default = $http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
+ self::assertSame(1, $default['totalItems'], 'RG-2.17 : archives exclus par defaut.');
+
+ $all = $http->request('GET', '/api/suppliers?search='.$token.'&includeArchived=true', ['headers' => ['Accept' => self::LD]])->toArray();
+ self::assertSame(2, $all['totalItems'], 'RG-2.17 : ?includeArchived reintegre les archives.');
+ }
+
+ public function testListIsSortedByCompanyNameAsc(): void
+ {
+ $http = $this->createAdminClient();
+ $token = $this->token();
+
+ // Inseres dans le desordre ; le tri par defaut doit remonter ALPHA avant ZETA.
+ $this->seedSupplier($token.' Zeta');
+ $this->seedSupplier($token.' Alpha');
+
+ $names = array_map(
+ static fn (array $m): string => (string) $m['companyName'],
+ $http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray()['member'],
+ );
+
+ self::assertCount(2, $names);
+ self::assertStringContainsString('ALPHA', $names[0], 'RG-2.17 : tri companyName ASC.');
+ self::assertStringContainsString('ZETA', $names[1]);
+ }
+
+ public function testPaginationDisabledReturnsFullCollection(): void
+ {
+ $http = $this->createAdminClient();
+ $token = $this->token();
+
+ for ($i = 0; $i < 3; ++$i) {
+ $this->seedSupplier($token.' Item'.$i);
+ }
+
+ // ?pagination=false : echappatoire pour alimenter un (regle n°13).
+ $data = $http->request('GET', '/api/suppliers?search='.$token.'&pagination=false', ['headers' => ['Accept' => self::LD]])->toArray();
+
+ self::assertArrayHasKey('member', $data);
+ self::assertCount(3, $data['member']);
+ }
+
+ /**
+ * Anti N+1 (§ 2.12) : le nombre de requetes SQL de la liste ne doit PAS croitre
+ * avec le nombre de fournisseurs. On mesure pour N=2 puis N=4 (memes relations
+ * embarquees : categories + addresses.sites) et on exige un compte IDENTIQUE —
+ * preuve que l'hydratation est batchee (WHERE IN) et non par ligne.
+ */
+ public function testListQueryCountDoesNotGrowWithRowCount(): void
+ {
+ $this->skipIfSitesModuleDisabled();
+ $token = $this->token();
+
+ // Premiere mesure : 2 fournisseurs complets (avec adresses/sites/categories).
+ $this->seedCompleteSupplier($token.' A');
+ $this->seedCompleteSupplier($token.' B');
+ $countFor2 = $this->countListQueries($token);
+
+ // Seconde mesure : 2 de plus (4 au total, tous sur la meme page).
+ $this->seedCompleteSupplier($token.' C');
+ $this->seedCompleteSupplier($token.' D');
+ $countFor4 = $this->countListQueries($token);
+
+ self::assertSame(
+ $countFor2,
+ $countFor4,
+ sprintf('Anti N+1 : le nombre de requetes liste doit etre constant (%d pour 2, %d pour 4).', $countFor2, $countFor4),
+ );
+ }
+
+ /**
+ * Compte les requetes SQL emises par UN GET liste filtre, via le data holder de
+ * debug Doctrine (actif car kernel.debug=true en test). Le holder est remis a
+ * zero juste avant la requete pour isoler ses requetes (hors login).
+ */
+ private function countListQueries(string $token): int
+ {
+ $http = $this->createAdminClient();
+ $holder = self::getContainer()->get('doctrine.debug_data_holder');
+ $holder->reset();
+
+ $http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]]);
+
+ $data = $holder->getData();
+
+ return count($data['default'] ?? []);
+ }
+
+ private function token(): string
+ {
+ return 'List'.substr(bin2hex(random_bytes(4)), 0, 8);
+ }
+}
diff --git a/tests/Module/Commercial/Api/SupplierMigrationTest.php b/tests/Module/Commercial/Api/SupplierMigrationTest.php
new file mode 100644
index 0000000..d7ed259
--- /dev/null
+++ b/tests/Module/Commercial/Api/SupplierMigrationTest.php
@@ -0,0 +1,68 @@
+supplierIndexes();
+
+ $companyNameIndexes = array_filter(
+ $rows,
+ static fn (array $r): bool => 'uq_supplier_company_name_active' === $r['indexname'],
+ );
+
+ self::assertCount(1, $companyNameIndexes, 'Il doit exister exactement UN index uq_supplier_company_name_active.');
+
+ $def = strtolower((string) array_values($companyNameIndexes)[0]['indexdef']);
+ self::assertStringContainsString('unique', $def);
+ self::assertStringContainsString('lower', $def);
+ self::assertStringContainsString('company_name', $def);
+ self::assertStringContainsString('where', $def, 'L\'index doit etre partiel (clause WHERE sur les actifs).');
+ }
+
+ public function testNoSirenOrEmailUniqueIndexOnSupplier(): void
+ {
+ $names = array_map(static fn (array $r): string => $r['indexname'], $this->supplierIndexes());
+
+ // § 2.6 : SIREN et email NON uniques sur le fournisseur.
+ self::assertNotContains('uq_supplier_siren_active', $names);
+ self::assertNotContains('uq_supplier_email_active', $names);
+ }
+
+ public function testFournisseurCategoryTypeExists(): void
+ {
+ self::bootKernel();
+
+ $count = (int) $this->getEm()->getConnection()->fetchOne(
+ "SELECT COUNT(*) FROM category_type WHERE code = 'FOURNISSEUR'",
+ );
+
+ self::assertSame(1, $count, 'Le type de categorie FOURNISSEUR doit etre present (migration + fixture).');
+ }
+
+ /**
+ * @return list
+ */
+ private function supplierIndexes(): array
+ {
+ self::bootKernel();
+
+ /** @var list $rows */
+ return $this->getEm()->getConnection()->fetchAllAssociative(
+ "SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = 'supplier'",
+ );
+ }
+}
diff --git a/tests/Module/Commercial/Api/SupplierPatchStrictTest.php b/tests/Module/Commercial/Api/SupplierPatchStrictTest.php
new file mode 100644
index 0000000..7b4e8db
--- /dev/null
+++ b/tests/Module/Commercial/Api/SupplierPatchStrictTest.php
@@ -0,0 +1,45 @@
+seedSupplier('Strict Mix');
+ $credentials = $this->createUserWithPermission('commercial.suppliers.manage');
+ $client = $this->authenticatedClient($credentials['username'], $credentials['password']);
+
+ $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
+ 'headers' => ['Content-Type' => self::MERGE],
+ 'json' => [
+ 'companyName' => 'Renamed Strict',
+ 'siren' => '123456789',
+ ],
+ ]);
+
+ // RG-2.16 : 403 strict (le champ comptable siren exige accounting.manage).
+ self::assertResponseStatusCodeSame(403);
+
+ // Aucun champ applique : le companyName d'origine est intact.
+ $em = $this->getEm();
+ $em->clear();
+ $reloaded = $em->getRepository(Supplier::class)->find($seed->getId());
+ self::assertNotNull($reloaded);
+ self::assertSame('STRICT MIX', $reloaded->getCompanyName());
+ }
+}
diff --git a/tests/Module/Commercial/Api/SupplierRBACMatrixTest.php b/tests/Module/Commercial/Api/SupplierRBACMatrixTest.php
new file mode 100644
index 0000000..9d5b3dd
--- /dev/null
+++ b/tests/Module/Commercial/Api/SupplierRBACMatrixTest.php
@@ -0,0 +1,303 @@
+setAutoExit(false);
+ $exit = $application->run(
+ new ArrayInput([
+ 'command' => 'app:seed-rbac',
+ '--with-demo-users' => true,
+ '--password' => self::PWD,
+ ]),
+ new NullOutput(),
+ );
+ self::assertSame(
+ 0,
+ $exit,
+ 'app:seed-rbac a echoue : les permissions commercial.suppliers.* sont-elles synchronisees (app:sync-permissions) ?',
+ );
+
+ self::ensureKernelShutdown();
+ }
+
+ public function testUsineIsForbiddenEverywhere(): void
+ {
+ $seed = $this->seedSupplier('Usine Target');
+ $client = $this->authAs('usine');
+
+ $client->request('GET', '/api/suppliers', ['headers' => ['Accept' => self::LD]]);
+ self::assertResponseStatusCodeSame(403);
+
+ $client->request('GET', '/api/suppliers/'.$seed->getId(), ['headers' => ['Accept' => self::LD]]);
+ self::assertResponseStatusCodeSame(403);
+
+ $client->request('POST', '/api/suppliers', [
+ 'headers' => ['Content-Type' => self::LD],
+ 'json' => $this->validMainPayload('Usine Post'),
+ ]);
+ self::assertResponseStatusCodeSame(403);
+
+ $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
+ 'headers' => ['Content-Type' => self::MERGE],
+ 'json' => ['companyName' => 'Renamed By Usine'],
+ ]);
+ self::assertResponseStatusCodeSame(403);
+ }
+
+ public function testBureauHasViewAndManageButNoAccountingNoArchive(): void
+ {
+ $seed = $this->seedSupplier('Bureau Target');
+ $cat = $this->supplierCategory('NEGOCIANT');
+ $client = $this->authAs('bureau');
+
+ // view
+ $client->request('GET', '/api/suppliers', ['headers' => ['Accept' => self::LD]]);
+ self::assertResponseStatusCodeSame(200);
+
+ // manage : creation OK (bureau n'est pas gate par RG-2.03)
+ $client->request('POST', '/api/suppliers', [
+ 'headers' => ['Content-Type' => self::LD],
+ 'json' => $this->validMainPayload('Bureau Created', $cat->getId()),
+ ]);
+ self::assertResponseStatusCodeSame(201);
+
+ // manage : edition onglet principal OK
+ $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
+ 'headers' => ['Content-Type' => self::MERGE],
+ 'json' => ['companyName' => 'Bureau Renamed'],
+ ]);
+ self::assertResponseStatusCodeSame(200);
+
+ // PAS accounting : edition onglet Comptabilite refusee
+ $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
+ 'headers' => ['Content-Type' => self::MERGE],
+ 'json' => ['siren' => '123456789'],
+ ]);
+ self::assertResponseStatusCodeSame(403);
+
+ // PAS archive : archivage refuse
+ $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
+ 'headers' => ['Content-Type' => self::MERGE],
+ 'json' => ['isArchived' => true],
+ ]);
+ self::assertResponseStatusCodeSame(403);
+ }
+
+ public function testBureauDetailHasNoAccountingFields(): void
+ {
+ // Bureau a view mais PAS accounting.view : les champs comptables sont
+ // ABSENTS du JSON (gating par omission, pas null).
+ $supplier = $this->seedCompleteSupplier('Bureau Gating Co');
+ $client = $this->authAs('bureau');
+
+ $data = $client->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
+
+ // Gating par omission sur l'ensemble des champs comptables (pas seulement
+ // siren/ribs) : une regression reintroduisant accountNumber/nTva/tvaMode/
+ // paymentType dans le groupe bureau serait sinon invisible.
+ self::assertArrayNotHasKey('siren', $data);
+ self::assertArrayNotHasKey('accountNumber', $data);
+ self::assertArrayNotHasKey('nTva', $data);
+ self::assertArrayNotHasKey('tvaMode', $data);
+ self::assertArrayNotHasKey('paymentType', $data);
+ self::assertArrayNotHasKey('ribs', $data);
+ }
+
+ public function testComptaCanEditAccountingOnly(): void
+ {
+ $seed = $this->seedSupplier('Compta Target');
+ $client = $this->authAs('compta');
+
+ // view
+ $client->request('GET', '/api/suppliers', ['headers' => ['Accept' => self::LD]]);
+ self::assertResponseStatusCodeSame(200);
+
+ // PAS manage : creation refusee
+ $client->request('POST', '/api/suppliers', [
+ 'headers' => ['Content-Type' => self::LD],
+ 'json' => $this->validMainPayload('Compta Post'),
+ ]);
+ self::assertResponseStatusCodeSame(403);
+
+ // accounting.manage : edition onglet Comptabilite OK
+ $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
+ 'headers' => ['Content-Type' => self::MERGE],
+ 'json' => ['siren' => '123456789'],
+ ]);
+ self::assertResponseStatusCodeSame(200);
+
+ // PAS manage : edition onglet principal refusee (guardManage)
+ $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
+ 'headers' => ['Content-Type' => self::MERGE],
+ 'json' => ['companyName' => 'Compta Renamed'],
+ ]);
+ self::assertResponseStatusCodeSame(403);
+
+ // PAS manage : edition onglet Information refusee (guardManage)
+ $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
+ 'headers' => ['Content-Type' => self::MERGE],
+ 'json' => ['description' => 'Une description'],
+ ]);
+ self::assertResponseStatusCodeSame(403);
+
+ // PAS archive : archivage refuse
+ $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
+ 'headers' => ['Content-Type' => self::MERGE],
+ 'json' => ['isArchived' => true],
+ ]);
+ self::assertResponseStatusCodeSame(403);
+ }
+
+ public function testComptaDetailHasAccountingFields(): void
+ {
+ // Compta a accounting.view : siren + ribs presents dans le JSON.
+ $supplier = $this->seedCompleteSupplier('Compta View Co');
+ $client = $this->authAs('compta');
+
+ $data = $client->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
+
+ self::assertArrayHasKey('siren', $data);
+ self::assertSame('123456789', $data['siren']);
+ self::assertArrayHasKey('ribs', $data);
+ self::assertNotEmpty($data['ribs']);
+ }
+
+ public function testCommercialeHasViewAndManageButNoAccountingNoArchive(): void
+ {
+ $seed = $this->seedSupplier('Commerciale Target');
+ $client = $this->authAs('commerciale');
+
+ // view
+ $client->request('GET', '/api/suppliers', ['headers' => ['Accept' => self::LD]]);
+ self::assertResponseStatusCodeSame(200);
+
+ // manage : la creation passe la security d'operation (pas un 403 comme
+ // Compta) mais bute sur RG-2.03 (onglet Information incomplet) -> 422.
+ $response = $client->request('POST', '/api/suppliers', [
+ 'headers' => ['Content-Type' => self::LD],
+ 'json' => $this->validMainPayload('Commerciale Post'),
+ ]);
+ self::assertResponseStatusCodeSame(422);
+ // Le 422 doit bien etre celui de RG-2.03 (onglet Information) et non un
+ // 422 orthogonal : on exige une violation sur un champ de completude.
+ self::assertArrayHasKey('description', $this->violationsByPath($response->toArray(false)));
+
+ // PAS accounting : edition onglet Comptabilite refusee
+ $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
+ 'headers' => ['Content-Type' => self::MERGE],
+ 'json' => ['siren' => '123456789'],
+ ]);
+ self::assertResponseStatusCodeSame(403);
+
+ // PAS archive : archivage refuse
+ $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
+ 'headers' => ['Content-Type' => self::MERGE],
+ 'json' => ['isArchived' => true],
+ ]);
+ self::assertResponseStatusCodeSame(403);
+ }
+
+ public function testCommercialeDetailHasNoAccountingFields(): void
+ {
+ $supplier = $this->seedCompleteSupplier('Commerciale Gating Co');
+ $client = $this->authAs('commerciale');
+
+ $data = $client->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
+
+ self::assertArrayNotHasKey('siren', $data);
+ self::assertArrayNotHasKey('accountNumber', $data);
+ self::assertArrayNotHasKey('nTva', $data);
+ self::assertArrayNotHasKey('tvaMode', $data);
+ self::assertArrayNotHasKey('paymentType', $data);
+ self::assertArrayNotHasKey('ribs', $data);
+ }
+
+ public function testRG203CommercialePostIncompleteIs422AdminIs201(): void
+ {
+ $cat = $this->supplierCategory('NEGOCIANT');
+
+ // RG-2.03 : Commerciale POST sans onglet Information complet -> 422.
+ $commerciale = $this->authAs('commerciale');
+ $response = $commerciale->request('POST', '/api/suppliers', [
+ 'headers' => ['Content-Type' => self::LD],
+ 'json' => $this->validMainPayload('RG203 Commerciale', $cat->getId()),
+ ]);
+ self::assertResponseStatusCodeSame(422);
+ self::assertArrayHasKey('description', $this->violationsByPath($response->toArray(false)));
+
+ // Meme payload par un Admin (non gate par RG-2.03) -> 201.
+ $admin = $this->createAdminClient();
+ $admin->request('POST', '/api/suppliers', [
+ 'headers' => ['Content-Type' => self::LD],
+ 'json' => $this->validMainPayload('RG203 Admin', $cat->getId()),
+ ]);
+ self::assertResponseStatusCodeSame(201);
+ }
+
+ public function testRG203CommercialePatchIncompleteIs422(): void
+ {
+ // RG-2.03 : tout PATCH par une Commerciale exige l'Information complete.
+ // Le fournisseur seede a une Information vide -> meme un PATCH du nom -> 422.
+ $seed = $this->seedSupplier('Commerciale Patch Incomplete');
+ $commerciale = $this->authAs('commerciale');
+
+ $response = $commerciale->request('PATCH', '/api/suppliers/'.$seed->getId(), [
+ 'headers' => ['Content-Type' => self::MERGE],
+ 'json' => ['companyName' => 'Commerciale Renamed'],
+ ]);
+ self::assertResponseStatusCodeSame(422);
+ self::assertArrayHasKey('description', $this->violationsByPath($response->toArray(false)));
+
+ // Le meme PATCH par un Admin passe (non gate par RG-2.03) -> 200.
+ $admin = $this->createAdminClient();
+ $admin->request('PATCH', '/api/suppliers/'.$seed->getId(), [
+ 'headers' => ['Content-Type' => self::MERGE],
+ 'json' => ['companyName' => 'Admin Renamed'],
+ ]);
+ self::assertResponseStatusCodeSame(200);
+ }
+
+ private function authAs(string $role): Client
+ {
+ return $this->authenticatedClient($role, self::PWD);
+ }
+}
diff --git a/tests/Module/Commercial/Api/SupplierSerializationContractTest.php b/tests/Module/Commercial/Api/SupplierSerializationContractTest.php
new file mode 100644
index 0000000..8f0611b
--- /dev/null
+++ b/tests/Module/Commercial/Api/SupplierSerializationContractTest.php
@@ -0,0 +1,371 @@
+ clé `ribs`
+ * ABSENTE pour la Commerciale.
+ * - #3 : booleens droppes (Groups sur la propriete `isX`, getter derivant `x`)
+ * -> triageProvider (adresse) et isArchived (fournisseur) presents.
+ * - #1 : categories embarquees sans code/name -> code + name presents en LISTE
+ * ET DETAIL.
+ * - #2 : sites embarques en IRI nu -> name + postalCode presents en LISTE
+ * (via getSites()) ET DETAIL (addresses[].sites[]).
+ * Plus l'enveloppe AP4 (member/totalItems/view sans prefixe hydra:, archives
+ * exclus) et la suppression du contact inline (refonte-contact V0.2).
+ *
+ * REGLE D'OR : ces tests assertent sur le CORPS JSON reel, jamais sur les
+ * annotations. Toute regression de groupe de serialisation casse ici.
+ *
+ * @internal
+ */
+final class SupplierSerializationContractTest extends AbstractSupplierApiTestCase
+{
+ // === #4 — Gating des RIB par accounting.view ===
+
+ public function testRibsPresentForAdminWithAccountingView(): void
+ {
+ $this->skipIfSitesModuleDisabled();
+
+ $supplier = $this->seedCompleteSupplier('Rib Admin Co');
+
+ $http = $this->createAdminClient();
+ $data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
+
+ // Admin bypass RBAC -> accounting.view -> RIB embarques (label/bic/iban).
+ self::assertArrayHasKey('ribs', $data);
+ self::assertNotEmpty($data['ribs']);
+ self::assertSame('Compte principal', $data['ribs'][0]['label']);
+ self::assertSame(self::VALID_IBAN, $data['ribs'][0]['iban']);
+ self::assertSame(self::VALID_BIC, $data['ribs'][0]['bic']);
+ }
+
+ public function testRibsAbsentForCommercialeWithoutAccountingView(): void
+ {
+ $this->skipIfSitesModuleDisabled();
+
+ $supplier = $this->seedCompleteSupplier('Rib Commerciale Co');
+
+ // Commerciale : commercial.suppliers.view SANS accounting.view.
+ $creds = $this->createUserWithPermission('commercial.suppliers.view');
+ $http = $this->authenticatedClient($creds['username'], $creds['password']);
+
+ $data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
+
+ // La clé `ribs` est ABSENTE (pas null) : le groupe supplier:read:accounting
+ // n'est pas ajoute au contexte -> getRibs() jamais serialise. Fin de la
+ // fuite IBAN/BIC (piege n°4 du M1).
+ self::assertArrayNotHasKey('ribs', $data);
+ }
+
+ // === #4.bis — Gating par OMISSION des scalaires comptables ===
+
+ public function testAccountingScalarsGatedByOmission(): void
+ {
+ $this->skipIfSitesModuleDisabled();
+
+ $supplier = $this->seedCompleteSupplier('Compta Gating Co');
+ $id = $supplier->getId();
+
+ // Admin : scalaires comptables presents.
+ $admin = $this->createAdminClient();
+ $adminData = $admin->request('GET', '/api/suppliers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
+ self::assertArrayHasKey('siren', $adminData);
+ self::assertSame('123456789', $adminData['siren']);
+ self::assertArrayHasKey('accountNumber', $adminData);
+ self::assertArrayHasKey('paymentType', $adminData);
+
+ // Commerciale : scalaires comptables ABSENTS (omission, pas null).
+ $creds = $this->createUserWithPermission('commercial.suppliers.view');
+ $http = $this->authenticatedClient($creds['username'], $creds['password']);
+ $data = $http->request('GET', '/api/suppliers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
+
+ self::assertArrayNotHasKey('siren', $data);
+ self::assertArrayNotHasKey('accountNumber', $data);
+ self::assertArrayNotHasKey('nTva', $data);
+ self::assertArrayNotHasKey('tvaMode', $data);
+ self::assertArrayNotHasKey('paymentType', $data);
+ self::assertArrayNotHasKey('ribs', $data);
+ }
+
+ // === Refs comptables embarquees {id,label} et non IRI nu (ERP-92) ===
+
+ public function testAccountingReferentialsEmbedIdAndLabel(): void
+ {
+ $this->skipIfSitesModuleDisabled();
+
+ // Reglement Virement -> banque renseignee : on couvre les 4 referentiels.
+ $supplier = $this->seedCompleteSupplier('Refs Embed Co', 'VIREMENT');
+
+ $http = $this->createAdminClient();
+ $data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
+
+ // Avant fix ERP-92 : ces refs sortaient en IRI nu ("/api/tva_modes/30")
+ // car les entites partagees ne portaient que `client:read:accounting` (M1),
+ // pas `supplier:read:accounting`. Apres fix : objet {id, label} embarque
+ // (le front consultation/edition affiche le libelle sans fetch — § 4.0).
+ foreach (['tvaMode', 'paymentDelay', 'paymentType', 'bank'] as $ref) {
+ self::assertArrayHasKey($ref, $data, sprintf('Le ref comptable "%s" doit etre present.', $ref));
+ self::assertIsArray($data[$ref], sprintf('Le ref "%s" doit etre un objet embarque, pas un IRI nu.', $ref));
+ self::assertArrayHasKey('id', $data[$ref]);
+ self::assertArrayHasKey('label', $data[$ref]);
+ self::assertNotSame('', (string) $data[$ref]['label']);
+ }
+
+ // paymentType embarque aussi son code (logique front VIREMENT/LCR).
+ self::assertArrayHasKey('code', $data['paymentType']);
+ self::assertSame('VIREMENT', $data['paymentType']['code']);
+ }
+
+ // === #3 — Booleens presents dans le JSON (triageProvider + isArchived) ===
+
+ public function testAddressTriageProviderBooleanIsPresentInDetail(): void
+ {
+ $this->skipIfSitesModuleDisabled();
+
+ $supplier = $this->seedCompleteSupplier('Bool Addr Co');
+
+ $http = $this->createAdminClient();
+ $data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
+
+ self::assertArrayHasKey('addresses', $data);
+ self::assertNotEmpty($data['addresses']);
+ $address = $data['addresses'][0];
+
+ // Le bug M1 droppait TOTALEMENT la cle (Groups sur la propriete `triageProvider`,
+ // getter derivant `triage`). Apres parade (Groups + SerializedName sur le
+ // getter isTriageProvider), la cle est presente ET typee bool `true`.
+ self::assertArrayHasKey('triageProvider', $address);
+ self::assertTrue($address['triageProvider']);
+ }
+
+ public function testSupplierIsArchivedBooleanIsPresentInDetail(): void
+ {
+ $this->skipIfSitesModuleDisabled();
+
+ $supplier = $this->seedCompleteSupplier('Bool Archived Co');
+
+ $http = $this->createAdminClient();
+ $data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
+
+ // isArchived expose via Groups + SerializedName('isArchived') sur le getter :
+ // sans cela Symfony exposerait la cle "archived" et la droppait (piege n°3 M1).
+ self::assertArrayHasKey('isArchived', $data);
+ self::assertFalse($data['isArchived']);
+ }
+
+ // === #1 — Embed code/name des Category (liste ET detail) ===
+
+ public function testCategoriesEmbedCodeAndNameInDetail(): void
+ {
+ $this->skipIfSitesModuleDisabled();
+
+ $supplier = $this->seedCompleteSupplier('Embed Cat Detail Co');
+
+ $http = $this->createAdminClient();
+ $data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
+
+ self::assertNotEmpty($data['categories']);
+ $category = $data['categories'][0];
+ // Avant correctif M1 : seuls @id/@type (category:read absent du contexte).
+ // Apres : code + name embarques.
+ self::assertArrayHasKey('code', $category);
+ self::assertArrayHasKey('name', $category);
+ self::assertSame('NEGOCIANT', $category['code']);
+
+ // Categories d'adresse aussi (category:read dans le contexte du detail).
+ self::assertArrayHasKey('categories', $data['addresses'][0]);
+ self::assertNotEmpty($data['addresses'][0]['categories']);
+ self::assertArrayHasKey('code', $data['addresses'][0]['categories'][0]);
+ }
+
+ public function testCategoriesEmbedCodeAndNameInList(): void
+ {
+ $this->skipIfSitesModuleDisabled();
+
+ $token = 'CatList'.substr(bin2hex(random_bytes(3)), 0, 6);
+ $supplier = $this->seedCompleteSupplier($token);
+
+ $http = $this->createAdminClient();
+ $list = $http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
+
+ $row = $this->memberById($list, (int) $supplier->getId());
+ self::assertNotNull($row, 'Le fournisseur seede doit apparaitre dans la liste filtree.');
+ self::assertNotEmpty($row['categories']);
+ self::assertArrayHasKey('code', $row['categories'][0]);
+ self::assertArrayHasKey('name', $row['categories'][0]);
+ self::assertSame('NEGOCIANT', $row['categories'][0]['code']);
+ }
+
+ // === #2 — Embed name/postalCode des Site (liste via getSites + detail) ===
+
+ public function testSitesEmbedNameAndPostalCodeInList(): void
+ {
+ $this->skipIfSitesModuleDisabled();
+
+ $token = 'SiteList'.substr(bin2hex(random_bytes(3)), 0, 6);
+ $supplier = $this->seedCompleteSupplier($token);
+
+ $http = $this->createAdminClient();
+ $list = $http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
+
+ $row = $this->memberById($list, (int) $supplier->getId());
+ self::assertNotNull($row);
+ // sites agreges depuis les adresses via getSites() : objet Site entier
+ // (name + postalCode), pas un IRI nu (piege n°2 M1). Multi-sites (>= 2).
+ self::assertArrayHasKey('sites', $row);
+ self::assertGreaterThanOrEqual(2, count($row['sites']));
+ self::assertArrayHasKey('name', $row['sites'][0]);
+ self::assertArrayHasKey('postalCode', $row['sites'][0]);
+ self::assertNotSame('', (string) $row['sites'][0]['name']);
+ }
+
+ public function testSitesEmbedNameAndPostalCodeInDetail(): void
+ {
+ $this->skipIfSitesModuleDisabled();
+
+ $supplier = $this->seedCompleteSupplier('Site Detail Co');
+
+ $http = $this->createAdminClient();
+ $data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
+ $address = $data['addresses'][0];
+
+ self::assertArrayHasKey('sites', $address);
+ self::assertGreaterThanOrEqual(2, count($address['sites']), 'L\'adresse seedee est multi-sites.');
+ self::assertArrayHasKey('name', $address['sites'][0]);
+ self::assertArrayHasKey('postalCode', $address['sites'][0]);
+ self::assertNotSame('', (string) $address['sites'][0]['name']);
+ }
+
+ // === Detail : sous-collections embarquees ===
+
+ public function testDetailEmbedsContactsAddressesRibs(): void
+ {
+ $this->skipIfSitesModuleDisabled();
+
+ $supplier = $this->seedCompleteSupplier('Embed Subres Co');
+
+ $http = $this->createAdminClient();
+ $data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
+
+ self::assertNotEmpty($data['contacts']);
+ self::assertSame('Marie', $data['contacts'][0]['firstName']);
+ self::assertSame('Martin', $data['contacts'][0]['lastName']);
+ self::assertArrayHasKey('email', $data['contacts'][0]);
+
+ self::assertNotEmpty($data['addresses']);
+ self::assertSame('DEPART', $data['addresses'][0]['addressType']);
+
+ self::assertNotEmpty($data['ribs']);
+ }
+
+ // === refonte-contact V0.2 : plus de contact inline sur le fournisseur ===
+
+ public function testSupplierHasNoInlineContactFields(): void
+ {
+ $this->skipIfSitesModuleDisabled();
+
+ $supplier = $this->seedCompleteSupplier('No Inline Contact Co');
+
+ $http = $this->createAdminClient();
+ $data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
+
+ // Les champs de contact vivent UNIQUEMENT sous contacts[] (refonte-contact).
+ foreach (['firstName', 'lastName', 'phonePrimary', 'phoneSecondary', 'email'] as $key) {
+ self::assertArrayNotHasKey($key, $data, sprintf('Le champ inline "%s" ne doit plus exister au niveau du fournisseur.', $key));
+ }
+ }
+
+ // === Enveloppe AP4 (sans prefixe hydra:) + exclusion des archives ===
+
+ public function testCollectionEnvelopeShapeAndArchivedExcluded(): void
+ {
+ $this->skipIfSitesModuleDisabled();
+
+ $http = $this->createAdminClient();
+ $token = 'EnvCheck'.substr(bin2hex(random_bytes(3)), 0, 6);
+
+ $this->seedSupplier($token.' Active');
+ $this->seedSupplier($token.' Archived', true);
+
+ // Liste par defaut filtree sur le token : enveloppe member/totalItems sans
+ // prefixe hydra:, archive EXCLU du totalItems (RG-2.17).
+ $default = $http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
+
+ self::assertArrayHasKey('member', $default);
+ self::assertArrayHasKey('totalItems', $default);
+ self::assertArrayNotHasKey('hydra:member', $default);
+ self::assertArrayNotHasKey('hydra:totalItems', $default);
+ self::assertSame(1, $default['totalItems'], 'Archive exclu du totalItems par defaut.');
+
+ // includeArchived : l'archive reintegre le total.
+ $all = $http->request('GET', '/api/suppliers?search='.$token.'&includeArchived=true', ['headers' => ['Accept' => self::LD]])->toArray();
+ self::assertSame(2, $all['totalItems']);
+
+ // `view` (PartialCollectionView) sans prefixe hydra:.
+ $paged = $http->request('GET', '/api/suppliers?search='.$token.'&includeArchived=true&itemsPerPage=1', ['headers' => ['Accept' => self::LD]])->toArray();
+ self::assertArrayHasKey('view', $paged);
+ self::assertArrayNotHasKey('hydra:view', $paged);
+ }
+
+ /**
+ * DoD (§ 4.0.bis) : capture des reponses JSON REELLES (liste + detail admin +
+ * detail commerciale) pour les coller dans la spec avant de lancer les tickets
+ * front. Le test asserte la forme ; si la variable d'env SUPPLIER_DOD_DUMP est
+ * positionnee, il ecrit aussi les 3 corps formates sous /tmp pour copie.
+ */
+ public function testDodReferenceJsonShape(): void
+ {
+ $this->skipIfSitesModuleDisabled();
+
+ $token = 'DoD'.substr(bin2hex(random_bytes(3)), 0, 6);
+ $supplier = $this->seedCompleteSupplier($token);
+ $id = (int) $supplier->getId();
+
+ $admin = $this->createAdminClient();
+ $list = $admin->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
+ $detailAdmin = $admin->request('GET', '/api/suppliers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
+
+ $creds = $this->createUserWithPermission('commercial.suppliers.view');
+ $commerciale = $this->authenticatedClient($creds['username'], $creds['password']);
+ $detailCommerciale = $commerciale->request('GET', '/api/suppliers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
+
+ // Forme minimale attendue (la DoD valide que tout champ front est present).
+ self::assertArrayHasKey('member', $list);
+ self::assertArrayHasKey('siren', $detailAdmin);
+ self::assertArrayHasKey('ribs', $detailAdmin);
+ self::assertArrayNotHasKey('siren', $detailCommerciale);
+ self::assertArrayNotHasKey('ribs', $detailCommerciale);
+
+ if (false !== getenv('SUPPLIER_DOD_DUMP')) {
+ $flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
+ file_put_contents('/tmp/supplier-dod-list.json', json_encode($list, $flags));
+ file_put_contents('/tmp/supplier-dod-detail-admin.json', json_encode($detailAdmin, $flags));
+ file_put_contents('/tmp/supplier-dod-detail-commerciale.json', json_encode($detailCommerciale, $flags));
+ }
+ }
+
+ /**
+ * Retrouve un membre de la collection par son id (liste filtree).
+ *
+ * @param array $collection
+ *
+ * @return array|null
+ */
+ private function memberById(array $collection, int $id): ?array
+ {
+ foreach ($collection['member'] ?? [] as $member) {
+ if (($member['id'] ?? null) === $id) {
+ return $member;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/tests/Module/Commercial/Api/SupplierSubResourceApiTest.php b/tests/Module/Commercial/Api/SupplierSubResourceApiTest.php
new file mode 100644
index 0000000..77df0a9
--- /dev/null
+++ b/tests/Module/Commercial/Api/SupplierSubResourceApiTest.php
@@ -0,0 +1,361 @@
+= 1 site), RG-2.09 (enum addressType),
+ * RG-2.10 (categorie FOURNISSEUR sur adresse), RG-2.08 (DELETE dernier RIB sous
+ * LCR -> 409), DELETE contact libre au M2 (pas de garde « dernier contact ») et le
+ * gating comptable des RIB (manage seul -> 403). Jumeau de ClientSubResourceApiTest.
+ *
+ * @internal
+ */
+final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
+{
+ // === Contacts ===
+
+ public function testPostContactNormalizesFields(): void
+ {
+ $client = $this->createAdminClient();
+ $seed = $this->seedSupplier('Contact Host');
+
+ $data = $client->request('POST', '/api/suppliers/'.$seed->getId().'/contacts', [
+ 'headers' => ['Content-Type' => self::LD],
+ 'json' => [
+ 'firstName' => 'JEAN',
+ 'lastName' => 'dupont',
+ 'phonePrimary' => '06.12.34.56.78',
+ 'email' => 'Jean.DUPONT@ACME.FR',
+ ],
+ ])->toArray();
+
+ self::assertResponseStatusCodeSame(201);
+ // RG-2.12 : prenom/nom Title Case, telephone chiffres seuls, email lowercase.
+ self::assertSame('Jean', $data['firstName']);
+ self::assertSame('Dupont', $data['lastName']);
+ self::assertSame('0612345678', $data['phonePrimary']);
+ self::assertSame('jean.dupont@acme.fr', $data['email']);
+ }
+
+ public function testPostContactWithoutNameReturns422OnFirstNamePath(): void
+ {
+ $client = $this->createAdminClient();
+ $seed = $this->seedSupplier('Contact No Name');
+
+ $response = $client->request('POST', '/api/suppliers/'.$seed->getId().'/contacts', [
+ 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
+ 'json' => ['jobTitle' => 'Directeur'],
+ ]);
+
+ // RG-2.04 (prenom OU nom obligatoire) -> 422 rattachee a firstName.
+ self::assertResponseStatusCodeSame(422);
+ $byPath = $this->violationsByPath($response->toArray(false));
+ self::assertArrayHasKey('firstName', $byPath);
+ }
+
+ public function testPostContactOnMissingSupplierReturns404(): void
+ {
+ $client = $this->createAdminClient();
+
+ $client->request('POST', '/api/suppliers/999999/contacts', [
+ 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
+ 'json' => ['firstName' => 'Orphan'],
+ ]);
+
+ self::assertResponseStatusCodeSame(404);
+ }
+
+ public function testDeleteLastContactReturns204(): void
+ {
+ // M2 : pas de garde « dernier contact » (RG-2.13 front-driven) — la
+ // suppression du dernier contact est libre (204), contrairement au M1.
+ $client = $this->createAdminClient();
+ $seed = $this->seedSupplier('Contact Solo');
+ $contact = $this->addContact($seed, 'Unique', 'Contact');
+
+ $client->request('DELETE', '/api/supplier_contacts/'.$contact->getId());
+
+ self::assertResponseStatusCodeSame(204);
+ }
+
+ public function testContactWriteWithoutManageReturns403(): void
+ {
+ // Un user sans aucune permission suppliers -> 403 sur la sous-ressource.
+ $seed = $this->seedSupplier('Contact Forbidden');
+ $creds = $this->createUserWithPermission('core.users.view');
+ $http = $this->authenticatedClient($creds['username'], $creds['password']);
+
+ $http->request('POST', '/api/suppliers/'.$seed->getId().'/contacts', [
+ 'headers' => ['Content-Type' => self::LD],
+ 'json' => ['firstName' => 'Nope'],
+ ]);
+ self::assertResponseStatusCodeSame(403);
+ }
+
+ // === Adresses ===
+
+ public function testPostAddressWithValidPayloadReturns201(): void
+ {
+ $this->skipIfSitesModuleDisabled();
+ $client = $this->createAdminClient();
+ $seed = $this->seedSupplier('Address Host');
+ $category = $this->supplierCategory('NEGOCIANT');
+
+ $data = $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
+ 'headers' => ['Content-Type' => self::LD],
+ 'json' => [
+ 'addressType' => 'DEPART',
+ 'postalCode' => '86100',
+ 'city' => 'Châtellerault',
+ 'street' => '1 rue du Test',
+ 'sites' => [$this->firstSiteIri()],
+ 'categories' => ['/api/categories/'.$category->getId()],
+ ],
+ ])->toArray();
+
+ self::assertResponseStatusCodeSame(201);
+ self::assertSame('DEPART', $data['addressType']);
+ }
+
+ public function testPostAddressWithoutSiteReturns422(): void
+ {
+ // Sans cette garde, un module Sites desactive renverrait 404 (route
+ // /addresses indisponible) et le test passerait pour la MAUVAISE raison
+ // au lieu de prouver RG-2.06 (Assert\Count min 1 sur sites).
+ $this->skipIfSitesModuleDisabled();
+ $client = $this->createAdminClient();
+ $seed = $this->seedSupplier('Address No Site');
+
+ $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
+ 'headers' => ['Content-Type' => self::LD],
+ 'json' => [
+ 'addressType' => 'DEPART',
+ 'postalCode' => '86100',
+ 'city' => 'Châtellerault',
+ 'street' => '1 rue du Test',
+ 'sites' => [],
+ ],
+ ]);
+
+ // RG-2.06 (Assert\Count min 1 sur sites).
+ self::assertResponseStatusCodeSame(422);
+ }
+
+ public function testPostAddressWithInvalidPostalCodeReturns422(): void
+ {
+ $this->skipIfSitesModuleDisabled();
+ $client = $this->createAdminClient();
+ $seed = $this->seedSupplier('Address Bad CP');
+
+ $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
+ 'headers' => ['Content-Type' => self::LD],
+ 'json' => [
+ 'addressType' => 'DEPART',
+ 'postalCode' => '123',
+ 'city' => 'Châtellerault',
+ 'street' => '1 rue du Test',
+ 'sites' => [$this->firstSiteIri()],
+ ],
+ ]);
+
+ // RG-2.05 (Assert\Regex ^[0-9]{4,5}$).
+ self::assertResponseStatusCodeSame(422);
+ }
+
+ public function testPostAddressWithIncoherentCityAndPostalCodeReturns201(): void
+ {
+ $this->skipIfSitesModuleDisabled();
+ $client = $this->createAdminClient();
+ $seed = $this->seedSupplier('Address Incoherent');
+
+ // RG-2.05 : pas de controle strict de coherence CP/ville cote serveur.
+ $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
+ 'headers' => ['Content-Type' => self::LD],
+ 'json' => [
+ 'addressType' => 'DEPART',
+ 'postalCode' => '86100',
+ 'city' => 'Marseille',
+ 'street' => '1 rue du Test',
+ 'sites' => [$this->firstSiteIri()],
+ ],
+ ]);
+
+ self::assertResponseStatusCodeSame(201);
+ }
+
+ public function testPostAddressWithInvalidTypeReturns422(): void
+ {
+ $this->skipIfSitesModuleDisabled();
+ $client = $this->createAdminClient();
+ $seed = $this->seedSupplier('Address Bad Type');
+
+ $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
+ 'headers' => ['Content-Type' => self::LD],
+ 'json' => [
+ 'addressType' => 'INVALID',
+ 'postalCode' => '86100',
+ 'city' => 'Châtellerault',
+ 'street' => '1 rue du Test',
+ 'sites' => [$this->firstSiteIri()],
+ ],
+ ]);
+
+ // RG-2.09 (Assert\Choice PROSPECT|DEPART|RENDU).
+ self::assertResponseStatusCodeSame(422);
+ }
+
+ /**
+ * RG-2.09 : les 3 valeurs valides de addressType sont acceptees.
+ */
+ public function testPostAddressWithEachValidTypeReturns201(): void
+ {
+ $this->skipIfSitesModuleDisabled();
+ $client = $this->createAdminClient();
+ $seed = $this->seedSupplier('Address Types');
+ $siteIri = $this->firstSiteIri();
+
+ foreach (['PROSPECT', 'DEPART', 'RENDU'] as $type) {
+ $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
+ 'headers' => ['Content-Type' => self::LD],
+ 'json' => [
+ 'addressType' => $type,
+ 'postalCode' => '86100',
+ 'city' => 'Châtellerault',
+ 'street' => '1 rue du Test',
+ 'sites' => [$siteIri],
+ ],
+ ]);
+ self::assertResponseStatusCodeSame(201, sprintf('addressType=%s doit etre accepte.', $type));
+ }
+ }
+
+ public function testPostAddressWithNonFournisseurCategoryReturns422(): void
+ {
+ $this->skipIfSitesModuleDisabled();
+ $client = $this->createAdminClient();
+ $seed = $this->seedSupplier('Address Bad Cat');
+ // categorie de type CLIENT -> interdite sur une adresse fournisseur.
+ $clientTypedCategory = $this->createCategory('SECTEUR');
+
+ $response = $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
+ 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
+ 'json' => [
+ 'addressType' => 'DEPART',
+ 'postalCode' => '86100',
+ 'city' => 'Châtellerault',
+ 'street' => '1 rue du Test',
+ 'sites' => [$this->firstSiteIri()],
+ 'categories' => ['/api/categories/'.$clientTypedCategory->getId()],
+ ],
+ ]);
+
+ // RG-2.10 -> 422 rattachee a categories.
+ self::assertResponseStatusCodeSame(422);
+ self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false)));
+ }
+
+ // === RIBs ===
+
+ public function testPostRibByAdminReturns201(): void
+ {
+ $client = $this->createAdminClient();
+ $seed = $this->seedSupplier('Rib Host');
+
+ $data = $client->request('POST', '/api/suppliers/'.$seed->getId().'/ribs', [
+ 'headers' => ['Content-Type' => self::LD],
+ 'json' => [
+ 'label' => 'Compte principal',
+ 'bic' => self::VALID_BIC,
+ 'iban' => self::VALID_IBAN,
+ ],
+ ])->toArray();
+
+ self::assertResponseStatusCodeSame(201);
+ self::assertSame('Compte principal', $data['label']);
+ }
+
+ public function testPostRibWithInvalidIbanReturns422(): void
+ {
+ $client = $this->createAdminClient();
+ $seed = $this->seedSupplier('Rib Bad Iban');
+
+ $client->request('POST', '/api/suppliers/'.$seed->getId().'/ribs', [
+ 'headers' => ['Content-Type' => self::LD],
+ 'json' => ['label' => 'Compte invalide', 'bic' => self::VALID_BIC, 'iban' => 'INVALID-IBAN'],
+ ]);
+
+ self::assertResponseStatusCodeSame(422);
+ }
+
+ public function testDeleteRibNonLcrReturns204(): void
+ {
+ $client = $this->createAdminClient();
+ $seed = $this->seedSupplier('Rib Non LCR');
+ $rib = $this->addRib($seed);
+
+ $client->request('DELETE', '/api/supplier_ribs/'.$rib->getId());
+
+ self::assertResponseStatusCodeSame(204);
+ }
+
+ public function testDeleteLastRibUnderLcrReturns409(): void
+ {
+ $client = $this->createAdminClient();
+ $seed = $this->seedSupplier('Rib LCR Solo');
+ $rib = $this->addRib($seed);
+ // Passe le fournisseur en LCR (seed direct).
+ $em = $this->getEm();
+ $managed = $em->getRepository(Supplier::class)->find($seed->getId());
+ $managed->setPaymentType($this->paymentType('LCR'));
+ $em->flush();
+
+ $client->request('DELETE', '/api/supplier_ribs/'.$rib->getId());
+
+ // RG-2.08 : LCR exige >= 1 RIB -> suppression du dernier refusee.
+ self::assertResponseStatusCodeSame(409);
+ }
+
+ public function testRibWriteWithoutAccountingManageReturns403(): void
+ {
+ // Un user portant seulement suppliers.manage (sans accounting.manage) ne
+ // peut ni creer, ni modifier, ni supprimer un RIB (gating renforce § 4.5).
+ $seed = $this->seedSupplier('Rib Forbidden');
+ $rib = $this->addRib($seed);
+ $creds = $this->createUserWithPermission('commercial.suppliers.manage');
+ $http = $this->authenticatedClient($creds['username'], $creds['password']);
+
+ $http->request('POST', '/api/suppliers/'.$seed->getId().'/ribs', [
+ 'headers' => ['Content-Type' => self::LD],
+ 'json' => ['label' => 'X', 'bic' => self::VALID_BIC, 'iban' => self::VALID_IBAN],
+ ]);
+ self::assertResponseStatusCodeSame(403);
+
+ $http->request('PATCH', '/api/supplier_ribs/'.$rib->getId(), [
+ 'headers' => ['Content-Type' => self::MERGE],
+ 'json' => ['label' => 'Y'],
+ ]);
+ self::assertResponseStatusCodeSame(403);
+
+ $http->request('DELETE', '/api/supplier_ribs/'.$rib->getId());
+ self::assertResponseStatusCodeSame(403);
+ }
+
+ // === Helpers ===
+
+ // violationsByPath() : helper mutualise dans AbstractSupplierApiTestCase.
+
+ private function firstSiteIri(): string
+ {
+ $site = $this->getEm()->getRepository(Site::class)->findOneBy([]);
+ self::assertNotNull($site, 'Aucun site seede : impossible de tester les adresses.');
+
+ return '/api/sites/'.$site->getId();
+ }
+}
diff --git a/tests/Module/Commercial/Api/SupplierUniquenessTest.php b/tests/Module/Commercial/Api/SupplierUniquenessTest.php
new file mode 100644
index 0000000..683c2d1
--- /dev/null
+++ b/tests/Module/Commercial/Api/SupplierUniquenessTest.php
@@ -0,0 +1,36 @@
+getEm();
+
+ $one = $this->seedSupplier('Siren Share One');
+ $two = $this->seedSupplier('Siren Share Two');
+
+ // Le SIREN n'est pas ecrivable au POST (groupe accounting) : seed direct.
+ $one->setSiren('123456789');
+ $two->setSiren('123456789');
+ $em->flush();
+
+ // Aucune exception : pas d'index unique sur siren (§ 2.6).
+ self::assertSame('123456789', $em->getRepository(Supplier::class)->find($one->getId())?->getSiren());
+ self::assertSame('123456789', $em->getRepository(Supplier::class)->find($two->getId())?->getSiren());
+ }
+}
diff --git a/tests/Module/Commercial/Domain/Entity/SupplierValidationTest.php b/tests/Module/Commercial/Domain/Entity/SupplierValidationTest.php
new file mode 100644
index 0000000..04ec47f
--- /dev/null
+++ b/tests/Module/Commercial/Domain/Entity/SupplierValidationTest.php
@@ -0,0 +1,188 @@
+ banque), RG-2.08 (LCR -> >= 1 RIB).
+ *
+ * On valide l'entite avec le validator Symfony (mapping par attributs) et on
+ * assert le propertyPath exact de chaque violation (contrat ERP-101 :
+ * exploitable par extractApiViolations). Pas de base : les Callback ne touchent
+ * que des champs en memoire (categories via un double CategoryInterface).
+ *
+ * @internal
+ */
+final class SupplierValidationTest extends TestCase
+{
+ private ValidatorInterface $validator;
+
+ protected function setUp(): void
+ {
+ $this->validator = Validation::createValidatorBuilder()
+ ->enableAttributeMapping()
+ ->getValidator()
+ ;
+ }
+
+ // === RG-2.10 : categories de type FOURNISSEUR ===
+
+ public function testFournisseurCategoryIsAccepted(): void
+ {
+ $supplier = $this->validSupplier();
+
+ self::assertSame([], $this->violationPaths($supplier));
+ }
+
+ public function testNonFournisseurCategoryIsRejectedOnCategoriesPath(): void
+ {
+ $supplier = new Supplier();
+ $supplier->setCompanyName('Recycla SAS');
+ $supplier->addCategory($this->category('CLIENT'));
+
+ self::assertContains('categories', $this->violationPaths($supplier));
+ }
+
+ public function testMultiTypeCategoryContainingFournisseurIsAccepted(): void
+ {
+ // RG-2.10 sous ManyToMany : une categorie qui PORTE FOURNISSEUR (parmi
+ // d'autres types) reste autorisee sur un fournisseur.
+ $supplier = new Supplier();
+ $supplier->setCompanyName('Recycla SAS');
+ $supplier->addCategory($this->category('CLIENT', 'FOURNISSEUR'));
+
+ self::assertNotContains('categories', $this->violationPaths($supplier));
+ }
+
+ // === RG-2.07 : Virement impose une banque ===
+
+ public function testVirementWithoutBankIsRejectedOnBankPath(): void
+ {
+ $supplier = $this->validSupplier();
+ $supplier->setPaymentType($this->paymentType('VIREMENT'));
+
+ self::assertContains('bank', $this->violationPaths($supplier));
+ }
+
+ public function testVirementWithBankPasses(): void
+ {
+ $supplier = $this->validSupplier();
+ $supplier->setPaymentType($this->paymentType('VIREMENT'));
+ $supplier->setBank(new Bank());
+
+ self::assertNotContains('bank', $this->violationPaths($supplier));
+ }
+
+ // === RG-2.08 : LCR impose au moins un RIB ===
+
+ public function testLcrWithoutRibIsRejectedOnRibsPath(): void
+ {
+ $supplier = $this->validSupplier();
+ $supplier->setPaymentType($this->paymentType('LCR'));
+
+ self::assertContains('ribs', $this->violationPaths($supplier));
+ }
+
+ public function testLcrWithRibPasses(): void
+ {
+ $supplier = $this->validSupplier();
+ $supplier->setPaymentType($this->paymentType('LCR'));
+ $supplier->addRib(new SupplierRib());
+
+ self::assertNotContains('ribs', $this->violationPaths($supplier));
+ }
+
+ public function testNeutralPaymentTypeRequiresNeitherBankNorRib(): void
+ {
+ // Un type de reglement neutre (ni VIREMENT ni LCR) n'exige ni banque ni RIB.
+ $supplier = $this->validSupplier();
+ $supplier->setPaymentType($this->paymentType('CHEQUE'));
+
+ $paths = $this->violationPaths($supplier);
+ self::assertNotContains('bank', $paths);
+ self::assertNotContains('ribs', $paths);
+ }
+
+ /**
+ * Fournisseur valide (nom + 1 categorie FOURNISSEUR), sans onglet
+ * Comptabilite renseigne : sert de base aux tests RG-2.07/2.08.
+ */
+ private function validSupplier(): Supplier
+ {
+ $supplier = new Supplier();
+ $supplier->setCompanyName('Recycla SAS');
+ $supplier->addCategory($this->category('FOURNISSEUR'));
+
+ return $supplier;
+ }
+
+ /**
+ * @return list propertyPaths des violations levees par le validator
+ */
+ private function violationPaths(Supplier $supplier): array
+ {
+ $paths = [];
+ foreach ($this->validator->validate($supplier) as $violation) {
+ $paths[] = $violation->getPropertyPath();
+ }
+
+ return $paths;
+ }
+
+ /**
+ * Double minimal de CategoryInterface (pas d'acces base) PORTANT les codes de
+ * type voulus — seul element regarde par validateCategoryType. Variadic pour
+ * couvrir le cas multi-types (ManyToMany).
+ *
+ * @return list n'est pas le type de retour : helper renvoyant un double
+ */
+ private function category(string ...$typeCodes): CategoryInterface
+ {
+ return new class(array_values($typeCodes)) implements CategoryInterface {
+ /** @param list $typeCodes */
+ public function __construct(private readonly array $typeCodes) {}
+
+ public function getId(): ?int
+ {
+ return 1;
+ }
+
+ public function getName(): ?string
+ {
+ return 'Categorie test';
+ }
+
+ public function getCode(): ?string
+ {
+ return 'TEST';
+ }
+
+ /** @return list */
+ public function getCategoryTypeCodes(): array
+ {
+ return $this->typeCodes;
+ }
+ };
+ }
+
+ private function paymentType(string $code): PaymentType
+ {
+ $type = new PaymentType();
+ $type->setCode($code);
+ $type->setLabel($code);
+
+ return $type;
+ }
+}
diff --git a/tests/Module/Commercial/Unit/SupplierInformationCompletenessValidatorTest.php b/tests/Module/Commercial/Unit/SupplierInformationCompletenessValidatorTest.php
new file mode 100644
index 0000000..40cc545
--- /dev/null
+++ b/tests/Module/Commercial/Unit/SupplierInformationCompletenessValidatorTest.php
@@ -0,0 +1,129 @@
+completeSupplier();
+
+ $this->validator()->validate($supplier);
+
+ // Aucune exception levee : la completude est satisfaite.
+ $this->addToAssertionCount(1);
+ }
+
+ public function testEmptyInformationListsEveryMissingField(): void
+ {
+ $supplier = new Supplier();
+ $supplier->setCompanyName('Recycla SAS'); // onglet principal, hors Information
+
+ try {
+ $this->validator()->validate($supplier);
+ self::fail('Une ValidationException etait attendue (onglet Information vide).');
+ } catch (ValidationException $e) {
+ $paths = [];
+ foreach ($e->getConstraintViolationList() as $violation) {
+ $paths[] = $violation->getPropertyPath();
+ }
+
+ // Les 8 champs Information (dont volumeForecast, NEW vs Client) sont
+ // tous signales d'un coup, chacun sous son propre propertyPath.
+ sort($paths);
+ self::assertSame([
+ 'competitors',
+ 'description',
+ 'directorName',
+ 'employeesCount',
+ 'foundedAt',
+ 'profitAmount',
+ 'revenueAmount',
+ 'volumeForecast',
+ ], $paths);
+ }
+ }
+
+ public function testPartialInformationReportsOnlyMissingFields(): void
+ {
+ $supplier = $this->completeSupplier();
+ $supplier->setDirectorName(null);
+ $supplier->setVolumeForecast(null);
+
+ try {
+ $this->validator()->validate($supplier);
+ self::fail('Une ValidationException etait attendue (2 champs manquants).');
+ } catch (ValidationException $e) {
+ $paths = [];
+ foreach ($e->getConstraintViolationList() as $violation) {
+ $paths[] = $violation->getPropertyPath();
+ }
+
+ sort($paths);
+ self::assertSame(['directorName', 'volumeForecast'], $paths);
+ }
+ }
+
+ public function testZeroNumericValuesAreNotMissing(): void
+ {
+ // employeesCount = 0, profitAmount = "0.00", volumeForecast = 0 sont des
+ // valeurs valides (un zero n'est pas une absence) -> pas de violation.
+ $supplier = $this->completeSupplier();
+ $supplier->setEmployeesCount(0);
+ $supplier->setProfitAmount('0.00');
+ $supplier->setVolumeForecast(0);
+
+ $this->validator()->validate($supplier);
+
+ $this->addToAssertionCount(1);
+ }
+
+ public function testBlankStringIsMissing(): void
+ {
+ // Une chaine vide apres trim compte comme manquante.
+ $supplier = $this->completeSupplier();
+ $supplier->setDescription(' ');
+
+ $this->expectException(ValidationException::class);
+ $this->validator()->validate($supplier);
+ }
+
+ /**
+ * Fournisseur dont l'onglet Information est entierement renseigne.
+ */
+ private function completeSupplier(): Supplier
+ {
+ $supplier = new Supplier();
+ $supplier->setCompanyName('Recycla SAS');
+ $supplier->setDescription('Specialiste du recyclage');
+ $supplier->setCompetitors('Concurrent A, Concurrent B');
+ $supplier->setFoundedAt(new DateTimeImmutable('2010-01-01'));
+ $supplier->setEmployeesCount(42);
+ $supplier->setRevenueAmount('1000000.00');
+ $supplier->setDirectorName('Marie Durand');
+ $supplier->setProfitAmount('150000.00');
+ $supplier->setVolumeForecast(5000);
+
+ return $supplier;
+ }
+
+ private function validator(): SupplierInformationCompletenessValidator
+ {
+ return new SupplierInformationCompletenessValidator();
+ }
+}
diff --git a/tests/Module/Commercial/Unit/SupplierProcessorTest.php b/tests/Module/Commercial/Unit/SupplierProcessorTest.php
new file mode 100644
index 0000000..250ae0a
--- /dev/null
+++ b/tests/Module/Commercial/Unit/SupplierProcessorTest.php
@@ -0,0 +1,244 @@
+ 422, meme
+ // sur un POST (les champs Information n'y sont pas renseignables).
+ $supplier = $this->minimalSupplier();
+ $supplier->setDescription('Une description'); // les autres champs Information restent null
+
+ $processor = $this->makeProcessor(
+ payload: ['description' => 'Une description'],
+ user: $this->commercialeUser(),
+ );
+
+ $this->expectException(ValidationException::class);
+ $processor->process($supplier, $this->operation());
+ }
+
+ public function testCommercialeIncompleteInformationOnMainOnlyPatchIsUnprocessable(): void
+ {
+ // RG-2.03 : pour une Commerciale, la completude Information est exigee
+ // meme quand le payload ne touche PAS l'onglet Information (ici
+ // companyName seul) -> 422.
+ $supplier = $this->minimalSupplier();
+ $supplier->setCompanyName('Renamed Co');
+
+ $processor = $this->makeProcessor(
+ granted: ['commercial.suppliers.manage'],
+ payload: ['companyName' => 'Renamed Co'],
+ user: $this->commercialeUser(),
+ managed: true,
+ originalData: [
+ 'companyName' => 'TEST CO',
+ 'isArchived' => false,
+ ],
+ );
+
+ $this->expectException(ValidationException::class);
+ $processor->process($supplier, $this->operation());
+ }
+
+ public function testCommercialeCompleteInformationPasses(): void
+ {
+ // RG-2.03 satisfaite : tous les champs Information renseignes -> 200.
+ $supplier = $this->completeInformationSupplier();
+
+ $processor = $this->makeProcessor(
+ granted: ['commercial.suppliers.manage'],
+ payload: ['description' => 'desc'],
+ user: $this->commercialeUser(),
+ );
+
+ self::assertInstanceOf(Supplier::class, $processor->process($supplier, $this->operation()));
+ }
+
+ public function testNonCommercialeSkipsInformationCompleteness(): void
+ {
+ // Meme onglet Information incomplet, mais user non-Commerciale -> aucun
+ // blocage (la completude est specifique a la Commerciale).
+ $supplier = $this->minimalSupplier();
+ $supplier->setDescription('Une description');
+
+ $processor = $this->makeProcessor(
+ payload: ['description' => 'Une description'],
+ user: null,
+ );
+
+ self::assertInstanceOf(Supplier::class, $processor->process($supplier, $this->operation()));
+ }
+
+ public function testAdminIncompleteInformationPasses(): void
+ {
+ // Distinct du cas user=null : un utilisateur AUTHENTIFIE mais non-Commerciale
+ // (ici un admin, BusinessRoleAwareInterface renvoyant false pour tout role
+ // metier) n'est pas soumis a la completude Information -> 200 malgre un
+ // onglet Information incomplet. Prouve que le gate porte bien sur le ROLE
+ // metier Commerciale, et pas sur « il y a un utilisateur connecte ».
+ $supplier = $this->minimalSupplier();
+ $supplier->setDescription('Une description');
+
+ $processor = $this->makeProcessor(
+ payload: ['description' => 'Une description'],
+ user: $this->adminUser(),
+ );
+
+ self::assertInstanceOf(Supplier::class, $processor->process($supplier, $this->operation()));
+ }
+
+ /**
+ * @param list $granted Permissions accordees a l'utilisateur courant
+ * @param array $payload Corps JSON simule de la requete
+ * @param bool $managed true = entite geree par l'ORM (PATCH), false = creation (POST)
+ * @param array $originalData Etat persiste simule (getOriginalEntityData)
+ */
+ private function makeProcessor(
+ array $granted = [],
+ array $payload = [],
+ ?UserInterface $user = null,
+ bool $managed = false,
+ array $originalData = [],
+ ): SupplierProcessor {
+ $persist = new class implements ProcessorInterface {
+ public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
+ {
+ return $data;
+ }
+ };
+
+ $security = $this->createStub(Security::class);
+ $security->method('isGranted')->willReturnCallback(
+ static fn (mixed $attribute): bool => is_string($attribute) && in_array($attribute, $granted, true),
+ );
+ $security->method('getUser')->willReturn($user);
+
+ $requestStack = new RequestStack();
+ $requestStack->push(new Request([], [], [], [], [], [], json_encode($payload, JSON_THROW_ON_ERROR)));
+
+ $uow = $this->createMock(UnitOfWork::class);
+ $uow->method('getOriginalEntityData')->willReturn($originalData);
+
+ $em = $this->createMock(EntityManagerInterface::class);
+ $em->method('contains')->willReturn($managed);
+ $em->method('getUnitOfWork')->willReturn($uow);
+
+ return new SupplierProcessor(
+ $persist,
+ new SupplierFieldNormalizer(),
+ new SupplierInformationCompletenessValidator(),
+ $security,
+ $requestStack,
+ $em,
+ );
+ }
+
+ private function minimalSupplier(): Supplier
+ {
+ $supplier = new Supplier();
+ $supplier->setCompanyName('Test Co');
+
+ return $supplier;
+ }
+
+ private function completeInformationSupplier(): Supplier
+ {
+ $supplier = $this->minimalSupplier();
+ $supplier->setDescription('desc');
+ $supplier->setCompetitors('concurrents');
+ $supplier->setFoundedAt(new DateTimeImmutable('2010-01-01'));
+ $supplier->setEmployeesCount(10);
+ $supplier->setRevenueAmount('1000.00');
+ $supplier->setDirectorName('Marie Durand');
+ $supplier->setProfitAmount('100.00');
+ $supplier->setVolumeForecast(500);
+
+ return $supplier;
+ }
+
+ private function operation(): Operation
+ {
+ return $this->createStub(Operation::class);
+ }
+
+ /**
+ * Utilisateur authentifie non-Commerciale (profil admin) : porte
+ * BusinessRoleAwareInterface mais ne reconnait aucun role metier. Sert a
+ * distinguer « pas de role Commerciale » de « pas d'utilisateur » (null).
+ */
+ private function adminUser(): UserInterface
+ {
+ return new class implements UserInterface, BusinessRoleAwareInterface {
+ public function hasBusinessRole(string $roleCode): bool
+ {
+ return false;
+ }
+
+ public function getRoles(): array
+ {
+ return ['ROLE_ADMIN'];
+ }
+
+ public function eraseCredentials(): void {}
+
+ public function getUserIdentifier(): string
+ {
+ return 'admin-test';
+ }
+ };
+ }
+
+ private function commercialeUser(): UserInterface
+ {
+ return new class implements UserInterface, BusinessRoleAwareInterface {
+ public function hasBusinessRole(string $roleCode): bool
+ {
+ return BusinessRoles::COMMERCIALE === $roleCode;
+ }
+
+ public function getRoles(): array
+ {
+ return ['ROLE_USER'];
+ }
+
+ public function eraseCredentials(): void {}
+
+ public function getUserIdentifier(): string
+ {
+ return 'commerciale-test';
+ }
+ };
+ }
+}
diff --git a/tests/Module/Core/Api/AbstractApiTestCase.php b/tests/Module/Core/Api/AbstractApiTestCase.php
index 9f7bcc2..a258f32 100644
--- a/tests/Module/Core/Api/AbstractApiTestCase.php
+++ b/tests/Module/Core/Api/AbstractApiTestCase.php
@@ -90,6 +90,26 @@ abstract class AbstractApiTestCase extends ApiTestCase
* @return array{username: string, password: string} Les identifiants pour authenticatedClient()
*/
protected function createUserWithPermission(string $permissionCode): array
+ {
+ return $this->createUserWithPermissions([$permissionCode]);
+ }
+
+ /**
+ * Variante multi-permissions de {@see createUserWithPermission()} : cree un
+ * utilisateur non-admin portant PLUSIEURS permissions via un unique role
+ * jetable. Utile pour prouver qu'une combinaison precise de permissions
+ * (sans le bypass admin) suffit a debloquer un comportement — ex. la colonne
+ * SIREN de l'export, gatee par accounting.view EN PLUS de suppliers.view.
+ *
+ * Memes garanties que le singulier : suffixe aleatoire, password "testpass",
+ * rattachement a tous les sites, echec explicite si une permission est
+ * introuvable en base.
+ *
+ * @param list $permissionCodes codes des permissions a accorder
+ *
+ * @return array{username: string, password: string} identifiants pour authenticatedClient()
+ */
+ protected function createUserWithPermissions(array $permissionCodes): array
{
if (!self::$kernel) {
self::bootKernel();
@@ -97,17 +117,6 @@ abstract class AbstractApiTestCase extends ApiTestCase
$em = $this->getEm();
- /** @var null|Permission $permission */
- $permission = $em->getRepository(Permission::class)->findOneBy(['code' => $permissionCode]);
-
- self::assertNotNull(
- $permission,
- sprintf(
- 'Permission "%s" introuvable en base. Assurez-vous que `app:sync-permissions` a ete execute.',
- $permissionCode,
- ),
- );
-
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
$username = 'testuser_'.$suffix;
$password = 'testpass';
@@ -116,7 +125,22 @@ abstract class AbstractApiTestCase extends ApiTestCase
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$role = new Role('test_'.$suffix, 'Test Role '.$suffix, false);
- $role->addPermission($permission);
+
+ foreach ($permissionCodes as $permissionCode) {
+ /** @var null|Permission $permission */
+ $permission = $em->getRepository(Permission::class)->findOneBy(['code' => $permissionCode]);
+
+ self::assertNotNull(
+ $permission,
+ sprintf(
+ 'Permission "%s" introuvable en base. Assurez-vous que `app:sync-permissions` a ete execute.',
+ $permissionCode,
+ ),
+ );
+
+ $role->addPermission($permission);
+ }
+
$em->persist($role);
$user = new User();