diff --git a/config/modules.php b/config/modules.php
index c4f8f54..1681bb8 100644
--- a/config/modules.php
+++ b/config/modules.php
@@ -5,10 +5,12 @@ use App\Module\Catalog\CatalogModule;
use App\Module\Commercial\CommercialModule;
use App\Module\Core\CoreModule;
use App\Module\Sites\SitesModule;
+use App\Module\Technique\TechniqueModule;
return [
CoreModule::class,
CommercialModule::class,
SitesModule::class,
CatalogModule::class,
+ TechniqueModule::class,
];
diff --git a/docs/specs/M3-prestataires/spec-back.md b/docs/specs/M3-prestataires/spec-back.md
new file mode 100644
index 0000000..b9a1dbd
--- /dev/null
+++ b/docs/specs/M3-prestataires/spec-back.md
@@ -0,0 +1,1013 @@
+---
+# === 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, '
')`.
+
+### 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** (`/providers/{id}/...`) : le détail étant déjà gardé en 404 hors périmètre, les sous-ressources héritent du garde-fou parent (cf. `site-aware.md § 6.1`).
+
+> **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
+ ['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 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 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 */
+ #[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[Groups(['provider:item:read'])]
+ private Collection $contacts;
+
+ /** @var Collection */
+ #[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[Groups(['provider:item:read'])]
+ private Collection $addresses;
+
+ /** @var Collection 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`).
+
+`GET /api/providers` (liste, ADMIN — un membre, forme attendue) :
+```json
+{
+ "@context": "/api/contexts/Provider",
+ "@id": "/api/providers",
+ "@type": "Collection",
+ "totalItems": 1,
+ "member": [
+ {
+ "@id": "/api/providers/1", "@type": "Provider", "id": 1,
+ "companyName": "MAINTENANCE PRO SAS",
+ "categories": [
+ {"@type": "Category", "@id": "/api/categories/300", "id": 300, "name": "Maintenance industrielle", "code": "MAINTENANCE",
+ "categoryType": {"@id": "/api/category_types/3", "@type": "CategoryType", "id": 3, "code": "PRESTATAIRE", "label": "Prestataire"}}
+ ],
+ "sites": [
+ {"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}
+ ],
+ "siren": "987654321", "accountNumber": "P0001",
+ "tvaMode": {"@id": "/api/tva_modes/30", "@type": "TvaMode", "id": 30, "code": "FRANCE_VENTES", "label": "France (ventes)"},
+ "paymentType": {"@id": "/api/payment_types/14", "@type": "PaymentType", "id": 14, "code": "LCR", "label": "LCR"},
+ "ribs": [
+ {"@id": "/api/provider_ribs/1", "@type": "ProviderRib", "id": 1, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606"}
+ ],
+ "updatedAt": "2026-06-11T10:00:00+02:00",
+ "isArchived": false
+ }
+ ],
+ "view": {"@id": "/api/providers", "@type": "PartialCollectionView"}
+}
+```
+
+> Les prestataires archivés sont **exclus** du `totalItems` (RG-3.16). Pour la **Commerciale** (sans `accounting.view`), `siren`/`tvaMode`/`paymentType`/`ribs`… **disparaissent** de chaque membre.
+
+`GET /api/providers/{id}` (détail — user avec `accounting.view`, forme attendue) :
+```json
+{
+ "@id": "/api/providers/1", "@type": "Provider", "id": 1,
+ "companyName": "MAINTENANCE PRO SAS",
+ "categories": [{"@type": "Category", "@id": "/api/categories/300", "id": 300, "name": "Maintenance industrielle", "code": "MAINTENANCE"}],
+ "sites": [{"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}],
+ "siren": "987654321", "accountNumber": "P0001",
+ "tvaMode": {"@id": "/api/tva_modes/30", "@type": "TvaMode", "id": 30, "code": "FRANCE_VENTES", "label": "France (ventes)"},
+ "nTva": "FR00987654321",
+ "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/provider_contacts/1", "@type": "ProviderContact", "id": 1, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test"}
+ ],
+ "addresses": [
+ {"@id": "/api/provider_addresses/1", "@type": "ProviderAddress", "id": 1, "country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias",
+ "sites": [{"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}],
+ "contacts": [{"@id": "/api/provider_contacts/1", "@type": "ProviderContact", "id": 1, "firstName": "Marie", "lastName": "Martin"}],
+ "categories": [{"@type": "Category", "@id": "/api/categories/300", "id": 300, "name": "Maintenance industrielle", "code": "MAINTENANCE"}]}
+ ],
+ "ribs": [{"@id": "/api/provider_ribs/1", "@type": "ProviderRib", "id": 1, "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** (gating par omission — à confirmer par test).
+
+### 4.1 `GET /api/providers` — Liste
+
+- **Security** : `is_granted('technique.providers.view')`
+- **Query params** (alimentent le panneau « Filtrer ») :
+ - `includeArchived=true|false` (default `false`)
+ - `categoryCode=` (filtre les prestataires ayant ≥ 1 `Category` de ce code ; répétable)
+ - `siteId=` (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=` (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
+- [ ] `` : `` + « + 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**
+- [ ] **Réponses JSON RÉELLES** à capturer (§ 4.0.bis) — gabarit posé, capture à faire au 1er ticket back (DoD avant front)
+- [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).
diff --git a/docs/specs/M3-prestataires/spec-front.md b/docs/specs/M3-prestataires/spec-front.md
new file mode 100644
index 0000000..bc6720b
--- /dev/null
+++ b/docs/specs/M3-prestataires/spec-front.md
@@ -0,0 +1,339 @@
+---
+# === 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) — Restitution Markdown du docx « M3-reportoire-prestataires.docx » (04/06/2026).
+# Alignement refonte-contact (comme M1/M2) : le contact principal inline du formulaire principal
+# du PDF V0.1 (Nom contact / Prénom contact / Téléphone + / Email) est RETIRÉ — saisie via
+# l'onglet Contacts uniquement (décision Matthieu, 11/06 : « oublie le contact inline, comme client »).
+# RG-3.01 / RG-3.02 (contact inline + max 2 tél sur le formulaire principal) supprimées en conséquence.
+# V0.1 (PDF) — version fonctionnelle plus ancienne, NON retenue (contact inline sur le formulaire principal).
+
+# === LIENS ===
+maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-42090&p=f&m=dev"
+regles_metier: [RG-3.03, RG-3.04, RG-3.05, RG-3.06, RG-3.07, RG-3.08, RG-3.09, RG-3.10, RG-3.11, RG-3.12, RG-3.13, RG-3.14, RG-3.15, RG-3.16, RG-3.17]
+roles: [Admin, Bureau, Compta, Commerciale, Usine]
+lien_spec_back: ./spec-back.md
+
+# === VALIDATION CLIENT ===
+client_validation_1:
+ statut: validee
+ date: 2026-05-22
+ version: V0
+ valide_par: "Matthieu (CP MALIO)"
+client_validation_2:
+ statut: validee
+ date: 2026-06-01
+ version: V0.1
+ valide_par: "Matthieu (CP MALIO)"
+client_validation_3:
+ statut: a_valider
+ date: 2026-06-04
+ version: V0.2
+ resume: "Module 3 — Répertoire prestataires. Pôle Technique (nouvelle section sidebar). Datatable + 3 écrans (Ajouter / Consulter / Modifier). Création par onglets : Contact / Adresse / Comptabilité (Rapports, Échanges = placeholders 'À venir'). PAS d'onglet Information. Sélecteur de site aussi sur le formulaire principal."
+ trace_archivee: "uploads/M3-reportoire-prestataires.docx (V0.2) + M3-reportoire-prestataires-V01.pdf (V0.1, obsolète)"
+
+# === LIEN LESSTIME ===
+lesstime_taskgroup_id: 29 # M3 — Répertoire prestataires (projet STARSEED #6)
+lesstime_project_id: 6
+statut_global: en_dev
+---
+
+# Module 3 — Répertoire prestataires (V0.2 front)
+
+> **Origine** : spec fonctionnelle `M3-reportoire-prestataires.docx` (V0.2 du 04/06/2026 ; historique V0 22/05 → V0.1 01/06). Restitution Markdown pour intégration au workflow MALIO. Le contenu fonctionnel original n'est pas modifié, **sauf** l'alignement refonte-contact (cf. ci-dessous). Toute décision technique (back) vit dans [`spec-back.md`](./spec-back.md). Le M3 réutilise massivement le pattern et les composants posés au [M1 clients](../M1-clients/spec-front.md) et au [M2 fournisseurs](../M2-suppliers/spec-front.md).
+
+> **⚠️ Alignement refonte-contact (décision Matthieu, 11/06/2026)** : le PDF V0.1 portait un **contact principal inline** sur le formulaire principal (Nom du contact / Prénom du contact / Téléphone + bouton + / Email) avec RG-3.01 (Nom OU Prénom) et RG-3.02 (max 2 téléphones). Ce contact inline est **retiré**, exactement comme l'a fait M1/M2 (refonte-contact). Les coordonnées du contact se saisissent **uniquement dans l'onglet Contacts**. **RG-3.01 et RG-3.02 sont donc supprimées du formulaire principal** ; la garantie « au moins un contact nommé » est portée par RG-3.04 + RG-3.12, et le « maximum 2 téléphones » s'applique aux blocs Contact.
+
+> **⚠️ Décision d'architecture (à confirmer) — pôle « Technique »** : le docx place le répertoire prestataires dans un **Module « Technique »**. Confirmé par Matthieu (11/06) : c'est bien un **nouveau pôle Technique**, distinct du Commercial. Côté front cela se traduit par une **nouvelle section sidebar « Technique »** (route `/providers`). Côté back, voir [`spec-back.md § 2.1`](./spec-back.md) (nouveau module `Technique`, entités jumelles du fournisseur, référentiels comptables consommés en relation ORM partagée).
+
+## But
+
+Lister tous les prestataires de l'organisation et accéder rapidement à leurs fiches : consultation, création, modification, archivage. C'est la **porte d'entrée du pôle Technique**.
+
+## Accès
+
+- **Depuis** : menu principal → section **Technique** → entrée « Répertoire prestataires » (route `/providers`).
+- **Rôles autorisés** (tableau « Rôles & permissions » du docx) :
+
+| Rôle | Consultation | Création / Modification | Archivage |
+|---|---|---|---|
+| **Admin** | ✅ Tout | ✅ Tout | ✅ |
+| **Bureau** | ✅ Tout | ✅ Tout sauf onglet Comptabilité | ❌ |
+| **Compta** | ✅ Tout | ✅ Onglet Comptabilité uniquement | ❌ |
+| **Commerciale** | ✅ Tout sauf Comptabilité | ✅ Tout sauf Comptabilité | ❌ |
+| **Usine** | ✅ Son site uniquement | — | ❌ |
+
+> **Notes** :
+> - RBAC transposée sur `technique.providers.*` (cf. [`spec-back.md § 2.9 / § 5`](./spec-back.md)). Compta édite uniquement l'onglet Comptabilité d'un prestataire existant ; Compta ne peut pas **créer** un prestataire. **L'archivage est réservé à Admin**.
+> - **Cloisonnement par site (décision 11/06 — DANS LE PÉRIMÈTRE M3)** : « Tout » vs « son site uniquement » n'est **pas porté par le rôle** mais par l'**utilisateur**. Chaque user a un site courant ; **par défaut il ne voit que les prestataires rattachés à son site**. Les profils qui doivent voir tous les sites (Admin, et par défaut Bureau / Compta / Commerciale) ont la permission `sites.bypass_scope` (Admin l'a automatiquement). **Usine** n'a pas le bypass → cloisonnée à son site. Filtrage **automatique côté back** (cf. [`spec-back.md § 2.13`](./spec-back.md)) — aucun filtre à coder côté front.
+
+## Navigation
+
+Page d'entrée du pôle **Technique** (route `/providers`). Titre : « **Répertoire prestataires** ».
+
+- Affichage principal : un **datatable** listant tous les prestataires **actifs** (les archivés sont masqués par défaut — toggle/filtre dédié).
+- **Clic sur une ligne** → écran **Consultation prestataire** (page dédiée).
+- **Bouton « + Ajouter »** (haut droite) → écran **Ajouter un prestataire**.
+- **Bouton « Filtrer »** (haut droite) → panneau de filtres (cf. ci-dessous).
+- **Bouton « Exporter »** (haut droite) → télécharge un **XLSX** des prestataires **affichés** (cf. filtres actifs). Format dans [`spec-back.md § 4.6`](./spec-back.md).
+
+### Panneau de filtres (bouton « Filtrer »)
+
+Réutilise le pattern M1/M2. Filtres branchés sur les query params de `GET /api/providers` (cf. [`spec-back.md § 4.1`](./spec-back.md)) :
+
+| Filtre | Composant | Query param back |
+|---|---|---|
+| **Recherche** (nom entreprise / contact / email) | `` | `?search=` |
+| **Catégorie** | `` (multi, type PRESTATAIRE) | `?categoryCode=` |
+| **Site** | `` (86 / 17 / 82) | `?siteId=` |
+| **Inclure les archivés** | `` | `?includeArchived=true` |
+
+- À l'application des filtres → `setFilters(...)` de `usePaginatedList` (retombe en **page 1**), qui relance `GET /api/providers`.
+- **État 100 % local** (jamais dans l'URL — règle ABSOLUE n°6).
+
+## Datatable du Répertoire
+
+Composant : `` branché sur `usePaginatedList({ url: '/providers' })` (règle frontend obligatoire — pagination Hydra, état 100 % local). Colonnes (alignées M2) :
+
+| Colonne | Source | Tri |
+|---|---|---|
+| **Nom** | `provider.companyName` | ASC par défaut |
+| **Catégories** | `provider.categories[].name` (embarquées en liste — cohérence M1/M2 ; libellé = `name`, pas `label`) | Non |
+| **Site** | `provider.sites[].name` (sites du prestataire — cf. note ci-dessous) | Non |
+| **Dernière activité** | `provider.updatedAt` (format `JJ-MM-AAAA`) — exposé dans `provider:read` | Oui |
+
+> **Source de la colonne « Site »** : le M3 porte un sélecteur de site **sur le formulaire principal** (RG-3.03) — donc `provider.sites[]` est une relation **directe** du prestataire (≠ M2 où les sites venaient de l'agrégat des adresses). La colonne liste affiche ces sites directs. Voir [`spec-back.md § 2.12`](./spec-back.md).
+> **Clic sur une ligne** → écran Consultation. **Pagination** : standard Starseed 10 / 25 / 50 (défaut 10). Tri serveur `companyName ASC` par défaut.
+
+## Écran « Ajouter un prestataire »
+
+Création par **onglets successifs avec validation incrémentale** : pour passer à l'onglet suivant, il faut avoir validé l'onglet en cours. **Une fois un onglet validé, on passe automatiquement au suivant** ; les champs validés passent en lecture seule + bouton « Valider » désactivé (disabled). Cf. [`spec-back.md § 2.10`](./spec-back.md) (PATCH partiels par groupe de sérialisation).
+
+**Accès** : bouton « + Ajouter » du Répertoire. **Rôles** : Admin, Bureau.
+
+**Barre d'onglets en création (3 onglets)** : `Contact` · `Adresse` · `Comptabilité`. Les onglets `Rapports` et `Échanges` **n'apparaissent PAS dans le flux de création** — ils ne sont présents qu'en Consultation / Modification (placeholders « À venir »).
+
+> **Différence majeure avec M2 : PAS d'onglet « Information ».** Le M3 n'a aucun champ Description / Concurrent / Date création / Salariés / CA / Dirigeant / Résultat / Volume. Le formulaire principal est minimal (3 champs).
+
+> **Règle « placeholder par défaut » (convention MALIO)** : tout onglet ou écran que la spec ne détaille pas explicitement (ici **Rapports** et **Échanges**) est livré en **placeholder « À venir »** (frame vide, navigable, pas de validation ni d'API), à l'identique des autres modules (M1/M2). Aucun champ inventé hors spec.
+
+### Formulaire principal (pré-onglets)
+
+1er bloc à remplir. Sans validation, les onglets ne sont pas accessibles. Une fois validé → POST `/api/providers`, puis bascule sur l'onglet Contact ; les champs passent en readonly.
+
+| Champ | Type composant | Obligatoire | Règle |
+|---|---|---|---|
+| **Nom du prestataire (Entreprise)** | `` | Oui | RG-3.11 (UPPERCASE serveur) ; RG-3.10 (unicité) |
+| **Catégorie** | `` (multi) | Oui | `Category` de **type PRESTATAIRE** via `GET /api/categories?typeCode=PRESTATAIRE` (RG-3.09). Libellé affiché = `category.name`. |
+| **Sélecteur de site** | `` (86 / 17 / 82) | Oui | RG-3.03 — ≥ 1 site. Les 3 cases = les 3 `Site` fixes ; libellés « 86/17/82 » = **préfixe du `postalCode`** (86100 / 17400 / 82400), pas un `Site.code` (qui n'existe pas). La sélection stocke des **IDs de Site** (M2M `provider_site`). |
+
+**Action** : « Valider » (``) → POST `/api/providers` ([`spec-back.md § 4.3`](./spec-back.md)). Succès → onglet « Contact ».
+
+### Onglet « Contact »
+
+Saisir un ou plusieurs contacts. Au moins un bloc Contact valide est requis (RG-3.12). **(Refonte-contact : pas de pré-remplissage depuis le formulaire principal ; les coordonnées du contact se saisissent directement ici.)**
+
+**Bloc Contact** :
+
+| Champ | Type | Obligatoire | Règle |
+|---|---|---|---|
+| **Nom** | `` | Conditionnel | RG-3.04 + RG-3.11 (Capitalize) |
+| **Prénom** | `` | Conditionnel | RG-3.04 + RG-3.11 (Capitalize) |
+| **Fonction** | `` | Non | — |
+| **Téléphone** (x1, +1 possible, **max 2**) | `` | Non | RG-3.11 (format) ; max 2 téléphones par contact |
+| **Email** | `` type email | Non | RG-3.11 (lowercase) |
+
+**RG-3.04 / RG-3.12** : un bloc Contact est valide dès qu'au moins 1 champ est rempli ; au moins 1 bloc Contact valide pour finaliser l'onglet — l'onglet Contact ne peut pas être validé vide.
+
+**Actions** :
+- « + Nouveau contact » : ajoute un bloc. **Désactivé tant que le bloc précédent n'a pas au moins 1 champ rempli** (RG-3.04).
+- « Supprimer » (icône) : modal de confirmation, puis suppression du bloc.
+- « Valider » → PATCH `/api/providers/{id}/contacts`.
+
+### Onglet « Adresse »
+
+Saisir une ou plusieurs adresses, rattachées à un ou plusieurs sites (86 / 17 / 82) et à des contacts.
+
+**Bloc Adresse** :
+
+| Champ | Type | Obligatoire | Règle |
+|---|---|---|---|
+| **Sélecteur de site** | `` (86 / 17 / 82) | Oui | RG-3.05 — ≥ 1 site. Stocke des IDs de Site (M2M `provider_address_site`). |
+| **Adresse** | `` (saisie assistée) | Oui | RG-3.06 — autocomplete BAN |
+| **Adresse complémentaire** | `` | Non | — |
+| **Code postal** | `` (saisie assistée) | Oui | RG-3.06 — déclenche autocomplete ville (BAN) |
+| **Ville** | `` (saisie assistée) | Oui | RG-3.06 — alimentée par api-adresse.data.gouv.fr suivant le CP ; si plusieurs villes, choix dans le select |
+| **Pays** | `` (préremplie « France ») | Oui | — |
+| **Catégories** | `` (multi) | Oui | Catégories de type PRESTATAIRE (RG-3.09) |
+| **Contact** | `` (multi) | Non | Liste = blocs Contact saisis dans l'onglet Contact |
+
+> **Différence avec M2** : l'adresse prestataire n'a **PAS** de Type d'adresse (Prospect/Départ/Rendu), **PAS** de Bennes, **PAS** de Prestation de triage. C'est une adresse « simple » (site + adresse postale + catégories + contacts).
+
+**Actions** :
+- « + Nouvelle Adresse » : ajoute un bloc identique au premier.
+- « Supprimer » (icône) : modal de confirmation puis suppression.
+- « Valider » → PATCH `/api/providers/{id}/addresses`.
+
+### Onglet « Comptabilité »
+
+⚠ **Accessible aux rôles avec `technique.providers.accounting.view`** (Admin + Compta). Bureau et Commerciale ne voient pas l'onglet. **Compta peut éditer** cet onglet (`accounting.manage`). Compta ne peut pas créer un prestataire (pas de `manage` global).
+
+**Champs comptables** :
+
+| Champ | Type | Obligatoire | Règle |
+|---|---|---|---|
+| **SIREN** | `` (masque 9 chiffres) | Oui | 9 chiffres. **Pas d'unicité** (cf. [`spec-back.md § 2.6`](./spec-back.md)) |
+| **Numéro de compte** | `` | Oui | — |
+| **Mode de TVA** | `` | Oui | Liste depuis `/api/tva_modes` (référentiel partagé M1) |
+| **N° de TVA** | `` | Oui | — |
+| **Délai de règlement** | `` | Oui | Liste depuis `/api/payment_delays` |
+| **Type de règlement** | `` | Oui | Liste depuis `/api/payment_types` |
+| **Banque** | `` | Conditionnel | RG-3.07 — visible et obligatoire **si** Type de règlement = `VIREMENT`. Liste depuis `/api/banks` (SG / CIC / CA). |
+
+**Bloc RIB** (0..n, présence obligatoire conditionnée par RG-3.08) :
+
+| Champ | Type | Obligatoire | Règle |
+|---|---|---|---|
+| **Libellé** | `` | Oui (si LCR) | RG-3.08 |
+| **BIC** | `` | Oui (si LCR) | RG-3.08 |
+| **IBAN** | `` | Oui (si LCR) | RG-3.08 |
+
+**Actions** :
+- « + RIB » : ajoute un bloc.
+- « Supprimer » (icône) : modal de confirmation.
+- « Valider » → PATCH `/api/providers/{id}` (groupe `provider:write:accounting`) + sous-ressource RIBs.
+
+## Écran « Consultation prestataire »
+
+Tous les champs en **lecture seule**. La page s'ouvre par défaut sur l'onglet **Contacts**. Layout identique à l'écran Ajouter mais sans bouton « Valider », sans `+` pour ajouter des blocs.
+
+- **Flèche retour** (gauche) → revient au Répertoire.
+- **Bouton « Modifier »** (droite, visible si `technique.providers.manage`) → écran Modification.
+- **Bouton « Archiver »** (droite, visible **uniquement Admin** via `technique.providers.archive`) → modal de confirmation, puis PATCH `/api/providers/{id}` `{ "isArchived": true }`.
+
+> Un prestataire archivé peut être restauré (`isArchived: false`) — bouton « Restaurer » remplace « Archiver » dans la consultation d'un archivé.
+
+### Onglets affichés en consultation
+
+`Contacts` · `Adresse` · `Rapports` · `Échanges` · `Comptabilité`. Navigation **libre** entre onglets (pas de séquence forcée). `Rapports` et `Échanges` = placeholders « À venir ». `Comptabilité` selon permission.
+
+- **Onglet Contacts** : un bloc par contact, 5 champs en lecture seule (Nom / Prénom / Fonction / Téléphone / Email).
+- **Onglet Adresse** : un bloc par adresse, en lecture seule (Sélecteur de site / Adresse / Adresse complémentaire / Code postal / Ville / Pays / Catégorie / Contact).
+- **Onglet Comptabilité** : bloc principal (champs comptables) + un bloc par RIB. Le champ **Banque** n'apparaît que si Type de règlement = Virement (RG-3.07).
+
+## Écran « Modification prestataire »
+
+Comportement identique à l'écran Ajouter (mêmes formulaires, mêmes RG-3.03 → RG-3.08) sauf :
+- **Pas de formulaire principal** réaffiché (champs principaux édités via l'onglet correspondant / pré-remplis).
+- Les champs sont **pré-remplis** avec les valeurs actuelles du prestataire.
+- **Validation par onglet** : on peut modifier UN onglet sans toucher aux autres (PATCH partiel).
+- Les onglets pour lesquels l'utilisateur n'a **pas** la permission `manage` (ou `accounting.manage`) restent en **lecture seule** (pas de bouton Valider, pas d'icône suppression).
+- **Accès** : Admin, Bureau (Compta pour l'onglet Comptabilité uniquement).
+
+## Composants UI à utiliser (`@malio/layer-ui`)
+
+- **Datatable** : `` (+ `usePaginatedList`)
+- **Input texte** : ``
+- **Select simple** : `` (Pays, Ville, référentiels comptables)
+- **Select multi (cases à cocher)** : `` (Catégorie, Sites, Contacts rattachés)
+- **Bouton** : ``, ``
+- **Toasts** : standards via `useApi()`
+- **Validation par champ** : `useFormErrors` (mapping 422 inline — règle frontend obligatoire)
+
+**Exceptions autorisées** (commenter `// TODO migrer quand Malio couvre`) :
+- Modal de confirmation : `` ou wrapper partagé dans `frontend/shared/` (réutiliser celui du M1/M2).
+
+## Composables & appels API
+
+- `usePaginatedList({ url: '/providers' })` — liste paginée (obligatoire). La liste consomme `categories[]` (libellé = `name`) et `sites[]` (libellé = `name`, pas de `code`) **embarqués** + `updatedAt` (cf. [`spec-back.md § 2.12 / § 4.0`](./spec-back.md)).
+- `useProvider(id)` — charge le détail via `GET /api/providers/{id}`, qui **embarque** `contacts`, `addresses` (avec `sites` / `categories` / `contacts` imbriqués) et, si permission, `ribs` + scalaires compta. Écrans Consultation et Modification peuplés depuis cette seule réponse (RETEX M1 §2 : embed borné, pas de N+1). **DoD avant intégration** : vérifier que le JSON réel contient ces blocs (cf. [`spec-back.md § 4.0.bis`](./spec-back.md)).
+- `useProviderForm()` — workflow par onglet (POST principal + PATCH partiels par groupe), miroir de `useSupplierForm()`.
+- `useAddressAutocomplete()` — **réutilisé du M1/M2** (BAN), pas de réécriture.
+- `usePermissions()` — masque l'onglet Comptabilité et le bouton Archiver.
+- Tous les appels passent par `useApi()` (jamais `$fetch` direct — règle ABSOLUE n°4).
+- Filter `formatPhoneFR()` — **réutilisé** pour l'affichage `XX XX XX XX XX`.
+
+## Règles de formatage et normalisation
+
+Le serveur normalise systématiquement (RG-3.11 — cf. [`spec-back.md`](./spec-back.md)) :
+
+| Champ | Normalisation serveur | Affichage front |
+|---|---|---|
+| Nom prestataire (`companyName`) | UPPERCASE intégral | UPPERCASE |
+| Nom + Prénom contact | Capitalize | identique |
+| Téléphones (blocs `ProviderContact`) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` (filter Vue) |
+| Email | lowercase intégral | identique |
+
+> Le front **ne normalise pas** : il envoie la valeur saisie, le serveur normalise et renvoie la valeur normalisée que l'UI affiche.
+
+## API adresse postale
+
+Code postal + Ville + Adresse branchés sur **api-adresse.data.gouv.fr** (BAN) via le composable `useAddressAutocomplete()` **déjà créé au M1/M2** (réutilisé tel quel) :
+- À la saisie du CP (5 chiffres) : `GET https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville (RG-3.06 : si plusieurs villes, choix dans le select).
+- À la saisie d'adresse : `?q={addr}&postcode={cp}&type=housenumber` → suggestions.
+- Cas dégradé (timeout / offline) : Ville en `` libre + toast d'avertissement.
+
+## Différences notables avec le M2 (fournisseurs)
+
+| Zone | M2 fournisseurs | M3 prestataires |
+|---|---|---|
+| Onglet Information | 8 champs (Description … Volume) | **Absent** (aucun champ Information) |
+| Sélecteur de site sur formulaire principal | Non (sites uniquement via adresses) | **Oui** (RG-3.03 — relation directe `provider.sites`) |
+| Type d'adresse | Radio Prospect / Départ / Rendu (RG-2.09) | **Absent** |
+| Bennes / Prestation de triage (adresse) | Présents | **Absents** |
+| Onglet Transport | Placeholder | **Absent** |
+| Onglet Statistiques | Placeholder | **Absent** |
+| Onglets « À venir » | Transport / Stats / Rapports / Échanges | **Rapports / Échanges** uniquement |
+| Catégories | type `FOURNISSEUR` | **nouveau type `PRESTATAIRE`** |
+| Pôle / module | Commercial | **Technique** (nouvelle section sidebar + module back) |
+| Cloisonnement par site | aucun | **Visibilité par site, pilotée par l'utilisateur** (bypass via `sites.bypass_scope`) — § 2.13 |
+
+## Points résolus côté back
+
+| # | Zone d'ombre | Résolution (cf. `spec-back.md`) |
+|---|---|---|
+| 1 | Catégorie multi-select | M2M `provider_category`, `Category` de type **PRESTATAIRE** (RG-3.09) |
+| 2 | Site sur le formulaire principal | M2M `provider_site` (≥ 1 — RG-3.03), distinct de `provider_address_site` (RG-3.05) |
+| 3 | Onglet Comptabilité : qui édite ? | Admin + Compta (`accounting.manage`) ; Bureau/Commerciale ne le voient pas |
+| 4 | Workflow par onglet | Sauvegarde incrémentale (POST principal + PATCH partiels) — pas d'état « draft » |
+| 5 | Onglets « À venir » | Placeholder minimal « À venir » (Rapports / Échanges) |
+| 6 | Archive vs delete | Flag `is_archived` séparé de `deleted_at` ; archivage Admin seul ; soft delete = HP |
+| 7 | Unicité métier | Nom de prestataire uniquement (à valider — § 2.6). SIREN/email non uniques |
+| 8 | Référentiels comptables | Réutilisés M1/M2 (zéro duplication) ; relation ORM partagée |
+| 9 | API code postal | BAN via `useAddressAutocomplete()` du M1/M2 (RG-3.06) |
+| 10 | Format export | XLSX uniquement (CSV = HP) |
+| 11 | Cloisonnement par site (Usine « son site ») | Filtre back automatique par `currentSite` + bypass `sites.bypass_scope` (§ 2.13 / RG-3.17) |
+
+---
+
+## 📦 Tickets Lesstime
+
+**TaskGroup Lesstime** : **#29 — M3 — Répertoire prestataires** (projet `ERP / Starseed`, projectId=6) — créé le 11/06/2026, 16 tickets `ERP-131` → `ERP-146`, statut « Prêt à dev », assignés à **Tristan**.
+
+| # | Ticket | Réf | Tag |
+|---|---|---|---|
+| 1.1 | Créer module Technique + taxonomie PRESTATAIRE | ERP-131 | Backend |
+| 1.2 | Migrer le schéma BDD M3 (provider + sous-collections) | ERP-132 | Backend |
+| 1.3 | Créer entités + repositories Provider* | ERP-133 | Backend |
+| 1.4 | ProviderProvider + ProviderProcessor + cloisonnement site | ERP-134 | Backend |
+| 1.5 | Sous-ressources Contacts / Adresses / RIBs | ERP-135 | Backend |
+| 1.6 | Valider les RG métier server-side (RG-3.03→3.09) | ERP-136 | Backend |
+| 1.7 | Export XLSX des prestataires | ERP-137 | Backend |
+| 1.8 | RBAC technique.providers.* (3 sources) | ERP-138 | Backend |
+| 1.9 | PHPUnit RG-3.x + capture contrat JSON | ERP-139 | Backend |
+| 1.10 | Page Répertoire (/providers) | ERP-140 | Frontend |
+| 1.11 | Page Ajouter (/providers/new) + formulaire principal | ERP-141 | Frontend |
+| 1.12 | Onglet Contact | ERP-142 | Frontend |
+| 1.13 | Onglet Adresse (autocomplete BAN) | ERP-143 | Frontend |
+| 1.14 | Onglet Comptabilité + RIB | ERP-144 | Frontend |
+| 1.15 | Pages Consultation + Modification | ERP-145 | Frontend |
+| 1.16 | i18n + sidebar Technique + libellés audit | ERP-146 | Frontend |
+
+> Détail back complet → voir [`spec-back.md § Tickets Lesstime`](./spec-back.md#-tickets-lesstime-à-découper).
diff --git a/docs/specs/_RETEX-M1-pour-M2.md b/docs/specs/_RETEX-M1-pour-M2.md
new file mode 100644
index 0000000..3d62c81
--- /dev/null
+++ b/docs/specs/_RETEX-M1-pour-M2.md
@@ -0,0 +1,80 @@
+# RETEX M1 (Clients) → à appliquer pour M2 (Fournisseurs)
+
+> But : éviter de reproduire en M2 les erreurs de **contrat de sérialisation** qui ont bloqué M1.
+> ~80 % des frictions M1 venaient du contrat API (sérialisation / groupes / sous-ressources), **pas** du métier.
+> À lire AVANT de rédiger `spec-back.md` et `spec-front.md` du M2, et à garder ouvert pendant la rédaction.
+
+---
+
+## 0. TL;DR (les 3 erreurs à ne jamais refaire)
+
+1. **Affirmer qu'un champ est « embarqué » sans vérifier les 3 maillons de sérialisation.** En M1 : `Category.code` annoncé dans `client:read`, détail annoncé embarquant contacts/adresses/ribs → **faux dans le code**. Résultat : colonnes liste vides, onglets détail impossibles à peupler.
+2. **Livrer des sous-ressources en POST-only** (pas de `GetCollection`, pas d'embed) → le front ne peut pas lister les enfants de l'agrégat.
+3. **Écrire la spec/les tickets sur une intention, pas sur le contrat réel.** Le docblock `Client` décrivait un embed jamais implémenté.
+
+---
+
+## 1. Contrat de sérialisation : les 3 maillons obligatoires
+
+Pour **chaque champ affiché** (liste OU détail), la spec back doit prouver les trois maillons. Si un seul manque → le champ sort en quasi-IRI (`@id`/`@type` seulement) ou pas du tout.
+
+| Maillon | Question | Exemple M1 raté |
+|---|---|---|
+| (a) Groupe sur la **propriété** | `#[Groups([...])]` contient-il un read-group ? | `Supplier::$addresses` sans groupe → jamais sérialisé |
+| (b) Groupe dans le **`normalizationContext` de l'opération** | l'opération (`GetCollection`/`Get`) liste-t-elle ce groupe ? | `GetCollection` en `['client:read','default:read']` |
+| (c) Read-group de l'**entité imbriquée** dans le contexte parent | pour embarquer les champs d'une relation (catégorie, site…), le contexte parent inclut-il `category:read` / `site:read` ? | `Category.code` ∈ `category:read`, absent du contexte client → pas de `code` |
+
+**Règle de rédaction** : dans `spec-back.md`, faire un tableau « champ → groupe propriété → groupe(s) à ajouter au contexte de chaque opération » pour la liste ET le détail. Inclure explicitement les **relations imbriquées** (ex. catégories d'une adresse, sites d'une adresse).
+
+## 2. Collections enfant d'un agrégat : décider embed vs GetCollection, et câbler en ENTIER
+
+Décision à acter dès la spec back pour chaque sous-collection (contacts, adresses, RIB, lignes…) :
+
+- **Embed dans le détail (recommandé pour un agrégat DDD)** : poser `#[Groups([':item:read'])]` sur la propriété + ajouter au `normalizationContext` du `Get` racine les read-groups des entités enfant **et** de leurs relations imbriquées. 1 requête, cohérent avec un composable `useX(id)`. Réservé aux ensembles **bornés** (ne viole pas la règle n°13 : elle vise les collections exposées, pas un embed borné d'item).
+- **GetCollection sous-ressource** : `//{id}/children` paginé. À réserver aux collections potentiellement volumineuses. Si choisi, **créer l'opération** (pas seulement POST).
+
+❌ Anti-pattern M1 : sous-ressources avec `POST` + `Get` unitaire seulement → **aucun moyen de lister** (ids non découvrables). Interdit.
+
+## 3. Vérifier le contrat sur l'API RÉELLE avant d'écrire les tickets front
+
+Le blocage M1 (codes/sites/sous-collections) aurait été vu en 5 min. À mettre dans la **definition of done de la spec back** :
+
+> Créer un enregistrement de test, appeler `GET /api/` (liste) ET `GET /api//{id}` (détail), **coller la réponse JSON réelle** dans la spec. Toute donnée affichée par le front doit apparaître dans ce JSON collé.
+
+## 4. La spec décrit le RÉEL, pas l'intention
+
+- Bannir les « devrait être embarqué », « est exposé » non vérifiés. Décrire ce qui existe (ou ce qui sera livré dans le ticket, en le marquant clairement « à livrer »).
+- Si un docblock/commentaire existant contredit le code, le **corriger**, pas le recopier.
+
+## 5. Réutiliser les acquis M1 (ne pas réinventer)
+
+- **Taxonomie ERP-78** : si M2 catégorise les fournisseurs, repartir du modèle **type unique + `code` stable** (slug MAJUSCULE auto-généré, NOT NULL, figé, **lecture seule** `category:read`), filtrage métier via `?categoryCode=`. Réutiliser le contrat partagé `CategoryInterface` (pas d'import inter-module).
+- **Front** : `usePaginatedList` (listes), composants `Malio*`, `useApi()`, `formatPhoneFR`, blocs réutilisables (Contact/Adresse), pattern de blocs dynamiques + modal de confirmation.
+- **Archive** : flag `is_archived` **distinct** de `deleted_at` (soft delete). Restauration → gérer le 409 homonyme.
+- **Normalisation = serveur** (UPPERCASE nom société, Capitalize noms, lowercase email, téléphone en chiffres). Le front envoie la saisie, réaffiche la valeur normalisée renvoyée. À documenter dans la spec.
+- **Gating fin + mode strict PATCH** : PATCH par groupe de sérialisation ; tout champ hors-permission dans le payload = **403 sur l'intégralité** (pas de filtrage silencieux). Spécifier la matrice rôle × onglet.
+
+## 6. Règles ABSOLUES transverses à rappeler dans la spec M2
+
+- **Pagination obligatoire** (règle n°13) sur toute `GetCollection` ; échappatoire `?pagination=false` réservée aux selects de référentiels bornés.
+- **`COMMENT ON COLUMN`** (règle n°12) sur chaque colonne créée/modifiée (sinon `make test` casse). Helper standard pour les colonnes Timestampable/Blamable.
+- **Timestampable + Blamable** sur toute nouvelle entité métier (4 colonnes + trait) ; garde-fou archi.
+- **`#[Auditable]`** sur les entités métier ; **`#[AuditIgnore]`** sur les champs sensibles (équivalents BIC/IBAN/secret).
+- **`declare(strict_types=1);`** partout ; commentaires FR, code EN.
+- **Routes front à plat** (pas de préfixe module), état tableau **jamais** dans l'URL.
+- **3 miroirs RBAC** à toucher ensemble : `config/sidebar.php`, `frontend/tests/e2e/_fixtures/personas.ts`, `SeedE2ECommand.php`.
+- **Communication inter-module** uniquement via `Shared/Domain/Contract/` ou domain events — jamais d'import direct.
+
+## 7. Fixtures & seed dès le départ
+
+M1 a subi un aller-retour (ERP-68) faute de fixtures alignées. Pour M2 : prévoir dès la spec un seed de fournisseurs démo **couvrant tous les cas des règles métier** (relations, catégories codées, archivés, cas comptables) + comptes de rôles démo, pour vérifier le gating et le golden path sans bricolage.
+
+## 8. Mini-checklist de relecture de la spec M2 (avant de la déclarer prête)
+
+- [ ] Chaque champ affiché (liste + détail) a ses 3 maillons de sérialisation documentés (propriété, contexte opération, relations imbriquées).
+- [ ] Chaque sous-collection a une décision **embed vs GetCollection** explicite et **complètement câblée** (pas de POST-only).
+- [ ] Réponses JSON réelles (liste + détail) collées dans la spec back.
+- [ ] Matrice RBAC rôle × écran × onglet + mode strict PATCH spécifiés.
+- [ ] Pagination, COMMENT ON COLUMN, Timestampable/Blamable, Audit, routes à plat : rappelés.
+- [ ] Réutilisations M1 identifiées (taxonomie code, usePaginatedList, blocs, archive, normalisation).
+- [ ] Seed/fixtures démo planifiés.
diff --git a/frontend/modules/technique/nuxt.config.ts b/frontend/modules/technique/nuxt.config.ts
new file mode 100644
index 0000000..268da7f
--- /dev/null
+++ b/frontend/modules/technique/nuxt.config.ts
@@ -0,0 +1 @@
+export default defineNuxtConfig({})
diff --git a/migrations/Version20260612080000.php b/migrations/Version20260612080000.php
new file mode 100644
index 0000000..bf4d7cd
--- /dev/null
+++ b/migrations/Version20260612080000.php
@@ -0,0 +1,121 @@
+ pas de `COMMENT ON COLUMN` (regle ABSOLUE n°12) :
+ * la migration ne fait que des INSERT de donnees de reference.
+ *
+ * Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire :
+ * avec plusieurs migrations_paths, Doctrine Migrations 3.x trie par FQCN
+ * alphabetique -> une migration `App\Module\...` passerait avant les
+ * `DoctrineMigrations\...` sur base vide, donc avant la creation des tables
+ * `category` / `category_type` / `category_category_type`. Le namespace racine
+ * garantit l'ordre par timestamp.
+ *
+ * Idempotence : `INSERT ... ON CONFLICT (code) DO NOTHING` pour le type,
+ * `INSERT ... SELECT ... WHERE NOT EXISTS` pour chaque categorie et chaque ligne
+ * de jonction (aligne sur le pattern ERP-84 / Version20260605120000). En prod la
+ * table `category` est vide (aucune fixture metier). En dev/test, le purger
+ * Doctrine vide `category` / `category_type` avant les fixtures qui reproduisent
+ * le meme etat final (CategoryTypeFixtures / CategoryFixtures etendus a PRESTATAIRE).
+ */
+final class Version20260612080000 extends AbstractMigration
+{
+ /**
+ * Categories de demonstration du type PRESTATAIRE : nom => code stable. Le
+ * code est la cle metier (slug MAJUSCULE du nom, miroir du
+ * CategoryCodeGenerator) et reste unique parmi les actifs (uq_category_code,
+ * partage avec les codes CLIENT / FOURNISSEUR — aucune collision ici). Le nom
+ * est unique GLOBALEMENT parmi les actifs (uq_category_name_active) : les
+ * libelles ci-dessous n'entrent en collision avec aucune categorie seedee.
+ */
+ private const array PROVIDER_CATEGORIES = [
+ 'Maintenance industrielle' => 'MAINTENANCE_INDUSTRIELLE',
+ 'Nettoyage' => 'NETTOYAGE',
+ 'Transport' => 'TRANSPORT',
+ ];
+
+ public function getDescription(): string
+ {
+ return 'M3 1.1 : cree le CategoryType PRESTATAIRE + seed des categories prestataires (Maintenance industrielle, Nettoyage, Transport).';
+ }
+
+ public function up(Schema $schema): void
+ {
+ // 1. Type PRESTATAIRE (idempotent via l'index unique uq_category_type_code).
+ $this->addSql(<<<'SQL'
+ INSERT INTO category_type (code, label) VALUES ('PRESTATAIRE', 'Prestataire')
+ ON CONFLICT (code) DO NOTHING
+ SQL);
+
+ foreach (self::PROVIDER_CATEGORIES as $name => $code) {
+ // 2a. Categorie sous PRESTATAIRE (si le code est libre parmi les
+ // actifs). created_at/updated_at NOT NULL -> NOW() ; le blame
+ // reste null (seed hors contexte HTTP, libelle « Systeme » cote front).
+ $this->addSql(<<<'SQL'
+ INSERT INTO category (name, code, created_at, updated_at)
+ SELECT :name, :code, NOW(), NOW()
+ WHERE NOT EXISTS (
+ SELECT 1 FROM category c WHERE c.code = :code AND c.deleted_at IS NULL
+ )
+ SQL, ['name' => $name, 'code' => $code]);
+
+ // 2b. Jonction M2M categorie <-> type PRESTATAIRE (modele courant).
+ $this->addSql(<<<'SQL'
+ INSERT INTO category_category_type (category_id, category_type_id)
+ SELECT c.id, ct.id
+ FROM category c
+ CROSS JOIN category_type ct
+ WHERE c.code = :code AND c.deleted_at IS NULL
+ AND ct.code = 'PRESTATAIRE'
+ AND NOT EXISTS (
+ SELECT 1 FROM category_category_type cct
+ WHERE cct.category_id = c.id AND cct.category_type_id = ct.id
+ )
+ SQL, ['code' => $code]);
+ }
+ }
+
+ public function down(Schema $schema): void
+ {
+ // Best-effort : on retire d'abord les categories seedees (par code) — la FK
+ // category_category_type est ON DELETE CASCADE cote category, donc les
+ // lignes de jonction partent avec —, puis le type s'il n'est plus reference.
+ $this->addSql(
+ 'DELETE FROM category WHERE code IN (:codes) '
+ ."AND id IN (SELECT category_id FROM category_category_type cct "
+ ."JOIN category_type ct ON ct.id = cct.category_type_id WHERE ct.code = 'PRESTATAIRE')",
+ ['codes' => array_values(self::PROVIDER_CATEGORIES)],
+ ['codes' => ArrayParameterType::STRING],
+ );
+
+ $this->addSql(<<<'SQL'
+ DELETE FROM category_type
+ WHERE code = 'PRESTATAIRE'
+ AND NOT EXISTS (
+ SELECT 1 FROM category_category_type cct WHERE cct.category_type_id = category_type.id
+ )
+ SQL);
+ }
+}
diff --git a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php
index 760cf89..b150a45 100644
--- a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php
+++ b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php
@@ -17,7 +17,9 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
* Fixtures dev/test du module Catalog : categories de demonstration rattachees
* a leur CategoryType. Le type CLIENT porte ~11 categories clients (refonte
* taxonomie ERP-78) ; le type FOURNISSEUR porte les categories fournisseurs
- * (ERP-84 : Negociant, Cooperative...). Chaque categorie porte un `code` stable.
+ * (ERP-84 : Negociant, Cooperative...) ; le type PRESTATAIRE porte les categories
+ * prestataires (M3 1.1 : Maintenance industrielle, Nettoyage, Transport). Chaque
+ * categorie porte un `code` stable.
* Alimente le repertoire clients (ClientFixtures, module Commercial) avec des
* donnees realistes couvrant RG-1.03 (codes DISTRIBUTEUR / COURTIER) et RG-1.29
* (codes interdits sur adresse), et le multi-select Categorie fournisseur (M2).
@@ -71,6 +73,11 @@ class CategoryFixtures extends Fixture implements DependentFixtureInterface
'Grossiste' => 'GROSSISTE',
'Importateur' => 'IMPORTATEUR',
],
+ 'PRESTATAIRE' => [
+ 'Maintenance industrielle' => 'MAINTENANCE_INDUSTRIELLE',
+ 'Nettoyage' => 'NETTOYAGE',
+ 'Transport' => 'TRANSPORT',
+ ],
];
public function __construct(
diff --git a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php
index b329414..8d5c518 100644
--- a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php
+++ b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php
@@ -21,6 +21,10 @@ use Doctrine\Persistence\ObjectManager;
* taxonomie distincte des fournisseurs (Negociant, Cooperative...). Mirroir de
* la migration Version20260605120000.
*
+ * M3 1.1 : ajout du type PRESTATAIRE (code PRESTATAIRE, label « Prestataire »),
+ * taxonomie distincte des prestataires (Maintenance industrielle, Nettoyage,
+ * Transport). Mirroir de la migration Version20260612080000.
+ *
* Pourquoi une fixture EN PLUS du seed de la migration : `category_type` est une
* entite managee par l ORM, donc le purger Doctrine la vide avant chaque
* `doctrine:fixtures:load`. Sans cette fixture, le type CLIENT seede par la
@@ -36,12 +40,13 @@ class CategoryTypeFixtures extends Fixture
{
/**
* Source unique des types : code technique => libelle FR. Doit rester aligne
- * sur le seed des migrations Version20260602100000 (CLIENT) et
- * Version20260605120000 (FOURNISSEUR).
+ * sur le seed des migrations Version20260602100000 (CLIENT),
+ * Version20260605120000 (FOURNISSEUR) et Version20260612080000 (PRESTATAIRE).
*/
private const TYPES = [
'CLIENT' => 'Client',
'FOURNISSEUR' => 'Fournisseur',
+ 'PRESTATAIRE' => 'Prestataire',
];
public function __construct(
diff --git a/src/Module/Technique/TechniqueModule.php b/src/Module/Technique/TechniqueModule.php
new file mode 100644
index 0000000..a5653cc
--- /dev/null
+++ b/src/Module/Technique/TechniqueModule.php
@@ -0,0 +1,58 @@
+
+ */
+ public static function permissions(): array
+ {
+ return [
+ ['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'],
+ ];
+ }
+}
diff --git a/tests/Module/Catalog/Api/CategoryPrestataireSeedTest.php b/tests/Module/Catalog/Api/CategoryPrestataireSeedTest.php
new file mode 100644
index 0000000..b1a9b05
--- /dev/null
+++ b/tests/Module/Catalog/Api/CategoryPrestataireSeedTest.php
@@ -0,0 +1,107 @@
+getOrCreatePrestataireType();
+ foreach (self::PROVIDER_CATEGORIES as $name) {
+ $this->createCategory($name, $providerType);
+ }
+
+ // Bruit : un type + une categorie d'un autre type ne doivent PAS fuiter.
+ $noiseType = $this->createCategoryType('TEST_FOURNISSEUR', 'Test Fournisseur');
+ $this->createCategory(self::TEST_CATEGORY_PREFIX.'noise', $noiseType);
+
+ $client = $this->createAdminClient();
+ $response = $client->request('GET', '/api/categories?typeCode=PRESTATAIRE&pagination=false');
+ self::assertSame(200, $response->getStatusCode());
+
+ $members = $response->toArray()['member'];
+ $names = array_map(static fn (array $m): string => $m['name'], $members);
+ sort($names);
+
+ $expected = self::PROVIDER_CATEGORIES;
+ sort($expected);
+ self::assertSame(
+ $expected,
+ $names,
+ 'Le filtre ?typeCode=PRESTATAIRE doit ne renvoyer QUE les categories du type PRESTATAIRE.',
+ );
+
+ // Chaque categorie remontee doit PORTER le type PRESTATAIRE.
+ foreach ($members as $member) {
+ self::assertContains('PRESTATAIRE', array_column($member['categoryTypes'], 'code'));
+ }
+ }
+
+ public function testTypeCodePrestataireKeepsHydraPagination(): void
+ {
+ $providerType = $this->getOrCreatePrestataireType();
+ $this->createCategory('Maintenance industrielle', $providerType);
+
+ $client = $this->createAdminClient();
+ $response = $client->request('GET', '/api/categories?typeCode=PRESTATAIRE');
+ self::assertSame(200, $response->getStatusCode());
+
+ $data = $response->toArray();
+ self::assertArrayHasKey('totalItems', $data, 'Le filtre ne doit pas casser la pagination Hydra.');
+ self::assertArrayHasKey('member', $data);
+
+ foreach ($data['member'] as $member) {
+ self::assertContains('PRESTATAIRE', array_column($member['categoryTypes'], 'code'));
+ }
+ }
+
+ /**
+ * Recupere le type PRESTATAIRE reel, ou le cree s'il est absent. Le code
+ * `PRESTATAIRE` est seede par CategoryTypeFixtures (present en debut de suite),
+ * mais le cleanup purge tous les `category_type` entre les tests : selon
+ * l'ordre d'execution, le type peut donc exister ou non. Le get-or-create rend
+ * le test robuste sans dependre du seed ni le dupliquer.
+ */
+ private function getOrCreatePrestataireType(): CategoryType
+ {
+ $em = $this->getEm();
+ $existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'PRESTATAIRE']);
+
+ if ($existing instanceof CategoryType) {
+ return $existing;
+ }
+
+ return $this->createCategoryType('PRESTATAIRE', 'Prestataire');
+ }
+}
diff --git a/tests/Module/Technique/TechniqueModuleTest.php b/tests/Module/Technique/TechniqueModuleTest.php
new file mode 100644
index 0000000..bf3286d
--- /dev/null
+++ b/tests/Module/Technique/TechniqueModuleTest.php
@@ -0,0 +1,59 @@
+