---
# === IDENTITÉ ===
module: M2
nom: "Répertoire fournisseurs"
ecran: repertoire-fournisseurs
owner_spec: Matthieu
backup_spec: Tristan
version: V0.2
# Historique : V0.2 (2026-06-03) — Refonte contact : suppression du contact inline du Supplier
#   (firstName/lastName/phonePrimary/phoneSecondary/email retirés). Contacts uniquement dans
#   SupplierContact. Aligné sur M1. Cf. docs/specs/M1-clients/refonte-contact/README.md
date_redaction: 2026-06-02

# === LIENS ===
spec_front: ./spec-front.md
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-36987&p=f&m=dev"

# === LIEN LESSTIME ===
lesstime_taskgroup_id: 26   # M2 — Répertoire fournisseurs (projet STARSEED #6)
lesstime_project_id: 6
statut_global: a_dev

# === DÉPENDANCES AMONT ===
depend_de:
  - M1-clients      # référentiels comptables (TvaMode/PaymentDelay/PaymentType/Bank) + pattern Client* réutilisé
  - M0-categories   # Category + CategoryType (étendu par seed M2 : type FOURNISSEUR)
  - Sites           # SitesModule + 3 sites seedés (86 / 17 / 82) déjà en place
  - Core            # User, Role, Permission, Audit, JWT déjà en place
  - Shared          # TimestampableBlamableTrait + Subscriber (ERP-52)
---

# Spec back — Module 2 : Répertoire fournisseurs

## 1. Contexte

Cette spec **complète et précise** la [spec front V0.1](./spec-front.md) (`M2-reportoire-fournisseurs.docx` du 01/06/2026, historique V0 22/05 → V0.1 01/06) avec tout ce qui touche au back : décisions d'archi, modèle de données, migration, API REST, RBAC, règles de gestion, tests, hors-périmètre.

**Module cible** : extension du module `Commercial` existant (`src/Module/Commercial/`), aux côtés des Clients (M1). Le M2 est la **deuxième sous-section métier Tiers** du Commercial (Fournisseurs), construite sur le **pattern jumeau de `Client`** déjà éprouvé au M1 (`Supplier` / `SupplierContact` / `SupplierAddress` / `SupplierRib`).

**Dépendances déjà en place sur `develop`** (héritées du M1) :
- `Commercial` → `Client*` + 4 référentiels comptables `TvaMode` / `PaymentDelay` / `PaymentType` / `Bank` (entités lecture seule, déjà seedées — **partagées sans duplication** par le M2).
- `Catalog` (M0) → `Category` + `CategoryType` (le M2 ajoute le type `FOURNISSEUR`).
- `Sites` → 3 sites Châtellerault (86) / Saint-Jean (17) / Pommevic (82).
- `Shared` → `TimestampableBlamableTrait` + `Subscriber` (ERP-52).
- `Core` → User, Role, Permission, Audit, JWT.

## 2. Décisions d'archi

### 2.1 Module — Extension de `Commercial`, entités jumelles de `Client`

Le fournisseur M2 vit sous `src/Module/Commercial/` (déjà existant). Pas de nouveau module `Suppliers`. Rationale identique au M1 :

- Cohérence MALIO : `Commercial` = couche **Tiers** (Clients + Fournisseurs + Prestataires).
- Le M1 a déjà posé le pattern `Client` / `ClientContact` / `ClientAddress` / `ClientRib` + Provider/Processor + normalisation + archivage. Le M2 le **réplique à l'identique** sous `Supplier*` (décision : tables dédiées, pas de table polymorphe partagée — clients et fournisseurs divergeront fonctionnellement, l'isolation prime).
- La sidebar porte déjà l'item `suppliers` → `/suppliers` (sans permission). Le M2 lui attache `commercial.suppliers.view`.

Le `CommercialModule.php` actuel expose déjà les 5 permissions `commercial.clients.*`. Le M2 **ajoute 5 permissions `commercial.suppliers.*`** (cf. § 5.1).

### 2.2 IDs entier auto-increment Postgres natif

Cohérent avec M0/M1 et l'ensemble Starseed. Pas d'UUID, pas de ULID.

### 2.3 Référentiels comptables — réutilisation M1 (zéro duplication)

Les 4 tables `tva_mode` / `payment_delay` / `payment_type` / `bank` (+ leurs entités lecture seule et leurs seeds) sont **celles du M1**. Le M2 ne crée **aucune** nouvelle table de référentiel comptable : `supplier.tva_mode_id`, `supplier.payment_delay_id`, `supplier.payment_type_id`, `supplier.bank_id` pointent vers les mêmes tables.

Conséquence sur les endpoints : `GET /api/tva_modes`, `/api/payment_delays`, `/api/payment_types`, `/api/banks` existent déjà (M1). La seule évolution : leur `security` doit autoriser **aussi** les rôles fournisseurs (cf. § 4.7).

> **Confirmé sur le JSON réel (02/06)** : les formes sont conformes (`id`/`code`/`label`/`position`). Les codes pivots `VIREMENT` et `LCR` (RG-2.07/2.08) existent bien dans `payment_types`. **Nuance** : `tva_modes` ne contient que des modes « ventes » (`FRANCE_VENTES`/`EXPORT_VENTES`/`INTRACOM_VENTES`). La spec fonctionnelle (docx) dit seulement « Mode de TVA — liste depuis une table », **sans** distinguer achats/ventes → au M2 on **réutilise les modes existants** (pas de seed « achats »). Point à confirmer avec le métier : si un mode « achats » est requis pour les fournisseurs, l'ajouter via un seed (référentiel partagé). Tracé en HP-M3-2.

### 2.4 Catégories — nouveau `CategoryType` `FOURNISSEUR`

Le multi-select « Catégorie » du fournisseur référence des `Category` rattachées à un **nouveau `CategoryType` de code `FOURNISSEUR`** (label « Fournisseur »), seedé par le M2. Décision Matthieu (02/06) : on assume des **types distincts** (`CLIENT` / `FOURNISSEUR`, et `PRESTA` à venir) — chacun avec sa taxonomie. Rationale : les catégories clients (Agro-alimentaire…) ne sont pas valides pour un fournisseur (Négociant, Coopérative…).

> ⚠️ **CONSTAT JSON RÉEL (02/06) — brique manquante à construire** : la refonte **ERP-78 a unifié sur un type unique `CLIENT`** et **le filtre `?typeCode=` est INOPÉRANT** (`GET /api/categories?typeCode=FOURNISSEUR` renvoie les 11 catégories CLIENT, filtre ignoré ; `GET /api/category_types` → un seul type `CLIENT`). Donc le M2 doit :
> 1. **recréer** un `CategoryType` `FOURNISSEUR` (seed migration + fixture idempotente) ;
> 2. **implémenter** un vrai filtre `?typeCode=` sur `/api/categories` (module Catalog) — il n'existe pas en prod ;
> 3. seeder les catégories fournisseurs (Négociant, Coopérative…) sous ce type.
> → matérialisé en **ticket back dédié** (cf. § Tickets). Réintroduit volontairement le multi-type qu'ERP-78 avait retiré.

> ⚠️ **Forme réelle de `Category`** : expose `code` **et `name`** (PAS `label`) sous `category:read`, plus `categoryType{ id, code, label }`. Le **libellé affiché côté front = `category.name`**. Le M2M `supplier_category` / `supplier_address_category` ne contraint que des `Category` de type `FOURNISSEUR` (RG-2.10).

> **Pas d'auto-référence distributeur/courtier au M2** : contrairement au `Client`, le `Supplier` n'a pas de relation `distributor`/`broker`. On ne réimporte aucune classe d'un autre module : on consomme le contrat partagé / les read-groups de `Category`.

### 2.5 Archive vs soft delete — deux mécanismes distincts (identique M1)

| Mécanisme | Colonne | Visibilité défaut | Restauration | Utilisateur |
|---|---|---|---|---|
| **Archive** (fonctionnel) | `is_archived` (bool, default false) + `archived_at` | masqué | Oui (toggle UI) | **Admin seul** via `commercial.suppliers.archive` |
| **Soft delete** (technique) | `deleted_at` (timestamptz nullable) | masqué | HP M3+ | Aucun rôle au M2 (HP) |

Conséquences (miroir M1) :
- `DELETE /api/suppliers/{id}` **non exposé** au M2 (404 si appelé).
- `GET /api/suppliers?includeArchived=true` permet de voir les archivés (permission `commercial.suppliers.view`).
- PATCH `{ "isArchived": true }` archive ; PATCH `{ "isArchived": false }` restaure.
- L'unicité métier ignore les archivés ET les soft-deletés (cf. § 2.6).

> **Différence RBAC notable avec le docx** : le tableau « Rôles & permissions » du docx ne donne l'Archive qu'à **Admin** (Bureau/Compta/Commerciale = « Non »). On s'aligne strictement : `commercial.suppliers.archive` = Admin uniquement.

### 2.6 Unicité partielle Postgres — nom de société

> **Décision validée (Matthieu, 02/06/2026 — alignée sur la décision Q4 du M1)** : l'unicité métier porte **uniquement sur le nom de fournisseur** (`company_name`). Le SIREN et l'email principal ne sont **pas** uniques (un même SIREN peut couvrir plusieurs établissements ; un email peut servir plusieurs fiches).

Index unique partiel (`WHERE is_archived = FALSE AND deleted_at IS NULL`) sur `LOWER(company_name)`. Doublon → `409 Conflict` géré par le `SupplierProcessor`.

### 2.7 Audit & traces temporelles

Pattern Starseed standard, miroir M1 :
- `#[Auditable]` sur `Supplier`, `SupplierContact`, `SupplierAddress`, `SupplierRib`.
- **Tous les champs auditables** (pas d'`#[AuditIgnore]`) — y compris `SupplierRib.iban` et `SupplierRib.bic` (audit admin-only côté Starseed → traçabilité comptable, décision M1 reportée).
- Audit M2M automatique sur `supplier.categories` (`{categories: {added:[...], removed:[...]}}`).

### 2.8 Timestampable + Blamable

Toutes les entités métier nouvelles implémentent `TimestampableInterface` + `BlamableInterface` et utilisent `TimestampableBlamableTrait` : `Supplier`, `SupplierContact`, `SupplierAddress`, `SupplierRib`. Les référentiels partagés (`TvaMode`...) restent whitelistés dans `EntitiesAreTimestampableBlamableTest::EXCLUDED` (déjà fait au M1).

### 2.9 Permissions RBAC — granularité (5 permissions, identique M1)

| Permission | Admin | Bureau | Compta | Commerciale | Usine |
|---|---|---|---|---|---|
| `commercial.suppliers.view` | ✅ | ✅ | ✅ | ✅ (sauf compta) | ❌ |
| `commercial.suppliers.manage` | ✅ | ✅ | ❌ | ✅ | ❌ |
| `commercial.suppliers.accounting.view` | ✅ | ❌ | ✅ | ❌ | ❌ |
| `commercial.suppliers.accounting.manage` | ✅ | ❌ | ✅ | ❌ | ❌ |
| `commercial.suppliers.archive` | ✅ | ❌ | ❌ | ❌ | ❌ |

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). 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.

### 2.10 Validation incrémentale par onglet (workflow front-driven, identique M1)

Le `Supplier` est créé en BDD **dès validation du formulaire principal** via `POST /api/suppliers`. Les onglets suivants déclenchent des **PATCH partiels** avec des groupes de sérialisation dédiés :

- `supplier:write:main` — formulaire principal (POST + PATCH)
- `supplier:write:information` — onglet Information
- `supplier:write:contacts` — onglet Contact (sous-ressource `supplier_contact`)
- `supplier:write:addresses` — onglet Adresse (sous-ressource `supplier_address`)
- `supplier:write:accounting` — onglet Comptabilité (security séparée)
- `supplier:write:archive` — toggle archive (security `commercial.suppliers.archive`)

**Pas de state machine côté back** (pas de `status = draft|active`). Le fournisseur est actif dès POST réussi. La complétude des onglets est de la responsabilité du front.

### 2.11 Normalisation serveur des entrées texte (identique M1)

Réutilisation du même pattern que `ClientFieldNormalizer`, dupliqué en `SupplierFieldNormalizer` (service interne appelé par les Processors avant validation) :

```php
final class SupplierFieldNormalizer
{
    public function normalizeCompanyName(?string $v): ?string  // mb_strtoupper(trim)
    public function normalizePersonName(?string $v): ?string    // mb_convert_case TITLE
    public function normalizeEmail(?string $v): ?string          // mb_strtolower(trim)
    public function normalizePhone(?string $v): ?string          // preg_replace('/\D+/', '')
}
```

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 + 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 — **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é.

## 3. Modèle de données

### 3.1 Diagramme

```
+----------------------+      +--------------------------+      +--------------+
|     supplier         |--n:m-->|   supplier_category    |<--n:m--|  category    |
|                      |      +--------------------------+      | type=FOURNI. |
| id (PK)              |                                        +--------------+
| company_name         |
| (contact inline      |      +--------------------------+      +--------------+
|  retiré V1 —         |--1:n-->|   supplier_contact     |      |    site      |
|  firstName,          |      +--------------------------+      |  (Sites)     |
|  lastName, phones,   |                                        +--------------+
|  email)              |      +--------------------------+            ^
| is_archived          |--1:n-->|   supplier_address     |--n:m-------+
| archived_at          |      +--------------------------+
| deleted_at           |                  | (address_type radio)
| -- Information --     |                  +--n:m--+--> supplier_contact
| description           |                          |
| competitors           |      +--------------------------+   +-----------------+
| founded_at            |--1:n-->|   supplier_rib         |   |  tva_mode (M1)  |
| employees_count       |      +--------------------------+   |  payment_* (M1) |
| revenue_amount        |      label / bic / iban           |  bank (M1)      |
| director_name         |                                    +-----------------+
| profit_amount         |
| volume_forecast (NEW) |      -- Comptabilité (sur supplier) --
+----------------------+       siren / account_number / tva_mode_id /
                               n_tva / payment_delay_id / payment_type_id /
                               bank_id (nullable)
```

**Particularités M2 (différences vs `client`)** :
- **Pas** de `distributor_id` / `broker_id` (pas d'auto-référence), donc pas de contrainte CHECK distributor/broker.
- **Pas** de `triage_service` sur l'entité principale — le « Prestataire de triage » est porté **par l'adresse** (`supplier_address.triage_provider`).
- Ajout d'un champ Information **`volume_forecast`** (Volume prévisionnel — entier) absent du `client`.
- `supplier_address` remplace les 3 booléens M1 (`is_prospect`/`is_delivery`/`is_billing`) par **un seul champ enum `address_type`** (radio Prospect / Départ / Rendu — mutuellement exclusifs par construction). Plus de `billing_email` (pas d'email facturation au M2).
- `supplier_address` ajoute `bennes` (entier, nullable) et `triage_provider` (booléen).
- Les référentiels comptables (`tva_mode`...) **ne sont pas recréés** — FK vers les tables M1.

### 3.2 Migration Doctrine — SQL Postgres

Namespace : **`DoctrineMigrations` (racine `migrations/`)** — fichier `migrations/VersionYYYYMMDDHHMMSS.php` (à dater par le dev).

> **Même justification qu'au M1** : la migration crée un schéma avec **FK cross-module** (`user`, `category`, `site`, et FK vers les référentiels comptables M1). Le namespace modulaire casserait l'ordre (`make db-reset`) car Doctrine Migrations 3.x trie par FQCN alphabétique. → exception racine de la règle ABSOLUE n°11. Le seed du `CategoryType FOURNISSEUR` se fait **en deux endroits** (migration `ON CONFLICT` pour la prod + fixture idempotente pour survivre au purger en dev/test — cf. M1 § 3.3).

> **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, '<table>')`. 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)
-- =====================================================================
INSERT INTO category_type (code, label) VALUES ('FOURNISSEUR', 'Fournisseur')
    ON CONFLICT (code) DO NOTHING;

-- =====================================================================
-- Table principale `supplier`
-- =====================================================================
CREATE TABLE supplier (
    id                  SERIAL PRIMARY KEY,
    -- Formulaire principal
    company_name        VARCHAR(180) NOT NULL,
    -- Contact inline retiré (V1, refonte-contact) : first_name / last_name / phone_primary /
    -- phone_secondary / email vivent uniquement dans supplier_contact (onglet Contacts).
    -- Onglet Information (Commerciale obligatoire — RG-2.03 — null sinon)
    description         TEXT,
    competitors         VARCHAR(255),
    founded_at          DATE,
    employees_count     INT,
    revenue_amount      NUMERIC(15,2),
    director_name       VARCHAR(120),
    profit_amount       NUMERIC(15,2),
    volume_forecast     INT,                       -- NEW vs client
    -- Onglet Comptabilité (FK référentiels M1 — partagés)
    siren               VARCHAR(20),
    account_number      VARCHAR(40),
    tva_mode_id         INT REFERENCES tva_mode(id) ON DELETE RESTRICT,
    n_tva               VARCHAR(40),
    payment_delay_id    INT REFERENCES payment_delay(id) ON DELETE RESTRICT,
    payment_type_id     INT REFERENCES payment_type(id) ON DELETE RESTRICT,
    bank_id             INT REFERENCES bank(id) ON DELETE RESTRICT,
    -- Archive (exposé M2)
    is_archived         BOOLEAN NOT NULL DEFAULT FALSE,
    archived_at         TIMESTAMPTZ,
    -- Soft delete (préparé, non exposé au M2)
    deleted_at          TIMESTAMPTZ,
    -- Timestampable + Blamable
    created_at          TIMESTAMPTZ NOT NULL,
    updated_at          TIMESTAMPTZ NOT NULL,
    created_by          INT REFERENCES "user"(id) ON DELETE SET NULL,
    updated_by          INT REFERENCES "user"(id) ON DELETE SET NULL
);

CREATE INDEX idx_supplier_is_archived ON supplier(is_archived);
CREATE INDEX idx_supplier_deleted_at ON supplier(deleted_at);
CREATE INDEX idx_supplier_created_by ON supplier(created_by);
CREATE INDEX idx_supplier_updated_by ON supplier(updated_by);

-- Unicité métier (partielle : ignore archives + soft-delete) — nom de société uniquement (cf. § 2.6)
CREATE UNIQUE INDEX uq_supplier_company_name_active
    ON supplier (LOWER(company_name))
    WHERE is_archived = FALSE AND deleted_at IS NULL;

-- =====================================================================
-- M2M supplier ↔ category (catégories de type FOURNISSEUR — RG-2.10)
-- =====================================================================
CREATE TABLE supplier_category (
    supplier_id  INT NOT NULL REFERENCES supplier(id) ON DELETE CASCADE,
    category_id  INT NOT NULL REFERENCES category(id) ON DELETE RESTRICT,
    PRIMARY KEY (supplier_id, category_id)
);
CREATE INDEX idx_supplier_category_category ON supplier_category(category_id);

-- =====================================================================
-- Sous-collection : Contacts (1:n)
-- =====================================================================
CREATE TABLE supplier_contact (
    id              SERIAL PRIMARY KEY,
    supplier_id     INT NOT NULL REFERENCES supplier(id) ON DELETE CASCADE,
    first_name      VARCHAR(120),
    last_name       VARCHAR(120),
    job_title       VARCHAR(120),
    phone_primary   VARCHAR(20),
    phone_secondary VARCHAR(20),
    email           VARCHAR(180),
    position        INT NOT NULL DEFAULT 0,
    created_at      TIMESTAMPTZ NOT NULL,
    updated_at      TIMESTAMPTZ NOT NULL,
    created_by      INT REFERENCES "user"(id) ON DELETE SET NULL,
    updated_by      INT REFERENCES "user"(id) ON DELETE SET NULL,
    -- RG-2.04 : au moins Nom OU Prénom
    CONSTRAINT chk_supplier_contact_name
        CHECK (first_name IS NOT NULL OR last_name IS NOT NULL)
);
CREATE INDEX idx_supplier_contact_supplier ON supplier_contact(supplier_id);

-- =====================================================================
-- Sous-collection : Adresses (1:n)
-- =====================================================================
CREATE TABLE supplier_address (
    id                SERIAL PRIMARY KEY,
    supplier_id       INT NOT NULL REFERENCES supplier(id) ON DELETE CASCADE,
    -- Radio Prospect / Départ / Rendu (mutuellement exclusifs — RG-2.09)
    address_type      VARCHAR(20) NOT NULL,         -- 'PROSPECT' | 'DEPART' | 'RENDU'
    country           VARCHAR(80) NOT NULL DEFAULT 'France',
    postal_code       VARCHAR(20) NOT NULL,
    city              VARCHAR(120) NOT NULL,
    street            VARCHAR(255) NOT NULL,
    street_complement VARCHAR(255),
    bennes            INT,                            -- NEW (spécifique fournisseur)
    triage_provider   BOOLEAN NOT NULL DEFAULT FALSE, -- NEW (Prestataire de triage)
    position          INT NOT NULL DEFAULT 0,
    created_at        TIMESTAMPTZ NOT NULL,
    updated_at        TIMESTAMPTZ NOT NULL,
    created_by        INT REFERENCES "user"(id) ON DELETE SET NULL,
    updated_by        INT REFERENCES "user"(id) ON DELETE SET NULL,
    -- RG-2.09 : valeur enum contrôlée
    CONSTRAINT chk_supplier_address_type
        CHECK (address_type IN ('PROSPECT', 'DEPART', 'RENDU'))
);
CREATE INDEX idx_supplier_address_supplier ON supplier_address(supplier_id);

-- M2M supplier_address ↔ site (RG-2.06 : ≥ 1 site)
CREATE TABLE supplier_address_site (
    supplier_address_id INT NOT NULL REFERENCES supplier_address(id) ON DELETE CASCADE,
    site_id             INT NOT NULL REFERENCES site(id) ON DELETE RESTRICT,
    PRIMARY KEY (supplier_address_id, site_id)
);

-- M2M supplier_address ↔ supplier_contact
CREATE TABLE supplier_address_contact (
    supplier_address_id INT NOT NULL REFERENCES supplier_address(id) ON DELETE CASCADE,
    supplier_contact_id INT NOT NULL REFERENCES supplier_contact(id) ON DELETE CASCADE,
    PRIMARY KEY (supplier_address_id, supplier_contact_id)
);

-- M2M supplier_address ↔ category (catégorie d'adresse, type FOURNISSEUR — RG-2.10)
CREATE TABLE supplier_address_category (
    supplier_address_id INT NOT NULL REFERENCES supplier_address(id) ON DELETE CASCADE,
    category_id         INT NOT NULL REFERENCES category(id) ON DELETE RESTRICT,
    PRIMARY KEY (supplier_address_id, category_id)
);

-- =====================================================================
-- Sous-collection : RIB (1:n)
-- =====================================================================
CREATE TABLE supplier_rib (
    id          SERIAL PRIMARY KEY,
    supplier_id INT NOT NULL REFERENCES supplier(id) ON DELETE CASCADE,
    label       VARCHAR(120) NOT NULL,
    bic         VARCHAR(20) NOT NULL,
    iban        VARCHAR(34) NOT NULL,
    position    INT NOT NULL DEFAULT 0,
    created_at  TIMESTAMPTZ NOT NULL,
    updated_at  TIMESTAMPTZ NOT NULL,
    created_by  INT REFERENCES "user"(id) ON DELETE SET NULL,
    updated_by  INT REFERENCES "user"(id) ON DELETE SET NULL
);
CREATE INDEX idx_supplier_rib_supplier ON supplier_rib(supplier_id);
```

### 3.2.bis Commentaires SQL obligatoires (échantillon)

```php
$this->addSql("COMMENT ON TABLE supplier IS 'Répertoire fournisseurs (M2 Commercial) — entités archivables.'");
$this->addSql("COMMENT ON COLUMN supplier.company_name IS 'Raison sociale du fournisseur — stockée en MAJUSCULES. Unique parmi non-archivés/non-supprimés (RG-2.06).'");
$this->addSql("COMMENT ON COLUMN supplier.volume_forecast IS 'Volume prévisionnel (entier) — onglet Information. Obligatoire pour le rôle Commerciale (RG-2.03).'");
$this->addSql("COMMENT ON COLUMN supplier.payment_type_id IS 'Type de règlement — FK -> payment_type.id (référentiel partagé M1), ON DELETE RESTRICT. Pilote RG-2.07 (Banque) et RG-2.08 (RIB).'");
$this->addSql("COMMENT ON COLUMN supplier.bank_id IS 'Banque — FK -> bank.id (M1). Obligatoire ssi payment_type=VIREMENT (RG-2.07), null sinon.'");
$this->addSql("COMMENT ON COLUMN supplier_address.address_type IS 'Type d''adresse : PROSPECT | DEPART | RENDU (radio exclusif — RG-2.09).'");
$this->addSql("COMMENT ON COLUMN supplier_address.bennes IS 'Nombre de bennes sur le site fournisseur (entier nullable).'");
$this->addSql("COMMENT ON COLUMN supplier_address.triage_provider IS 'Le fournisseur est prestataire de triage sur cette adresse. Faux par défaut.'");
// + COMMENT ON COLUMN sur TOUTES les autres colonnes métier (cf. règle n°12)
$this->addStandardTimestampableBlamableComments($schema, 'supplier');
$this->addStandardTimestampableBlamableComments($schema, 'supplier_contact');
$this->addStandardTimestampableBlamableComments($schema, 'supplier_address');
$this->addStandardTimestampableBlamableComments($schema, 'supplier_rib');
```

### 3.3 Entité `Supplier` — squelette

```php
<?php

declare(strict_types=1);

namespace App\Module\Commercial\Domain\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Sites\Domain\Entity\Site; // référence ORM partagée (comme M1) — pas de logique inter-module
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\SupplierProcessor;
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Provider\SupplierProvider;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineSupplierRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
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\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;

#[ApiResource(
    operations: [
        new GetCollection(
            security: "is_granted('commercial.suppliers.view')",
            // 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).
            // ⚠ 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',
                'site:read',
                'default:read',
            ]],
            provider: SupplierProvider::class,
        ),
        new Get(
            security: "is_granted('commercial.suppliers.view')",
            // RETEX M1 §1/§2 : le DÉTAIL embarque les sous-collections (contacts,
            // adresses, ribs) ET leurs relations imbriquées. Les 3 maillons doivent
            // être présents : groupe sur la propriété (supplier:item:read), groupe
            // dans ce contexte, ET read-group de chaque entité imbriquée
            // (category:read, site:read) — sinon embed = IRI vide.
            normalizationContext: ['groups' => [
                'supplier:read',
                'supplier:item:read',        // embed contacts / addresses
                // ⚠ 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',
            ]],
            provider: SupplierProvider::class,
        ),
        new Post(
            security: "is_granted('commercial.suppliers.manage')",
            normalizationContext: ['groups' => ['supplier:read', 'default:read']],
            denormalizationContext: ['groups' => ['supplier:write:main']],
            processor: SupplierProcessor::class,
        ),
        new Patch(
            // 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' => [
                '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). Archivage via PATCH { isArchived: true }.
    ],
)]
#[ORM\Entity(repositoryClass: DoctrineSupplierRepository::class)]
#[ORM\Table(name: 'supplier')]
#[Auditable]
class Supplier implements TimestampableInterface, BlamableInterface
{
    use TimestampableBlamableTrait;

    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    #[Groups(['supplier:read'])]
    private ?int $id = null;

    #[ORM\Column(length: 180)]
    #[Assert\NotBlank(message: 'Le nom du fournisseur est obligatoire.', normalizer: 'trim')]
    #[Assert\Length(min: 2, max: 180, normalizer: 'trim')]
    #[Groups(['supplier:read', 'supplier:write:main'])]
    private ?string $companyName = null;

    // Contact inline retiré (V1, refonte-contact) : firstName / lastName / phonePrimary /
    // phoneSecondary / email ne sont plus portés par Supplier — ils vivent dans SupplierContact
    // (onglet Contacts). Garantie « ≥ 1 contact nommé » via RG-2.04 + RG-2.13.

    /** @var Collection<int, Category> Catégories de type FOURNISSEUR (RG-2.10) */
    // Embarquée en LISTE et DÉTAIL (cohérence M1/ERP-62). Collection bornée.
    // Maillon (c) : pour voir id/code/name, le contexte inclut 'category:read'.
    #[ORM\ManyToMany(targetEntity: Category::class)]
    #[ORM\JoinTable(name: 'supplier_category')]
    #[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')]
    #[Groups(['supplier:read', 'supplier:write:main'])]
    private Collection $categories;

    // === Sites agrégés pour la LISTE (colonne « Site » du répertoire) ===
    // Cohérence M1/ERP-62 : on EMBARQUE les Site (objets entiers). Renvoie les Site
    // dédoublonnés issus des adresses ; sérialisés via 'site:read' → name/postalCode/
    // city/color (⚠ Site N'A PAS de champ `code` : « 86/17/82 » = préfixe du postalCode,
    // libellé = `name`). Identique au Client.getSites() racine déjà en prod (fix #82).
    // ⚠ Fetch-join obligatoire (addresses.sites) côté repository — anti N+1 (§ 2.12).
    /** @return array<int, Site> */
    #[Groups(['supplier:read'])]
    public function getSites(): array
    {
        $sites = [];
        foreach ($this->addresses as $a) {
            foreach ($a->getSites() as $s) {
                $sites[$s->getId()] = $s; // dédoublonnage par id
            }
        }
        return array_values($sites);
    }

    // === Onglet Information ===
    #[ORM\Column(type: 'text', nullable: true)]
    #[Groups(['supplier:read', 'supplier:write:information'])]
    private ?string $description = null;

    #[ORM\Column(length: 255, nullable: true)]
    #[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]
    #[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)]
    #[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 prévisionnel
    #[ORM\Column(nullable: true)]
    #[Assert\PositiveOrZero]
    #[Groups(['supplier:read', 'supplier:write:information'])]
    private ?int $volumeForecast = null;

    // === Onglet Comptabilité (lecture/écriture conditionnées par permission — cf. M1) ===
    #[ORM\Column(length: 20, nullable: true)]
    #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
    private ?string $siren = null;

    #[ORM\Column(length: 40, nullable: true)]
    #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
    private ?string $accountNumber = null;

    #[ORM\ManyToOne(targetEntity: TvaMode::class)]
    #[ORM\JoinColumn(name: 'tva_mode_id', nullable: true, onDelete: 'RESTRICT')]
    #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
    private ?TvaMode $tvaMode = null;

    #[ORM\Column(length: 40, nullable: true)]
    #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
    private ?string $nTva = null;

    #[ORM\ManyToOne(targetEntity: PaymentDelay::class)]
    #[ORM\JoinColumn(name: 'payment_delay_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', 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', nullable: true, onDelete: 'RESTRICT')]
    #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
    private ?Bank $bank = null;

    // === Sous-collections — EMBARQUÉES dans le DÉTAIL (RETEX M1 §2) ===
    // Maillon (a) OBLIGATOIRE : sans #[Groups], jamais sérialisées (erreur n°1 du M1).
    // Embed borné dans le Get racine → ne viole pas la règle n°13 (pas une GetCollection exposée).
    // Édition via sous-ressources POST/PATCH/DELETE (cf. § 4.5).
    /** @var Collection<int, SupplierContact> */
    #[ORM\OneToMany(mappedBy: 'supplier', targetEntity: SupplierContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
    #[Groups(['supplier:item:read'])]
    private Collection $contacts;

    /** @var Collection<int, SupplierAddress> */
    #[ORM\OneToMany(mappedBy: 'supplier', targetEntity: SupplierAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
    #[Groups(['supplier:item:read'])]
    private Collection $addresses;

    /** @var Collection<int, SupplierRib> RIB embarqués dans le groupe COMPTA (gated par le Provider) */
    #[ORM\OneToMany(mappedBy: 'supplier', targetEntity: SupplierRib::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
    #[Groups(['supplier:read:accounting'])]
    private Collection $ribs;

    // === Archive / Soft delete ===
    #[ORM\Column(name: 'is_archived', options: ['default' => false])]
    private bool $isArchived = false;

    // ⚠ PIÈGE BOOLÉEN (bug #3 du M1, cf. § 4.0.ter) : le #[Groups] DOIT être sur
    // le GETTER avec #[SerializedName] — sinon Symfony dérive l'attribut "archived"
    // (strip de "is") et droppe la clé "isArchived" du JSON. À tester sur JSON réel.
    #[Groups(['supplier:read', 'supplier:write:archive'])]
    #[SerializedName('isArchived')]
    public function isArchived(): bool
    {
        return $this->isArchived;
    }

    #[ORM\Column(type: 'datetime_immutable', nullable: true)]
    #[Groups(['supplier:read'])]
    private ?DateTimeImmutable $archivedAt = null;

    // NB : `updatedAt` (du TimestampableBlamableTrait) doit être exposé dans le
    // groupe `supplier:read` — il alimente la colonne « Dernière activité » du
    // datatable du répertoire (cf. spec-front).

    #[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();
    }

    // Getters / setters omis — pattern Starseed standard.
}
```

### 3.4 Squelettes des autres entités

Même pattern que les jumelles `Client*` (`#[Auditable]`, `TimestampableBlamableTrait`, FK `supplier_id`). **Chaque propriété affichée porte un read-group** (RETEX M1 §1 maillon (a)) :

**`SupplierContact`** — toutes les propriétés métier dans `['supplier:item:read', 'supplier:write:contacts']` :
`firstName`, `lastName`, `jobTitle`, `phonePrimary`, `phoneSecondary`, `email`, `id`. Embed sous `supplier.contacts` au détail ; éditables via la sous-ressource.

**`SupplierAddress`** — propriétés dans `['supplier:item:read', 'supplier:write:addresses']` :
`addressType` (enum string `PROSPECT|DEPART|RENDU`, `#[Assert\Choice]`), `country`, `postalCode`, `city`, `street`, `streetComplement`, `bennes` (int nullable), `triageProvider` (bool — ⚠ piège #3 : `#[Groups]` + `#[SerializedName('triageProvider')]` **sur le getter** `isTriageProvider()`/`getTriageProvider()`, sinon clé droppée), `id`. Relations imbriquées (maillon (c) — read-groups à inclure dans le contexte du `Get` racine) :
- M2M `sites` → `#[Groups(['supplier:item:read'])]` sur la propriété ; `Site` expose `id`/`name`/`postalCode`/`city`/`color` en `site:read` (**pas de `code`** — cf. § 2.4) (`Assert\Count(min:1)` — RG-2.06).
- M2M `contacts` → `#[Groups(['supplier:item:read'])]` ; embarque des `SupplierContact` (déjà en `supplier:item:read`).
- M2M `categories` → `#[Groups(['supplier:item:read'])]` ; `Category` expose `id`/`code`/`name` en `category:read` (libellé = `name` ; type FOURNISSEUR — RG-2.10).
**Pas** de `billingEmail`.

**`SupplierRib`** — propriétés dans `['supplier:read:accounting', 'supplier:write:accounting']` :
`label`, `bic`, `iban`, `id`. Embed sous `supplier.ribs` **uniquement** si l'user a `accounting.view` (le Provider gère le retrait du groupe). Aucun `#[AuditIgnore]` sur `iban`/`bic` (audit admin-only, décision M1 reportée).

> ⚠ **`Site` et `Category` appartiennent à d'autres modules** — on ne les importe pas pour de la logique ; on consomme leurs read-groups (`site:read`, `category:read`), confirmés sur le JSON réel : `Category` = `code` + **`name`** (pas `label`) ; `Site` = `name`/`postalCode`/`city`/`color` (**pas de `code`** ; « 86/17/82 » = préfixe `postalCode`). L'embed est pleinement matérialisé (fix M1 #82 OK). Côté Catalog, le **filtre `?typeCode=` reste à implémenter** (cf. § 2.4).

**Référentiels (`TvaMode`, `PaymentDelay`, `PaymentType`, `Bank`)** : **réutilisés du M1**, aucune nouvelle entité (cf. § 2.3). Embarqués dans les scalaires compta via `supplier:read:accounting` (id + label).

## 4. API REST (API Platform)

### 4.0 Contrat de sérialisation (RETEX M1 — section critique)

> **Leçon M1** : ~80 % des frictions venaient du contrat de sérialisation, pas du métier. Pour **chaque champ affiché** par le front (liste OU détail), les **3 maillons** doivent être prouvés ici : (a) groupe sur la propriété, (b) groupe dans le `normalizationContext` de l'opération, (c) read-group de l'entité imbriquée présent dans le contexte parent. Si un seul manque → champ vide / IRI.

**Contexte par opération** :

| Opération | `normalizationContext` (groupes) |
|---|---|
| `GetCollection` (liste) | `supplier:read` + `category:read` + `site:read` + `default:read` |
| `Get` (détail) | `supplier:read` + `supplier:item:read` + `supplier:read:accounting`¹ + `category:read` + `site:read` + `default:read` |

¹ `supplier:read:accounting` retiré par le `SupplierProvider` si l'user n'a pas `commercial.suppliers.accounting.view`.

**LISTE — champ datatable → maillons** :

| Champ affiché | Propriété (a) | Dans contexte liste (b) | Imbriqué (c) |
|---|---|---|---|
| Nom | `companyName` ∈ `supplier:read` | ✅ | — |
| Catégories | `categories` ∈ `supplier:read` (embed) | ✅ | `category:read` ✅ (code/**name**) |
| Site | `getSites()` ∈ `supplier:read` (embed, Site[] dédoublonné) | ✅ | `site:read` ✅ (**name**/postalCode, pas de code) |
| Dernière activité | `updatedAt` ∈ `supplier:read` | ✅ | — |

> Choix d'alignement M1/ERP-62 (§ 2.12) : la liste **embarque** `categories[]` (code/name) et `sites[]` (name/postalCode). Elle n'embarque pas `contacts`/`addresses` complets. **Fetch-joins obligatoires** (`categories`, `addresses.sites`) dans le repository pour éviter le N+1.

**DÉTAIL — champ → maillons** :

| Bloc / champ | Propriété (a) | Dans contexte détail (b) | Imbriqué (c) |
|---|---|---|---|
| Scalaires principaux + Information | `supplier:read` | ✅ | — |
| `categories[]` (id/code/name) | `categories` ∈ `supplier:read` | ✅ | `category:read` ✅ |
| `contacts[]` (5 champs) | `contacts` ∈ `supplier:item:read` | ✅ | propriétés `SupplierContact` ∈ `supplier:item:read` ✅ |
| `addresses[]` (scalaires) | `addresses` ∈ `supplier:item:read` | ✅ | propriétés `SupplierAddress` ∈ `supplier:item:read` ✅ |
| `addresses[].sites[]` | `sites` ∈ `supplier:item:read` | ✅ | `site:read` ✅ |
| `addresses[].categories[]` | `categories` ∈ `supplier:item:read` | ✅ | `category:read` ✅ |
| `addresses[].contacts[]` | `contacts` ∈ `supplier:item:read` | ✅ | propriétés `SupplierContact` ∈ `supplier:item:read` ✅ |
| 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 — RÉELLES, capturées ERP-92)

> **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?search=…` (liste, ADMIN — un membre) :
```json
{
  "@context": "/api/contexts/Supplier",
  "@id": "/api/suppliers",
  "@type": "Collection",
  "totalItems": 1,
  "member": [
    {
      "@id": "/api/suppliers/85",
      "@type": "Supplier",
      "id": 85,
      "companyName": "DOD59393F 862875",
      "categories": [
        {"@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": [
        {"@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"}
      ],
      "isArchived": false
    }
  ],
  "view": {"@id": "/api/suppliers?search=…", "@type": "PartialCollectionView"}
}
```

> 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/85` (détail — user avec `accounting.view`) :
```json
{
  "@context": "/api/contexts/Supplier",
  "@id": "/api/suppliers/85",
  "@type": "Supplier",
  "id": 85,
  "companyName": "DOD59393F 862875",
  "categories": [
    {"@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": "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/39", "@type": "SupplierContact", "id": 39, "firstName": "Marie", "lastName": "Martin",
     "jobTitle": "Responsable achats", "phonePrimary": "0612345678", "email": "marie.martin@seed.test"}
  ],
  "addresses": [
    {"@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": [
       {"@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"}]}
  ],
  "ribs": [
    {"@id": "/api/supplier_ribs/27", "@type": "SupplierRib", "id": 27, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606"}
  ],
  "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 : 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)

> Capture réelle du contrat M1 (clients) effectuée le 02/06/2026. Les 5 divergences ci-dessous sont **des bugs présents en prod sur le M1** ; chacune a une parade à appliquer/vérifier au M2. Tous sont des oublis silencieux du contrat de sérialisation (aucune erreur levée).

| # | Bug constaté sur M1 réel | Cause | Parade M2 |
|---|---|---|---|
| 1 | `categories[]` embarquées sous Client = `@id`/`@type`/`createdAt`/`updatedAt` seulement — **pas de `code` ni `name`** | `Category.code`/`name` portent **uniquement** `category:read`, absent du contexte de sérialisation du `Get` Client | LISTE **et** DÉTAIL : `category:read` est inclus dans le `normalizationContext` (§ 3.3 / § 2.12). **Test sur JSON réel** que `categories[].code` et `.name` sont présents en liste ET en détail. ✅ Confirmé OK sur M1 réel (fix). |
| 2 | `addresses[].sites[]` embarqués = `@id`/`@type` nu | `Site` expose ses champs sous `site:read`/`me:read`, absent du contexte Client | LISTE (via `getSites()`) **et** DÉTAIL : `site:read` inclus dans le contexte (§ 3.3 / § 2.12). **Fetch-joins** repository pour le N+1. ✅ **Fix M1 #82 confirmé OK** : `Site` embarqué entier (`name`/`postalCode`/`city`/`color` — pas de `code`). |
| 3 | 🔴 `ClientAddress.isProspect/isDelivery/isBilling` **totalement absents du JSON** alors que `is_delivery=TRUE` en base | Le `#[Groups]` est sur la **propriété** `isDelivery`, mais le **getter** `isDelivery()` n'a ni `#[Groups]` ni `#[SerializedName]` → Symfony dérive l'attribut `delivery` (strip du préfixe `is`) et **droppe** le champ | M2 a **éliminé ces 3 booléens** (remplacés par l'enum `addressType` string — RG-2.09, donc immunisé). MAIS pour **tout booléen restant** (`triageProvider`, `isArchived`), poser `#[Groups]` **+** `#[SerializedName('isX')]` **sur le getter** (cf. § 3.3) et le **tester sur le JSON réel**. |
| 4 | 🔴 `ribs[]` (label/bic/iban) **visibles par la Commerciale** (sans `accounting.view`) | `ClientRib` sous `client_rib:read`, présent **inconditionnellement** dans le contexte `Get` ; le context builder ne gate QUE les 7 scalaires de Client, pas les RIB | M2 met `ribs` dans le groupe **`supplier:read:accounting`** (§ 3.3) — le même groupe gaté/retiré par le `SupplierProvider`. **Test obligatoire** : Commerciale → `ribs` ABSENT (§ 8.1). |
| 5 | `member`/`totalItems`/`view` sans préfixe `hydra:` ; `updatedBy` en IRI `/api/me` | Forme JSON-LD d'API Platform 4.2 | Contrat documenté tel quel (§ 4.0.bis). Le front consomme `member`/`totalItems`/`view` (déjà géré par `usePaginatedList`). |

> **Dépendance confirmée sur le JSON réel (02/06)** : l'embed des `sites[]` (liste via `getSites()` ET détail via `addresses[].sites[]`) est **pleinement matérialisé** (fix M1 #82 OK). `site:read` expose `name`/`street`/`postalCode`/`city`/`color`/`fullAddress` — **il n'y a PAS de champ `code`** : le « 86/17/82 » de la maquette est le **préfixe du `postalCode`** (86100/17400/82400) et le libellé du site est `name` (Chatellerault/Saint-Jean/Pommevic). La spec front référence donc `name` + `postalCode`, jamais `Site.code`. Côté Catalog, le **filtre `?typeCode=` reste à implémenter** (§ 2.4) et le type `FOURNISSEUR` à recréer.

> **Règle de rédaction M2 (anti-régression)** : aucun champ n'est déclaré « exposé/embarqué » sans avoir été **vu dans un JSON réel**. Les tests fonctionnels assertent sur le **corps de réponse**, jamais sur l'annotation.

### 4.1 `GET /api/suppliers` — Liste

- **Security** : `is_granted('commercial.suppliers.view')`
- **Query params** (alimentent le panneau « Filtrer » du front — cf. spec-front) :
  - `includeArchived=true|false` (default `false`)
  - `categoryCode=<code>` (filtre les fournisseurs ayant ≥ 1 `Category` de ce code ; répétable pour multi-sélection)
  - `siteId=<id>` (filtre les fournisseurs ayant ≥ 1 adresse rattachée à ce site ; répétable — jointure `addresses.sites`)
  - `search=<text>` (fuzzy sur `companyName` + contacts liés `supplier_contact` (firstName / lastName / email) via LEFT JOIN groupé par `supplier.id` — décision D1, refonte-contact)
- **Tri par défaut** : `companyName ASC`
- **Pagination** : standard Starseed (règle ABSOLUE n°13) — Hydra activée, 10/page, `?pagination=false` pour les selects. `SupplierProvider` branché sur `ApiPlatform\Doctrine\Orm\Paginator`.
- **Fetch-joins (anti N+1, § 2.12)** : la requête de liste du `DoctrineSupplierRepository` pose des `leftJoin`+`addSelect` sur `categories` et `addresses.sites` (la pagination Doctrine reste correcte car ces relations sont des collections chargées via Paginator).
- **Réponse 200** (JSON-LD) : champs `supplier:read` + `categories[]` (code/name) / `sites[]` (name/postalCode) embarqués. Les champs `supplier:read:accounting` n'apparaissent que si l'user a `accounting.view`.
- **Codes** : `200` / `401` / `403`

### 4.2 `GET /api/suppliers/{id}` — Détail

- **Security** : `is_granted('commercial.suppliers.view')`
- **Comportement** : fournisseur + contacts + adresses + RIBs. Les champs `supplier:read:accounting` sont inclus seulement si `commercial.suppliers.accounting.view`.
- **Codes** : `200` / `404` / `401` / `403`

### 4.3 `POST /api/suppliers` — Création (formulaire principal)

- **Security** : `is_granted('commercial.suppliers.manage')`
- **Body** (groupe `supplier:write:main`) :
```json
{
  "companyName": "RECYCLA SAS",
  "categories": ["/api/categories/12", "/api/categories/15"]
}
```
- **Réponse 201** : le fournisseur créé avec son `id`. Le front enchaîne les PATCH par onglet.
- **Codes** :
  - `201` / `400` / `401` / `403`
  - `409 Conflict` si doublon de nom (`companyName` — RG-2.11). SIREN/email non uniques.
  - `422` : catégories vides ; catégorie hors type FOURNISSEUR (RG-2.10). _(RG-2.01 supprimée V1 — complétude contact via onglet Contacts : RG-2.04 / RG-2.13.)_

### 4.4 `PATCH /api/suppliers/{id}` — Modification

- **Security base** : `is_granted('commercial.suppliers.manage')`
- **Security additionnelle** (dans le `SupplierProcessor`) :
  - payload contenant un champ `supplier:write:accounting` → exige `commercial.suppliers.accounting.manage`
  - payload contenant `isArchived` → exige `commercial.suppliers.archive`
  - **mode strict** (RG-2.16) : payload mélangeant des groupes hors permissions → 403 sur tout le payload.
- **Body** : merge-patch+json, champs modifiés uniquement.
- **Codes** : `200` / `400` / `401` / `403` / `404` / `409` / `422`

### 4.5 Sous-ressources

**Contacts** : `POST /api/suppliers/{id}/contacts`, `PATCH /api/supplier_contacts/{id}`, `DELETE /api/supplier_contacts/{id}`.
- **Security** : `is_granted('commercial.suppliers.manage')`
- **RG-2.13** : au moins 1 bloc Contact valide (Nom OU Prénom) pour finaliser l'onglet côté front. Côté back, la collection peut rester vide (pas de state machine).

**Adresses** : `POST /api/suppliers/{id}/addresses`, `PATCH /api/supplier_addresses/{id}`, `DELETE /api/supplier_addresses/{id}`.
- **Security** : `is_granted('commercial.suppliers.manage')`
- Validations : `addressType ∈ {PROSPECT,DEPART,RENDU}` (RG-2.09) ; ≥ 1 site (RG-2.06) ; catégories de type FOURNISSEUR uniquement (RG-2.10) ; `postalCode` matche `^[0-9]{4,5}$` (RG-2.05).

**RIBs** : `POST /api/suppliers/{id}/ribs`, `PATCH /api/supplier_ribs/{id}`, `DELETE /api/supplier_ribs/{id}`.
- **Security** : `is_granted('commercial.suppliers.accounting.manage')`
- **RG-2.08** : si `paymentType.code = LCR`, suppression du dernier RIB → 409.

### 4.6 `GET /api/suppliers/export.xlsx` — Export

- **Security** : `is_granted('commercial.suppliers.view')`
- **Comportement** : XLSX des fournisseurs **affichés** (mêmes filtres que la liste, non archivés par défaut).
- Colonnes : Nom fournisseur, Contact principal (Nom + Prénom), Téléphone principal, Téléphone secondaire, Email, Catégories (CSV), Sites (CSV), SIREN (omis si pas `accounting.view`), Date de création. _(V1, décision D2 : colonnes contact alimentées depuis le **contact principal** `supplier_contact` de plus petit `position` — plus de contact inline sur le Supplier.)_
- **Implémentation** : controller custom `SupplierExportController` avec `#[Route(priority: 1)]` (règle ABSOLUE — conflit API Platform `{id}`). Lib : PhpSpreadsheet (déjà présente, M1).
- **Réponse 200** : `Content-Disposition: attachment; filename="repertoire-fournisseurs-{YYYYMMDD}.xlsx"`

### 4.7 Référentiels (réutilisés M1 — évolution security)

`GET /api/tva_modes`, `/api/payment_delays`, `/api/payment_types`, `/api/banks` existent (M1). **Évolution M2** : élargir leur `security` pour autoriser aussi les rôles fournisseurs, p.ex. `is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')`. Tri `position ASC` puis `label ASC`. Pas d'écriture exposée (HP).

`GET /api/categories?typeCode=FOURNISSEUR` alimentera les multi-selects Catégorie (fournisseur + adresse). ⚠️ **Ce filtre n'existe pas en prod** (vérifié sur le JSON réel : `?typeCode=` est ignoré, seul le type CLIENT existe — ERP-78). Le M2 doit le **recréer** : type `FOURNISSEUR` + filtre `?typeCode=` sur `/api/categories` (module Catalog). Cf. § 2.4 + ticket dédié.

## 5. Autorisation

### 5.1 Déclaration des permissions

Enrichir `CommercialModule::permissions()` (ajout aux 5 permissions clients existantes) :

```php
// ... commercial.clients.* déjà présentes ...
['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'],
```

Synchronisation : `php bin/console app:sync-permissions`.

### 5.2 Mapping rôles MALIO ↔ permissions

Cf. § 2.9 (matrice détaillée — identique à la matrice M1 transposée sur `suppliers`).

### 5.3 Synchronisation RBAC (3 sources OBLIGATOIRES — règle ABSOLUE Starseed n°8)

1. **`config/sidebar.php`** — item « Répertoire fournisseurs » déjà présent (`to => '/suppliers'`), **à compléter** avec la permission :
```php
[
    'label'      => 'sidebar.commercial.suppliers',
    'to'         => '/suppliers',
    'icon'       => 'mdi:account-arrow-left-outline',
    'module'     => 'commercial',
    'permission' => 'commercial.suppliers.view',   // ← à ajouter
],
```

2. **`frontend/tests/e2e/_fixtures/personas.ts`** — étendre les personas existants :
   - Admin : `view` + `manage` + `accounting.view` + `accounting.manage` + `archive`
   - Bureau : `view` + `manage`
   - Compta : `view` + `accounting.view` + `accounting.manage`
   - Commerciale : `view` + `manage`
   - Usine : aucune

3. **`src/Module/Core/Infrastructure/Console/SeedE2ECommand.php`** — miroir back des mêmes personas.

> ⚠ Toute modif d'une de ces 3 sources sans les 2 autres = drift garanti (test cassé). Les 3 doivent être touchées dans le même commit.

### 5.4 Vérification front

- `usePermissions()` filtre l'item sidebar et masque l'onglet Comptabilité (`commercial.suppliers.accounting.view`).
- Bouton « Archiver » visible si `commercial.suppliers.archive` (Admin seul).

## 6. Audit & dates

- `Supplier`, `SupplierContact`, `SupplierAddress`, `SupplierRib` : `#[Auditable]`, tous champs audités (y compris `iban`/`bic` — cf. § 2.7).
- Audit M2M automatique sur `supplier.categories` → `{categories: {added:[...], removed:[...]}}`.
- Timestampable + Blamable : pattern Shared standard (cf. § 2.8).

## 7. Règles de gestion (RG)

Les RG-2.01 → RG-2.08 reprennent **mot pour mot** le docx source. Les RG-2.09 → RG-2.17 sont des **précisions back** (miroir M1) explicitement marquées.

### Formulaire principal

- ~~**RG-2.01**~~ _(SUPPRIMÉE — V1, 2026-06-03, refonte-contact)_ : le contact principal inline est retiré du `Supplier`. Garantie « au moins un contact nommé » portée par **RG-2.04** + **RG-2.13** sur `SupplierContact`.
- ~~**RG-2.02**~~ _(SUPPRIMÉE du Supplier — V1)_ : plus de téléphones inline sur le `Supplier`. Le « maximum 2 téléphones » reste applicable aux blocs `SupplierContact`.

### Onglet Information

- **RG-2.03** : Pour le rôle **Commerciale**, **tous** les champs de l'onglet Information (`description`, `competitors`, `foundedAt`, `employeesCount`, `revenueAmount`, `directorName`, `profitAmount`, `volumeForecast`) sont obligatoires sur **POST et tout PATCH**. Pour les autres rôles, optionnels. Validator custom `SupplierInformationCompletenessValidator` invoqué par le `SupplierProcessor` quand l'user porte le rôle Commerciale.
  - **Conséquence** (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 complète → la complétude se fait via les PATCH `supplier:write:information`. Un Admin (non gaté) crée normalement (201).

### Onglet Contact

- **RG-2.04** : Un bloc Contact est valide dès qu'**au moins** `firstName` OU `lastName` est rempli. CHECK BDD `chk_supplier_contact_name`. Côté UI, le bouton « + Nouveau contact » est bloqué tant que le bloc en cours n'a pas Prénom OU Nom.

### Onglet Adresse

- **RG-2.05** : `city` préremplie depuis `postalCode` via l'API **BAN** (api-adresse.data.gouv.fr), appel **direct front** via `useAddressAutocomplete()` (composable déjà créé au M1, réutilisé). L'adresse est une saisie assistée basée sur CP + ville. Cas dégradé (API down) : Ville en texte libre + toast. Validation serveur : `postalCode` matche `^[0-9]{4,5}$` ; pas de contrôle strict de cohérence CP/Ville.
- **RG-2.06** : Au moins un des 3 sites (86 / 17 / 82) doit être sélectionné sur chaque adresse. `Assert\Count(min: 1)` sur `supplierAddress.sites`.

### Onglet Comptabilité

- **RG-2.07** : Le champ `bank` est visible et obligatoire **uniquement** si `paymentType.code = 'VIREMENT'` (options SG / CIC / CA). Validation server-side dans le `SupplierProcessor` : `payment_type = VIREMENT` et `bank IS NULL` → 422.
- **RG-2.08** : Les champs RIB (`label`, `bic`, `iban`) sont obligatoires si `paymentType.code = 'LCR'`. C'est-à-dire :
  - `paymentType = LCR` ET `supplier.ribs.count() = 0` → 422 « Au moins un RIB est obligatoire pour le type LCR ».
  - DELETE du dernier RIB d'un fournisseur en LCR → 409.
  - Autres types : RIBs optionnels (0..n).

### Précisions back (miroir M1)

- **RG-2.09** _(précision back)_ : `address_type` est un **enum exclusif** `PROSPECT | DEPART | RENDU` (radio côté front, une seule valeur). CHECK BDD `chk_supplier_address_type`. Remplace les 3 booléens prospect/livraison/facturation du `client`.
- **RG-2.10** _(précision back)_ : les `Category` posées sur `supplier.categories` ET sur `supplier_address.categories` doivent être de **type `FOURNISSEUR`**. Toute catégorie d'un autre type → **422** (`categories: "Type de catégorie non autorisé (FOURNISSEUR attendu)."`). Front : les multi-selects sont alimentés par `GET /api/categories?typeCode=FOURNISSEUR`.
- **RG-2.11** _(précision back)_ : `companyName` unique (case-insensitive) parmi les fournisseurs non archivés ET non soft-deletés (index partiel `uq_supplier_company_name_active`). Doublon → 409 « Un fournisseur nommé "{companyName}" existe déjà. » SIREN et email **non** uniques (cf. § 2.6).
- **RG-2.12** _(normalisation serveur)_ : `companyName` **UPPERCASE** ; `firstName`/`lastName` (sur `SupplierContact` ; scope `Supplier` retiré en V1) **Capitalize** ; téléphones **chiffres uniquement** ; `email` **lowercase**. Formatage `XX XX XX XX XX` à l'affichage front.
- **RG-2.13** _(front-driven)_ : au moins 1 bloc Contact valide pour finaliser l'onglet (cf. RG-2.04). Pas de test back.
- **RG-2.14** _(archivage)_ : PATCH `{ "isArchived": true }` exige `commercial.suppliers.archive` (**Admin seul**). Pose `isArchived = true` + `archivedAt = now()`. Aucun autre champ dans la même requête.
- **RG-2.15** _(restauration)_ : PATCH `{ "isArchived": false }` exige la même permission. Pose `isArchived = false` + `archivedAt = null`. Conflit d'unicité (un autre fournisseur actif a pris le nom) → 409.
- **RG-2.16** _(PATCH mix de groupes, mode strict)_ : un PATCH mélangeant plusieurs groupes de sérialisation alors que l'user n'a pas toutes les permissions → **403 sur tout le payload** (pas de filtrage silencieux). Le front ne doit jamais envoyer de champ hors-permission (onglets masqués = pas de payload).
- **RG-2.17** _(liste / tri)_ : `GET /api/suppliers` exclut par défaut archivés (`is_archived = TRUE`) + soft-deletés (`deleted_at IS NOT NULL`). `?includeArchived=true` inclut les archivés (pas les soft-deletés). Tri par défaut `companyName ASC`.

## 8. Tests à automatiser

### 8.1 Cas à couvrir (back — PHPUnit)

- [ ] ~~RG-2.01~~ _(supprimée V1)_ : complétude contact couverte par RG-2.04 / RG-2.13 sur `SupplierContact`
- [ ] ~~RG-2.02~~ _(supprimée du Supplier V1)_ : téléphones inline retirés du Supplier (testés sur `SupplierContact`)
- [ ] **RG-2.03** : PATCH Information par Commerciale incomplet → 422 ; par Admin → 200 ; POST par Commerciale → 422 (Information non renseignable au POST)
- [ ] **RG-2.04** : POST contact sans firstName ni lastName → 422 (CHECK)
- [ ] **RG-2.05** : POST adresse `postalCode` invalide (3 chiffres) → 422 ; CP/ville incohérents → 200 (pas de contrôle strict)
- [ ] **RG-2.06** : POST adresse sans aucun site → 422
- [ ] **RG-2.07** : POST Comptabilité `paymentType=VIREMENT` sans `bank` → 422 ; avec `bank` → 200
- [ ] **RG-2.08** : POST `paymentType=LCR` sans RIB → 422 ; DELETE du dernier RIB en LCR → 409
- [ ] **RG-2.09** : POST adresse `addressType` hors enum → 422 (CHECK / Assert\Choice) ; les 3 valeurs valides → 200
- [ ] **RG-2.10** : POST `categories` avec une `Category` de type ≠ FOURNISSEUR → 422 (sur supplier ET sur supplier_address)
- [ ] **RG-2.11** : POST `companyName` déjà pris → 409 ; même nom après archivage de l'ancien → 201 ; SIREN/email dupliqués → 201
- [ ] **RG-2.12** : POST `companyName="recycla sas"` → persiste `"RECYCLA SAS"` ; normalisation `firstName`/`phonePrimary`/`email` testée via un bloc `SupplierContact` (`"MARIE"`→`"Marie"`, `"06.12.34.56.78"`→`"0612345678"`, `"Marie@RECYCLA.FR"`→`"marie@recycla.fr"`)
- [ ] **RG-2.14/15** : PATCH isArchived=true par Bureau (sans `archive`) → 403 ; par Admin → 200 + archivedAt rempli ; restauration en conflit de nom → 409
- [ ] **RG-2.16** : Bureau PATCH `{companyName, siren}` → 403 sur tout le payload (strict)
- [ ] **RG-2.17** : GET liste sans flag → exclut archivés ; `?includeArchived=true` → inclut ; tri `companyName ASC`
- [ ] **RBAC** : Bureau / Commerciale / Compta / Usine sur chaque permission (matrice § 2.9) — 200/403 selon le verbe
- [ ] **Compta** : GET fournisseur retourne les champs accounting ; PATCH accounting → 200 ; PATCH info/contacts/adresses → 403 ; POST création → 403 (pas de `manage` global)
- [ ] **Commerciale** : GET fournisseur **sans** les champs accounting ; onglet Comptabilité masqué
- [ ] **🔴 Gating RIB (bug #4 M1)** : GET détail en tant que Commerciale → la clé `ribs` est **ABSENTE** (assertion sur le corps JSON, pas sur l'annotation)
- [ ] **🔴 Sérialisation booléens (bug #3 M1)** : POST fournisseur + adresse `triageProvider=true`, fournisseur `isArchived` → GET détail expose bien les clés `triageProvider` et `isArchived` dans le JSON réel
- [ ] **Embed relations (bugs #1/#2 M1)** : GET **liste ET détail** → `categories[].code` + `.name` présents ; `sites[]` (liste, via `getSites()`) et `addresses[].sites[]` (détail) exposent `name` + `postalCode` (objet Site entier, PAS un IRI nu)
- [ ] **Filtre typeCode (brique à créer)** : `GET /api/categories?typeCode=FOURNISSEUR` ne renvoie QUE les catégories de type FOURNISSEUR (aujourd'hui le filtre est ignoré → test rouge tant que non implémenté)
- [ ] **Anti N+1 liste (§ 2.12)** : sur `GET /api/suppliers` avec N fournisseurs, compter les requêtes SQL — les fetch-joins (`categories`, `addresses.sites`) doivent éviter l'explosion (pas de requête par ligne)
- [ ] **Audit** : POST + PATCH + archive → audit_log `entity_type='Supplier'`, `changes` correct ; iban/bic présents dans le diff
- [ ] **Pagination** (règle n°13) : `GET /api/suppliers` renvoie l'envelope Hydra (`totalItems` / `view`) ; `?pagination=false` renvoie tout (alim. select)
- [ ] **Migration** : `make db-reset` → schéma OK ; namespace racine ; CategoryType FOURNISSEUR présent APRÈS db-reset (fixture idempotente) ; index partiel `uq_supplier_company_name_active` présent ; **toutes les colonnes ont un `COMMENT ON COLUMN`** (`ColumnsHaveSqlCommentTest` vert)

### 8.2 Cas à couvrir (front — Vitest)

- [ ] `useSuppliersRepository()` / `usePaginatedList({url:'/suppliers'})` : exclusion archivés par défaut, envelope Hydra
- [ ] `useSupplierForm()` : workflow par onglet (validation incrémentale, PATCH partiel)
- [ ] `useAddressAutocomplete()` : réutilisation M1 (cas nominal + dégradé) — pas de nouveau test si déjà couvert
- [ ] Radio `addressType` (Prospect/Départ/Rendu) : exclusivité, mapping enum
- [ ] `<SuppliersRepositoryPage>` : `<MalioDataTable>` + « + Ajouter » → `/suppliers/new`
- [ ] Permissions : Compta accède à `/suppliers/{id}` mais onglet Comptabilité éditable seul ; Commerciale ne voit pas l'onglet Comptabilité

### 8.3 Tests E2E

**Non prévus au M2** (règle ABSOLUE n°7). Extension des personas existants pour ajouter les permissions `commercial.suppliers.*` — cf. § 5.3.

### 8.4 Seed & fixtures démo (RETEX M1 §7 — prévu dès la spec)

Le M1 a subi un aller-retour (ERP-68) faute de fixtures alignées. Pour le M2, prévoir **dès le ticket migration/fixtures** un `SupplierFixtures` idempotent couvrant **tous les cas des RG**, pour vérifier le gating et le golden path sans bricolage :
- Catégories de type FOURNISSEUR seedées (`CategoryFixtures` étendu) — au moins « Négociant », « Coopérative ».
- ≥ 1 fournisseur **complet** (Information remplie, ≥ 1 contact, ≥ 1 adresse multi-sites, comptabilité + RIB).
- 1 fournisseur **en LCR avec RIB** (RG-2.08) et 1 **en VIREMENT avec banque** (RG-2.07).
- 1 fournisseur avec une adresse de chaque `addressType` (PROSPECT / DEPART / RENDU — RG-2.09).
- 1 fournisseur **archivé** (vérifier exclusion liste + restauration).
- Réutiliser les comptes de rôles démo existants (`bureau`, `compta`, `commerciale`, `usine`, `admin`) pour tester la matrice § 2.9.

> Idempotence obligatoire (le purger Doctrine vide `category`/`category_type` au `db-reset` — cf. M1 § 3.3). Le `CategoryType FOURNISSEUR` est seedé **en migration ET en fixture**.

### 8.5 Checklist RETEX (à cocher avant « spec prête »)

- [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**
- [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)
- [x] Seed/fixtures démo planifiés (§ 8.4)

## 9. Hors-périmètre (HP)

- **HP-M3-1** : **DELETE / soft delete d'un fournisseur** (colonne `deleted_at` préparée, non exposée au M2).
- **HP-M3-2** : **CRUD admin des référentiels comptables** (`TvaMode` / `PaymentDelay` / `PaymentType` / `Bank`) — partagés M1, seed seulement.
- **HP-M3-3** : **CRUD admin de `CategoryType`** (le M2 seed seulement le type FOURNISSEUR).
- **HP-M3-4** : **Onglet Transport** (front placeholder « À venir » — cf. spec-front ; aucun modèle ni API back).
- **HP-M3-5** : **Onglet Statistiques** (placeholder « À venir »).
- **HP-M3-6** : **Onglet Rapports** (placeholder « À venir »).
- **HP-M3-7** : **Onglet Échanges** (placeholder « À venir »).
- **HP-M3-8** : **Périmètre Commerciale** (« consultation selon périmètre » — formulation floue du docx). Au M2, Commerciale voit **tous** les fournisseurs (sauf Comptabilité). Cloisonnement par portefeuille = spec dédiée.
- **HP-M3-9** : **Validation IBAN/BIC stricte** (au M2, `Assert\Iban` / `Assert\Bic` standard sur `SupplierRib`).
- **HP-M3-10** : **Validation SIREN stricte** (Luhn) — au M2, `Assert\Length(9)` + `Assert\Regex('/^\d{9}$/')`.
- **HP-M3-11** : **Référencement entrant** (modules futurs ajoutant une FK `supplier_id` : Commandes fournisseurs, Réceptions, etc.).
- **HP-M3-12** : **Export CSV** (XLSX uniquement au M2).
- **HP-M3-13** : **Liaison Client ↔ Fournisseur** (un même tiers à la fois client et fournisseur). Au M2, entités strictement séparées.

## 10. Liens & dépendances

### Liens

- Spec front : [`./spec-front.md`](./spec-front.md)
- Spec M1 clients (pattern de référence) : [`../M1-clients/spec-back.md`](../M1-clients/spec-back.md)
- Spec M0 catégories : [`../M0-categories/spec-back.md`](../M0-categories/spec-back.md)
- Doc audit-log : [`../../audit-log.md`](../../audit-log.md)
- BAN api : `https://adresse.data.gouv.fr/api-doc/adresse`
- Maquette Figma : `https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-36987&p=f&m=dev`
- Trace fonctionnelle V0.1 : `M2-reportoire-fournisseurs.docx` / `M2-reportoire-fournisseurs-V01.pdf`

### Dépendances amont (déjà en place dans Starseed)

- Module `Commercial` (M1) : `Client*` + référentiels comptables `TvaMode` / `PaymentDelay` / `PaymentType` / `Bank` (**partagés**)
- Module `Catalog` (M0) : `Category` + `CategoryType` (+ seed type FOURNISSEUR au M2)
- Module `Sites` : `Site` (3 sites 86/17/82) — M2M `supplier_address_site`
- Module `Core` : `User`, `Role`, `Permission`, `Audit`, JWT
- `Shared` : `TimestampableBlamableTrait` + `Subscriber`
- API Platform 4 + Doctrine ORM + PostgreSQL 16 + PhpSpreadsheet (export)

### Specs futures qui dépendent du M2

- **M-Commandes fournisseurs** : FK `supplier_id`.
- **M-Réceptions / Triage** : exploitation de `supplier_address.bennes` + `triage_provider`.

---

## 📦 Tickets Lesstime (à découper)

**TaskGroup Lesstime** : à créer — `M2 — Répertoire fournisseurs` (projet `ERP / Starseed`, projectId=6).

Ordre indicatif (back avant front, migration en tête) :
0. **Taxonomie FOURNISSEUR (Catalog)** — recréer le `CategoryType` `FOURNISSEUR` (seed migration + fixture idempotente) + **implémenter le filtre `?typeCode=`** sur `/api/categories` (inopérant en prod, ERP-78) + seed catégories fournisseurs (Négociant, Coopérative…). Prérequis du multi-select Catégorie.
1. **Migration BDD M2** (tables supplier + sous-collections + M2M + index partiel + COMMENT ON COLUMN)
2. **Entités + Repositories** (Supplier, SupplierContact, SupplierAddress, SupplierRib) + **fetch-joins liste** (categories, addresses.sites — § 2.12)
3. **Provider + Processor** (SupplierProvider paginé, SupplierProcessor — normalisation, archivage, accounting conditionnel, mode strict)
4. **Sous-ressources** (SupplierContactProcessor, SupplierAddressProcessor, SupplierRibProcessor)
5. **Validators** (SupplierInformationCompletenessValidator, contrôle catégorie type FOURNISSEUR, RG-2.07/2.08)
6. **Export XLSX** (SupplierExportController, priority:1)
7. **RBAC** : `CommercialModule::permissions()` + sync 3 sources + tests personas
8. **Tests PHPUnit** : matrice RG-2.01 → RG-2.17 (§ 8.1)
9. **Front : page Répertoire** (`/suppliers`) + `usePaginatedList`
10. **Front : page Création** (`/suppliers/new`) + `useSupplierForm`
11. **Front : page Consultation** (`/suppliers/{id}`) + onglets placeholder « À venir »
12. **Front : page Modification** (`/suppliers/{id}/edit`)
13. **i18n + Sidebar** (clé `sidebar.commercial.suppliers` + permission, traductions)

### Actions manuelles dans Lesstime (Matthieu)

1. Créer le TaskGroup `M2 — Répertoire fournisseurs` (projet ERP / Starseed).
2. Créer les ~14 tickets ci-dessus (ticket 0 taxonomie inclus) avec dépendances séquentielles.
3. Mettre à jour le frontmatter (`lesstime_taskgroup_id`) avec l'id réel.
