---
# === IDENTITÉ ===
module: M3
nom: "Répertoire prestataires"
ecran: repertoire-prestataires
owner_spec: Matthieu
backup_spec: Tristan
version: V0.2
date_redaction: 2026-06-11
# Historique : V0.2 (2026-06-11) — Spec back initiale, miroir M2 (fournisseurs).
#   Alignement refonte-contact (pas de contact inline sur le formulaire principal).
#   Différences M3 : pas d'onglet Information ; site sur le formulaire principal (provider_site) ;
#   adresse simple (pas de type/bennes/triage) ; nouveau pôle Technique.

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

# === LIEN LESSTIME ===
lesstime_taskgroup_id: 29   # M3 — Répertoire prestataires (projet STARSEED #6)
lesstime_project_id: 6
statut_global: en_dev

# === DÉPENDANCES AMONT ===
depend_de:
  - M2-suppliers    # pattern jumeau Supplier* répliqué en Provider* ; référentiels compta partagés
  - M1-clients      # référentiels comptables (TvaMode/PaymentDelay/PaymentType/Bank) + filtre ?typeCode= (créé au M2)
  - M0-categories   # Category + CategoryType (étendu par seed M3 : type PRESTATAIRE)
  - 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 3 : Répertoire prestataires

## 1. Contexte

Cette spec **complète et précise** la [spec front V0.2](./spec-front.md) (`M3-reportoire-prestataires.docx` du 04/06/2026) 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** : **nouveau module `Technique`** (`src/Module/Technique/`). Le prestataire est le **jumeau du fournisseur** (`Provider` / `ProviderContact` / `ProviderAddress` / `ProviderRib`), construit sur le pattern éprouvé M1/M2. Voir § 2.1 pour la justification du module séparé et la consommation des référentiels comptables.

**Dépendances déjà en place sur `develop`** (héritées M1/M2) :
- `Commercial` → référentiels comptables `TvaMode` / `PaymentDelay` / `PaymentType` / `Bank` (entités lecture seule, déjà seedées — **partagées sans duplication**, consommées en relation ORM).
- `Catalog` (M0) → `Category` + `CategoryType` + **filtre `?typeCode=` opérationnel** (créé au M2). Le M3 ajoute le type `PRESTATAIRE`.
- `Sites` → 3 sites Châtellerault (86) / Saint-Jean (17) / Pommevic (82).
- `Shared` → `TimestampableBlamableTrait` + `Subscriber` (ERP-52).
- `Core` → User, Role, Permission, Audit, JWT.

> **RETEX obligatoire** : lire [`../_RETEX-M1-pour-M2.md`](../_RETEX-M1-pour-M2.md) AVANT de coder. ~80 % des frictions M1 venaient du **contrat de sérialisation** (groupes / sous-ressources / embed), pas du métier. La section § 4.0 applique ce RETEX au M3.

## 2. Décisions d'archi

### 2.1 Module — Nouveau module `Technique`, entités jumelles de `Supplier`

> **⚠️ Décision à confirmer (Matthieu, 11/06/2026)** : le docx place le répertoire prestataires dans un **Module « Technique »**, confirmé comme **pôle distinct du Commercial**. On crée donc un **nouveau module back `Technique`** :
> - `src/Module/Technique/TechniqueModule.php` : `ID = 'technique'`, `LABEL = 'Technique'`, `REQUIRED = false`, méthode `permissions()` (cf. § 5.1).
> - Activation : ajouter `TechniqueModule::class` dans `config/modules.php`.
> - Front : layer Nuxt `frontend/modules/technique/` (auto-détecté) + nouvelle **section sidebar « Technique »** dans `config/sidebar.php`.

Le prestataire M3 **réplique à l'identique** le pattern `Supplier*` du M2 sous `Provider*` (tables dédiées, pas de table polymorphe partagée — clients / fournisseurs / prestataires divergent fonctionnellement, l'isolation prime).

**Référentiels comptables & Category — consommation cross-module (relation ORM partagée, PAS d'import de logique)** : `Provider` référence `TvaMode` / `PaymentDelay` / `PaymentType` / `Bank` (module Commercial) et `Category` / `Site` (modules Catalog / Sites) via des **relations ORM** (ManyToOne / ManyToMany), **exactement comme `Supplier` (Commercial) référence déjà `Site` (Sites) et `Category` (Catalog)**. Ce sont des **données de référence partagées**, pas de la logique inter-module : aucun service / repository d'un autre module n'est appelé. La règle ABSOLUE n°1 (« ne jamais importer d'un module à un autre — passer par Shared/Contract ») vise les **dépendances de logique métier** ; le projet a déjà acté (M1/M2) que la **référence ORM à une entité de référence partagée** est tolérée et documentée comme telle.

> **Décision Matthieu (11/06) : on fait « comme supplier »** — consommation ORM partagée des référentiels comptables (zéro refacto, zéro duplication). La remontée dans `Shared` (isolation stricte) reste une option future non retenue au M3 (tracé HP-M4-2).

### 2.2 IDs entier auto-increment Postgres natif

Cohérent avec M0/M1/M2 et l'ensemble Starseed. Pas d'UUID, pas de ULID. PK en `INT GENERATED BY DEFAULT AS IDENTITY` (style aligné M1/M2), horodatages en `TIMESTAMP(0) WITHOUT TIME ZONE` (le `TimestampableBlamableTrait` mappe `datetime_immutable`).

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

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

Endpoints : `GET /api/tva_modes`, `/api/payment_delays`, `/api/payment_types`, `/api/banks` existent déjà. **Évolution M3** : élargir leur `security` pour autoriser **aussi** les rôles prestataires (cf. § 4.7). Les codes pivots `VIREMENT` (RG-3.07) et `LCR` (RG-3.08) existent déjà dans `payment_types`.

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

Le multi-select « Catégorie » du prestataire (formulaire principal ET adresse) référence des `Category` rattachées à un **nouveau `CategoryType` de code `PRESTATAIRE`** (label « Prestataire »), seedé par le M3. On assume des **types distincts** (`CLIENT` / `FOURNISSEUR` / `PRESTATAIRE`) — chacun avec sa taxonomie.

> **Bonne nouvelle vs M2** : le **filtre `?typeCode=` a été implémenté au M2** sur `/api/categories` (module Catalog). Le M3 n'a donc **plus à le créer** : il suffit de **seeder le type `PRESTATAIRE`** + ses catégories (migration `ON CONFLICT` pour la prod + fixture idempotente pour survivre au purger en dev/test — cf. M2 § 3.2). **À vérifier sur le JSON réel** que `GET /api/categories?typeCode=PRESTATAIRE` filtre bien (DoD de la spec).

> **Forme réelle de `Category`** : expose `code` **et `name`** (PAS `label`) sous `category:read`, plus `categoryType{ id, code, label }`. Le **libellé affiché front = `category.name`**. Les M2M `provider_category` / `provider_address_category` ne contraignent que des `Category` de type `PRESTATAIRE` (RG-3.09).

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

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

Conséquences (miroir M2) :
- `DELETE /api/providers/{id}` **non exposé** au M3 (404 si appelé).
- `GET /api/providers?includeArchived=true` permet de voir les archivés (permission `technique.providers.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).

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

> **Décision à confirmer (alignée Q4 M1 / § 2.6 M2)** : l'unicité métier porte **uniquement sur le nom de prestataire** (`company_name`). Le SIREN et l'email principal ne sont **pas** uniques.

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

### 2.7 Audit & traces temporelles

Pattern Starseed standard, miroir M1/M2 :
- `#[Auditable]` sur `Provider`, `ProviderContact`, `ProviderAddress`, `ProviderRib`.
- **Tous les champs auditables** (pas d'`#[AuditIgnore]`) — y compris `ProviderRib.iban` et `ProviderRib.bic` (audit admin-only côté Starseed → traçabilité comptable).
- Audit M2M automatique sur `provider.categories` et `provider.sites` (`{categories: {added:[...], removed:[...]}}`).
- **Libellés i18n** (règle ABSOLUE backend — `AuditableEntitiesHaveI18nLabelTest`) : ajouter `audit.entity.technique_provider`, `audit.entity.technique_providercontact`, `audit.entity.technique_provideraddress`, `audit.entity.technique_providerrib` dans `frontend/i18n/locales/fr.json` (clé = `strtolower(module)` + `_` + `strtolower(Entity)`).

### 2.8 Timestampable + Blamable

`Provider`, `ProviderContact`, `ProviderAddress`, `ProviderRib` implémentent `TimestampableInterface` + `BlamableInterface` et utilisent `TimestampableBlamableTrait`. Migration : 4 colonnes par table (`created_at`/`updated_at` NOT NULL, `created_by`/`updated_by` nullable `ON DELETE SET NULL`) + commentaires via le helper `addStandardTimestampableBlamableComments($schema, '<table>')`.

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

| Permission | Admin | Bureau | Compta | Commerciale | Usine |
|---|---|---|---|---|---|
| `technique.providers.view` | ✅ | ✅ | ✅ | ✅ (sauf compta) | ✅ (cloisonné par site — § 2.13) |
| `technique.providers.manage` | ✅ | ✅ | ❌ | ✅ | ❌ |
| `technique.providers.accounting.view` | ✅ | ❌ | ✅ | ❌ | ❌ |
| `technique.providers.accounting.manage` | ✅ | ❌ | ✅ | ❌ | ❌ |
| `technique.providers.archive` | ✅ | ❌ | ❌ | ❌ | ❌ |

Notes (miroir M2) :
- **Compta édite uniquement l'onglet Comptabilité** (`accounting.manage`) d'un prestataire existant. Pas de création (pas de `manage` global).
- **Commerciale** : `view` + `manage` mais **pas** `accounting.view` → onglet Comptabilité masqué (front) et filtré (back) via le `ProviderReadGroupContextBuilder` (gating **par ajout** de groupe `provider:read:accounting`, jamais par retrait). Sans la permission, scalaires compta + `ribs` ne sont jamais sérialisés.
- **Bureau** : `view` + `manage` (tout sauf Comptabilité).
- **Usine** : `view` (lecture seule, pas de `manage`), **cloisonné par site** — voir § 2.13.
- **⚠️ Le « Tout » vs « son site uniquement » de la colonne Consultation du docx n'est PAS porté par le rôle** : c'est un **cloisonnement par site piloté par l'utilisateur** (décision Matthieu, 11/06). Tout user voit par défaut les prestataires de **son site courant** ; les profils qui doivent voir **tous les sites** (Admin, et selon besoin Bureau/Compta/Commerciale) l'obtiennent via la permission `sites.bypass_scope` (Admin l'a par bypass total). Mécanique complète en § 2.13.

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

`Provider` créé en BDD **dès validation du formulaire principal** via `POST /api/providers`. Onglets suivants → **PATCH partiels** avec groupes de sérialisation dédiés :

- `provider:write:main` — formulaire principal (POST + PATCH) : `companyName`, `categories`, `sites`
- `provider:write:contacts` — onglet Contact (sous-ressource `provider_contact`)
- `provider:write:addresses` — onglet Adresse (sous-ressource `provider_address`)
- `provider:write:accounting` — onglet Comptabilité (security séparée)
- `provider:write:archive` — toggle archive (security `technique.providers.archive`)

**Pas de groupe `provider:write:information`** (pas d'onglet Information au M3). **Pas de state machine** côté back (pas de `status = draft|active`).

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

`ProviderFieldNormalizer` (miroir `SupplierFieldNormalizer`), service interne appelé par les Processors avant validation :

```php
final class ProviderFieldNormalizer
{
    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 front. Le back stocke `0612345678` (chiffres seuls).

### 2.12 Liste : embed catégories + sites + hydratation anti-N+1 (cohérence M1/M2)

La **liste** `GET /api/providers` **embarque** les `categories[]` (avec `code`/`name`) et les `sites[]` (avec `name`/`postalCode` — pas de `code`), comme M1/M2.

> **Différence M3 (importante)** : au M2, `sites[]` de la liste était l'**agrégat dédoublonné des adresses** (`Supplier::getSites()`). Au M3, le **prestataire porte directement des sites** (formulaire principal — RG-3.03, M2M `provider_site`). La colonne « Site » de la liste affiche donc **`provider.sites` (relation directe)**, pas un agrégat d'adresses. Plus simple et plus performant.

Anti-N+1 (le code fera foi) : le `DoctrineProviderRepository` ne fetch-joine PAS les to-many dans la requête de liste (filtres + tri seulement) ; `hydrateListCollections()` remplit `categories` puis `sites` (relation directe) via des requêtes `IN` bornées séparées sur les mêmes instances (identity map), pour éviter le produit cartésien sur les chemins non paginés (export, `?pagination=false`). Le contrat de sérialisation (groupes `category:read` / `site:read` dans le contexte) est posé **une seule fois** sur l'entité.

### 2.13 Cloisonnement par site — visibilité pilotée par l'utilisateur (DÉCISION M3)

> **Décision Matthieu (11/06/2026)** : la visibilité des prestataires est **cloisonnée par site, automatiquement, côté back, en fonction de l'utilisateur** — **pas du rôle**. Un user a un (ou des) site(s) (`user_site`, + un `currentSite` actif). Il ne voit que les prestataires **rattachés à son site**. Les profils qui doivent voir tous les sites passent par `sites.bypass_scope` (Admin l'a par bypass total). Le rôle « Usine » n'est qu'un cas particulier de cette règle générale.

**Réutilisation de l'infra Sites existante** (`docs/modules/site-aware.md`) : `CurrentSiteProvider` (site courant de l'user), permission `sites.bypass_scope` (voit tous les sites — Admin automatique), users ↔ sites via M2M `user_site`.

**⚠️ Pourquoi PAS `SiteAwareInterface` standard** : le pattern opt-in `SiteAwareInterface` + `SiteScopedQueryExtension` est **mono-site** (`site_id INT NOT NULL`, ManyToOne unique, filtre `x.site = :currentSite`). Or le prestataire est **multi-site** (M2M `provider_site`, ≥ 1 — RG-3.03). Le pattern standard ne s'applique donc pas tel quel. On câble un **filtre de cloisonnement custom multi-site** (cas explicitement renvoyé au module par `site-aware.md § 6.1 / § 6.2`), qui réutilise `CurrentSiteProvider` + `sites.bypass_scope` :

- **Filtre LISTE** (`ProviderProvider` ou query extension dédiée `ProviderSiteScopeExtension`) : si l'user **n'a pas** `sites.bypass_scope` ET que `CurrentSiteProvider::get()` retourne un site → ne renvoyer que les prestataires dont `provider.sites` **contient** le `currentSite` (jointure `provider_site` + `WHERE site = :currentSite`). Si l'user a `bypass_scope` (Admin, profils consolidation) → aucun filtre (tous sites). Si `currentSite = null` (mode dégradé / module Sites off) → aligné `site-aware.md § 5` (no-op lecture, à documenter).
- **Filtre DÉTAIL** (`Get`) : un user sans `bypass_scope` qui demande un prestataire **hors de son site courant** → **404** (cohérence : ne pas révéler l'existence d'une ligne hors périmètre).
- **Écriture (décision Matthieu, 11/06)** : un user **sans** `bypass_scope` ne peut attacher **que les sites dont il dispose** (ses `user_site`) — sur le formulaire principal (`provider.sites`, RG-3.03) **comme** sur chaque adresse (`provider_address.sites`, RG-3.05). Tout site hors de ses `user_site` dans le payload → **422** sur `sites`. Un user `bypass_scope` (Admin) peut attacher n'importe quel site. Garde porté par le `ProviderProcessor` (POST + PATCH + sous-ressource adresses).
- **Cohérence sous-ressources** (Contacts / Adresses / RIB) : le cloisonnement du parent **n'est PAS hérité automatiquement** — les opérations `Get` / `Patch` / `Delete` des sous-ressources passent par le provider Doctrine par défaut (et `SiteScopedQueryExtension` ne filtre que les `SiteAwareInterface`, ce que ces entités ne sont pas). Le garde-fou est donc posé **explicitement** : (a) en lecture/édition/suppression via le provider décoré `ProviderSubResourceItemProvider` (un parent hors périmètre → **404**) ; (b) en création (`POST /providers/{id}/...`) via `ProviderSiteScopeChecker::assertInScope` dans chaque processor (parent hors périmètre → **404**). La décision de scope est centralisée dans `ProviderSiteScopeChecker` (source unique partagée avec `ProviderProvider`). ⚠️ Ne pas retirer ces gardes en les croyant redondants : sans eux, un user cloisonné peut lire l'IBAN/BIC d'un RIB d'un autre site.

> **Conséquence RBAC** : la colonne « Consultation » du docx (« Tout » vs « son site uniquement ») se réalise **par `sites.bypass_scope`**, pas par le code de rôle. Décision d'attribution par défaut (à acter au ticket RBAC) : `bypass_scope` aux profils Admin (auto) + Bureau + Compta + Commerciale (ils voient « Tout » d'après le docx) ; **Usine ne l'a pas** → cloisonné à son site. Si MALIO préfère que Bureau/Commerciale soient aussi cloisonnés, il suffit de ne pas leur donner `bypass_scope` — **aucun code à changer** (c'est l'intérêt de piloter par user/permission et non par rôle).

> **Index** : `idx_provider_site_site` sur `provider_site(site_id)` (déjà prévu § 3.2) sert le filtre `WHERE site = :currentSite`.

## 3. Modèle de données

### 3.1 Diagramme

```
+----------------------+      +--------------------------+      +-----------------+
|     provider         |--n:m-->|   provider_category    |<--n:m--|  category       |
|                      |      +--------------------------+      | type=PRESTATAIRE|
| id (PK)              |                                        +-----------------+
| company_name         |--n:m-->|   provider_site        |<--n:m--|  site (Sites)   |
| is_archived          |      +--------------------------+      | (RG-3.03)       |
| archived_at          |                                        +-----------------+
| deleted_at           |      +--------------------------+
| -- Comptabilité --   |--1:n-->|   provider_contact     |
| siren / account_num  |      +--------------------------+
| tva_mode_id          |                                        +-----------------+
| n_tva                |      +--------------------------+      |  tva_mode (M1)  |
| payment_delay_id     |--1:n-->|   provider_address     |      |  payment_* (M1) |
| payment_type_id      |      +--------------------------+      |  bank (M1)      |
| bank_id (nullable)   |             | (PAS de address_type)    +-----------------+
+----------------------+             +--n:m--> site
                                     +--n:m--> provider_contact
       +--------------------------+  +--n:m--> category (PRESTATAIRE)
       |   provider_rib           |
       +--------------------------+
       label / bic / iban
```

**Particularités M3 (différences vs `supplier`)** :
- **PAS d'onglet Information** : aucun champ `description` / `competitors` / `founded_at` / `employees_count` / `revenue_amount` / `director_name` / `profit_amount` / `volume_forecast`. Le `provider` est minimal : nom + comptabilité.
- **`provider.sites` (M2M `provider_site`)** : sélecteur de site **sur le formulaire principal** (RG-3.03, ≥ 1). NOUVEAU vs supplier (qui n'avait des sites que sur l'adresse).
- **`provider_address` simplifiée** : **pas** de `address_type`, **pas** de `bennes`, **pas** de `triage_provider`. Champs : sites[], street, street_complement, postal_code, city, country, categories[], contacts[].
- 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/M2** : la migration crée un schéma avec **FK cross-module** (`user`, `category`, `site`, FK vers les référentiels comptables M1). Le namespace modulaire casserait l'ordre (`make db-reset`) — exception racine de la règle ABSOLUE n°11. Le seed du `CategoryType PRESTATAIRE` se fait **en deux endroits** (migration `ON CONFLICT` pour la prod + fixture idempotente en dev/test).

> **Rappel règle ABSOLUE n°12** : chaque colonne créée DOIT recevoir son `COMMENT ON COLUMN` (FR, ≤ 200 car., sémantique + contrainte/RG). Les 4 colonnes Timestampable/Blamable passent par le helper. Le SQL ci-dessous est *illustratif* (style aligné M1/M2 : `INT GENERATED BY DEFAULT AS IDENTITY`, `TIMESTAMP(0) WITHOUT TIME ZONE`).

```sql
-- =====================================================================
-- Seed taxonomie : nouveau type PRESTATAIRE (référentiels comptables = M1, non recréés)
-- =====================================================================
INSERT INTO category_type (code, label) VALUES ('PRESTATAIRE', 'Prestataire')
    ON CONFLICT (code) DO NOTHING;

-- =====================================================================
-- Table principale `provider`
-- =====================================================================
CREATE TABLE provider (
    id                  INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    -- Formulaire principal
    company_name        VARCHAR(180) NOT NULL,
    -- (PAS d'onglet Information — aucun champ description/competitors/founded/employees/...)
    -- 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é M3)
    is_archived         BOOLEAN NOT NULL DEFAULT FALSE,
    archived_at         TIMESTAMP(0) WITHOUT TIME ZONE,
    -- Soft delete (préparé, non exposé au M3)
    deleted_at          TIMESTAMP(0) WITHOUT TIME ZONE,
    -- Timestampable + Blamable
    created_at          TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
    updated_at          TIMESTAMP(0) WITHOUT TIME ZONE 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_provider_is_archived ON provider(is_archived);
CREATE INDEX idx_provider_deleted_at ON provider(deleted_at);
CREATE INDEX idx_provider_created_by ON provider(created_by);
CREATE INDEX idx_provider_updated_by ON provider(updated_by);

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

-- =====================================================================
-- M2M provider ↔ category (catégories de type PRESTATAIRE — RG-3.09)
-- =====================================================================
CREATE TABLE provider_category (
    provider_id  INT NOT NULL REFERENCES provider(id) ON DELETE CASCADE,
    category_id  INT NOT NULL REFERENCES category(id) ON DELETE RESTRICT,
    PRIMARY KEY (provider_id, category_id)
);
CREATE INDEX idx_provider_category_category ON provider_category(category_id);

-- =====================================================================
-- M2M provider ↔ site (sélecteur de site du FORMULAIRE PRINCIPAL — RG-3.03)
-- =====================================================================
CREATE TABLE provider_site (
    provider_id  INT NOT NULL REFERENCES provider(id) ON DELETE CASCADE,
    site_id      INT NOT NULL REFERENCES site(id) ON DELETE RESTRICT,
    PRIMARY KEY (provider_id, site_id)
);
CREATE INDEX idx_provider_site_site ON provider_site(site_id);

-- =====================================================================
-- Sous-collection : Contacts (1:n)
-- =====================================================================
CREATE TABLE provider_contact (
    id              INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    provider_id     INT NOT NULL REFERENCES provider(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      TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
    updated_at      TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
    created_by      INT REFERENCES "user"(id) ON DELETE SET NULL,
    updated_by      INT REFERENCES "user"(id) ON DELETE SET NULL,
    -- RG-3.04 : au moins 1 champ rempli (garanti côté Processor ; le CHECK ci-dessous
    -- couvre le cas « nom OU prénom » comme garde-fou minimal aligné M2)
    CONSTRAINT chk_provider_contact_name
        CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL)
);
CREATE INDEX idx_provider_contact_provider ON provider_contact(provider_id);

-- =====================================================================
-- Sous-collection : Adresses (1:n) — PAS de address_type / bennes / triage
-- =====================================================================
CREATE TABLE provider_address (
    id                INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    provider_id       INT NOT NULL REFERENCES provider(id) ON DELETE CASCADE,
    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),
    position          INT NOT NULL DEFAULT 0,
    created_at        TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
    updated_at        TIMESTAMP(0) WITHOUT TIME ZONE 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_provider_address_provider ON provider_address(provider_id);

-- M2M provider_address ↔ site (RG-3.05 : ≥ 1 site)
CREATE TABLE provider_address_site (
    provider_address_id INT NOT NULL REFERENCES provider_address(id) ON DELETE CASCADE,
    site_id             INT NOT NULL REFERENCES site(id) ON DELETE RESTRICT,
    PRIMARY KEY (provider_address_id, site_id)
);

-- M2M provider_address ↔ provider_contact
CREATE TABLE provider_address_contact (
    provider_address_id INT NOT NULL REFERENCES provider_address(id) ON DELETE CASCADE,
    provider_contact_id INT NOT NULL REFERENCES provider_contact(id) ON DELETE CASCADE,
    PRIMARY KEY (provider_address_id, provider_contact_id)
);

-- M2M provider_address ↔ category (catégorie d'adresse, type PRESTATAIRE — RG-3.09)
CREATE TABLE provider_address_category (
    provider_address_id INT NOT NULL REFERENCES provider_address(id) ON DELETE CASCADE,
    category_id         INT NOT NULL REFERENCES category(id) ON DELETE RESTRICT,
    PRIMARY KEY (provider_address_id, category_id)
);

-- =====================================================================
-- Sous-collection : RIB (1:n)
-- =====================================================================
CREATE TABLE provider_rib (
    id          INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    provider_id INT NOT NULL REFERENCES provider(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  TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
    updated_at  TIMESTAMP(0) WITHOUT TIME ZONE 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_provider_rib_provider ON provider_rib(provider_id);
```

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

```php
$this->addSql("COMMENT ON TABLE provider IS 'Répertoire prestataires (M3 Technique) — entités archivables.'");
$this->addSql("COMMENT ON COLUMN provider.company_name IS 'Raison sociale du prestataire — stockée en MAJUSCULES. Unique parmi non-archivés/non-supprimés (RG-3.10).'");
$this->addSql("COMMENT ON COLUMN provider.payment_type_id IS 'Type de règlement — FK -> payment_type.id (référentiel partagé M1), ON DELETE RESTRICT. Pilote RG-3.07 (Banque) et RG-3.08 (RIB).'");
$this->addSql("COMMENT ON COLUMN provider.bank_id IS 'Banque — FK -> bank.id (M1). Obligatoire ssi payment_type=VIREMENT (RG-3.07), null sinon.'");
$this->addSql("COMMENT ON COLUMN provider.siren IS 'SIREN du prestataire (9 chiffres). Non unique (cf. RG-3.10). Saisi à l''onglet Comptabilité.'");
// provider_site (M2M) : commenter via COMMENT ON TABLE
$this->addSql("COMMENT ON TABLE provider_site IS 'Sites rattachés au prestataire (sélecteur du formulaire principal — RG-3.03, ≥ 1).'");
$this->addSql("COMMENT ON COLUMN provider_address.postal_code IS 'Code postal — déclenche l''autocomplétion ville via l''API BAN (RG-3.06).'");
// + COMMENT ON COLUMN sur TOUTES les autres colonnes métier (cf. règle n°12)
$this->addStandardTimestampableBlamableComments($schema, 'provider');
$this->addStandardTimestampableBlamableComments($schema, 'provider_contact');
$this->addStandardTimestampableBlamableComments($schema, 'provider_address');
$this->addStandardTimestampableBlamableComments($schema, 'provider_rib');
```

### 3.3 Entité `Provider` — squelette (extrait)

Miroir de `Supplier` (cf. [`../M2-suppliers/spec-back.md § 3.3`](../M2-suppliers/spec-back.md)), **amputé de l'onglet Information** et **augmenté de `sites` (relation directe)**.

```php
<?php

declare(strict_types=1);

namespace App\Module\Technique\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;       // relation ORM partagée (cf. § 2.1)
use App\Module\Sites\Domain\Entity\Site;             // relation ORM partagée (cf. § 2.1)
use App\Module\Commercial\Domain\Entity\TvaMode;     // référentiel partagé (cf. § 2.1) — sinon Shared (HP-M4-2)
use App\Module\Commercial\Domain\Entity\PaymentDelay;
use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Domain\Entity\Bank;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderProcessor;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderProvider;
use App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository;
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 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('technique.providers.view')",
            // Liste embarque catégories + sites (relation DIRECTE provider.sites — RG-3.03).
            // Maillon (c) : category:read + site:read dans le contexte.
            normalizationContext: ['groups' => ['provider:read', 'category:read', 'site:read', 'default:read']],
            provider: ProviderProvider::class,
        ),
        new Get(
            security: "is_granted('technique.providers.view')",
            // Détail embarque sous-collections (contacts, addresses, ribs) + relations imbriquées.
            // provider:read:accounting AJOUTÉ dynamiquement par le ReadGroupContextBuilder si accounting.view.
            normalizationContext: ['groups' => [
                'provider:read', 'provider:item:read',
                'category:read', 'site:read', 'default:read',
            ]],
            provider: ProviderProvider::class,
        ),
        new Post(
            security: "is_granted('technique.providers.manage')",
            normalizationContext: ['groups' => ['provider:read', 'default:read']],
            denormalizationContext: ['groups' => ['provider:write:main']],
            processor: ProviderProcessor::class,
        ),
        new Patch(
            // Security élargie : manage OU accounting.manage (Compta édite la compta sans manage global).
            security: "is_granted('technique.providers.manage') or is_granted('technique.providers.accounting.manage')",
            normalizationContext: ['groups' => ['provider:read', 'default:read']],
            denormalizationContext: ['groups' => [
                'provider:write:main', 'provider:write:accounting', 'provider:write:archive',
            ]],
            provider: ProviderProvider::class,
            processor: ProviderProcessor::class,
        ),
        // Pas de Delete au M3 (HP M4). Archivage via PATCH { isArchived: true }.
    ],
)]
#[ORM\Entity(repositoryClass: DoctrineProviderRepository::class)]
#[ORM\Table(name: 'provider')]
#[Auditable]
class Provider implements TimestampableInterface, BlamableInterface
{
    use TimestampableBlamableTrait;

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

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

    /** @var Collection<int, Category> Catégories de type PRESTATAIRE (RG-3.09) */
    #[ORM\ManyToMany(targetEntity: Category::class)]
    #[ORM\JoinTable(name: 'provider_category')]
    #[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')]
    #[Groups(['provider:read', 'provider:write:main'])]
    private Collection $categories;

    /** @var Collection<int, Site> Sites du prestataire — sélecteur du formulaire principal (RG-3.03) */
    #[ORM\ManyToMany(targetEntity: Site::class)]
    #[ORM\JoinTable(name: 'provider_site')]
    #[Assert\Count(min: 1, minMessage: 'Au moins un site est obligatoire.')]
    #[Groups(['provider:read', 'provider:write:main'])]
    private Collection $sites;

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

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

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

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

    #[ORM\ManyToOne(targetEntity: PaymentDelay::class)]
    #[ORM\JoinColumn(name: 'payment_delay_id', nullable: true, onDelete: 'RESTRICT')]
    #[Groups(['provider:read:accounting', 'provider:write:accounting'])]
    private ?PaymentDelay $paymentDelay = null;

    #[ORM\ManyToOne(targetEntity: PaymentType::class)]
    #[ORM\JoinColumn(name: 'payment_type_id', nullable: true, onDelete: 'RESTRICT')]
    #[Groups(['provider:read:accounting', 'provider:write:accounting'])]
    private ?PaymentType $paymentType = null;

    #[ORM\ManyToOne(targetEntity: Bank::class)]
    #[ORM\JoinColumn(name: 'bank_id', nullable: true, onDelete: 'RESTRICT')]
    #[Groups(['provider:read:accounting', 'provider:write:accounting'])]
    private ?Bank $bank = null;

    // === Sous-collections — EMBARQUÉES dans le DÉTAIL (RETEX M1 §2) ===
    /** @var Collection<int, ProviderContact> */
    #[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
    #[Groups(['provider:item:read'])]
    private Collection $contacts;

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

    /** @var Collection<int, ProviderRib> RIB embarqués dans le groupe COMPTA (gated par le Provider) */
    #[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderRib::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
    #[Groups(['provider: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 M1) : #[Groups] + #[SerializedName('isArchived')] SUR LE GETTER,
    // sinon Symfony strip "is" → attribut "archived" → clé droppée. À tester sur JSON réel.
    #[Groups(['provider:read', 'provider:write:archive'])]
    #[SerializedName('isArchived')]
    public function isArchived(): bool
    {
        return $this->isArchived;
    }
    // ... archivedAt, getters/setters, __construct (ArrayCollection) ...
}
```

### 3.4 Squelettes des autres entités

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

**`ProviderContact`** — propriétés dans `['provider:item:read', 'provider:write:contacts']` :
`firstName`, `lastName`, `jobTitle`, `phonePrimary`, `phoneSecondary`, `email`, `id`. Embed sous `provider.contacts` au détail ; éditables via la sous-ressource. **Max 2 téléphones** (`phonePrimary` + `phoneSecondary`).

**`ProviderAddress`** — propriétés dans `['provider:item:read', 'provider:write:addresses']` :
`country`, `postalCode`, `city`, `street`, `streetComplement`, `id`. **PAS** de `addressType` / `bennes` / `triageProvider`. Relations imbriquées (maillon (c) — read-groups dans le contexte du `Get` racine) :
- M2M `sites` → `#[Groups(['provider:item:read'])]` ; `Site` expose `id`/`name`/`postalCode`/`city`/`color` en `site:read` (**pas de `code`**) (`Assert\Count(min:1)` — RG-3.05).
- M2M `contacts` → `#[Groups(['provider:item:read'])]` ; embarque des `ProviderContact`.
- M2M `categories` → `#[Groups(['provider:item:read'])]` ; `Category` (id/code/name, type PRESTATAIRE — RG-3.09).

**`ProviderRib`** — propriétés dans `['provider:read:accounting', 'provider:write:accounting']` :
`label`, `bic`, `iban`, `id`. Embed sous `provider.ribs` **uniquement** si l'user a `accounting.view`. Aucun `#[AuditIgnore]` sur `iban`/`bic`.

> ⚠ `Site` / `Category` / référentiels comptables appartiennent à d'autres modules — on consomme leurs read-groups (`site:read`, `category:read`, `provider:read:accounting` pour les réfs compta), **pas de logique inter-module** (§ 2.1).

## 4. API REST (API Platform)

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

> **Leçon M1/M2** : ~80 % des frictions venaient du contrat de sérialisation. Pour **chaque champ affiché** (liste OU détail), les **3 maillons** doivent être prouvés : (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.

**Contexte par opération** :

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

¹ `provider:read:accounting` retiré par le `ProviderProvider` / `ProviderReadGroupContextBuilder` si l'user n'a pas `technique.providers.accounting.view`.

**LISTE — champ datatable → maillons** :

| Champ affiché | Propriété (a) | Dans contexte liste (b) | Imbriqué (c) |
|---|---|---|---|
| Nom | `companyName` ∈ `provider:read` | ✅ | — |
| Catégories | `categories` ∈ `provider:read` (embed) | ✅ | `category:read` ✅ (code/**name**) |
| Site | `sites` ∈ `provider:read` (embed, relation **directe** — RG-3.03) | ✅ | `site:read` ✅ (**name**/postalCode, pas de code) |
| Dernière activité | `updatedAt` ∈ `provider:read` | ✅ | — |

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

| Bloc / champ | Propriété (a) | Dans contexte détail (b) | Imbriqué (c) |
|---|---|---|---|
| Scalaires principaux | `provider:read` | ✅ | — |
| `categories[]` (id/code/name) | `categories` ∈ `provider:read` | ✅ | `category:read` ✅ |
| `sites[]` (formulaire principal) | `sites` ∈ `provider:read` | ✅ | `site:read` ✅ |
| `contacts[]` (5 champs) | `contacts` ∈ `provider:item:read` | ✅ | propriétés `ProviderContact` ∈ `provider:item:read` ✅ |
| `addresses[]` (scalaires) | `addresses` ∈ `provider:item:read` | ✅ | propriétés `ProviderAddress` ∈ `provider:item:read` ✅ |
| `addresses[].sites[]` | `sites` ∈ `provider:item:read` | ✅ | `site:read` ✅ |
| `addresses[].categories[]` | `categories` ∈ `provider:item:read` | ✅ | `category:read` ✅ |
| `addresses[].contacts[]` | `contacts` ∈ `provider:item:read` | ✅ | propriétés `ProviderContact` ∈ `provider:item:read` ✅ |
| Scalaires Comptabilité | `provider:read:accounting` | ✅ (gated) | réfs (`tvaMode`…) id+label ∈ `provider:read:accounting` |
| `ribs[]` (label/bic/iban) | `ribs` ∈ `provider:read:accounting` | ✅ (gated) | — |

### 4.0.bis Réponses JSON de référence (DoD — à CAPTURER sur l'API réelle)

> **Definition of Done** (miroir ERP-92 du M2) : avant de démarrer les écrans front, **capturer les réponses RÉELLES** via un test PHPUnit (`ProviderSerializationContractTest`, prestataire complet seedé) et les coller ici. Toute donnée affichée par le front DOIT apparaître dans ce JSON. **Ne jamais déclarer un champ « embarqué » sans l'avoir vu dans un JSON réel** (règle anti-régression M2).
>
> **2 pièges hérités M1/M2 à re-tester sur le M3** :
> 1. Réfs comptables (`tvaMode`/`paymentDelay`/`paymentType`/`bank`) : doivent sortir en **objet `{id, code, label}`**, pas en IRI nu → vérifier que les entités partagées portent bien le groupe `provider:read:accounting` (sinon les annoter, comme le fix ERP-92 l'a fait pour `supplier:read:accounting`).
> 2. Gating compta par **omission de clé** : pour un user sans `accounting.view`, les clés `siren`/`tvaMode`/`ribs`/… sont **absentes** (pas `null`).

> **✅ Capturé sur l'API réelle (ERP-139)** via `ProviderSerializationContractTest::testDodReferenceJsonShape` (`PROVIDER_DOD_DUMP=1`). Les `id`/`companyName`/noms de catégorie ci-dessous proviennent du prestataire seedé par le test (`seedCompleteProvider`) ; la **forme** (clés, embed, gating) est le contrat réel à respecter côté front.

`GET /api/providers` (liste, ADMIN avec `accounting.view` — un membre, capture réelle) :
```json
{
    "@context": "/api/contexts/Provider",
    "@id": "/api/providers",
    "@type": "Collection",
    "totalItems": 1,
    "member": [
        {
            "@id": "/api/providers/572",
            "@type": "Provider",
            "id": 572,
            "companyName": "DOD21AADC 0E3CCE",
            "categories": [
                {
                    "@type": "Category",
                    "@id": "/api/categories/3006",
                    "id": 3006,
                    "name": "test_prov_cat_nettoyage",
                    "code": "NETTOYAGE",
                    "categoryTypes": [
                        {"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}
                    ],
                    "createdAt": "2026-06-12T15:17:29+02:00",
                    "updatedAt": "2026-06-12T15:17:29+02:00"
                }
            ],
            "sites": [
                {"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
                {"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
            ],
            "siren": "987654321",
            "accountNumber": "P0001",
            "tvaMode": {"@id": "/api/tva_modes/13", "@type": "TvaMode", "id": 13, "code": "FRANCE_VENTES", "label": "France (ventes)"},
            "nTva": "FR00987654321",
            "paymentDelay": {"@id": "/api/payment_delays/8", "@type": "PaymentDelay", "id": 8, "code": "J30", "label": "30 jours"},
            "paymentType": {"@id": "/api/payment_types/10", "@type": "PaymentType", "id": 10, "code": "LCR", "label": "LCR"},
            "ribs": [
                {"@id": "/api/provider_ribs/60", "@type": "ProviderRib", "id": 60, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
            ],
            "createdAt": "2026-06-12T15:17:29+02:00",
            "updatedAt": "2026-06-12T15:17:29+02:00",
            "isArchived": false
        }
    ],
    "view": {"@id": "/api/providers?search=DoD21aadc", "@type": "PartialCollectionView"}
}
```

> Les `sites[]` de la liste sont la **relation directe** `provider.sites` (formulaire principal — RG-3.03), objet `Site` entier (pas un IRI nu). Les catégories embarquent `code` + `name`. Les prestataires archivés sont **exclus** du `totalItems` (RG-3.16). Pour un profil **sans `accounting.view`** (ex. Commerciale), `siren`/`accountNumber`/`tvaMode`/`nTva`/`paymentDelay`/`paymentType`/`bank`/`ribs` **disparaissent** de chaque membre (gating par omission — cf. détail restreint ci-dessous).

`GET /api/providers/{id}` (détail — user **avec** `accounting.view`, capture réelle) :
```json
{
    "@context": "/api/contexts/Provider",
    "@id": "/api/providers/572",
    "@type": "Provider",
    "id": 572,
    "companyName": "DOD21AADC 0E3CCE",
    "categories": [
        {"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
    ],
    "sites": [
        {"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
        {"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
    ],
    "siren": "987654321",
    "accountNumber": "P0001",
    "tvaMode": {"@id": "/api/tva_modes/13", "@type": "TvaMode", "id": 13, "code": "FRANCE_VENTES", "label": "France (ventes)"},
    "nTva": "FR00987654321",
    "paymentDelay": {"@id": "/api/payment_delays/8", "@type": "PaymentDelay", "id": 8, "code": "J30", "label": "30 jours"},
    "paymentType": {"@id": "/api/payment_types/10", "@type": "PaymentType", "id": 10, "code": "LCR", "label": "LCR"},
    "contacts": [
        {"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
    ],
    "addresses": [
        {
            "@id": "/api/provider_addresses/35", "@type": "ProviderAddress", "id": 35,
            "country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias",
            "sites": [
                {"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
                {"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
            ],
            "contacts": [
                {"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
            ],
            "categories": [
                {"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
            ],
            "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"
        }
    ],
    "ribs": [
        {"@id": "/api/provider_ribs/60", "@type": "ProviderRib", "id": 60, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
    ],
    "createdAt": "2026-06-12T15:17:29+02:00",
    "updatedAt": "2026-06-12T15:17:29+02:00",
    "isArchived": false
}
```

`GET /api/providers/{id}` (même prestataire, user **sans** `accounting.view` — capture réelle) :
```json
{
    "@context": "/api/contexts/Provider",
    "@id": "/api/providers/572",
    "@type": "Provider",
    "id": 572,
    "companyName": "DOD21AADC 0E3CCE",
    "categories": [
        {"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
    ],
    "sites": [
        {"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
        {"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
    ],
    "contacts": [
        {"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
    ],
    "addresses": [
        {
            "@id": "/api/provider_addresses/35", "@type": "ProviderAddress", "id": 35,
            "country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias",
            "sites": [
                {"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
                {"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
            ],
            "contacts": [
                {"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
            ],
            "categories": [
                {"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
            ],
            "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"
        }
    ],
    "createdAt": "2026-06-12T15:17:29+02:00",
    "updatedAt": "2026-06-12T15:17:29+02:00",
    "isArchived": false
}
```

> **Gating par omission confirmé sur le JSON réel** : pour un user **sans** `accounting.view`, les clés `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType`, `bank` **et `ribs`** sont **absentes** (pas `null`). `isArchived`, `contacts[]`, `addresses[]` (avec `sites[]`/`contacts[]`/`categories[]`) restent exposés. Vérifié par `ProviderSerializationContractTest::testRibsAbsentForUserWithoutAccountingView` + `testAccountingScalarsGatedByOmission`.
>
> **Réfs comptables = objets embarqués `{id, code, label}`** (pas IRI nu) : le fix ERP-139 a ajouté `provider:read:accounting` sur `TvaMode`/`PaymentDelay`/`PaymentType`/`Bank` (réplique du fix ERP-92 du M2). Vérifié par `testAccountingReferentialsEmbedIdCodeLabel`.

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

- **Security** : `is_granted('technique.providers.view')`
- **Query params** (alimentent le panneau « Filtrer ») :
  - `includeArchived=true|false` (default `false`)
  - `categoryCode=<code>` (filtre les prestataires ayant ≥ 1 `Category` de ce code ; répétable)
  - `siteId=<id>` (filtre via la relation **directe** `provider.sites` ; répétable) — *NB : au M3 le site est porté par le prestataire, le filtre joint `provider_site` (pas les adresses).*
  - `search=<text>` (fuzzy sur `companyName` + contacts liés `provider_contact` (firstName / lastName / email) via LEFT JOIN groupé par `provider.id`)
- **Tri par défaut** : `companyName ASC`
- **Cloisonnement par site (§ 2.13)** : si l'user **n'a pas** `sites.bypass_scope`, la liste est filtrée sur les prestataires dont `provider.sites` contient le `currentSite` (RG-3.17). Transparent pour le client (pas de query param).
- **Pagination** : standard Starseed (règle ABSOLUE n°13) — Hydra, 10/page, `?pagination=false` pour les selects. `ProviderProvider` branché sur `ApiPlatform\Doctrine\Orm\Paginator`. ⚠️ Le filtre de cloisonnement s'applique **avant** la pagination (le `totalItems` reflète le périmètre de l'user).
- **Anti N+1 (§ 2.12)** : hydratation des `categories` + `sites` via requêtes `IN` bornées séparées (pas de fetch-join combiné).
- **Codes** : `200` / `401` / `403`

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

- **Security** : `is_granted('technique.providers.view')`
- **Comportement** : prestataire + contacts + adresses + RIBs. Champs `provider:read:accounting` inclus seulement si `technique.providers.accounting.view`.
- **Cloisonnement par site (§ 2.13)** : un user sans `sites.bypass_scope` qui demande un prestataire **hors de son site courant** → **404** (ne pas révéler l'existence hors périmètre — RG-3.17).
- **Codes** : `200` / `404` / `401` / `403`

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

- **Security** : `is_granted('technique.providers.manage')`
- **Body** (groupe `provider:write:main`) :
```json
{
  "companyName": "MAINTENANCE PRO SAS",
  "categories": ["/api/categories/300"],
  "sites": ["/api/sites/87"]
}
```
- **Réponse 201** : le prestataire 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-3.10). SIREN/email non uniques.
  - `422` : catégories vides (RG-3.09) ; sites vides (RG-3.03) ; catégorie hors type PRESTATAIRE (RG-3.09).

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

- **Security base** : `is_granted('technique.providers.manage')`
- **Security additionnelle** (dans le `ProviderProcessor`) :
  - payload contenant un champ `provider:write:accounting` → exige `technique.providers.accounting.manage`
  - payload contenant `isArchived` → exige `technique.providers.archive`
  - **mode strict** (RG-3.15) : 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/providers/{id}/contacts`, `PATCH /api/provider_contacts/{id}`, `DELETE /api/provider_contacts/{id}`.
- **Security** : `is_granted('technique.providers.manage')`
- **RG-3.12** : au moins 1 bloc Contact valide pour finaliser l'onglet côté front. Côté back, la collection peut rester vide (pas de state machine).

**Adresses** : `POST /api/providers/{id}/addresses`, `PATCH /api/provider_addresses/{id}`, `DELETE /api/provider_addresses/{id}`.
- **Security** : `is_granted('technique.providers.manage')`
- Validations : ≥ 1 site (RG-3.05) ; catégories de type PRESTATAIRE uniquement (RG-3.09) ; `postalCode` matche `^[0-9]{4,5}$` (RG-3.06).

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

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

- **Security** : `is_granted('technique.providers.view')`
- **Comportement** : XLSX des prestataires **affichés** (mêmes filtres que la liste, non archivés par défaut).
- Colonnes : Nom prestataire, 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. _(Colonnes contact alimentées depuis le contact principal `provider_contact` de plus petit `position`.)_
- **Implémentation** : controller custom `ProviderExportController` avec `#[Route(priority: 1)]` (règle ABSOLUE — conflit API Platform `{id}`). Lib : PhpSpreadsheet (déjà présente).
- **Réponse 200** : `Content-Disposition: attachment; filename="repertoire-prestataires-{YYYYMMDD}.xlsx"`

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

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

`GET /api/categories?typeCode=PRESTATAIRE` alimente les multi-selects Catégorie (prestataire + adresse). ✅ **Le filtre `?typeCode=` existe** (créé au M2) — il suffit de **seeder le type `PRESTATAIRE`** + ses catégories. **À vérifier** que le filtre fonctionne pour ce nouveau type (DoD).

## 5. Autorisation

### 5.1 Déclaration des permissions

Créer `TechniqueModule::permissions()` :

```php
['code' => 'technique.providers.view', 'label' => 'Voir les prestataires'],
['code' => 'technique.providers.manage', 'label' => 'Créer / modifier les prestataires (hors onglet Comptabilité)'],
['code' => 'technique.providers.accounting.view', 'label' => 'Voir l\'onglet Comptabilité d\'un prestataire'],
['code' => 'technique.providers.accounting.manage', 'label' => 'Modifier l\'onglet Comptabilité d\'un prestataire'],
['code' => 'technique.providers.archive', 'label' => 'Archiver / restaurer un prestataire'],
```

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

### 5.2 Mapping rôles MALIO ↔ permissions

Cf. § 2.9 (matrice détaillée — identique à la matrice M2 transposée sur `technique.providers`) + § 2.13 (cloisonnement par site via `sites.bypass_scope`). **Attribution `sites.bypass_scope` par défaut** : Admin (auto) + Bureau + Compta + Commerciale ; **Usine non** (cloisonnée à son site).

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

1. **`config/sidebar.php`** — **nouvelle section « Technique »** + item :
```php
[
    'key'   => 'technique',
    'label' => 'sidebar.technique.section',
    'items' => [
        [
            'label'      => 'sidebar.technique.providers',
            'to'         => '/providers',
            'icon'       => 'mdi:account-wrench-outline',
            'module'     => 'technique',
            'permission' => 'technique.providers.view',
        ],
    ],
],
```

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` + `sites.bypass_scope`
   - Bureau / Compta : + `sites.bypass_scope` (voient tous les sites)
   - Usine : `view` **sans** `sites.bypass_scope` → cloisonné à son site (§ 2.13). Persona avec un `currentSite` positionné pour tester le filtre.

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

> ⚠ Les 3 sources doivent être touchées dans le **même commit** (sinon drift / test cassé).

### 5.4 Vérification front

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

## 6. Audit & dates

- `Provider`, `ProviderContact`, `ProviderAddress`, `ProviderRib` : `#[Auditable]`, tous champs audités (y compris `iban`/`bic`).
- Audit M2M automatique sur `provider.categories` et `provider.sites`.
- Timestampable + Blamable : pattern Shared standard (§ 2.8).
- Libellés i18n `audit.entity.technique_*` (§ 2.7).

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

> Les RG-3.03 → RG-3.08 reprennent le docx source. RG-3.01 / RG-3.02 sont **supprimées** (refonte-contact). Les RG-3.09 → RG-3.16 sont des **précisions back** (miroir M2) explicitement marquées.

### Formulaire principal

- ~~**RG-3.01**~~ _(SUPPRIMÉE — refonte-contact, 11/06)_ : le contact principal inline (Nom OU Prénom) est retiré du formulaire principal. Garantie « au moins un contact nommé » portée par **RG-3.04** + **RG-3.12** sur `ProviderContact`.
- ~~**RG-3.02**~~ _(SUPPRIMÉE du formulaire principal — refonte-contact)_ : plus de téléphones inline sur le formulaire principal. Le « maximum 2 téléphones » reste applicable aux blocs `ProviderContact` (`phonePrimary` + `phoneSecondary`).
- **RG-3.03** : Au moins un des 3 sites (86 / 17 / 82) doit être sélectionné sur le **formulaire principal** pour valider la création. `Assert\Count(min: 1)` sur `provider.sites` (M2M `provider_site`). **Spécificité M3** (le fournisseur n'avait pas de site sur le formulaire principal). **Écriture cloisonnée (§ 2.13)** : un user sans `sites.bypass_scope` ne peut choisir que des sites de ses `user_site` (sinon 422).

### Onglet Contact

- **RG-3.04** : Un bloc Contact est valide dès qu'**au moins 1 champ** est rempli (Nom, Prénom, Fonction, Téléphone ou Email). CHECK BDD `chk_provider_contact_name` (garde-fou minimal). Côté UI, le bouton « + Nouveau contact » est bloqué tant que le bloc en cours n'a aucun champ rempli.

### Onglet Adresse

- **RG-3.05** : Au moins un des 3 sites (86 / 17 / 82) doit être sélectionné sur **chaque adresse**. `Assert\Count(min: 1)` sur `providerAddress.sites` (M2M `provider_address_site`).
- **RG-3.06** : `city` préremplie depuis `postalCode` via l'API **BAN** (api-adresse.data.gouv.fr), appel **direct front** via `useAddressAutocomplete()` (réutilisé M1/M2). Si plusieurs villes correspondent → choix dans le select. 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.

### Onglet Comptabilité

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

### Précisions back (miroir M2)

- **RG-3.09** _(précision back)_ : les `Category` posées sur `provider.categories` ET sur `provider_address.categories` doivent être de **type `PRESTATAIRE`**. Toute catégorie d'un autre type → **422** (`categories: "Type de catégorie non autorisé (PRESTATAIRE attendu)."`). Front : multi-selects alimentés par `GET /api/categories?typeCode=PRESTATAIRE`.
- **RG-3.10** _(précision back)_ : `companyName` unique (case-insensitive) parmi les prestataires non archivés ET non soft-deletés (index partiel `uq_provider_company_name_active`). Doublon → 409 « Un prestataire nommé "{companyName}" existe déjà. » SIREN et email **non** uniques (§ 2.6).
- **RG-3.11** _(normalisation serveur)_ : `companyName` **UPPERCASE** ; `firstName`/`lastName` (sur `ProviderContact`) **Capitalize** ; téléphones **chiffres uniquement** ; `email` **lowercase**. Formatage `XX XX XX XX XX` à l'affichage front.
- **RG-3.12** _(front-driven)_ : au moins 1 bloc Contact valide pour finaliser l'onglet (cf. RG-3.04). Pas de test back.
- **RG-3.13** _(archivage)_ : PATCH `{ "isArchived": true }` exige `technique.providers.archive` (**Admin seul**). Pose `isArchived = true` + `archivedAt = now()`. Aucun autre champ dans la même requête.
- **RG-3.14** _(restauration)_ : PATCH `{ "isArchived": false }` exige la même permission. Pose `isArchived = false` + `archivedAt = null`. Conflit d'unicité (un autre prestataire actif a pris le nom) → 409.
- **RG-3.15** _(PATCH mix de groupes, mode strict)_ : un PATCH mélangeant plusieurs groupes 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.
- **RG-3.16** _(liste / tri)_ : `GET /api/providers` 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`.
- **RG-3.17** _(cloisonnement par site — § 2.13)_ : un user **sans** `sites.bypass_scope` ne voit (liste + détail) que les prestataires dont `provider.sites` contient son `currentSite`. Liste : filtrée avant pagination (`totalItems` = périmètre user). Détail hors périmètre → **404**. Users `bypass_scope` (Admin auto) → tous sites. Cloisonnement **piloté par l'utilisateur, pas par le rôle**.

## 8. Tests à automatiser

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

- [ ] **RG-3.03** : POST prestataire sans site → 422 ; avec ≥ 1 site → 201
- [ ] **RG-3.04** : POST contact totalement vide → 422 (CHECK) ; 1 champ rempli → 200
- [ ] **RG-3.05** : POST adresse sans aucun site → 422
- [ ] **RG-3.06** : POST adresse `postalCode` invalide (3 chiffres) → 422 ; CP/ville incohérents → 200 (pas de contrôle strict)
- [ ] **RG-3.07** : POST Comptabilité `paymentType=VIREMENT` sans `bank` → 422 ; avec `bank` → 200
- [ ] **RG-3.08** : POST `paymentType=LCR` sans RIB → 422 ; DELETE du dernier RIB en LCR → 409
- [ ] **RG-3.09** : POST `categories` avec une `Category` de type ≠ PRESTATAIRE → 422 (sur provider ET sur provider_address)
- [ ] **RG-3.10** : POST `companyName` déjà pris → 409 ; même nom après archivage de l'ancien → 201 ; SIREN/email dupliqués → 201
- [ ] **RG-3.11** : POST `companyName="maintenance pro"` → persiste `"MAINTENANCE PRO"` ; normalisation `firstName`/`phonePrimary`/`email` testée via un bloc `ProviderContact`
- [ ] **RG-3.13/14** : PATCH isArchived=true par Bureau (sans `archive`) → 403 ; par Admin → 200 + archivedAt rempli ; restauration en conflit de nom → 409
- [ ] **RG-3.15** : Bureau PATCH `{companyName, siren}` → 403 sur tout le payload (strict)
- [ ] **RG-3.16** : 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
- [ ] **🔴 Cloisonnement par site (RG-3.17 / § 2.13)** : user **sans** `bypass_scope`, `currentSite = 86` → la liste ne contient QUE les prestataires rattachés au site 86 (assertion sur `member` + `totalItems`) ; GET détail d'un prestataire site 17 → **404** ; user `bypass_scope` (admin) → voit tous les sites ; **écriture cloisonnée** : POST/PATCH par un user non-bypass avec un site hors de ses `user_site` (formulaire principal OU adresse) → 422 ; avec uniquement ses propres sites → 201/200
- [ ] **Compta** : GET prestataire retourne les champs accounting ; PATCH accounting → 200 ; PATCH contacts/adresses → 403 ; POST création → 403
- [ ] **Commerciale** : GET prestataire **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)
- [ ] **🔴 Sérialisation booléen (bug #3 M1)** : GET détail expose bien la clé `isArchived` dans le JSON réel
- [ ] **Embed relations (bugs #1/#2 M1)** : GET **liste ET détail** → `categories[].code` + `.name` présents ; `sites[]` (relation directe) exposent `name` + `postalCode` (objet Site entier, PAS un IRI nu) ; `addresses[].sites[]` au détail
- [ ] **Filtre typeCode** : `GET /api/categories?typeCode=PRESTATAIRE` ne renvoie QUE les catégories de type PRESTATAIRE
- [ ] **Anti N+1 liste (§ 2.12)** : sur `GET /api/providers` avec N prestataires, nombre de requêtes SQL constant
- [ ] **Audit** : POST + PATCH + archive → audit_log `entity_type='Provider'`, `changes` correct ; iban/bic présents dans le diff ; M2M `sites`/`categories` tracés
- [ ] **Pagination** (règle n°13) : enveloppe Hydra (`totalItems` / `view`) ; `?pagination=false` renvoie tout
- [ ] **Migration** : `make db-reset` → schéma OK ; namespace racine ; CategoryType PRESTATAIRE présent APRÈS db-reset (fixture idempotente) ; index partiel `uq_provider_company_name_active` présent ; **toutes les colonnes ont un `COMMENT ON COLUMN`** (`ColumnsHaveSqlCommentTest` vert)
- [ ] **i18n audit** : `audit.entity.technique_provider`… présents (`AuditableEntitiesHaveI18nLabelTest` vert)

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

- [ ] `usePaginatedList({url:'/providers'})` : exclusion archivés par défaut, envelope Hydra
- [ ] `useProviderForm()` : workflow par onglet (validation incrémentale, PATCH partiel) — **sans onglet Information**
- [ ] `useAddressAutocomplete()` : réutilisation M1/M2 (nominal + dégradé) — pas de nouveau test si déjà couvert
- [ ] Sélecteur de site formulaire principal (RG-3.03) : ≥ 1 requis
- [ ] `<ProvidersRepositoryPage>` : `<MalioDataTable>` + « + Ajouter » → `/providers/new`
- [ ] Permissions : Compta accède à `/providers/{id}` mais onglet Comptabilité éditable seul ; Commerciale ne voit pas l'onglet Comptabilité
- [ ] `useFormErrors` : mapping 422 inline par champ (formulaire principal + blocs)

### 8.3 Tests E2E

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

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

`ProviderFixtures` idempotent couvrant tous les cas des RG :
- Catégories de type PRESTATAIRE seedées (au moins « Maintenance industrielle », « Nettoyage », « Transport »).
- ≥ 1 prestataire **complet** (≥ 1 site sur le formulaire principal, ≥ 1 contact, ≥ 1 adresse multi-sites, comptabilité + RIB).
- 1 prestataire **en LCR avec RIB** (RG-3.08) et 1 **en VIREMENT avec banque** (RG-3.07).
- 1 prestataire **archivé** (vérifier exclusion liste + restauration).
- Réutiliser les comptes de rôles démo (`bureau`, `compta`, `commerciale`, `usine`, `admin`).

> Idempotence obligatoire (le purger Doctrine vide `category`/`category_type` au `db-reset`). Le `CategoryType PRESTATAIRE` 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** capturées (§ 4.0.bis) — liste + détail (avec/sans `accounting.view`) collés depuis `ProviderSerializationContractTest` (ERP-139)
- [x] Matrice RBAC rôle × onglet + mode strict PATCH (§ 2.9 / RG-3.15)
- [x] Pagination (n°13), COMMENT ON COLUMN (n°12), Timestampable/Blamable, Audit + i18n, routes à plat : rappelés
- [x] Réutilisations M1/M2 identifiées (référentiels compta partagés, taxonomie code/type, filtre `?typeCode=`, `usePaginatedList`, blocs, archive, normalisation, `useAddressAutocomplete`)
- [x] Seed/fixtures démo planifiés (§ 8.4)
- [x] **Décisions tranchées (Matthieu, 11/06)** : module `Technique` (§ 2.1) ✅ ; référentiels comptables « comme supplier » (ORM partagée) ✅ ; cloisonnement par site piloté user via `sites.bypass_scope` (§ 2.13 / RG-3.17) ✅ ; unicité nom seul (§ 2.6) ✅

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

- **HP-M4-2** : **Remontée des référentiels comptables dans `Shared`** (ou module neutre) si isolation stricte souhaitée (cf. § 2.1). _NB : décision M3 = consommation ORM partagée, comme `Supplier` (validée Matthieu, 11/06)._
- _**(ex-HP-M4-1 — DÉSORMAIS DANS LE PÉRIMÈTRE M3)**_ : le **cloisonnement par site** (visibilité prestataires selon le site de l'utilisateur) est implémenté au M3 — cf. § 2.13 + RG-3.17. Le « bypass multi-sites » passe par `sites.bypass_scope`.
- **HP-M4-3** : **DELETE / soft delete d'un prestataire** (colonne `deleted_at` préparée, non exposée au M3).
- **HP-M4-4** : **CRUD admin des référentiels comptables** (`TvaMode` / `PaymentDelay` / `PaymentType` / `Bank`) — partagés, seed seulement.
- **HP-M4-5** : **CRUD admin de `CategoryType`** (le M3 seed seulement le type PRESTATAIRE).
- **HP-M4-6** : **Onglet Rapports** (front placeholder « À venir » ; aucun modèle ni API back).
- **HP-M4-7** : **Onglet Échanges** (placeholder « À venir »).
- **HP-M4-8** : **Validation IBAN/BIC stricte** (au M3, `Assert\Iban` / `Assert\Bic` standard sur `ProviderRib`).
- **HP-M4-9** : **Validation SIREN stricte** (Luhn) — au M3, `Assert\Length(9)` + `Assert\Regex('/^\d{9}$/')`.
- **HP-M4-10** : **Référencement entrant** (modules futurs ajoutant une FK `provider_id` : interventions, maintenance, etc.).
- **HP-M4-11** : **Export CSV** (XLSX uniquement au M3).
- **HP-M4-12** : **Liaison Prestataire ↔ Fournisseur / Client** (un même tiers multi-rôles). Au M3, entités strictement séparées.

## 10. Liens & dépendances

### Liens

- Spec front : [`./spec-front.md`](./spec-front.md)
- Spec M2 fournisseurs (pattern de référence direct) : [`../M2-suppliers/spec-back.md`](../M2-suppliers/spec-back.md)
- Spec M1 clients : [`../M1-clients/spec-back.md`](../M1-clients/spec-back.md)
- RETEX sérialisation : [`../_RETEX-M1-pour-M2.md`](../_RETEX-M1-pour-M2.md)
- Doc audit-log : [`../../audit-log.md`](../../audit-log.md)
- Site-aware (périmètre Usine) : [`../../modules/site-aware.md`](../../modules/site-aware.md)
- BAN api : `https://adresse.data.gouv.fr/api-doc/adresse`
- Trace fonctionnelle : `M3-reportoire-prestataires.docx` (V0.2) / `M3-reportoire-prestataires-V01.pdf` (V0.1, obsolète)

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

- Module `Commercial` : référentiels comptables `TvaMode` / `PaymentDelay` / `PaymentType` / `Bank` (**partagés**, relation ORM)
- Module `Catalog` (M0) : `Category` + `CategoryType` + **filtre `?typeCode=`** (créé au M2) (+ seed type PRESTATAIRE au M3)
- Module `Sites` : `Site` (3 sites 86/17/82) — M2M `provider_site` + `provider_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 M3

- **M-Interventions / Maintenance** : FK `provider_id`.

---

## 📦 Tickets Lesstime (à découper)

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

Ordre indicatif (back avant front, migration en tête) :
0. **Module `Technique` + Taxonomie PRESTATAIRE** — créer `TechniqueModule` (ID/LABEL/REQUIRED/permissions) + activer dans `config/modules.php` + layer front `modules/technique/` ; seed `CategoryType PRESTATAIRE` (migration `ON CONFLICT` + fixture idempotente) + catégories prestataires ; **vérifier** que le filtre `?typeCode=PRESTATAIRE` fonctionne. Prérequis du multi-select Catégorie.
1. **Migration BDD M3** (tables provider + provider_site + sous-collections + M2M + index partiel + COMMENT ON COLUMN)
2. **Entités + Repositories** (Provider, ProviderContact, ProviderAddress, ProviderRib) + **hydratation liste** (categories, sites — § 2.12)
3. **Provider + Processor** (ProviderProvider paginé, ProviderProcessor — normalisation, archivage, accounting conditionnel, mode strict, gating) + **filtre de cloisonnement par site** (§ 2.13 / RG-3.17 : `ProviderSiteScopeExtension` réutilisant `CurrentSiteProvider` + `sites.bypass_scope` ; liste filtrée, détail 404 hors périmètre)
4. **Sous-ressources** (ProviderContactProcessor, ProviderAddressProcessor, ProviderRibProcessor)
5. **Validators** (contrôle catégorie type PRESTATAIRE, RG-3.07/3.08, ≥1 site formulaire principal RG-3.03)
6. **Export XLSX** (ProviderExportController, priority:1)
7. **RBAC** : `TechniqueModule::permissions()` + sync 3 sources + tests personas
8. **Tests PHPUnit** : matrice RG-3.03 → RG-3.16 (§ 8.1) + capture JSON réel (§ 4.0.bis)
9. **Front : page Répertoire** (`/providers`) + `usePaginatedList`
10. **Front : page Création** (`/providers/new`) + `useProviderForm` (sans onglet Information)
11. **Front : page Consultation** (`/providers/{id}`) + onglets placeholder « À venir » (Rapports / Échanges)
12. **Front : page Modification** (`/providers/{id}/edit`)
13. **i18n + Sidebar** (section `sidebar.technique.section` + `sidebar.technique.providers` + permission, traductions, libellés audit)

### Actions manuelles dans Lesstime (Matthieu)

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

### ✅ Décisions tranchées (Matthieu, 11/06/2026)

1. **Module `Technique`** (§ 2.1) — nouveau module back + section sidebar « Technique ». ✅
2. **Référentiels comptables** — « comme supplier » : consommation ORM partagée (pas de remontée dans `Shared`). ✅
3. **Cloisonnement par site** (§ 2.13 / RG-3.17) — visibilité pilotée par l'**utilisateur** (son `currentSite`), automatique côté back ; bypass multi-sites via `sites.bypass_scope` (Admin auto + Bureau/Compta/Commerciale ; **Usine cloisonnée**). Indépendant du rôle. ✅
4. **Unicité = nom seul** (§ 2.6). ✅

5. **Écriture cloisonnée** (§ 2.13 / RG-3.03 / RG-3.05) — un user non-bypass ne peut attacher que **les sites dont il dispose** (`user_site`), formulaire principal ET adresses ; site hors périmètre → 422. ✅

### ⚠️ Point de raffinement à confirmer (non bloquant)

- **Attribution `sites.bypass_scope`** : confirmer la liste des profils « voient tous les sites » (défaut : Admin + Bureau + Compta + Commerciale ; Usine non).
