diff --git a/config/modules.php b/config/modules.php
index e55d2ed..aad4939 100644
--- a/config/modules.php
+++ b/config/modules.php
@@ -5,6 +5,7 @@ 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;
use App\Module\Transport\TransportModule;
return [
@@ -12,5 +13,6 @@ return [
CommercialModule::class,
SitesModule::class,
CatalogModule::class,
+ TechniqueModule::class,
TransportModule::class,
];
diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml
index 5aeb29c..a6a4377 100644
--- a/config/packages/doctrine.yaml
+++ b/config/packages/doctrine.yaml
@@ -80,6 +80,16 @@ doctrine:
dir: '%kernel.project_dir%/src/Module/Commercial/Domain/Entity'
prefix: 'App\Module\Commercial\Domain\Entity'
alias: Commercial
+ # Mapping inconditionnel du module Technique (meme logique que Commercial) :
+ # les tables prestataires (provider + sous-collections + jointures M2M)
+ # creees par la migration M3 (Version20260612100000) doivent etre connues
+ # de l'ORM. L'activation fonctionnelle passe par config/modules.php.
+ Technique:
+ type: attribute
+ is_bundle: false
+ dir: '%kernel.project_dir%/src/Module/Technique/Domain/Entity'
+ prefix: 'App\Module\Technique\Domain\Entity'
+ alias: Technique
controller_resolver:
auto_mapping: false
diff --git a/config/sidebar.php b/config/sidebar.php
index 9be6d4a..5d5ccb3 100644
--- a/config/sidebar.php
+++ b/config/sidebar.php
@@ -61,6 +61,23 @@ return [
],
],
],
+ // Section "Technique" (M3, ERP-138) : pole distinct du Commercial, porte le
+ // repertoire prestataires. L'item est gate par `technique.providers.view` ;
+ // la section disparait automatiquement (SidebarProvider) si le module
+ // `technique` est desactive ou si l'user n'a pas la permission.
+ [
+ 'label' => 'sidebar.technique.section',
+ 'icon' => 'mdi:account-convert-outline',
+ 'items' => [
+ [
+ 'label' => 'sidebar.technique.providers',
+ 'to' => '/providers',
+ 'icon' => 'mdi:account-wrench-outline',
+ 'module' => 'technique',
+ 'permission' => 'technique.providers.view',
+ ],
+ ],
+ ],
// Section "Administration" : regroupe toutes les pages de configuration
// applicative (RBAC, users, sites, audit log).
//
diff --git a/config/version.yaml b/config/version.yaml
index 03c8d72..ea4b1d5 100644
--- a/config/version.yaml
+++ b/config/version.yaml
@@ -1,2 +1,2 @@
parameters:
- app.version: '0.1.110'
+ app.version: '0.1.120'
diff --git a/docs/specs/M3-prestataires/spec-back.md b/docs/specs/M3-prestataires/spec-back.md
new file mode 100644
index 0000000..7d9825c
--- /dev/null
+++ b/docs/specs/M3-prestataires/spec-back.md
@@ -0,0 +1,1099 @@
+---
+# === 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** (Contacts / Adresses / RIB) : le cloisonnement du parent **n'est PAS hérité automatiquement** — les opérations `Get` / `Patch` / `Delete` des sous-ressources passent par le provider Doctrine par défaut (et `SiteScopedQueryExtension` ne filtre que les `SiteAwareInterface`, ce que ces entités ne sont pas). Le garde-fou est donc posé **explicitement** : (a) en lecture/édition/suppression via le provider décoré `ProviderSubResourceItemProvider` (un parent hors périmètre → **404**) ; (b) en création (`POST /providers/{id}/...`) via `ProviderSiteScopeChecker::assertInScope` dans chaque processor (parent hors périmètre → **404**). La décision de scope est centralisée dans `ProviderSiteScopeChecker` (source unique partagée avec `ProviderProvider`). ⚠️ Ne pas retirer ces gardes en les croyant redondants : sans eux, un user cloisonné peut lire l'IBAN/BIC d'un RIB d'un autre site.
+
+> **Conséquence RBAC** : la colonne « Consultation » du docx (« Tout » vs « son site uniquement ») se réalise **par `sites.bypass_scope`**, pas par le code de rôle. Décision d'attribution par défaut (à acter au ticket RBAC) : `bypass_scope` aux profils Admin (auto) + Bureau + Compta + Commerciale (ils voient « Tout » d'après le docx) ; **Usine ne l'a pas** → cloisonné à son site. Si MALIO préfère que Bureau/Commerciale soient aussi cloisonnés, il suffit de ne pas leur donner `bypass_scope` — **aucun code à changer** (c'est l'intérêt de piloter par user/permission et non par rôle).
+
+> **Index** : `idx_provider_site_site` sur `provider_site(site_id)` (déjà prévu § 3.2) sert le filtre `WHERE site = :currentSite`.
+
+## 3. Modèle de données
+
+### 3.1 Diagramme
+
+```
++----------------------+ +--------------------------+ +-----------------+
+| provider |--n:m-->| provider_category |<--n:m--| category |
+| | +--------------------------+ | type=PRESTATAIRE|
+| id (PK) | +-----------------+
+| company_name |--n:m-->| provider_site |<--n:m--| site (Sites) |
+| is_archived | +--------------------------+ | (RG-3.03) |
+| archived_at | +-----------------+
+| deleted_at | +--------------------------+
+| -- Comptabilité -- |--1:n-->| provider_contact |
+| siren / account_num | +--------------------------+
+| tva_mode_id | +-----------------+
+| n_tva | +--------------------------+ | tva_mode (M1) |
+| payment_delay_id |--1:n-->| provider_address | | payment_* (M1) |
+| payment_type_id | +--------------------------+ | bank (M1) |
+| bank_id (nullable) | | (PAS de address_type) +-----------------+
++----------------------+ +--n:m--> site
+ +--n:m--> provider_contact
+ +--------------------------+ +--n:m--> category (PRESTATAIRE)
+ | provider_rib |
+ +--------------------------+
+ label / bic / iban
+```
+
+**Particularités M3 (différences vs `supplier`)** :
+- **PAS d'onglet Information** : aucun champ `description` / `competitors` / `founded_at` / `employees_count` / `revenue_amount` / `director_name` / `profit_amount` / `volume_forecast`. Le `provider` est minimal : nom + comptabilité.
+- **`provider.sites` (M2M `provider_site`)** : sélecteur de site **sur le formulaire principal** (RG-3.03, ≥ 1). NOUVEAU vs supplier (qui n'avait des sites que sur l'adresse).
+- **`provider_address` simplifiée** : **pas** de `address_type`, **pas** de `bennes`, **pas** de `triage_provider`. Champs : sites[], street, street_complement, postal_code, city, country, categories[], contacts[].
+- Les référentiels comptables (`tva_mode`...) **ne sont pas recréés** — FK vers les tables M1.
+
+### 3.2 Migration Doctrine — SQL Postgres
+
+Namespace : **`DoctrineMigrations` (racine `migrations/`)** — fichier `migrations/VersionYYYYMMDDHHMMSS.php` (à dater par le dev).
+
+> **Même justification qu'au M1/M2** : la migration crée un schéma avec **FK cross-module** (`user`, `category`, `site`, FK vers les référentiels comptables M1). Le namespace modulaire casserait l'ordre (`make db-reset`) — exception racine de la règle ABSOLUE n°11. Le seed du `CategoryType PRESTATAIRE` se fait **en deux endroits** (migration `ON CONFLICT` pour la prod + fixture idempotente en dev/test).
+
+> **Rappel règle ABSOLUE n°12** : chaque colonne créée DOIT recevoir son `COMMENT ON COLUMN` (FR, ≤ 200 car., sémantique + contrainte/RG). Les 4 colonnes Timestampable/Blamable passent par le helper. Le SQL ci-dessous est *illustratif* (style aligné M1/M2 : `INT GENERATED BY DEFAULT AS IDENTITY`, `TIMESTAMP(0) WITHOUT TIME ZONE`).
+
+```sql
+-- =====================================================================
+-- Seed taxonomie : nouveau type PRESTATAIRE (référentiels comptables = M1, non recréés)
+-- =====================================================================
+INSERT INTO category_type (code, label) VALUES ('PRESTATAIRE', 'Prestataire')
+ ON CONFLICT (code) DO NOTHING;
+
+-- =====================================================================
+-- Table principale `provider`
+-- =====================================================================
+CREATE TABLE provider (
+ id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ -- Formulaire principal
+ company_name VARCHAR(180) NOT NULL,
+ -- (PAS d'onglet Information — aucun champ description/competitors/founded/employees/...)
+ -- Onglet Comptabilité (FK référentiels M1 — partagés)
+ siren VARCHAR(20),
+ account_number VARCHAR(40),
+ tva_mode_id INT REFERENCES tva_mode(id) ON DELETE RESTRICT,
+ n_tva VARCHAR(40),
+ payment_delay_id INT REFERENCES payment_delay(id) ON DELETE RESTRICT,
+ payment_type_id INT REFERENCES payment_type(id) ON DELETE RESTRICT,
+ bank_id INT REFERENCES bank(id) ON DELETE RESTRICT,
+ -- Archive (exposé M3)
+ is_archived BOOLEAN NOT NULL DEFAULT FALSE,
+ archived_at TIMESTAMP(0) WITHOUT TIME ZONE,
+ -- Soft delete (préparé, non exposé au M3)
+ deleted_at TIMESTAMP(0) WITHOUT TIME ZONE,
+ -- Timestampable + Blamable
+ created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
+ updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
+ created_by INT REFERENCES "user"(id) ON DELETE SET NULL,
+ updated_by INT REFERENCES "user"(id) ON DELETE SET NULL
+);
+
+CREATE INDEX idx_provider_is_archived ON provider(is_archived);
+CREATE INDEX idx_provider_deleted_at ON provider(deleted_at);
+CREATE INDEX idx_provider_created_by ON provider(created_by);
+CREATE INDEX idx_provider_updated_by ON provider(updated_by);
+
+-- Unicité métier (partielle : ignore archives + soft-delete) — nom de société uniquement (cf. § 2.6)
+CREATE UNIQUE INDEX uq_provider_company_name_active
+ ON provider (LOWER(company_name))
+ WHERE is_archived = FALSE AND deleted_at IS NULL;
+
+-- =====================================================================
+-- M2M provider ↔ category (catégories de type PRESTATAIRE — RG-3.09)
+-- =====================================================================
+CREATE TABLE provider_category (
+ provider_id INT NOT NULL REFERENCES provider(id) ON DELETE CASCADE,
+ category_id INT NOT NULL REFERENCES category(id) ON DELETE RESTRICT,
+ PRIMARY KEY (provider_id, category_id)
+);
+CREATE INDEX idx_provider_category_category ON provider_category(category_id);
+
+-- =====================================================================
+-- M2M provider ↔ site (sélecteur de site du FORMULAIRE PRINCIPAL — RG-3.03)
+-- =====================================================================
+CREATE TABLE provider_site (
+ provider_id INT NOT NULL REFERENCES provider(id) ON DELETE CASCADE,
+ site_id INT NOT NULL REFERENCES site(id) ON DELETE RESTRICT,
+ PRIMARY KEY (provider_id, site_id)
+);
+CREATE INDEX idx_provider_site_site ON provider_site(site_id);
+
+-- =====================================================================
+-- Sous-collection : Contacts (1:n)
+-- =====================================================================
+CREATE TABLE provider_contact (
+ id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ provider_id INT NOT NULL REFERENCES provider(id) ON DELETE CASCADE,
+ first_name VARCHAR(120),
+ last_name VARCHAR(120),
+ job_title VARCHAR(120),
+ phone_primary VARCHAR(20),
+ phone_secondary VARCHAR(20),
+ email VARCHAR(180),
+ position INT NOT NULL DEFAULT 0,
+ created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
+ updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
+ created_by INT REFERENCES "user"(id) ON DELETE SET NULL,
+ updated_by INT REFERENCES "user"(id) ON DELETE SET NULL,
+ -- RG-3.04 : au moins 1 champ rempli (garanti côté Processor ; le CHECK ci-dessous
+ -- couvre le cas « nom OU prénom » comme garde-fou minimal aligné M2)
+ CONSTRAINT chk_provider_contact_name
+ CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL)
+);
+CREATE INDEX idx_provider_contact_provider ON provider_contact(provider_id);
+
+-- =====================================================================
+-- Sous-collection : Adresses (1:n) — PAS de address_type / bennes / triage
+-- =====================================================================
+CREATE TABLE provider_address (
+ id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ provider_id INT NOT NULL REFERENCES provider(id) ON DELETE CASCADE,
+ country VARCHAR(80) NOT NULL DEFAULT 'France',
+ postal_code VARCHAR(20) NOT NULL,
+ city VARCHAR(120) NOT NULL,
+ street VARCHAR(255) NOT NULL,
+ street_complement VARCHAR(255),
+ position INT NOT NULL DEFAULT 0,
+ created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
+ updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
+ created_by INT REFERENCES "user"(id) ON DELETE SET NULL,
+ updated_by INT REFERENCES "user"(id) ON DELETE SET NULL
+);
+CREATE INDEX idx_provider_address_provider ON provider_address(provider_id);
+
+-- M2M provider_address ↔ site (RG-3.05 : ≥ 1 site)
+CREATE TABLE provider_address_site (
+ provider_address_id INT NOT NULL REFERENCES provider_address(id) ON DELETE CASCADE,
+ site_id INT NOT NULL REFERENCES site(id) ON DELETE RESTRICT,
+ PRIMARY KEY (provider_address_id, site_id)
+);
+
+-- M2M provider_address ↔ provider_contact
+CREATE TABLE provider_address_contact (
+ provider_address_id INT NOT NULL REFERENCES provider_address(id) ON DELETE CASCADE,
+ provider_contact_id INT NOT NULL REFERENCES provider_contact(id) ON DELETE CASCADE,
+ PRIMARY KEY (provider_address_id, provider_contact_id)
+);
+
+-- M2M provider_address ↔ category (catégorie d'adresse, type PRESTATAIRE — RG-3.09)
+CREATE TABLE provider_address_category (
+ provider_address_id INT NOT NULL REFERENCES provider_address(id) ON DELETE CASCADE,
+ category_id INT NOT NULL REFERENCES category(id) ON DELETE RESTRICT,
+ PRIMARY KEY (provider_address_id, category_id)
+);
+
+-- =====================================================================
+-- Sous-collection : RIB (1:n)
+-- =====================================================================
+CREATE TABLE provider_rib (
+ id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ provider_id INT NOT NULL REFERENCES provider(id) ON DELETE CASCADE,
+ label VARCHAR(120) NOT NULL,
+ bic VARCHAR(20) NOT NULL,
+ iban VARCHAR(34) NOT NULL,
+ position INT NOT NULL DEFAULT 0,
+ created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
+ updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
+ created_by INT REFERENCES "user"(id) ON DELETE SET NULL,
+ updated_by INT REFERENCES "user"(id) ON DELETE SET NULL
+);
+CREATE INDEX idx_provider_rib_provider ON provider_rib(provider_id);
+```
+
+### 3.2.bis Commentaires SQL obligatoires (échantillon)
+
+```php
+$this->addSql("COMMENT ON TABLE provider IS 'Répertoire prestataires (M3 Technique) — entités archivables.'");
+$this->addSql("COMMENT ON COLUMN provider.company_name IS 'Raison sociale du prestataire — stockée en MAJUSCULES. Unique parmi non-archivés/non-supprimés (RG-3.10).'");
+$this->addSql("COMMENT ON COLUMN provider.payment_type_id IS 'Type de règlement — FK -> payment_type.id (référentiel partagé M1), ON DELETE RESTRICT. Pilote RG-3.07 (Banque) et RG-3.08 (RIB).'");
+$this->addSql("COMMENT ON COLUMN provider.bank_id IS 'Banque — FK -> bank.id (M1). Obligatoire ssi payment_type=VIREMENT (RG-3.07), null sinon.'");
+$this->addSql("COMMENT ON COLUMN provider.siren IS 'SIREN du prestataire (9 chiffres). Non unique (cf. RG-3.10). Saisi à l''onglet Comptabilité.'");
+// provider_site (M2M) : commenter via COMMENT ON TABLE
+$this->addSql("COMMENT ON TABLE provider_site IS 'Sites rattachés au prestataire (sélecteur du formulaire principal — RG-3.03, ≥ 1).'");
+$this->addSql("COMMENT ON COLUMN provider_address.postal_code IS 'Code postal — déclenche l''autocomplétion ville via l''API BAN (RG-3.06).'");
+// + COMMENT ON COLUMN sur TOUTES les autres colonnes métier (cf. règle n°12)
+$this->addStandardTimestampableBlamableComments($schema, 'provider');
+$this->addStandardTimestampableBlamableComments($schema, 'provider_contact');
+$this->addStandardTimestampableBlamableComments($schema, 'provider_address');
+$this->addStandardTimestampableBlamableComments($schema, 'provider_rib');
+```
+
+### 3.3 Entité `Provider` — squelette (extrait)
+
+Miroir de `Supplier` (cf. [`../M2-suppliers/spec-back.md § 3.3`](../M2-suppliers/spec-back.md)), **amputé de l'onglet Information** et **augmenté de `sites` (relation directe)**.
+
+```php
+ ['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`).
+
+> **✅ Capturé sur l'API réelle (ERP-139)** via `ProviderSerializationContractTest::testDodReferenceJsonShape` (`PROVIDER_DOD_DUMP=1`). Les `id`/`companyName`/noms de catégorie ci-dessous proviennent du prestataire seedé par le test (`seedCompleteProvider`) ; la **forme** (clés, embed, gating) est le contrat réel à respecter côté front.
+
+`GET /api/providers` (liste, ADMIN avec `accounting.view` — un membre, capture réelle) :
+```json
+{
+ "@context": "/api/contexts/Provider",
+ "@id": "/api/providers",
+ "@type": "Collection",
+ "totalItems": 1,
+ "member": [
+ {
+ "@id": "/api/providers/572",
+ "@type": "Provider",
+ "id": 572,
+ "companyName": "DOD21AADC 0E3CCE",
+ "categories": [
+ {
+ "@type": "Category",
+ "@id": "/api/categories/3006",
+ "id": 3006,
+ "name": "test_prov_cat_nettoyage",
+ "code": "NETTOYAGE",
+ "categoryTypes": [
+ {"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}
+ ],
+ "createdAt": "2026-06-12T15:17:29+02:00",
+ "updatedAt": "2026-06-12T15:17:29+02:00"
+ }
+ ],
+ "sites": [
+ {"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
+ {"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
+ ],
+ "siren": "987654321",
+ "accountNumber": "P0001",
+ "tvaMode": {"@id": "/api/tva_modes/13", "@type": "TvaMode", "id": 13, "code": "FRANCE_VENTES", "label": "France (ventes)"},
+ "nTva": "FR00987654321",
+ "paymentDelay": {"@id": "/api/payment_delays/8", "@type": "PaymentDelay", "id": 8, "code": "J30", "label": "30 jours"},
+ "paymentType": {"@id": "/api/payment_types/10", "@type": "PaymentType", "id": 10, "code": "LCR", "label": "LCR"},
+ "ribs": [
+ {"@id": "/api/provider_ribs/60", "@type": "ProviderRib", "id": 60, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
+ ],
+ "createdAt": "2026-06-12T15:17:29+02:00",
+ "updatedAt": "2026-06-12T15:17:29+02:00",
+ "isArchived": false
+ }
+ ],
+ "view": {"@id": "/api/providers?search=DoD21aadc", "@type": "PartialCollectionView"}
+}
+```
+
+> Les `sites[]` de la liste sont la **relation directe** `provider.sites` (formulaire principal — RG-3.03), objet `Site` entier (pas un IRI nu). Les catégories embarquent `code` + `name`. Les prestataires archivés sont **exclus** du `totalItems` (RG-3.16). Pour un profil **sans `accounting.view`** (ex. Commerciale), `siren`/`accountNumber`/`tvaMode`/`nTva`/`paymentDelay`/`paymentType`/`bank`/`ribs` **disparaissent** de chaque membre (gating par omission — cf. détail restreint ci-dessous).
+
+`GET /api/providers/{id}` (détail — user **avec** `accounting.view`, capture réelle) :
+```json
+{
+ "@context": "/api/contexts/Provider",
+ "@id": "/api/providers/572",
+ "@type": "Provider",
+ "id": 572,
+ "companyName": "DOD21AADC 0E3CCE",
+ "categories": [
+ {"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
+ ],
+ "sites": [
+ {"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
+ {"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
+ ],
+ "siren": "987654321",
+ "accountNumber": "P0001",
+ "tvaMode": {"@id": "/api/tva_modes/13", "@type": "TvaMode", "id": 13, "code": "FRANCE_VENTES", "label": "France (ventes)"},
+ "nTva": "FR00987654321",
+ "paymentDelay": {"@id": "/api/payment_delays/8", "@type": "PaymentDelay", "id": 8, "code": "J30", "label": "30 jours"},
+ "paymentType": {"@id": "/api/payment_types/10", "@type": "PaymentType", "id": 10, "code": "LCR", "label": "LCR"},
+ "contacts": [
+ {"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
+ ],
+ "addresses": [
+ {
+ "@id": "/api/provider_addresses/35", "@type": "ProviderAddress", "id": 35,
+ "country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias",
+ "sites": [
+ {"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
+ {"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
+ ],
+ "contacts": [
+ {"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
+ ],
+ "categories": [
+ {"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
+ ],
+ "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"
+ }
+ ],
+ "ribs": [
+ {"@id": "/api/provider_ribs/60", "@type": "ProviderRib", "id": 60, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
+ ],
+ "createdAt": "2026-06-12T15:17:29+02:00",
+ "updatedAt": "2026-06-12T15:17:29+02:00",
+ "isArchived": false
+}
+```
+
+`GET /api/providers/{id}` (même prestataire, user **sans** `accounting.view` — capture réelle) :
+```json
+{
+ "@context": "/api/contexts/Provider",
+ "@id": "/api/providers/572",
+ "@type": "Provider",
+ "id": 572,
+ "companyName": "DOD21AADC 0E3CCE",
+ "categories": [
+ {"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
+ ],
+ "sites": [
+ {"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
+ {"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
+ ],
+ "contacts": [
+ {"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
+ ],
+ "addresses": [
+ {
+ "@id": "/api/provider_addresses/35", "@type": "ProviderAddress", "id": 35,
+ "country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias",
+ "sites": [
+ {"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
+ {"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
+ ],
+ "contacts": [
+ {"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
+ ],
+ "categories": [
+ {"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
+ ],
+ "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"
+ }
+ ],
+ "createdAt": "2026-06-12T15:17:29+02:00",
+ "updatedAt": "2026-06-12T15:17:29+02:00",
+ "isArchived": false
+}
+```
+
+> **Gating par omission confirmé sur le JSON réel** : pour un user **sans** `accounting.view`, les clés `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType`, `bank` **et `ribs`** sont **absentes** (pas `null`). `isArchived`, `contacts[]`, `addresses[]` (avec `sites[]`/`contacts[]`/`categories[]`) restent exposés. Vérifié par `ProviderSerializationContractTest::testRibsAbsentForUserWithoutAccountingView` + `testAccountingScalarsGatedByOmission`.
+>
+> **Réfs comptables = objets embarqués `{id, code, label}`** (pas IRI nu) : le fix ERP-139 a ajouté `provider:read:accounting` sur `TvaMode`/`PaymentDelay`/`PaymentType`/`Bank` (réplique du fix ERP-92 du M2). Vérifié par `testAccountingReferentialsEmbedIdCodeLabel`.
+
+### 4.1 `GET /api/providers` — Liste
+
+- **Security** : `is_granted('technique.providers.view')`
+- **Query params** (alimentent le panneau « Filtrer ») :
+ - `includeArchived=true|false` (default `false`)
+ - `categoryCode=` (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**
+- [x] **Réponses JSON RÉELLES** capturées (§ 4.0.bis) — liste + détail (avec/sans `accounting.view`) collés depuis `ProviderSerializationContractTest` (ERP-139)
+- [x] Matrice RBAC rôle × onglet + mode strict PATCH (§ 2.9 / RG-3.15)
+- [x] Pagination (n°13), COMMENT ON COLUMN (n°12), Timestampable/Blamable, Audit + i18n, routes à plat : rappelés
+- [x] Réutilisations M1/M2 identifiées (référentiels compta partagés, taxonomie code/type, filtre `?typeCode=`, `usePaginatedList`, blocs, archive, normalisation, `useAddressAutocomplete`)
+- [x] Seed/fixtures démo planifiés (§ 8.4)
+- [x] **Décisions tranchées (Matthieu, 11/06)** : module `Technique` (§ 2.1) ✅ ; référentiels comptables « comme supplier » (ORM partagée) ✅ ; cloisonnement par site piloté user via `sites.bypass_scope` (§ 2.13 / RG-3.17) ✅ ; unicité nom seul (§ 2.6) ✅
+
+## 9. Hors-périmètre (HP)
+
+- **HP-M4-2** : **Remontée des référentiels comptables dans `Shared`** (ou module neutre) si isolation stricte souhaitée (cf. § 2.1). _NB : décision M3 = consommation ORM partagée, comme `Supplier` (validée Matthieu, 11/06)._
+- _**(ex-HP-M4-1 — DÉSORMAIS DANS LE PÉRIMÈTRE M3)**_ : le **cloisonnement par site** (visibilité prestataires selon le site de l'utilisateur) est implémenté au M3 — cf. § 2.13 + RG-3.17. Le « bypass multi-sites » passe par `sites.bypass_scope`.
+- **HP-M4-3** : **DELETE / soft delete d'un prestataire** (colonne `deleted_at` préparée, non exposée au M3).
+- **HP-M4-4** : **CRUD admin des référentiels comptables** (`TvaMode` / `PaymentDelay` / `PaymentType` / `Bank`) — partagés, seed seulement.
+- **HP-M4-5** : **CRUD admin de `CategoryType`** (le M3 seed seulement le type PRESTATAIRE).
+- **HP-M4-6** : **Onglet Rapports** (front placeholder « À venir » ; aucun modèle ni API back).
+- **HP-M4-7** : **Onglet Échanges** (placeholder « À venir »).
+- **HP-M4-8** : **Validation IBAN/BIC stricte** (au M3, `Assert\Iban` / `Assert\Bic` standard sur `ProviderRib`).
+- **HP-M4-9** : **Validation SIREN stricte** (Luhn) — au M3, `Assert\Length(9)` + `Assert\Regex('/^\d{9}$/')`.
+- **HP-M4-10** : **Référencement entrant** (modules futurs ajoutant une FK `provider_id` : interventions, maintenance, etc.).
+- **HP-M4-11** : **Export CSV** (XLSX uniquement au M3).
+- **HP-M4-12** : **Liaison Prestataire ↔ Fournisseur / Client** (un même tiers multi-rôles). Au M3, entités strictement séparées.
+
+## 10. Liens & dépendances
+
+### Liens
+
+- Spec front : [`./spec-front.md`](./spec-front.md)
+- Spec M2 fournisseurs (pattern de référence direct) : [`../M2-suppliers/spec-back.md`](../M2-suppliers/spec-back.md)
+- Spec M1 clients : [`../M1-clients/spec-back.md`](../M1-clients/spec-back.md)
+- RETEX sérialisation : [`../_RETEX-M1-pour-M2.md`](../_RETEX-M1-pour-M2.md)
+- Doc audit-log : [`../../audit-log.md`](../../audit-log.md)
+- Site-aware (périmètre Usine) : [`../../modules/site-aware.md`](../../modules/site-aware.md)
+- BAN api : `https://adresse.data.gouv.fr/api-doc/adresse`
+- Trace fonctionnelle : `M3-reportoire-prestataires.docx` (V0.2) / `M3-reportoire-prestataires-V01.pdf` (V0.1, obsolète)
+
+### Dépendances amont (déjà en place dans Starseed)
+
+- Module `Commercial` : référentiels comptables `TvaMode` / `PaymentDelay` / `PaymentType` / `Bank` (**partagés**, relation ORM)
+- Module `Catalog` (M0) : `Category` + `CategoryType` + **filtre `?typeCode=`** (créé au M2) (+ seed type PRESTATAIRE au M3)
+- Module `Sites` : `Site` (3 sites 86/17/82) — M2M `provider_site` + `provider_address_site`
+- Module `Core` : `User`, `Role`, `Permission`, `Audit`, JWT
+- `Shared` : `TimestampableBlamableTrait` + `Subscriber`
+- API Platform 4 + Doctrine ORM + PostgreSQL 16 + PhpSpreadsheet (export)
+
+### Specs futures qui dépendent du M3
+
+- **M-Interventions / Maintenance** : FK `provider_id`.
+
+---
+
+## 📦 Tickets Lesstime (à découper)
+
+**TaskGroup Lesstime** : à créer — `M3 — Répertoire prestataires` (projet `ERP / Starseed`, projectId=6).
+
+Ordre indicatif (back avant front, migration en tête) :
+0. **Module `Technique` + Taxonomie PRESTATAIRE** — créer `TechniqueModule` (ID/LABEL/REQUIRED/permissions) + activer dans `config/modules.php` + layer front `modules/technique/` ; seed `CategoryType PRESTATAIRE` (migration `ON CONFLICT` + fixture idempotente) + catégories prestataires ; **vérifier** que le filtre `?typeCode=PRESTATAIRE` fonctionne. Prérequis du multi-select Catégorie.
+1. **Migration BDD M3** (tables provider + provider_site + sous-collections + M2M + index partiel + COMMENT ON COLUMN)
+2. **Entités + Repositories** (Provider, ProviderContact, ProviderAddress, ProviderRib) + **hydratation liste** (categories, sites — § 2.12)
+3. **Provider + Processor** (ProviderProvider paginé, ProviderProcessor — normalisation, archivage, accounting conditionnel, mode strict, gating) + **filtre de cloisonnement par site** (§ 2.13 / RG-3.17 : `ProviderSiteScopeExtension` réutilisant `CurrentSiteProvider` + `sites.bypass_scope` ; liste filtrée, détail 404 hors périmètre)
+4. **Sous-ressources** (ProviderContactProcessor, ProviderAddressProcessor, ProviderRibProcessor)
+5. **Validators** (contrôle catégorie type PRESTATAIRE, RG-3.07/3.08, ≥1 site formulaire principal RG-3.03)
+6. **Export XLSX** (ProviderExportController, priority:1)
+7. **RBAC** : `TechniqueModule::permissions()` + sync 3 sources + tests personas
+8. **Tests PHPUnit** : matrice RG-3.03 → RG-3.16 (§ 8.1) + capture JSON réel (§ 4.0.bis)
+9. **Front : page Répertoire** (`/providers`) + `usePaginatedList`
+10. **Front : page Création** (`/providers/new`) + `useProviderForm` (sans onglet Information)
+11. **Front : page Consultation** (`/providers/{id}`) + onglets placeholder « À venir » (Rapports / Échanges)
+12. **Front : page Modification** (`/providers/{id}/edit`)
+13. **i18n + Sidebar** (section `sidebar.technique.section` + `sidebar.technique.providers` + permission, traductions, libellés audit)
+
+### Actions manuelles dans Lesstime (Matthieu)
+
+1. Créer le TaskGroup `M3 — Répertoire prestataires` (projet ERP / Starseed, projectId=6).
+2. Créer les ~14 tickets ci-dessus (ticket 0 module+taxonomie inclus) avec dépendances séquentielles.
+3. Mettre à jour le frontmatter (`lesstime_taskgroup_id`) avec l'id réel.
+
+### ✅ Décisions tranchées (Matthieu, 11/06/2026)
+
+1. **Module `Technique`** (§ 2.1) — nouveau module back + section sidebar « Technique ». ✅
+2. **Référentiels comptables** — « comme supplier » : consommation ORM partagée (pas de remontée dans `Shared`). ✅
+3. **Cloisonnement par site** (§ 2.13 / RG-3.17) — visibilité pilotée par l'**utilisateur** (son `currentSite`), automatique côté back ; bypass multi-sites via `sites.bypass_scope` (Admin auto + Bureau/Compta/Commerciale ; **Usine cloisonnée**). Indépendant du rôle. ✅
+4. **Unicité = nom seul** (§ 2.6). ✅
+
+5. **Écriture cloisonnée** (§ 2.13 / RG-3.03 / RG-3.05) — un user non-bypass ne peut attacher que **les sites dont il dispose** (`user_site`), formulaire principal ET adresses ; site hors périmètre → 422. ✅
+
+### ⚠️ Point de raffinement à confirmer (non bloquant)
+
+- **Attribution `sites.bypass_scope`** : confirmer la liste des profils « voient tous les sites » (défaut : Admin + Bureau + Compta + Commerciale ; Usine non).
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/i18n/locales/fr.json b/frontend/i18n/locales/fr.json
index cd6a8ad..7e1bb0f 100644
--- a/frontend/i18n/locales/fr.json
+++ b/frontend/i18n/locales/fr.json
@@ -30,6 +30,10 @@
"clients": "Répertoire clients",
"suppliers": "Répertoire fournisseurs"
},
+ "technique": {
+ "section": "Technique",
+ "providers": "Répertoire prestataires"
+ },
"core": {
"roles": "Gestion des rôles",
"users": "Utilisateurs",
@@ -362,6 +366,130 @@
}
}
},
+ "technique": {
+ "providers": {
+ "title": "Répertoire prestataires",
+ "add": "Ajouter",
+ "export": "Exporter",
+ "empty": "Aucun prestataire pour l'instant.",
+ "column": {
+ "companyName": "Nom",
+ "categories": "Catégories",
+ "sites": "Site",
+ "lastActivity": "Dernière activité"
+ },
+ "filters": {
+ "title": "Filtres",
+ "search": "Recherche",
+ "categories": "Catégories",
+ "sites": "Sites",
+ "status": "Statut",
+ "includeArchived": "Inclure les archivés",
+ "apply": "Voir les résultats",
+ "reset": "Réinitialiser"
+ },
+ "tab": {
+ "contact": "Contact",
+ "contacts": "Contacts",
+ "address": "Adresse",
+ "reports": "Rapports",
+ "exchanges": "Échanges",
+ "accounting": "Comptabilité"
+ },
+ "action": {
+ "edit": "Modifier",
+ "archive": "Archiver",
+ "restore": "Restaurer"
+ },
+ "consultation": {
+ "title": "Fiche prestataire",
+ "back": "Retour au répertoire",
+ "loading": "Chargement…",
+ "notFound": "Prestataire introuvable.",
+ "confirmArchive": "Archiver ce prestataire ? Il n'apparaîtra plus dans le répertoire actif.",
+ "confirmRestore": "Restaurer ce prestataire ? Il réapparaîtra dans le répertoire actif."
+ },
+ "edit": {
+ "title": "Modifier le prestataire",
+ "back": "Retour à la fiche",
+ "loading": "Chargement…",
+ "notFound": "Prestataire introuvable.",
+ "save": "Enregistrer"
+ },
+ "form": {
+ "title": "Ajouter un prestataire",
+ "back": "Précédent",
+ "submit": "Valider",
+ "duplicateCompany": "Un prestataire portant ce nom de société existe déjà.",
+ "main": {
+ "companyName": "Nom du prestataire (Entreprise)",
+ "categories": "Catégorie",
+ "sites": "Site"
+ },
+ "errors": {
+ "nameRequired": "Le nom du prestataire est obligatoire.",
+ "siteRequired": "Sélectionnez au moins un site.",
+ "categoryRequired": "Sélectionnez au moins une catégorie."
+ },
+ "contact": {
+ "lastName": "Nom",
+ "firstName": "Prénom",
+ "jobTitle": "Fonction",
+ "email": "Email",
+ "phonePrimary": "Téléphone",
+ "phoneSecondary": "Téléphone (2)",
+ "addPhone": "Ajouter un numéro",
+ "remove": "Supprimer le contact",
+ "add": "Nouveau contact"
+ },
+ "address": {
+ "sites": "Sites",
+ "categories": "Catégorie",
+ "contacts": "Contact(s) rattaché(s)",
+ "country": "Pays",
+ "postalCode": "Code postal",
+ "city": "Ville",
+ "street": "Adresse",
+ "streetNotFound": "Adresse introuvable ? Saisissez-la directement.",
+ "streetComplement": "Adresse complémentaire",
+ "remove": "Supprimer l'adresse",
+ "add": "Nouvelle adresse",
+ "degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
+ },
+ "accounting": {
+ "siren": "SIREN",
+ "accountNumber": "Numéro de compte",
+ "tvaMode": "Mode de TVA",
+ "nTva": "N° de TVA",
+ "paymentDelay": "Délai de règlement",
+ "paymentType": "Type de règlement",
+ "bank": "Banque",
+ "ribLabel": "Libellé",
+ "ribBic": "BIC",
+ "ribIban": "IBAN",
+ "addRib": "Ajouter un RIB",
+ "removeRib": "Supprimer le RIB"
+ },
+ "confirmDelete": {
+ "title": "Confirmer la suppression",
+ "cancel": "Annuler",
+ "confirm": "Supprimer",
+ "contact": "Supprimer ce contact ?",
+ "address": "Supprimer cette adresse ?",
+ "rib": "Supprimer ce RIB ?"
+ }
+ },
+ "toast": {
+ "error": "Une erreur est survenue. Réessayez.",
+ "exportError": "L'export du répertoire prestataires a échoué. Réessayez.",
+ "createSuccess": "Prestataire créé avec succès",
+ "updateSuccess": "Prestataire mis à jour avec succès",
+ "addComplete": "Prestataire ajouté",
+ "archiveSuccess": "Prestataire archivé avec succès",
+ "restoreSuccess": "Prestataire restauré avec succès"
+ }
+ }
+ },
"auth": {
"login": "Connexion",
"logout": "Deconnexion",
@@ -416,7 +544,11 @@
"commercial_supplier": "Fournisseur",
"commercial_supplieraddress": "Adresse fournisseur",
"commercial_suppliercontact": "Contact fournisseur",
- "commercial_supplierrib": "RIB fournisseur"
+ "commercial_supplierrib": "RIB fournisseur",
+ "technique_provider": "Prestataire",
+ "technique_provideraddress": "Adresse prestataire",
+ "technique_providercontact": "Contact prestataire",
+ "technique_providerrib": "RIB prestataire"
},
"empty": "Aucune activité enregistrée",
"no_results": "Aucun résultat pour ces filtres",
diff --git a/frontend/modules/technique/components/ProviderAddressBlock.vue b/frontend/modules/technique/components/ProviderAddressBlock.vue
new file mode 100644
index 0000000..3d37def
--- /dev/null
+++ b/frontend/modules/technique/components/ProviderAddressBlock.vue
@@ -0,0 +1,269 @@
+
+
+
+
+
+
+
update('siteIris', v.map(String))"
+ />
+
+
+ update('categoryIris', v.map(String))"
+ />
+
+
+ update('contactIris', v.map(String))"
+ />
+
+ update('country', String(v ?? 'France'))"
+ />
+
+
+
+
+ update('city', v === null ? null : String(v))"
+ />
+ update('city', v)"
+ />
+
+
+
+ update('street', v === null ? null : String(v))"
+ @search="onAddressSearch"
+ @select="onAddressSelect"
+ />
+ update('street', v)"
+ />
+
+
+
+ update('streetComplement', v)"
+ />
+
+
+
+
+
diff --git a/frontend/modules/technique/components/ProviderContactBlock.vue b/frontend/modules/technique/components/ProviderContactBlock.vue
new file mode 100644
index 0000000..0db2702
--- /dev/null
+++ b/frontend/modules/technique/components/ProviderContactBlock.vue
@@ -0,0 +1,108 @@
+
+
+
+
+
+
update('lastName', v)"
+ />
+ update('firstName', v)"
+ />
+
+
+ update('jobTitle', v)"
+ />
+
+ update('email', v)"
+ />
+ update('phonePrimary', v)"
+ @add="revealSecondaryPhone"
+ />
+
+ update('phoneSecondary', v)"
+ />
+
+
+
+
diff --git a/frontend/modules/technique/components/__tests__/ProviderAddressBlock.spec.ts b/frontend/modules/technique/components/__tests__/ProviderAddressBlock.spec.ts
new file mode 100644
index 0000000..6159a15
--- /dev/null
+++ b/frontend/modules/technique/components/__tests__/ProviderAddressBlock.spec.ts
@@ -0,0 +1,157 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { mount, flushPromises } from '@vue/test-utils'
+import { defineComponent, h, ref, computed } from 'vue'
+import { emptyProviderAddress } from '~/modules/technique/types/providerForm'
+import ProviderAddressBlock from '../ProviderAddressBlock.vue'
+
+// Mocks controlables du composable BAN (hoisted), reutilise tel quel du M1/M2.
+const { searchCityMock, searchAddressMock } = vi.hoisted(() => ({
+ searchCityMock: vi.fn(),
+ searchAddressMock: vi.fn(),
+}))
+vi.mock('~/shared/composables/useAddressAutocomplete', () => ({
+ useAddressAutocomplete: () => ({
+ searchCity: searchCityMock,
+ searchAddress: searchAddressMock,
+ }),
+}))
+
+// Auto-imports Nuxt/Vue utilises sans import explicite par le composant.
+vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
+vi.stubGlobal('ref', ref)
+vi.stubGlobal('computed', computed)
+
+// Stub de MalioInputAutocomplete : expose les `value` des options + allowCreate.
+const MalioInputAutocompleteStub = defineComponent({
+ name: 'MalioInputAutocomplete',
+ props: {
+ modelValue: { type: [String, Number, null], default: undefined },
+ options: { type: Array as () => { value: string | number, label: string }[], default: () => [] },
+ loading: { type: Boolean, default: false },
+ minSearchLength: { type: Number, default: 0 },
+ label: { type: String, default: '' },
+ readonly: { type: Boolean, default: false },
+ allowCreate: { type: Boolean, default: false },
+ },
+ emits: ['update:modelValue', 'search', 'select'],
+ setup(props) {
+ return () => h('div', {
+ 'data-testid': 'addr-autocomplete',
+ 'data-options': JSON.stringify(props.options.map(o => o.value)),
+ })
+ },
+})
+
+function mountBlock(overrides: Record = {}, errors?: Record) {
+ return mount(ProviderAddressBlock, {
+ props: {
+ modelValue: { ...emptyProviderAddress(), ...overrides },
+ categoryOptions: [],
+ siteOptions: [],
+ contactOptions: [],
+ countryOptions: [],
+ ...(errors ? { errors } : {}),
+ },
+ global: {
+ stubs: {
+ MalioButtonIcon: true,
+ MalioSelect: true,
+ MalioSelectCheckbox: true,
+ MalioInputText: true,
+ MalioInputAutocomplete: MalioInputAutocompleteStub,
+ },
+ },
+ })
+}
+
+describe('ProviderAddressBlock — version simplifiee M3 (pas de type/bennes/triage)', () => {
+ it('ne rend NI type d\'adresse, NI bennes, NI prestation de triage (difference M2)', () => {
+ const wrapper = mountBlock()
+ // Pas de stepper (bennes) ni de case a cocher (triage) dans le bloc M3.
+ expect(wrapper.find('malio-input-number-stub').exists()).toBe(false)
+ expect(wrapper.find('malio-checkbox-stub').exists()).toBe(false)
+ // Aucun select ne porte le label « type d'adresse ».
+ const hasAddressType = wrapper.findAll('malio-select-stub').some(
+ el => el.attributes('label') === 'technique.providers.form.address.addressType',
+ )
+ expect(hasAddressType).toBe(false)
+ })
+})
+
+describe('ProviderAddressBlock — mapping erreur par champ (ERP-101)', () => {
+ it('affiche les erreurs serveur sur sites et categories (RG-3.05 / RG-3.09)', () => {
+ const wrapper = mountBlock({}, {
+ sites: 'Au moins un site est obligatoire.',
+ categories: 'Au moins une catégorie est obligatoire.',
+ })
+ const checkboxes = wrapper.findAll('malio-select-checkbox-stub')
+ const sitesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.sites')
+ const categoriesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.categories')
+
+ expect(sitesField?.attributes('error')).toBe('Au moins un site est obligatoire.')
+ expect(categoriesField?.attributes('error')).toBe('Au moins une catégorie est obligatoire.')
+ })
+
+ it('affiche l\'erreur serveur sur le code postal', () => {
+ const wrapper = mountBlock({}, { postalCode: 'Code postal invalide.' })
+ const field = wrapper.findAll('malio-input-text-stub').find(
+ el => el.attributes('label') === 'technique.providers.form.address.postalCode',
+ )
+ expect(field?.attributes('error')).toBe('Code postal invalide.')
+ })
+})
+
+describe('ProviderAddressBlock — autocompletion BAN (RG-3.06)', () => {
+ beforeEach(() => {
+ searchCityMock.mockReset()
+ searchAddressMock.mockReset()
+ })
+
+ it('n\'appelle pas la BAN en deca de 3 caracteres', async () => {
+ const wrapper = mountBlock()
+ wrapper.findComponent(MalioInputAutocompleteStub).vm.$emit('search', 'ab')
+ await flushPromises()
+ expect(searchAddressMock).not.toHaveBeenCalled()
+ })
+
+ it('relance la recherche apres une erreur (pas de bascule definitive)', async () => {
+ searchAddressMock
+ .mockRejectedValueOnce(new Error('BAN indisponible'))
+ .mockResolvedValueOnce([
+ { label: '1 rue du Test, Châtellerault', street: '1 rue du Test', postalCode: '86100', city: 'Châtellerault' },
+ ])
+ const wrapper = mountBlock()
+ const auto = wrapper.findComponent(MalioInputAutocompleteStub)
+
+ auto.vm.$emit('search', 'rue du test')
+ await flushPromises()
+ auto.vm.$emit('search', 'rue du teste')
+ await flushPromises()
+
+ expect(searchAddressMock).toHaveBeenCalledTimes(2)
+ })
+
+ it('cas degrade : la BAN echoue -> emet « degraded » une seule fois (RG-3.06)', async () => {
+ searchAddressMock.mockRejectedValue(new Error('BAN indisponible'))
+ const wrapper = mountBlock()
+ const auto = wrapper.findComponent(MalioInputAutocompleteStub)
+
+ auto.vm.$emit('search', 'rue du test')
+ await flushPromises()
+ auto.vm.$emit('search', 'rue du teste')
+ await flushPromises()
+
+ expect(wrapper.emitted('degraded')).toHaveLength(1)
+ })
+
+ it('active allow-create sur le champ Adresse (saisie manuelle libre)', () => {
+ const wrapper = mountBlock()
+ expect(wrapper.findComponent(MalioInputAutocompleteStub).props('allowCreate')).toBe(true)
+ })
+
+ it('inclut la rue courante dans les options meme sans recherche BAN', () => {
+ const wrapper = mountBlock({ street: '1 rue du Test' })
+ const values = JSON.parse(wrapper.find('[data-testid="addr-autocomplete"]').attributes('data-options') ?? '[]')
+ expect(values).toContain('1 rue du Test')
+ })
+})
diff --git a/frontend/modules/technique/components/__tests__/ProviderContactBlock.spec.ts b/frontend/modules/technique/components/__tests__/ProviderContactBlock.spec.ts
new file mode 100644
index 0000000..928a2d7
--- /dev/null
+++ b/frontend/modules/technique/components/__tests__/ProviderContactBlock.spec.ts
@@ -0,0 +1,55 @@
+import { describe, it, expect, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { defineComponent, h, ref, computed } from 'vue'
+import { emptyProviderContact } from '~/modules/technique/types/providerForm'
+import ProviderContactBlock from '../ProviderContactBlock.vue'
+
+// Auto-imports Nuxt/Vue utilises sans import explicite par le composant.
+vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
+vi.stubGlobal('ref', ref)
+vi.stubGlobal('computed', computed)
+
+/** Stub d'un champ Malio qui re-expose la prop `error` recue dans un data-* attribut. */
+function errorProbe(testid: string) {
+ return defineComponent({
+ name: `Probe-${testid}`,
+ props: {
+ modelValue: { type: [String, Number, null], default: undefined },
+ error: { type: String, default: '' },
+ label: { type: String, default: '' },
+ readonly: { type: Boolean, default: false },
+ },
+ setup(props) {
+ return () => h('div', { 'data-testid': testid, 'data-error': props.error })
+ },
+ })
+}
+
+function mountBlock(errors?: Record) {
+ return mount(ProviderContactBlock, {
+ props: {
+ modelValue: emptyProviderContact(),
+ ...(errors ? { errors } : {}),
+ },
+ global: {
+ stubs: {
+ MalioButtonIcon: true,
+ MalioInputPhone: true,
+ MalioInputText: errorProbe('contact-text'),
+ MalioInputEmail: errorProbe('contact-email'),
+ },
+ },
+ })
+}
+
+describe('ProviderContactBlock — mapping erreur par champ (ERP-101)', () => {
+ it('affiche l\'erreur serveur sur le champ email via la prop errors', () => {
+ const wrapper = mountBlock({ email: 'L\'adresse email n\'est pas valide.' })
+ expect(wrapper.find('[data-testid="contact-email"]').attributes('data-error')).toBe('L\'adresse email n\'est pas valide.')
+ })
+
+ it('laisse les champs sans erreur quand errors est absent', () => {
+ const wrapper = mountBlock()
+ expect(wrapper.find('[data-testid="contact-email"]').attributes('data-error')).toBe('')
+ })
+})
diff --git a/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts b/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts
new file mode 100644
index 0000000..a0ab65b
--- /dev/null
+++ b/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts
@@ -0,0 +1,653 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+/**
+ * Tests du workflow « Ajouter un prestataire » (M3 Technique, ERP-141).
+ *
+ * `useProviderForm` porte le formulaire principal (Nom + Categorie + Site) et
+ * l'orchestration des onglets de creation. On verifie ici le CONTRAT propre a la
+ * creation :
+ * - RG-3.03 (front) : au moins un site requis ; RG-3.09 : au moins une categorie
+ * -> POST bloque, erreurs inline, aucun appel reseau.
+ * - POST /providers (groupe provider:write:main) : payload IRIs + Accept ld+json
+ * + toast:false ; au succes, verrouillage + bascule sur l'onglet Contact +
+ * reaffichage du nom normalise.
+ * - 409 doublon (RG-3.10) -> erreur inline dediee sur companyName.
+ * - 422 -> mapping inline par champ (propertyPath).
+ * - Onglets : « Comptabilite » present uniquement avec accounting.view ;
+ * completeTab deverrouille/avance et signale le dernier onglet.
+ */
+
+const mockPost = vi.hoisted(() => vi.fn())
+const mockPatch = vi.hoisted(() => vi.fn())
+// Permissions comptables pilotables par test (presence/edition de l'onglet Comptabilite).
+const permState = vi.hoisted(() => ({ accountingView: false, accountingManage: false }))
+
+vi.stubGlobal('useApi', () => ({
+ get: vi.fn(),
+ post: mockPost,
+ put: vi.fn(),
+ patch: mockPatch,
+ delete: vi.fn(),
+}))
+vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
+vi.stubGlobal('useToast', () => ({
+ success: vi.fn(),
+ error: vi.fn(),
+ warning: vi.fn(),
+ info: vi.fn(),
+}))
+vi.stubGlobal('usePermissions', () => ({
+ can: (perm: string) => {
+ if (perm === 'technique.providers.accounting.view') return permState.accountingView
+ if (perm === 'technique.providers.accounting.manage') return permState.accountingManage
+ return true
+ },
+}))
+
+const { useProviderForm, buildProviderCreateTabKeys } = await import('../useProviderForm')
+const { emptyProviderContact, emptyProviderAddress } = await import('~/modules/technique/types/providerForm')
+type ProviderForm = ReturnType
+
+const SITE_86 = '/api/sites/1'
+const CAT_MAINT = '/api/categories/7'
+
+/** Accede a un bloc contact (cast : sous noUncheckedIndexedAccess l'index est optionnel). */
+function contactAt(form: ProviderForm, index = 0) {
+ return form.contacts.value[index] ?? emptyProviderContact()
+}
+
+/** Accede a un bloc adresse (idem). */
+function addressAt(form: ProviderForm, index = 0) {
+ return form.addresses.value[index] ?? emptyProviderAddress()
+}
+
+describe('useProviderForm', () => {
+ beforeEach(() => {
+ mockPost.mockReset()
+ mockPatch.mockReset()
+ permState.accountingView = false
+ permState.accountingManage = false
+ })
+
+ it('front : formulaire principal vide -> erreurs sur nom + site + categorie, pas de POST', async () => {
+ const form = useProviderForm()
+
+ const created = await form.submitMain()
+
+ expect(created).toBe(false)
+ expect(mockPost).not.toHaveBeenCalled()
+ expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.errors.nameRequired')
+ expect(form.mainErrors.errors.sites).toBe('technique.providers.form.errors.siteRequired')
+ expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired')
+ expect(form.mainLocked.value).toBe(false)
+ })
+
+ it('RG-3.03 (front) : un site present sans categorie n\'erre que sur categories', async () => {
+ const form = useProviderForm()
+ form.main.companyName = 'Maintenance Pro'
+ form.main.siteIris = [SITE_86]
+
+ await form.submitMain()
+
+ expect(mockPost).not.toHaveBeenCalled()
+ expect(form.mainErrors.errors.sites).toBeUndefined()
+ expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired')
+ })
+
+ it('POST /providers avec IRIs + Accept ld+json, verrouille et bascule sur Contact', async () => {
+ mockPost.mockResolvedValueOnce({ id: 42, companyName: 'MAINTENANCE PRO' })
+ const form = useProviderForm()
+ form.main.companyName = 'Maintenance Pro'
+ form.main.categoryIris = [CAT_MAINT]
+ form.main.siteIris = [SITE_86]
+
+ const created = await form.submitMain()
+
+ expect(created).toBe(true)
+ expect(mockPost).toHaveBeenCalledTimes(1)
+ const [url, body, opts] = mockPost.mock.calls[0] ?? []
+ expect(url).toBe('/providers')
+ expect(body).toEqual({
+ companyName: 'Maintenance Pro',
+ categories: [CAT_MAINT],
+ sites: [SITE_86],
+ })
+ expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
+
+ expect(form.providerId.value).toBe(42)
+ // RG-3.11 : reaffiche le nom normalise (UPPERCASE) renvoye par le serveur.
+ expect(form.main.companyName).toBe('MAINTENANCE PRO')
+ expect(form.mainLocked.value).toBe(true)
+ expect(form.activeTab.value).toBe('contact')
+ expect(form.unlockedIndex.value).toBe(0)
+ })
+
+ it('front : nom vide/espaces -> erreur inline sur companyName, pas de POST', async () => {
+ const form = useProviderForm()
+ form.main.companyName = ' '
+ form.main.categoryIris = [CAT_MAINT]
+ form.main.siteIris = [SITE_86]
+
+ const created = await form.submitMain()
+
+ expect(created).toBe(false)
+ expect(mockPost).not.toHaveBeenCalled()
+ expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.errors.nameRequired')
+ })
+
+ it('409 doublon (RG-3.10) : erreur inline dediee sur companyName, pas de verrouillage', async () => {
+ mockPost.mockRejectedValueOnce({ response: { status: 409 } })
+ const form = useProviderForm()
+ form.main.companyName = 'Doublon'
+ form.main.categoryIris = [CAT_MAINT]
+ form.main.siteIris = [SITE_86]
+
+ const created = await form.submitMain()
+
+ expect(created).toBe(false)
+ expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.duplicateCompany')
+ expect(form.mainLocked.value).toBe(false)
+ })
+
+ it('422 : mappe les violations serveur inline par champ', async () => {
+ mockPost.mockRejectedValueOnce({
+ response: {
+ status: 422,
+ _data: { violations: [{ propertyPath: 'sites', message: 'Au moins un site est requis.' }] },
+ },
+ })
+ const form = useProviderForm()
+ form.main.companyName = 'X'
+ form.main.categoryIris = [CAT_MAINT]
+ form.main.siteIris = [SITE_86]
+
+ const created = await form.submitMain()
+
+ expect(created).toBe(false)
+ expect(form.mainErrors.errors.sites).toBe('Au moins un site est requis.')
+ })
+
+ it('onglet Comptabilite : absent sans accounting.view, present avec', () => {
+ expect(buildProviderCreateTabKeys(false)).toEqual(['contact', 'address'])
+ expect(buildProviderCreateTabKeys(true)).toEqual(['contact', 'address', 'accounting'])
+
+ permState.accountingView = true
+ const form = useProviderForm()
+ expect(form.tabKeys.value).toEqual(['contact', 'address', 'accounting'])
+ })
+
+ it('completeTab : deverrouille/avance, et signale le dernier onglet du flux', () => {
+ const form = useProviderForm()
+
+ // Contact -> Adresse (pas le dernier).
+ expect(form.completeTab('contact')).toBe(false)
+ expect(form.isValidated('contact')).toBe(true)
+ expect(form.activeTab.value).toBe('address')
+ expect(form.unlockedIndex.value).toBe(1)
+
+ // Adresse = dernier onglet remplissable (sans accounting.view) -> true.
+ expect(form.completeTab('address')).toBe(true)
+ expect(form.isValidated('address')).toBe(true)
+ })
+
+ it('patchProvider : PATCH /providers/{id} en mode strict, no-op avant creation', async () => {
+ const form = useProviderForm()
+
+ await form.patchProvider({ siren: '123456789' })
+ expect(mockPatch).not.toHaveBeenCalled()
+
+ mockPost.mockResolvedValueOnce({ id: 9, companyName: 'ACME' })
+ form.main.companyName = 'Acme'
+ form.main.categoryIris = [CAT_MAINT]
+ form.main.siteIris = [SITE_86]
+ await form.submitMain()
+
+ await form.patchProvider({ siren: '123456789' })
+ expect(mockPatch).toHaveBeenCalledWith('/providers/9', { siren: '123456789' }, { toast: false })
+ })
+})
+
+describe('useProviderForm — onglet Contact (ERP-142)', () => {
+ beforeEach(() => {
+ mockPost.mockReset()
+ mockPatch.mockReset()
+ permState.accountingView = false
+ permState.accountingManage = false
+ })
+
+ /** Place le formulaire en etat « prestataire cree » (onglet Contact accessible). */
+ function createdForm() {
+ const form = useProviderForm()
+ form.providerId.value = 7
+ return form
+ }
+
+ it('RG-3.04 : « + Nouveau contact » desactive tant que le dernier bloc est vide', () => {
+ const form = createdForm()
+ expect(form.canAddContact.value).toBe(false)
+
+ // addContact est un no-op tant que le bloc est vide.
+ form.addContact()
+ expect(form.contacts.value).toHaveLength(1)
+
+ contactAt(form).lastName = 'Doe'
+ expect(form.canAddContact.value).toBe(true)
+ form.addContact()
+ expect(form.contacts.value).toHaveLength(2)
+ })
+
+ it('removeContact retire le bloc et son erreur de ligne', () => {
+ const form = createdForm()
+ contactAt(form).lastName = 'Doe'
+ form.addContact()
+ form.contactErrors.value = [{}, { lastName: 'x' }]
+
+ form.removeContact(1)
+ expect(form.contacts.value).toHaveLength(1)
+ expect(form.contactErrors.value).toHaveLength(1)
+ })
+
+ it('submitContacts : POST des nouveaux, capture id + IRI, finalise l\'onglet', async () => {
+ mockPost.mockResolvedValueOnce({ '@id': '/api/provider_contacts/55', id: 55 })
+ const form = createdForm()
+ contactAt(form).lastName = 'Doe'
+
+ const ok = await form.submitContacts(vi.fn())
+
+ expect(ok).toBe(true)
+ const [url, body, opts] = mockPost.mock.calls[0] ?? []
+ expect(url).toBe('/providers/7/contacts')
+ expect(body).toMatchObject({ lastName: 'Doe' })
+ expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
+ expect(contactAt(form).id).toBe(55)
+ expect(contactAt(form).iri).toBe('/api/provider_contacts/55')
+ expect(form.isValidated('contact')).toBe(true)
+ })
+
+ it('submitContacts : PATCH des contacts existants sur /provider_contacts/{id}', async () => {
+ mockPatch.mockResolvedValueOnce({})
+ const form = createdForm()
+ contactAt(form).id = 55
+ contactAt(form).lastName = 'Doe'
+
+ await form.submitContacts(vi.fn())
+
+ expect(mockPost).not.toHaveBeenCalled()
+ expect(mockPatch).toHaveBeenCalledWith('/provider_contacts/55', expect.objectContaining({ lastName: 'Doe' }), { toast: false })
+ })
+
+ it('RG-3.12 : onglet vide -> soumet l\'amorce pour declencher la 422 firstName inline', async () => {
+ mockPost.mockRejectedValueOnce({
+ response: {
+ status: 422,
+ _data: { violations: [{ propertyPath: 'firstName', message: 'Au moins un champ du contact est obligatoire.' }] },
+ },
+ })
+ const form = createdForm()
+
+ const ok = await form.submitContacts(vi.fn())
+
+ expect(ok).toBe(false)
+ expect(mockPost).toHaveBeenCalledTimes(1)
+ expect(form.contactErrors.value[0]?.firstName).toBe('Au moins un champ du contact est obligatoire.')
+ expect(form.isValidated('contact')).toBe(false)
+ })
+
+ it('mappe les erreurs 422 PAR LIGNE (le bloc 2 echoue, le bloc 1 passe)', async () => {
+ mockPost
+ .mockResolvedValueOnce({ '@id': '/api/provider_contacts/1', id: 1 })
+ .mockRejectedValueOnce({
+ response: {
+ status: 422,
+ _data: { violations: [{ propertyPath: 'email', message: 'L\'adresse email n\'est pas valide.' }] },
+ },
+ })
+ const form = createdForm()
+ contactAt(form).lastName = 'Doe'
+ form.addContact()
+ contactAt(form, 1).email = 'invalide'
+
+ const ok = await form.submitContacts(vi.fn())
+
+ expect(ok).toBe(false)
+ expect(form.contactErrors.value[0]).toBeUndefined()
+ expect(form.contactErrors.value[1]?.email).toBe('L\'adresse email n\'est pas valide.')
+ })
+})
+
+describe('useProviderForm — onglet Adresse (ERP-143)', () => {
+ beforeEach(() => {
+ mockPost.mockReset()
+ mockPatch.mockReset()
+ permState.accountingView = false
+ permState.accountingManage = false
+ })
+
+ /** Place le formulaire en etat « prestataire cree » (onglet Adresse accessible). */
+ function createdForm() {
+ const form = useProviderForm()
+ form.providerId.value = 7
+ return form
+ }
+
+ /** Remplit un bloc adresse valide (site + categorie + scalaires requis). */
+ function fillValidAddress(form: ProviderForm, index = 0): void {
+ const a = addressAt(form, index)
+ a.siteIris = [SITE_86]
+ a.categoryIris = [CAT_MAINT]
+ a.postalCode = '86100'
+ a.city = 'Châtellerault'
+ a.street = '1 rue du Test'
+ }
+
+ it('RG-3.05 : « + Nouvelle adresse » desactive tant que site + categorie manquent', () => {
+ const form = createdForm()
+ expect(form.canAddAddress.value).toBe(false)
+
+ // no-op tant que l'adresse n'est pas valide.
+ form.addAddress()
+ expect(form.addresses.value).toHaveLength(1)
+
+ addressAt(form).siteIris = [SITE_86]
+ expect(form.canAddAddress.value).toBe(false) // categorie manquante
+ addressAt(form).categoryIris = [CAT_MAINT]
+ expect(form.canAddAddress.value).toBe(true)
+ form.addAddress()
+ expect(form.addresses.value).toHaveLength(2)
+ })
+
+ it('removeAddress retire le bloc et son erreur de ligne', () => {
+ const form = createdForm()
+ fillValidAddress(form)
+ form.addAddress()
+ form.addressErrors.value = [{}, { city: 'x' }]
+
+ form.removeAddress(1)
+ expect(form.addresses.value).toHaveLength(1)
+ expect(form.addressErrors.value).toHaveLength(1)
+ })
+
+ it('submitAddresses : POST des nouvelles, capture l\'id, finalise l\'onglet', async () => {
+ mockPost.mockResolvedValueOnce({ id: 88 })
+ const form = createdForm()
+ fillValidAddress(form)
+
+ const ok = await form.submitAddresses(vi.fn())
+
+ expect(ok).toBe(true)
+ const [url, body, opts] = mockPost.mock.calls[0] ?? []
+ expect(url).toBe('/providers/7/addresses')
+ expect(body).toMatchObject({ sites: [SITE_86], categories: [CAT_MAINT], city: 'Châtellerault' })
+ expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
+ expect(addressAt(form).id).toBe(88)
+ expect(form.isValidated('address')).toBe(true)
+ })
+
+ it('submitAddresses : PATCH des adresses existantes sur /provider_addresses/{id}', async () => {
+ mockPatch.mockResolvedValueOnce({})
+ const form = createdForm()
+ fillValidAddress(form)
+ addressAt(form).id = 88
+
+ await form.submitAddresses(vi.fn())
+
+ expect(mockPost).not.toHaveBeenCalled()
+ expect(mockPatch).toHaveBeenCalledWith('/provider_addresses/88', expect.objectContaining({ sites: [SITE_86] }), { toast: false })
+ })
+
+ it('mappe les erreurs 422 PAR LIGNE et ne finalise pas l\'onglet', async () => {
+ mockPost.mockRejectedValueOnce({
+ response: {
+ status: 422,
+ _data: { violations: [{ propertyPath: 'city', message: 'La ville est obligatoire.' }] },
+ },
+ })
+ const form = createdForm()
+ fillValidAddress(form)
+
+ const ok = await form.submitAddresses(vi.fn())
+
+ expect(ok).toBe(false)
+ expect(form.addressErrors.value[0]?.city).toBe('La ville est obligatoire.')
+ expect(form.isValidated('address')).toBe(false)
+ })
+})
+
+describe('useProviderForm — onglet Comptabilite (ERP-144)', () => {
+ const TVA = '/api/tva_modes/1'
+ const DELAY = '/api/payment_delays/1'
+ const TYPE = '/api/payment_types/3'
+ const BANK = '/api/banks/2'
+
+ beforeEach(() => {
+ mockPost.mockReset()
+ mockPatch.mockReset()
+ permState.accountingView = true
+ permState.accountingManage = true
+ })
+
+ /** Prestataire cree, onglet Comptabilite editable (view + manage). */
+ function createdForm() {
+ const form = useProviderForm()
+ form.providerId.value = 7
+ return form
+ }
+
+ /** Remplit les scalaires comptables communs. */
+ function fillScalars(form: ProviderForm): void {
+ form.accounting.siren = '123456789'
+ form.accounting.accountNumber = '4010'
+ form.accounting.tvaModeIri = TVA
+ form.accounting.nTva = 'FR123'
+ form.accounting.paymentDelayIri = DELAY
+ form.accounting.paymentTypeIri = TYPE
+ }
+
+ it('lecture seule sans accounting.manage (Compta consultation / autres roles)', () => {
+ permState.accountingManage = false
+ const form = createdForm()
+ expect(form.accountingReadonly.value).toBe(true)
+
+ permState.accountingManage = true
+ const form2 = createdForm()
+ expect(form2.accountingReadonly.value).toBe(false)
+ })
+
+ it('RG-3.07 : setPaymentType(VIREMENT) garde la banque ; un autre type la vide', () => {
+ const form = createdForm()
+ form.accounting.bankIri = BANK
+
+ // Type VIREMENT -> banque requise, conservee.
+ form.setPaymentType(TYPE, true, false)
+ expect(form.accounting.bankIri).toBe(BANK)
+
+ // Type non-VIREMENT -> banque videe (sans objet).
+ form.setPaymentType(TYPE, false, false)
+ expect(form.accounting.bankIri).toBeNull()
+ })
+
+ it('RG-3.08 : setPaymentType(LCR) garantit au moins un bloc RIB', () => {
+ const form = createdForm()
+ expect(form.ribs.value).toHaveLength(0)
+
+ form.setPaymentType(TYPE, false, true)
+ expect(form.ribs.value).toHaveLength(1)
+ })
+
+ it('« + RIB » desactive tant que le dernier RIB est incomplet (RG-3.08)', () => {
+ const form = createdForm()
+ form.setPaymentType(TYPE, false, true)
+ expect(form.canAddRib.value).toBe(false)
+
+ const rib = form.ribs.value[0]
+ if (rib) {
+ rib.label = 'Compte'
+ rib.bic = 'BNPAFRPP'
+ rib.iban = 'FR76...'
+ }
+ expect(form.canAddRib.value).toBe(true)
+ })
+
+ it('VIREMENT : PATCH des scalaires avec banque, aucun appel RIB', async () => {
+ mockPatch.mockResolvedValueOnce({})
+ const form = createdForm()
+ fillScalars(form)
+ form.accounting.bankIri = BANK
+
+ const ok = await form.submitAccounting(true, false, vi.fn())
+
+ expect(ok).toBe(true)
+ expect(mockPost).not.toHaveBeenCalled()
+ expect(mockPatch).toHaveBeenCalledWith(
+ '/providers/7',
+ expect.objectContaining({ paymentType: TYPE, bank: BANK, siren: '123456789' }),
+ { toast: false },
+ )
+ expect(form.isValidated('accounting')).toBe(true)
+ })
+
+ it('hors VIREMENT : la banque part a null dans le payload (RG-3.07)', async () => {
+ mockPatch.mockResolvedValueOnce({})
+ const form = createdForm()
+ fillScalars(form)
+ form.accounting.bankIri = BANK // residu : doit etre ignore (isBankRequired=false)
+
+ await form.submitAccounting(false, false, vi.fn())
+
+ const body = mockPatch.mock.calls[0]?.[1] as Record
+ expect(body.bank).toBeNull()
+ })
+
+ it('LCR : POST des RIB AVANT le PATCH des scalaires (ordre RG-3.08)', async () => {
+ mockPost.mockResolvedValueOnce({ id: 50 })
+ mockPatch.mockResolvedValueOnce({})
+ const form = createdForm()
+ fillScalars(form)
+ form.setPaymentType(TYPE, false, true)
+ const rib = form.ribs.value[0]
+ if (rib) {
+ rib.label = 'Compte'
+ rib.bic = 'BNPAFRPP'
+ rib.iban = 'FR76...'
+ }
+
+ const ok = await form.submitAccounting(false, true, vi.fn())
+
+ expect(ok).toBe(true)
+ expect(mockPost).toHaveBeenCalledWith(
+ '/providers/7/ribs',
+ expect.objectContaining({ label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' }),
+ { headers: { Accept: 'application/ld+json' }, toast: false },
+ )
+ expect(form.ribs.value[0]?.id).toBe(50)
+ // Le PATCH des scalaires intervient APRES la creation du RIB.
+ expect(mockPatch).toHaveBeenCalledWith('/providers/7', expect.any(Object), { toast: false })
+ })
+
+ it('422 sur les scalaires (bank) : mapping inline, onglet non finalise', async () => {
+ mockPatch.mockRejectedValueOnce({
+ response: {
+ status: 422,
+ _data: { violations: [{ propertyPath: 'bank', message: 'La banque est obligatoire pour un virement.' }] },
+ },
+ })
+ const form = createdForm()
+ fillScalars(form)
+
+ const ok = await form.submitAccounting(true, false, vi.fn())
+
+ expect(ok).toBe(false)
+ expect(form.accountingErrors.errors.bank).toBe('La banque est obligatoire pour un virement.')
+ expect(form.isValidated('accounting')).toBe(false)
+ })
+
+ it('LCR : 422 RIB par ligne -> pas de PATCH des scalaires', async () => {
+ mockPost.mockRejectedValueOnce({
+ response: {
+ status: 422,
+ _data: { violations: [{ propertyPath: 'iban', message: 'L\'IBAN est obligatoire.' }] },
+ },
+ })
+ const form = createdForm()
+ fillScalars(form)
+ form.setPaymentType(TYPE, false, true)
+ const rib = form.ribs.value[0]
+ if (rib) {
+ rib.label = 'Compte'
+ rib.bic = 'BNPAFRPP'
+ }
+
+ const ok = await form.submitAccounting(false, true, vi.fn())
+
+ expect(ok).toBe(false)
+ expect(form.ribErrors.value[0]?.iban).toBe('L\'IBAN est obligatoire.')
+ expect(mockPatch).not.toHaveBeenCalled()
+ })
+})
+
+describe('useProviderForm — modification (ERP-145)', () => {
+ beforeEach(() => {
+ mockPost.mockReset()
+ mockPatch.mockReset()
+ permState.accountingView = false
+ permState.accountingManage = false
+ })
+
+ it('editMode : completeTab ne verrouille pas et ne bascule pas d\'onglet', () => {
+ const form = useProviderForm()
+ form.editMode.value = true
+ form.activeTab.value = 'contact'
+
+ expect(form.completeTab('contact')).toBe(false)
+ expect(form.isValidated('contact')).toBe(false)
+ expect(form.activeTab.value).toBe('contact')
+ })
+
+ it('updateMain : PATCH /providers/{id} sur le groupe principal (pas de POST)', async () => {
+ mockPatch.mockResolvedValueOnce({ id: 7, companyName: 'MAINTENANCE PRO' })
+ const form = useProviderForm()
+ form.providerId.value = 7
+ form.main.companyName = 'Maintenance Pro'
+ form.main.categoryIris = [CAT_MAINT]
+ form.main.siteIris = [SITE_86]
+
+ const ok = await form.updateMain()
+
+ expect(ok).toBe(true)
+ expect(mockPost).not.toHaveBeenCalled()
+ expect(mockPatch).toHaveBeenCalledWith(
+ '/providers/7',
+ { companyName: 'Maintenance Pro', categories: [CAT_MAINT], sites: [SITE_86] },
+ { toast: false },
+ )
+ // Reaffiche le nom normalise renvoye par le serveur.
+ expect(form.main.companyName).toBe('MAINTENANCE PRO')
+ })
+
+ it('updateMain : RG-3.03 front -> bloque le PATCH sans site', async () => {
+ const form = useProviderForm()
+ form.providerId.value = 7
+ form.main.companyName = 'X'
+ form.main.categoryIris = [CAT_MAINT]
+
+ const ok = await form.updateMain()
+
+ expect(ok).toBe(false)
+ expect(mockPatch).not.toHaveBeenCalled()
+ expect(form.mainErrors.errors.sites).toBe('technique.providers.form.errors.siteRequired')
+ })
+
+ it('updateMain : 409 doublon -> erreur inline sur companyName', async () => {
+ mockPatch.mockRejectedValueOnce({ response: { status: 409 } })
+ const form = useProviderForm()
+ form.providerId.value = 7
+ form.main.companyName = 'Doublon'
+ form.main.categoryIris = [CAT_MAINT]
+ form.main.siteIris = [SITE_86]
+
+ const ok = await form.updateMain()
+
+ expect(ok).toBe(false)
+ expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.duplicateCompany')
+ })
+})
diff --git a/frontend/modules/technique/composables/__tests__/useProvidersRepository.test.ts b/frontend/modules/technique/composables/__tests__/useProvidersRepository.test.ts
new file mode 100644
index 0000000..2bbf646
--- /dev/null
+++ b/frontend/modules/technique/composables/__tests__/useProvidersRepository.test.ts
@@ -0,0 +1,78 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { useProvidersRepository, type Provider } from '../useProvidersRepository'
+
+const mockApiGet = vi.hoisted(() => vi.fn())
+vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
+
+/**
+ * Tests du repertoire prestataires (ERP-140).
+ *
+ * `useProvidersRepository` est une fine enveloppe de `usePaginatedList`
+ * sur `/providers`. Les invariants generiques de pagination sont deja couverts
+ * par `usePaginatedList.test.ts` ; on verifie ici le CONTRAT propre au repertoire :
+ * - la ressource ciblee est bien `/providers`
+ * - l'enveloppe Hydra (member / totalItems) est consommee
+ * - le header `Accept: application/ld+json` est envoye (sinon API Platform 4
+ * renvoie un tableau plat sans pagination)
+ * - EXCLUSION DES ARCHIVES PAR DEFAUT : aucun `includeArchived` n'est envoye
+ * tant que l'utilisateur ne coche pas le filtre (le back masque alors les
+ * archives) ; le filtre `includeArchived` est bien transmis une fois applique.
+ */
+describe('useProvidersRepository', () => {
+ beforeEach(() => {
+ mockApiGet.mockReset()
+ })
+
+ /** Une page de prestataires Hydra, avec categories[] et sites[] embarques. */
+ const PAGE: Provider[] = [
+ {
+ id: 1,
+ companyName: 'ACME MAINTENANCE',
+ categories: [{ code: 'MAINTENANCE_INDUSTRIELLE', name: 'Maintenance industrielle' }],
+ sites: [{ id: 4, name: 'Chatellerault', color: '#056CF2' }],
+ updatedAt: '2026-06-15T08:12:01+02:00',
+ isArchived: false,
+ },
+ ]
+
+ it('cible /providers, consomme l\'enveloppe Hydra et envoie l\'Accept ld+json', async () => {
+ mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
+ const repo = useProvidersRepository()
+
+ await repo.fetch()
+
+ expect(mockApiGet).toHaveBeenCalledTimes(1)
+ const [url, query, opts] = mockApiGet.mock.calls[0]
+ expect(url).toBe('/providers')
+ expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
+ expect(opts).toMatchObject({
+ toast: false,
+ headers: { Accept: 'application/ld+json' },
+ })
+ expect(repo.items.value).toEqual(PAGE)
+ expect(repo.totalItems.value).toBe(1)
+ })
+
+ it('exclut les archives par defaut : aucun includeArchived au premier fetch', async () => {
+ mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
+ const repo = useProvidersRepository()
+
+ await repo.fetch()
+
+ const query = mockApiGet.mock.calls[0][1] as Record
+ expect(query.includeArchived).toBeUndefined()
+ })
+
+ it('transmet includeArchived une fois le filtre applique (retour page 1)', async () => {
+ mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
+ const repo = useProvidersRepository()
+ await repo.fetch()
+
+ mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
+ await repo.setFilters({ includeArchived: true })
+
+ expect(repo.currentPage.value).toBe(1)
+ const query = mockApiGet.mock.calls.at(-1)?.[1] as Record
+ expect(query.includeArchived).toBe(true)
+ })
+})
diff --git a/frontend/modules/technique/composables/useProvider.ts b/frontend/modules/technique/composables/useProvider.ts
new file mode 100644
index 0000000..db795e2
--- /dev/null
+++ b/frontend/modules/technique/composables/useProvider.ts
@@ -0,0 +1,70 @@
+import { ref } from 'vue'
+import type { ProviderDetail } from '~/modules/technique/utils/forms/providerDetail'
+
+/**
+ * Chargement et actions d'archivage d'un prestataire unique (ecrans Consultation /
+ * Modification, ERP-145). Miroir de `useSupplier` (M2). Lit le detail embarque via
+ * `GET /api/providers/{id}` (contacts / adresses + leurs sous-collections / ribs
+ * sous `provider:item:read` / `provider:read:accounting`) — une SEULE requete
+ * peuple les deux ecrans (embed borne, pas de N+1).
+ *
+ * L'en-tete `Accept: application/ld+json` est impose pour obtenir le payload Hydra
+ * complet (avec les `@id` des relations embarquees, indispensables au pre-remplissage).
+ *
+ * Etat 100 % local a l'instance (refs). Les erreurs d'archivage / restauration
+ * (notamment le 409 d'homonyme actif a la restauration) sont PROPAGEES a l'appelant,
+ * qui decide du toast a afficher.
+ */
+export function useProvider(id: number | string) {
+ const api = useApi()
+
+ const provider = ref(null)
+ const loading = ref(false)
+ const error = ref(false)
+
+ /** Recupere le detail complet (embed contacts/adresses/ribs + comptabilite). */
+ function fetchDetail(): Promise {
+ return api.get(
+ `/providers/${id}`,
+ {},
+ { headers: { Accept: 'application/ld+json' }, toast: false },
+ )
+ }
+
+ /** Charge le detail du prestataire. En cas d'echec : `error = true`, `provider = null`. */
+ async function load(): Promise {
+ loading.value = true
+ error.value = false
+ try {
+ provider.value = await fetchDetail()
+ }
+ catch {
+ error.value = true
+ provider.value = null
+ }
+ finally {
+ loading.value = false
+ }
+ }
+
+ /**
+ * Bascule l'archivage (PATCH `isArchived` SEUL — groupe provider:write:archive ;
+ * tout autre champ => 422), puis RECHARGE le detail complet : la reponse du PATCH
+ * ne porte que `provider:read` (ni l'embed des sous-collections ni les libelles
+ * comptables), un simple merge laisserait l'affichage incoherent. Toute erreur est
+ * propagee a l'appelant AVANT le rechargement.
+ */
+ async function setArchived(isArchived: boolean): Promise {
+ await api.patch(`/providers/${id}`, { isArchived }, { toast: false })
+ provider.value = await fetchDetail()
+ }
+
+ return {
+ provider,
+ loading,
+ error,
+ load,
+ archive: () => setArchived(true),
+ restore: () => setArchived(false),
+ }
+}
diff --git a/frontend/modules/technique/composables/useProviderForm.ts b/frontend/modules/technique/composables/useProviderForm.ts
new file mode 100644
index 0000000..34e7e91
--- /dev/null
+++ b/frontend/modules/technique/composables/useProviderForm.ts
@@ -0,0 +1,612 @@
+import { computed, reactive, ref, type Ref } from 'vue'
+import { useFormErrors } from '~/shared/composables/useFormErrors'
+import { mapViolationsToRecord } from '~/shared/utils/api'
+import {
+ emptyProviderAccounting,
+ emptyProviderAddress,
+ emptyProviderContact,
+ emptyProviderMain,
+ emptyProviderRib,
+ type ProviderAccountingDraft,
+ type ProviderAddressFormDraft,
+ type ProviderAddressResponse,
+ type ProviderContactFormDraft,
+ type ProviderContactResponse,
+ type ProviderMainDraft,
+ type ProviderMainResponse,
+ type ProviderRibFormDraft,
+ type ProviderRibResponse,
+} from '~/modules/technique/types/providerForm'
+import {
+ buildProviderContactPayload,
+ isProviderContactBlank,
+ isProviderContactNamed,
+} from '~/modules/technique/utils/forms/providerContact'
+import {
+ buildProviderAddressPayload,
+ isProviderAddressValid,
+} from '~/modules/technique/utils/forms/providerAddress'
+import {
+ buildProviderAccountingPayload,
+ buildProviderRibPayload,
+ isRibBlank,
+ isRibComplete,
+} from '~/modules/technique/utils/forms/providerAccounting'
+
+/**
+ * Workflow de l'ecran « Ajouter un prestataire » (M3 Technique, ERP-141) —
+ * miroir conceptuel de la logique de creation fournisseur (M2), extraite ici en
+ * composable.
+ *
+ * Particularites M3 (cf. spec-front § « Ecran Ajouter ») :
+ * - PAS d'onglet « Information » : le formulaire principal est minimal (Nom +
+ * Categorie + Site).
+ * - Selecteur de site SUR le formulaire principal (RG-3.03, relation directe
+ * `provider.sites`).
+ * - Creation incrementale par onglets (Contact · Adresse · Comptabilite) :
+ * POST principal puis PATCH partiels par groupe de serialisation
+ * (`provider:write:*`, mode strict — spec-back § 2.10). Le contenu des onglets
+ * arrive aux tickets ERP-142 → 144 ; ce composable pose le POST principal et
+ * l'orchestration des onglets.
+ *
+ * Etat 100 % local a l'instance (refs/reactive) — aucune persistance URL.
+ */
+
+/**
+ * Cles des onglets du FLUX DE CREATION. Pas d'onglet « Information » au M3 ;
+ * « Rapports » / « Echanges » n'apparaissent qu'en consultation/modification.
+ * L'onglet « Comptabilite » n'est present que pour les roles qui peuvent le voir
+ * (`technique.providers.accounting.view` — Admin, Compta).
+ */
+export function buildProviderCreateTabKeys(canAccountingView: boolean): string[] {
+ return canAccountingView
+ ? ['contact', 'address', 'accounting']
+ : ['contact', 'address']
+}
+
+export function useProviderForm() {
+ const api = useApi()
+ const { t } = useI18n()
+ const toast = useToast()
+ const { can } = usePermissions()
+
+ // Erreurs de validation par champ (ERP-101) du formulaire principal.
+ const mainErrors = useFormErrors()
+
+ // ── Etat du prestataire cree ────────────────────────────────────────────
+ const providerId = ref(null)
+ const mainLocked = ref(false)
+ const mainSubmitting = ref(false)
+ const tabSubmitting = ref(false)
+
+ // ── Formulaire principal ──────────────────────────────────────────────────
+ const main = reactive(emptyProviderMain())
+
+ // ── Onglets : ordre + gating progressif ───────────────────────────────────
+ const canAccountingView = computed(() => can('technique.providers.accounting.view'))
+ const canAccountingManage = computed(() => can('technique.providers.accounting.manage'))
+ const tabKeys = computed(() => buildProviderCreateTabKeys(canAccountingView.value))
+
+ // Index du dernier onglet deverrouille (-1 tant que le prestataire n'est pas cree).
+ const unlockedIndex = ref(-1)
+ const activeTab = ref('contact')
+ // Onglets valides (passent en lecture seule).
+ const validated = reactive>({})
+ // Mode MODIFICATION (ERP-145) : navigation libre, pas de verrouillage ni de
+ // bascule automatique d'onglet a la validation (cf. completeTab).
+ const editMode = ref(false)
+
+ function isValidated(key: string): boolean {
+ return validated[key] === true
+ }
+
+ function tabIndex(key: string): number {
+ return tabKeys.value.indexOf(key)
+ }
+
+ /**
+ * Validation FRONT du formulaire principal : RG-3.03 (>= 1 site) et RG-3.09
+ * (>= 1 categorie). Pose les erreurs inline et retourne false si invalide.
+ * Le back reste la couche autoritaire (ERP-101) ; ce pre-check evite un
+ * aller-retour inutile et porte la garantie RG-3.03 cote front.
+ */
+ function validateMainFront(): boolean {
+ let valid = true
+ if (!main.companyName?.trim()) {
+ mainErrors.setError('companyName', t('technique.providers.form.errors.nameRequired'))
+ valid = false
+ }
+ if (main.siteIris.length === 0) {
+ mainErrors.setError('sites', t('technique.providers.form.errors.siteRequired'))
+ valid = false
+ }
+ if (main.categoryIris.length === 0) {
+ mainErrors.setError('categories', t('technique.providers.form.errors.categoryRequired'))
+ valid = false
+ }
+ return valid
+ }
+
+ /**
+ * Payload du POST principal (groupe `provider:write:main`). `companyName` est
+ * omis s'il est vide afin que la 422 porte la violation NotBlank (RG-3.11) sur
+ * le champ plutot qu'une erreur de type. Les relations M2M partent en IRI.
+ */
+ function buildMainPayload(): Record {
+ const payload: Record = {
+ categories: [...main.categoryIris],
+ sites: [...main.siteIris],
+ }
+ if (main.companyName?.trim()) {
+ payload.companyName = main.companyName
+ }
+ return payload
+ }
+
+ /**
+ * POST /providers (groupe `provider:write:main`). Pre-check front RG-3.03/3.09,
+ * puis creation. Au succes : verrouille le bloc principal, deverrouille le 1er
+ * onglet et bascule sur « Contact ». Retourne true si cree, false sinon.
+ */
+ async function submitMain(): Promise {
+ if (mainSubmitting.value) return false
+ mainErrors.clearErrors()
+ if (!validateMainFront()) return false
+
+ mainSubmitting.value = true
+ try {
+ const created = await api.post('/providers', buildMainPayload(), {
+ headers: { Accept: 'application/ld+json' },
+ toast: false,
+ })
+
+ providerId.value = created.id
+ // Reaffiche la valeur normalisee renvoyee par le serveur (UPPERCASE, RG-3.11).
+ main.companyName = created.companyName ?? main.companyName
+
+ mainLocked.value = true
+ unlockedIndex.value = 0
+ activeTab.value = tabKeys.value[0] ?? 'contact'
+ toast.success({ title: t('technique.providers.toast.createSuccess') })
+ return true
+ }
+ catch (error) {
+ // 409 = doublon de nom (RG-3.10) → erreur inline dediee + toast ;
+ // 422 → mapping inline par champ ; autre → toast de fallback (ERP-101).
+ const status = (error as { response?: { status?: number } })?.response?.status
+ if (status === 409) {
+ const message = t('technique.providers.form.duplicateCompany')
+ mainErrors.setError('companyName', message)
+ toast.error({ title: t('technique.providers.toast.error'), message })
+ }
+ else {
+ mainErrors.handleApiError(error, { fallbackMessage: t('technique.providers.toast.error') })
+ }
+ return false
+ }
+ finally {
+ mainSubmitting.value = false
+ }
+ }
+
+ /**
+ * PATCH partiel du prestataire (mode strict : un seul groupe de serialisation
+ * par appel — spec-back § 2.10). Sert l'onglet Comptabilite a champs scalaires
+ * (ERP-144) ; les onglets Contact/Adresse passent par leurs sous-ressources
+ * (POST/PATCH par ligne, ERP-142/143). No-op tant que le prestataire n'existe pas.
+ */
+ async function patchProvider(payload: Record): Promise {
+ if (providerId.value === null) return
+ await api.patch(`/providers/${providerId.value}`, payload, { toast: false })
+ }
+
+ /**
+ * MODIFICATION du bloc principal (ERP-145) : PATCH /providers/{id} sur le groupe
+ * provider:write:main (nom + categories + sites). Pre-check front RG-3.03/3.09,
+ * 409 doublon de nom (RG-3.10) et 422 mappes inline comme a la creation. A la
+ * difference de `submitMain`, ne verrouille rien et ne bascule pas d'onglet (la
+ * navigation est libre en modification). Retourne true si le PATCH a reussi.
+ */
+ async function updateMain(): Promise {
+ if (providerId.value === null || mainSubmitting.value) return false
+ mainErrors.clearErrors()
+ if (!validateMainFront()) return false
+
+ mainSubmitting.value = true
+ try {
+ const updated = await api.patch(
+ `/providers/${providerId.value}`,
+ buildMainPayload(),
+ { toast: false },
+ )
+ main.companyName = updated.companyName ?? main.companyName
+ return true
+ }
+ catch (error) {
+ const status = (error as { response?: { status?: number } })?.response?.status
+ if (status === 409) {
+ const message = t('technique.providers.form.duplicateCompany')
+ mainErrors.setError('companyName', message)
+ toast.error({ title: t('technique.providers.toast.error'), message })
+ }
+ else {
+ mainErrors.handleApiError(error, { fallbackMessage: t('technique.providers.toast.error') })
+ }
+ return false
+ }
+ finally {
+ mainSubmitting.value = false
+ }
+ }
+
+ /**
+ * Marque un onglet valide (passe en lecture seule), deverrouille et avance a
+ * l'onglet suivant. Retourne true si c'etait le dernier onglet du flux
+ * (creation terminee), false sinon.
+ */
+ function completeTab(key: string): boolean {
+ // En modification : navigation libre, l'onglet reste editable apres validation.
+ if (editMode.value) {
+ return false
+ }
+ validated[key] = true
+ const index = tabIndex(key)
+ const next = tabKeys.value[index + 1]
+ if (next === undefined) {
+ return true
+ }
+ unlockedIndex.value = Math.max(unlockedIndex.value, index + 1)
+ activeTab.value = next
+ return false
+ }
+
+ /**
+ * Soumet TOUS les blocs d'une collection en collectant les erreurs PAR INDEX :
+ * on n'arrete pas au premier bloc en echec (decision ERP-101). Reinitialise la
+ * cible, tente chaque ligne via `saveRow`, mappe les 422 inline ou delegue le
+ * fallback a `onUnmappedError`. `shouldSkip` ignore les amorces vides. Retourne
+ * true si au moins un bloc a echoue. Miroir de `useSupplierFormErrors.submitRows`.
+ */
+ async function submitRows(
+ rows: T[],
+ target: Ref[]>,
+ saveRow: (row: T, index: number) => Promise,
+ onUnmappedError: (error: unknown, index: number) => void,
+ shouldSkip?: (row: T, index: number) => boolean,
+ ): Promise {
+ target.value = []
+ let hasError = false
+ for (let index = 0; index < rows.length; index++) {
+ const row = rows[index] as T
+ if (shouldSkip?.(row, index)) {
+ continue
+ }
+ try {
+ await saveRow(row, index)
+ }
+ catch (error) {
+ const response = (error as { response?: { status?: number, _data?: unknown } })?.response
+ const mapped = response?.status === 422 ? mapViolationsToRecord(response._data) : {}
+ if (Object.keys(mapped).length > 0) {
+ target.value[index] = mapped
+ }
+ else {
+ onUnmappedError(error, index)
+ }
+ hasError = true
+ }
+ }
+ return hasError
+ }
+
+ // ── Onglet Contact (ERP-142) ──────────────────────────────────────────────
+ const contacts = ref([emptyProviderContact()])
+ // Erreurs 422 par ligne (alignees sur l'index du v-for), peuplees par submitRows.
+ const contactErrors = ref[]>([])
+
+ // « + Nouveau contact » desactive tant que le dernier bloc n'a pas de nom OU
+ // prenom (RG-3.04, aligne M1/M2 — fonction/tel/email seuls ne suffisent pas).
+ const canAddContact = computed(() => {
+ const last = contacts.value[contacts.value.length - 1]
+ return last !== undefined && isProviderContactNamed(last)
+ })
+
+ function addContact(): void {
+ if (canAddContact.value) {
+ contacts.value.push(emptyProviderContact())
+ }
+ }
+
+ function removeContact(index: number): void {
+ contacts.value.splice(index, 1)
+ contactErrors.value.splice(index, 1)
+ }
+
+ /**
+ * Valide l'onglet Contact : POST des nouveaux contacts sur
+ * /providers/{id}/contacts, PATCH des existants sur /provider_contacts/{id}
+ * (sous-ressource, groupe provider:write:contacts). RG-3.12 : au moins un bloc
+ * valide. Si l'onglet ne contient QUE des amorces vides, on les soumet pour
+ * declencher la 422 RG-3.04 inline (sur `firstName`) plutot que de finaliser un
+ * onglet vide. Retourne true si l'onglet a ete valide (avance/termine).
+ */
+ async function submitContacts(onError: (error: unknown) => void): Promise {
+ if (providerId.value === null || tabSubmitting.value) {
+ return false
+ }
+ tabSubmitting.value = true
+ try {
+ const hasSubmittable = contacts.value.some(c => c.id !== null || !isProviderContactBlank(c))
+ const hasError = await submitRows(
+ contacts.value,
+ contactErrors,
+ async (contact) => {
+ const body = buildProviderContactPayload(contact)
+ if (contact.id === null) {
+ const created = await api.post(
+ `/providers/${providerId.value}/contacts`,
+ body,
+ { headers: { Accept: 'application/ld+json' }, toast: false },
+ )
+ contact.id = created.id
+ contact.iri = created['@id'] ?? null
+ }
+ else {
+ await api.patch(`/provider_contacts/${contact.id}`, body, { toast: false })
+ }
+ },
+ onError,
+ contact => hasSubmittable && contact.id === null && isProviderContactBlank(contact),
+ )
+ if (hasError) {
+ return false
+ }
+ completeTab('contact')
+ return true
+ }
+ finally {
+ tabSubmitting.value = false
+ }
+ }
+
+ // ── Onglet Adresse (ERP-143) ──────────────────────────────────────────────
+ const addresses = ref([emptyProviderAddress()])
+ // Erreurs 422 par ligne (alignees sur l'index du v-for).
+ const addressErrors = ref[]>([])
+
+ // « + Nouvelle adresse » desactive tant que la derniere adresse n'a pas
+ // au moins un site ET une categorie (RG-3.05 / RG-3.09).
+ const canAddAddress = computed(() => {
+ const last = addresses.value[addresses.value.length - 1]
+ return last !== undefined && isProviderAddressValid(last)
+ })
+
+ function addAddress(): void {
+ if (canAddAddress.value) {
+ addresses.value.push(emptyProviderAddress())
+ }
+ }
+
+ function removeAddress(index: number): void {
+ addresses.value.splice(index, 1)
+ addressErrors.value.splice(index, 1)
+ }
+
+ /**
+ * Valide l'onglet Adresse : POST des nouvelles adresses sur
+ * /providers/{id}/addresses, PATCH des existantes sur /provider_addresses/{id}
+ * (sous-ressource, groupe provider:write:addresses). Erreurs 422 collectees par
+ * ligne. Retourne true si l'onglet a ete valide (avance/termine).
+ */
+ async function submitAddresses(onError: (error: unknown) => void): Promise {
+ if (providerId.value === null || tabSubmitting.value) {
+ return false
+ }
+ tabSubmitting.value = true
+ try {
+ const hasError = await submitRows(
+ addresses.value,
+ addressErrors,
+ async (address) => {
+ const body = buildProviderAddressPayload(address)
+ if (address.id === null) {
+ const created = await api.post(
+ `/providers/${providerId.value}/addresses`,
+ body,
+ { headers: { Accept: 'application/ld+json' }, toast: false },
+ )
+ address.id = created.id
+ }
+ else {
+ await api.patch(`/provider_addresses/${address.id}`, body, { toast: false })
+ }
+ },
+ onError,
+ )
+ if (hasError) {
+ return false
+ }
+ completeTab('address')
+ return true
+ }
+ finally {
+ tabSubmitting.value = false
+ }
+ }
+
+ // ── Onglet Comptabilite (ERP-144) ─────────────────────────────────────────
+ const accounting = reactive(emptyProviderAccounting())
+ const ribs = ref([])
+ const accountingErrors = useFormErrors()
+ // Erreurs 422 par ligne de RIB (alignees sur l'index du v-for).
+ const ribErrors = ref[]>([])
+
+ // L'onglet est editable seulement avec accounting.manage (sinon lecture seule).
+ const accountingReadonly = computed(() => isValidated('accounting') || !canAccountingManage.value)
+
+ /**
+ * Met a jour le type de reglement (IRI) en propageant ses RG inter-champs :
+ * - hors VIREMENT (RG-3.07) : on vide la banque (sans objet) ;
+ * - LCR (RG-3.08) : on garantit au moins un bloc RIB visible ; hors LCR, on
+ * purge les erreurs de RIB (les blocs sont conserves mais non persistes).
+ * `isBankRequired` / `isRibRequired` sont calcules par l'appelant (page) a
+ * partir du code resolu via les referentiels.
+ */
+ function setPaymentType(iri: string | null, isBankRequired: boolean, isRibRequired: boolean): void {
+ accounting.paymentTypeIri = iri
+ if (!isBankRequired) {
+ accounting.bankIri = null
+ }
+ if (isRibRequired) {
+ if (ribs.value.length === 0) {
+ ribs.value.push(emptyProviderRib())
+ }
+ }
+ else {
+ ribErrors.value = []
+ }
+ }
+
+ // « + RIB » desactive tant que le dernier bloc RIB n'est pas complet (RG-3.08).
+ const canAddRib = computed(() => {
+ const last = ribs.value[ribs.value.length - 1]
+ return last !== undefined && isRibComplete(last)
+ })
+
+ function addRib(): void {
+ if (canAddRib.value) {
+ ribs.value.push(emptyProviderRib())
+ }
+ }
+
+ function removeRib(index: number): void {
+ ribs.value.splice(index, 1)
+ ribErrors.value.splice(index, 1)
+ // Garde au moins un bloc RIB visible (sous LCR).
+ if (ribs.value.length === 0) {
+ ribs.value.push(emptyProviderRib())
+ }
+ }
+
+ /**
+ * Valide l'onglet Comptabilite : (1) sous LCR, POST/PATCH des RIB d'abord
+ * (le back valide RG-3.08 sur le PATCH scalaires, les RIB doivent donc exister
+ * AVANT) ; (2) PATCH des scalaires comptables (groupe provider:write:accounting,
+ * banque envoyee seulement si VIREMENT — RG-3.07). Erreurs RIB par ligne ;
+ * erreurs scalaires inline (bank/paymentType). Retourne true si l'onglet a ete
+ * valide.
+ */
+ async function submitAccounting(
+ isBankRequired: boolean,
+ isRibRequired: boolean,
+ onRibError: (error: unknown) => void,
+ ): Promise {
+ if (providerId.value === null || tabSubmitting.value) {
+ return false
+ }
+ tabSubmitting.value = true
+ accountingErrors.clearErrors()
+ try {
+ // 1) RIB d'abord, uniquement sous LCR. Une amorce vide neuve est sautee
+ // s'il reste un autre RIB soumettable ; sinon (LCR sans aucun RIB rempli)
+ // on la soumet pour declencher la 422 NotBlank inline.
+ if (isRibRequired) {
+ const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
+ const ribHasError = await submitRows(
+ ribs.value,
+ ribErrors,
+ async (rib) => {
+ const body = buildProviderRibPayload(rib)
+ if (rib.id === null) {
+ const created = await api.post(
+ `/providers/${providerId.value}/ribs`,
+ body,
+ { headers: { Accept: 'application/ld+json' }, toast: false },
+ )
+ rib.id = created.id
+ }
+ else {
+ await api.patch(`/provider_ribs/${rib.id}`, body, { toast: false })
+ }
+ },
+ onRibError,
+ rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
+ )
+ if (ribHasError) {
+ return false
+ }
+ }
+
+ // 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
+ try {
+ await api.patch(
+ `/providers/${providerId.value}`,
+ buildProviderAccountingPayload(accounting, isBankRequired),
+ { toast: false },
+ )
+ }
+ catch (error) {
+ accountingErrors.handleApiError(error, { fallbackMessage: t('technique.providers.toast.error') })
+ return false
+ }
+
+ completeTab('accounting')
+ return true
+ }
+ finally {
+ tabSubmitting.value = false
+ }
+ }
+
+ return {
+ // etat
+ main,
+ providerId,
+ mainLocked,
+ mainSubmitting,
+ tabSubmitting,
+ mainErrors,
+ // onglets
+ canAccountingView,
+ canAccountingManage,
+ tabKeys,
+ activeTab,
+ unlockedIndex,
+ validated,
+ editMode,
+ isValidated,
+ // contacts
+ contacts,
+ contactErrors,
+ canAddContact,
+ addContact,
+ removeContact,
+ submitContacts,
+ // adresses
+ addresses,
+ addressErrors,
+ canAddAddress,
+ addAddress,
+ removeAddress,
+ submitAddresses,
+ // comptabilite
+ accounting,
+ ribs,
+ accountingErrors,
+ ribErrors,
+ accountingReadonly,
+ setPaymentType,
+ canAddRib,
+ addRib,
+ removeRib,
+ submitAccounting,
+ // actions
+ validateMainFront,
+ buildMainPayload,
+ submitMain,
+ updateMain,
+ patchProvider,
+ completeTab,
+ submitRows,
+ }
+}
diff --git a/frontend/modules/technique/composables/useProviderReferentials.ts b/frontend/modules/technique/composables/useProviderReferentials.ts
new file mode 100644
index 0000000..3b56d8c
--- /dev/null
+++ b/frontend/modules/technique/composables/useProviderReferentials.ts
@@ -0,0 +1,136 @@
+import { ref } from 'vue'
+
+/**
+ * Charge les referentiels (listes courtes) alimentant les selects du formulaire
+ * principal de l'ecran « Ajouter un prestataire » (M3 Technique, ERP-141) :
+ * categories (type PRESTATAIRE) et sites (86 / 17 / 82).
+ *
+ * Miroir reduit de `useSupplierReferentials` (M2) : a ce stade (formulaire
+ * principal) seuls categories + sites sont necessaires. Les referentiels
+ * comptables (modes de TVA, delais/types de reglement, banques) seront charges
+ * par l'onglet Comptabilite (ERP-144).
+ *
+ * Toutes les collections sont recuperees en entier via l'echappatoire prevue
+ * `?pagination=false` (referentiels de quelques entrees), avec l'en-tete
+ * `Accept: application/ld+json` impose par API Platform 4 pour obtenir l'enveloppe
+ * Hydra (`member`). La valeur d'option est l'IRI Hydra (`@id`), renvoyee telle
+ * quelle dans le payload POST (relations M2M).
+ *
+ * Chargement RESILIENT (Promise.allSettled) : chaque referentiel est isole ; un
+ * echec (permission manquante, reseau) laisse simplement la liste vide.
+ *
+ * Etat 100 % local a l'instance (refs) — aucune persistance URL.
+ */
+
+/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox. */
+export interface RefOption {
+ value: string
+ label: string
+}
+
+/** Option de type de reglement enrichie de son code stable (RG-3.07 / RG-3.08). */
+export interface PaymentTypeOption extends RefOption {
+ code: string
+}
+
+interface HydraMember {
+ '@id': string
+}
+
+interface ReferentialMember extends HydraMember {
+ code: string
+ label: string
+}
+
+interface CategoryMember extends HydraMember {
+ code: string
+ name: string
+}
+
+interface SiteMember extends HydraMember {
+ name: string
+ postalCode: string
+}
+
+interface CountryMember extends HydraMember {
+ code: string
+ name: string
+}
+
+const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
+
+export function useProviderReferentials() {
+ const api = useApi()
+
+ const categories = ref([])
+ const sites = ref([])
+ const countries = ref([])
+ // Referentiels comptables (charges a la demande via loadAccounting).
+ const tvaModes = ref([])
+ const paymentDelays = ref([])
+ const paymentTypes = ref([])
+ const banks = ref([])
+
+ /** Recupere une collection complete (pagination desactivee) en Hydra. */
+ async function fetchAll(
+ url: string,
+ query: Record = {},
+ ): Promise {
+ const res = await api.get<{ member?: T[] }>(
+ url,
+ { pagination: 'false', ...query },
+ { headers: LD_JSON_HEADERS, toast: false },
+ )
+ return res.member ?? []
+ }
+
+ /** Charge en parallele les referentiels du formulaire principal (categories + sites). */
+ async function loadMain(): Promise {
+ await Promise.allSettled([
+ // RG-3.09 : un prestataire ne porte que des categories de type
+ // PRESTATAIRE -> filtre cote API. Libelle affiche = `name`.
+ fetchAll('/categories', { typeCode: 'PRESTATAIRE' })
+ .then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name })) }),
+ // Sites (RG-3.03) : libelle = numero de departement (2 premiers chiffres
+ // du code postal du site), ex: 86100 -> « 86 », 17400 -> « 17 ».
+ fetchAll('/sites')
+ .then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }),
+ // Pays (ERP-116) : la valeur d'option est le NOM du pays (l'adresse stocke
+ // `country` en chaine libre, « France »...). value === label. Aligne sur
+ // les ecrans client/fournisseur. Sert le select Pays de l'onglet Adresse.
+ fetchAll('/countries')
+ .then((list) => { countries.value = list.map(c => ({ value: c.name, label: c.name })) }),
+ ])
+ }
+
+ /**
+ * Charge les referentiels comptables (onglet Comptabilite, ERP-144). Appele
+ * uniquement quand l'utilisateur peut voir l'onglet (accounting.view). Resilient
+ * (allSettled) : un referentiel en echec reste vide.
+ */
+ async function loadAccounting(): Promise {
+ await Promise.allSettled([
+ fetchAll('/tva_modes')
+ .then((list) => { tvaModes.value = list.map(t => ({ value: t['@id'], label: t.label })) }),
+ fetchAll('/payment_delays')
+ .then((list) => { paymentDelays.value = list.map(d => ({ value: d['@id'], label: d.label })) }),
+ // Le code stable du type sert les RG-3.07 (VIREMENT) / RG-3.08 (LCR).
+ fetchAll('/payment_types')
+ .then((list) => { paymentTypes.value = list.map(p => ({ value: p['@id'], label: p.label, code: p.code })) }),
+ fetchAll('/banks')
+ .then((list) => { banks.value = list.map(b => ({ value: b['@id'], label: b.label })) }),
+ ])
+ }
+
+ return {
+ categories,
+ sites,
+ countries,
+ tvaModes,
+ paymentDelays,
+ paymentTypes,
+ banks,
+ loadMain,
+ loadAccounting,
+ }
+}
diff --git a/frontend/modules/technique/composables/useProvidersRepository.ts b/frontend/modules/technique/composables/useProvidersRepository.ts
new file mode 100644
index 0000000..de3ff19
--- /dev/null
+++ b/frontend/modules/technique/composables/useProvidersRepository.ts
@@ -0,0 +1,62 @@
+import { usePaginatedList } from '~/shared/composables/usePaginatedList'
+
+/**
+ * Site Starseed rattache DIRECTEMENT au prestataire (M2M `provider_site`,
+ * RG-3.03), tel qu'embarque en LISTE (groupe site:read) pour la colonne « Site »
+ * du Repertoire (badges colores).
+ *
+ * Difference M3 vs M2 : au M2 les sites venaient de l'agregat dedoublonne des
+ * adresses (`Supplier::getSites()`) ; ici c'est une relation directe portee par
+ * le formulaire principal (cf. spec-back M3 § 2.12).
+ */
+export interface ProviderSite {
+ id: number
+ name: string
+ color: string
+}
+
+/**
+ * Categorie (type PRESTATAIRE) rattachee au prestataire, embarquee en LISTE
+ * (groupe category:read). La colonne « Catégories » affiche le `name` (cohérence
+ * M1/M2 — libellé = `name`, pas `code`).
+ */
+export interface ProviderCategory {
+ code: string
+ name: string
+}
+
+/**
+ * Vue MINIMALE d'un prestataire pour le Repertoire (datatable). Volontairement
+ * partielle : seuls les champs des colonnes + l'id (navigation) sont types ici.
+ * Le detail complet (onglets) est hors perimetre de cet ecran (ERP-140).
+ */
+export interface Provider {
+ id: number
+ companyName: string
+ categories: ProviderCategory[]
+ sites: ProviderSite[]
+ /** Date ISO de derniere modification (default:read) — colonne « Dernière activité ». */
+ updatedAt: string | null
+ isArchived: boolean
+}
+
+/**
+ * Repertoire prestataires (ERP-140) — simple enveloppe de `usePaginatedList`
+ * sur la ressource `/providers` (pagination serveur obligatoire ; jamais de
+ * chargement integral en memoire). Miroir de `useSuppliersRepository` (M2).
+ *
+ * Les filtres (recherche, categories, sites, inclusion des archives) sont pilotes
+ * par la page via `setFilters` du composable partage — la remise en page 1 est
+ * garantie. Par defaut, aucun `includeArchived` n'est envoye : le back masque
+ * donc les prestataires archives (exclusion par defaut, spec-back § 2.11).
+ *
+ * Le cloisonnement par site est applique AUTOMATIQUEMENT cote back (§ 2.13) en
+ * fonction de l'utilisateur — rien a filtrer cote front.
+ *
+ * Volontairement PAR INSTANCE (pas de singleton module-level) : l'etat tableau
+ * est propre a l'ecran Repertoire et meurt avec lui, comme tout consommateur de
+ * `usePaginatedList`. Aucun reset au logout a gerer.
+ */
+export function useProvidersRepository() {
+ return usePaginatedList({ url: '/providers' })
+}
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/frontend/modules/technique/pages/providers/[id]/edit.vue b/frontend/modules/technique/pages/providers/[id]/edit.vue
new file mode 100644
index 0000000..1340e21
--- /dev/null
+++ b/frontend/modules/technique/pages/providers/[id]/edit.vue
@@ -0,0 +1,538 @@
+
+
+
+
+
+
{{ headerTitle }}
+
+
+
+
{{ t('technique.providers.edit.loading') }}
+
{{ t('technique.providers.edit.notFound') }}
+
+
+
+
+
+ main.categoryIris = v.map(String)"
+ />
+ main.siteIris = v.map(String)"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
contacts[index] = v"
+ @remove="askRemoveContact(index)"
+ />
+
+
+
+
+
+
+
+
+
+
+
addresses[index] = v"
+ @remove="askRemoveAddress(index)"
+ @degraded="onAddressDegraded"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ accounting.tvaModeIri = v === null ? null : String(v)"
+ />
+
+ accounting.paymentDelayIri = v === null ? null : String(v)"
+ />
+
+ accounting.bankIri = v === null ? null : String(v)"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('technique.providers.form.confirmDelete.title') }}
+
+ {{ confirmModal.message }}
+
+
+
+
+
+
+
+
+
diff --git a/frontend/modules/technique/pages/providers/[id]/index.vue b/frontend/modules/technique/pages/providers/[id]/index.vue
new file mode 100644
index 0000000..fb6bfb4
--- /dev/null
+++ b/frontend/modules/technique/pages/providers/[id]/index.vue
@@ -0,0 +1,308 @@
+
+
+
+
+
+
{{ headerTitle }}
+
+
+
+
+
+
+
+
+
+
{{ t('technique.providers.consultation.loading') }}
+
{{ t('technique.providers.consultation.notFound') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ confirmArchive.title }}
+
+ {{ confirmArchive.message }}
+
+
+
+
+
+
+
+
+
diff --git a/frontend/modules/technique/pages/providers/index.vue b/frontend/modules/technique/pages/providers/index.vue
new file mode 100644
index 0000000..cfedde4
--- /dev/null
+++ b/frontend/modules/technique/pages/providers/index.vue
@@ -0,0 +1,438 @@
+
+
+
+ {{ t('technique.providers.title') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatCategories(item) }}
+
+
+
+
+
+
+ {{ site.name }}
+
+
+
+
+
+
+ {{ formatLastActivity(item) }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('technique.providers.filters.title') }}
+
+
+
+
+
+
+
+
+
+
+
+ toggleCategory(opt.value, val)"
+ />
+
+
+
+
+
+
+ toggleSite(opt.value, val)"
+ />
+
+
+
+
+
+ draftIncludeArchived = val"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/modules/technique/pages/providers/new.vue b/frontend/modules/technique/pages/providers/new.vue
new file mode 100644
index 0000000..5b9862d
--- /dev/null
+++ b/frontend/modules/technique/pages/providers/new.vue
@@ -0,0 +1,530 @@
+
+
+
+
+
+
{{ t('technique.providers.form.title') }}
+
+
+
+
+
+ main.categoryIris = v.map(String)"
+ />
+ main.siteIris = v.map(String)"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
contacts[index] = v"
+ @remove="askRemoveContact(index)"
+ />
+
+
+
+
+
+
+
+
+
+
addresses[index] = v"
+ @remove="askRemoveAddress(index)"
+ @degraded="onAddressDegraded"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ accounting.tvaModeIri = v === null ? null : String(v)"
+ />
+
+ accounting.paymentDelayIri = v === null ? null : String(v)"
+ />
+
+
+ accounting.bankIri = v === null ? null : String(v)"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('technique.providers.form.confirmDelete.title') }}
+
+ {{ confirmModal.message }}
+
+
+
+
+
+
+
+
+
diff --git a/frontend/modules/technique/types/providerForm.ts b/frontend/modules/technique/types/providerForm.ts
new file mode 100644
index 0000000..2a4f1b7
--- /dev/null
+++ b/frontend/modules/technique/types/providerForm.ts
@@ -0,0 +1,177 @@
+/**
+ * Types « brouillon » de l'ecran « Ajouter un prestataire » (M3 Technique).
+ *
+ * Miroir reduit de `types/supplierForm.ts` (M2) : le M3 n'a PAS d'onglet
+ * Information, et porte en plus un selecteur de site SUR le formulaire principal
+ * (RG-3.03 — relation directe `provider.sites`, distincte des sites d'adresse).
+ *
+ * Ces interfaces decrivent l'etat LOCAL du formulaire (refs Vue), distinct des
+ * DTO de l'API : la page de creation (ERP-141) et — a venir — les blocs d'onglet
+ * Contact / Adresse / Comptabilite (ERP-142 → 144) les partagent.
+ *
+ * Les relations M2M (categories, sites) sont portees par leurs IRI Hydra (`@id`),
+ * envoyees telles quelles dans le payload POST (cf. contrat back ERP-139 :
+ * `categories: ['/api/categories/{id}']`, `sites: ['/api/sites/{id}']`).
+ */
+
+/** Etat « plat » du formulaire principal (groupe `provider:write:main`). */
+export interface ProviderMainDraft {
+ /** Nom de l'entreprise prestataire. UPPERCASE serveur (RG-3.11), unicite RG-3.10. */
+ companyName: string | null
+ /** IRI des categories rattachees (M2M, type PRESTATAIRE — RG-3.09 ; >= 1). */
+ categoryIris: string[]
+ /** IRI des sites rattaches DIRECTEMENT au prestataire (M2M `provider_site`, RG-3.03 ; >= 1). */
+ siteIris: string[]
+}
+
+/** Fabrique un formulaire principal vierge. */
+export function emptyProviderMain(): ProviderMainDraft {
+ return {
+ companyName: null,
+ categoryIris: [],
+ siteIris: [],
+ }
+}
+
+/** Reponse minimale du POST /providers exploitee par l'ecran de creation. */
+export interface ProviderMainResponse {
+ id: number
+ /** Nom renvoye normalise (UPPERCASE) par le serveur, reaffiche en lecture seule. */
+ companyName: string | null
+}
+
+/**
+ * Un contact du prestataire (onglet Contact, ERP-142). Miroir de
+ * `SupplierContactFormDraft` (M2). Tous les champs sont nullable cote ORM ; la
+ * validite (RG-3.04) tient a la presence d'AU MOINS un champ rempli parmi
+ * prenom / nom / fonction / telephone principal / email (cf. back).
+ */
+export interface ProviderContactFormDraft {
+ /** Id serveur une fois le contact cree (null tant que non persiste). */
+ id: number | null
+ /** IRI Hydra du contact cree — servira au rattachement M2M cote adresse (ERP-143). */
+ iri: string | null
+ firstName: string | null
+ lastName: string | null
+ jobTitle: string | null
+ phonePrimary: string | null
+ phoneSecondary: string | null
+ email: string | null
+ /** UI : le 2e numero a ete revele via le bouton « + » (max 2 telephones). */
+ hasSecondaryPhone: boolean
+}
+
+/** Fabrique un contact vierge. */
+export function emptyProviderContact(): ProviderContactFormDraft {
+ return {
+ id: null,
+ iri: null,
+ firstName: null,
+ lastName: null,
+ jobTitle: null,
+ phonePrimary: null,
+ phoneSecondary: null,
+ email: null,
+ hasSecondaryPhone: false,
+ }
+}
+
+/** Reponse du POST /providers/{id}/contacts (groupe provider:item:read + IRI Hydra). */
+export interface ProviderContactResponse {
+ '@id'?: string
+ id: number
+}
+
+/**
+ * Une adresse du prestataire (onglet Adresse, ERP-143). Version SIMPLIFIEE de
+ * `SupplierAddressFormDraft` (M2) : PAS de type d'adresse (Prospect/Depart/Rendu),
+ * PAS de bennes, PAS de prestation de triage. Champs postaux + M2M sites /
+ * categories / contacts (par IRI).
+ */
+export interface ProviderAddressFormDraft {
+ /** Id serveur une fois l'adresse creee (null tant que non persistee). */
+ id: number | null
+ /** Pays (chaine libre, defaut « France »). */
+ country: string
+ postalCode: string | null
+ city: string | null
+ street: string | null
+ streetComplement: string | null
+ /** IRI des categories rattachees (type PRESTATAIRE, RG-3.09 ; >= 1). */
+ categoryIris: string[]
+ /** IRI des sites rattaches a l'adresse (M2M `provider_address_site`, RG-3.05 ; >= 1). */
+ siteIris: string[]
+ /** IRI des contacts rattaches (= blocs Contact deja persistes de l'onglet Contact). */
+ contactIris: string[]
+}
+
+/** Fabrique une adresse vierge (France presaisi). */
+export function emptyProviderAddress(): ProviderAddressFormDraft {
+ return {
+ id: null,
+ country: 'France',
+ postalCode: null,
+ city: null,
+ street: null,
+ streetComplement: null,
+ categoryIris: [],
+ siteIris: [],
+ contactIris: [],
+ }
+}
+
+/** Reponse du POST /providers/{id}/addresses (id suffisant pour le suivi cote front). */
+export interface ProviderAddressResponse {
+ id: number
+}
+
+/**
+ * Etat « plat » de l'onglet Comptabilite (groupe `provider:write:accounting`).
+ * Relations (TVA / delai / type de reglement / banque) portees par leur IRI.
+ */
+export interface ProviderAccountingDraft {
+ siren: string | null
+ accountNumber: string | null
+ tvaModeIri: string | null
+ nTva: string | null
+ paymentDelayIri: string | null
+ paymentTypeIri: string | null
+ /** Banque : requise et envoyee uniquement si Type de reglement = VIREMENT (RG-3.07). */
+ bankIri: string | null
+}
+
+/** Fabrique un onglet Comptabilite vierge. */
+export function emptyProviderAccounting(): ProviderAccountingDraft {
+ return {
+ siren: null,
+ accountNumber: null,
+ tvaModeIri: null,
+ nTva: null,
+ paymentDelayIri: null,
+ paymentTypeIri: null,
+ bankIri: null,
+ }
+}
+
+/** Un RIB du prestataire (sous-collection comptable, obligatoire si Type = LCR — RG-3.08). */
+export interface ProviderRibFormDraft {
+ id: number | null
+ label: string | null
+ bic: string | null
+ iban: string | null
+}
+
+/** Fabrique un RIB vierge. */
+export function emptyProviderRib(): ProviderRibFormDraft {
+ return {
+ id: null,
+ label: null,
+ bic: null,
+ iban: null,
+ }
+}
+
+/** Reponse du POST /providers/{id}/ribs (id suffisant pour le suivi cote front). */
+export interface ProviderRibResponse {
+ id: number
+}
diff --git a/frontend/modules/technique/utils/forms/__tests__/providerAccounting.spec.ts b/frontend/modules/technique/utils/forms/__tests__/providerAccounting.spec.ts
new file mode 100644
index 0000000..fc9915c
--- /dev/null
+++ b/frontend/modules/technique/utils/forms/__tests__/providerAccounting.spec.ts
@@ -0,0 +1,83 @@
+import { describe, it, expect } from 'vitest'
+import {
+ buildProviderAccountingPayload,
+ buildProviderRibPayload,
+ isBankRequiredForPaymentType,
+ isRibBlank,
+ isRibComplete,
+ isRibRequiredForPaymentType,
+} from '../providerAccounting'
+import { emptyProviderAccounting, emptyProviderRib } from '~/modules/technique/types/providerForm'
+
+/**
+ * Helpers purs de l'onglet Comptabilite prestataire (ERP-144) : RG inter-champs
+ * RG-3.07 (banque si VIREMENT) / RG-3.08 (RIB si LCR) + construction des payloads.
+ */
+describe('providerAccounting helpers', () => {
+ describe('RG-3.07 / RG-3.08 — type de reglement', () => {
+ it('banque requise uniquement pour VIREMENT', () => {
+ expect(isBankRequiredForPaymentType('VIREMENT')).toBe(true)
+ expect(isBankRequiredForPaymentType('LCR')).toBe(false)
+ expect(isBankRequiredForPaymentType('CHEQUE')).toBe(false)
+ expect(isBankRequiredForPaymentType(null)).toBe(false)
+ })
+
+ it('RIB requis uniquement pour LCR', () => {
+ expect(isRibRequiredForPaymentType('LCR')).toBe(true)
+ expect(isRibRequiredForPaymentType('VIREMENT')).toBe(false)
+ expect(isRibRequiredForPaymentType(null)).toBe(false)
+ })
+ })
+
+ describe('isRibBlank / isRibComplete', () => {
+ it('un RIB vierge est vide et incomplet', () => {
+ expect(isRibBlank(emptyProviderRib())).toBe(true)
+ expect(isRibComplete(emptyProviderRib())).toBe(false)
+ })
+
+ it('un RIB partiel n\'est ni vide ni complet', () => {
+ const rib = { ...emptyProviderRib(), iban: 'FR76...' }
+ expect(isRibBlank(rib)).toBe(false)
+ expect(isRibComplete(rib)).toBe(false)
+ })
+
+ it('un RIB avec libelle + BIC + IBAN est complet', () => {
+ const rib = { ...emptyProviderRib(), label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' }
+ expect(isRibComplete(rib)).toBe(true)
+ })
+ })
+
+ describe('buildProviderAccountingPayload (RG-3.07)', () => {
+ it('envoie la banque si requise (VIREMENT)', () => {
+ const payload = buildProviderAccountingPayload({
+ ...emptyProviderAccounting(),
+ paymentTypeIri: '/api/payment_types/3',
+ bankIri: '/api/banks/2',
+ }, true)
+ expect(payload.bank).toBe('/api/banks/2')
+ expect(payload.paymentType).toBe('/api/payment_types/3')
+ })
+
+ it('force la banque a null si non requise (hors VIREMENT)', () => {
+ const payload = buildProviderAccountingPayload({
+ ...emptyProviderAccounting(),
+ bankIri: '/api/banks/2',
+ }, false)
+ expect(payload.bank).toBeNull()
+ })
+ })
+
+ describe('buildProviderRibPayload', () => {
+ it('omet les champs requis vides (NotBlank back joue sur le champ)', () => {
+ const payload = buildProviderRibPayload(emptyProviderRib())
+ expect(payload).not.toHaveProperty('label')
+ expect(payload).not.toHaveProperty('bic')
+ expect(payload).not.toHaveProperty('iban')
+ })
+
+ it('conserve les champs remplis', () => {
+ const payload = buildProviderRibPayload({ ...emptyProviderRib(), label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' })
+ expect(payload).toEqual({ label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' })
+ })
+ })
+})
diff --git a/frontend/modules/technique/utils/forms/__tests__/providerAddress.spec.ts b/frontend/modules/technique/utils/forms/__tests__/providerAddress.spec.ts
new file mode 100644
index 0000000..a453a41
--- /dev/null
+++ b/frontend/modules/technique/utils/forms/__tests__/providerAddress.spec.ts
@@ -0,0 +1,73 @@
+import { describe, it, expect } from 'vitest'
+import {
+ buildProviderAddressPayload,
+ isProviderAddressValid,
+} from '../providerAddress'
+import { emptyProviderAddress } from '~/modules/technique/types/providerForm'
+
+/**
+ * Helpers purs de l'onglet Adresse prestataire (ERP-143). RG-3.05 (>= 1 site) et
+ * construction du payload de sous-ressource (relations en IRI, requis vides omis,
+ * pas de type d'adresse / bennes / triage — difference M2).
+ */
+describe('providerAddress helpers', () => {
+ const SITE = '/api/sites/1'
+ const CAT = '/api/categories/7'
+
+ describe('isProviderAddressValid (RG-3.05 / RG-3.09)', () => {
+ it('false sans site', () => {
+ const address = { ...emptyProviderAddress(), categoryIris: [CAT] }
+ expect(isProviderAddressValid(address)).toBe(false)
+ })
+
+ it('false sans categorie', () => {
+ const address = { ...emptyProviderAddress(), siteIris: [SITE] }
+ expect(isProviderAddressValid(address)).toBe(false)
+ })
+
+ it('true avec au moins un site ET une categorie', () => {
+ const address = { ...emptyProviderAddress(), siteIris: [SITE], categoryIris: [CAT] }
+ expect(isProviderAddressValid(address)).toBe(true)
+ })
+ })
+
+ describe('buildProviderAddressPayload', () => {
+ it('mappe les relations en IRI et n\'embarque PAS type/bennes/triage (difference M2)', () => {
+ const payload = buildProviderAddressPayload({
+ ...emptyProviderAddress(),
+ postalCode: '86100',
+ city: 'Châtellerault',
+ street: '1 rue du Test',
+ siteIris: [SITE],
+ categoryIris: [CAT],
+ contactIris: ['/api/provider_contacts/9'],
+ })
+ expect(payload).toEqual({
+ country: 'France',
+ postalCode: '86100',
+ city: 'Châtellerault',
+ street: '1 rue du Test',
+ streetComplement: null,
+ categories: [CAT],
+ sites: [SITE],
+ contacts: ['/api/provider_contacts/9'],
+ })
+ expect(payload).not.toHaveProperty('addressType')
+ expect(payload).not.toHaveProperty('bennes')
+ expect(payload).not.toHaveProperty('triageProvider')
+ })
+
+ it('omet les scalaires requis vides (NotBlank back joue sur le champ)', () => {
+ const payload = buildProviderAddressPayload({
+ ...emptyProviderAddress(),
+ siteIris: [SITE],
+ categoryIris: [CAT],
+ })
+ expect(payload).not.toHaveProperty('postalCode')
+ expect(payload).not.toHaveProperty('city')
+ expect(payload).not.toHaveProperty('street')
+ // streetComplement n'est PAS requis -> reste present a null.
+ expect(payload).toHaveProperty('streetComplement', null)
+ })
+ })
+})
diff --git a/frontend/modules/technique/utils/forms/__tests__/providerContact.spec.ts b/frontend/modules/technique/utils/forms/__tests__/providerContact.spec.ts
new file mode 100644
index 0000000..0b8f8a4
--- /dev/null
+++ b/frontend/modules/technique/utils/forms/__tests__/providerContact.spec.ts
@@ -0,0 +1,93 @@
+import { describe, it, expect } from 'vitest'
+import {
+ buildProviderContactPayload,
+ hasAtLeastOneFilledContact,
+ isProviderContactBlank,
+ isProviderContactNamed,
+} from '../providerContact'
+import { emptyProviderContact } from '~/modules/technique/types/providerForm'
+
+/**
+ * Helpers purs de l'onglet Contact prestataire (ERP-142). On verifie la
+ * definition de « bloc vide » (RG-3.04, alignee sur le back) et la construction
+ * du payload de sous-ressource.
+ */
+describe('providerContact helpers', () => {
+ describe('isProviderContactBlank (RG-3.04)', () => {
+ it('un bloc vierge est vide', () => {
+ expect(isProviderContactBlank(emptyProviderContact())).toBe(true)
+ })
+
+ it('un seul champ rempli parmi nom/prenom/fonction/tel/email suffit a le rendre non vide', () => {
+ for (const field of ['firstName', 'lastName', 'jobTitle', 'phonePrimary', 'email'] as const) {
+ const contact = { ...emptyProviderContact(), [field]: 'x' }
+ expect(isProviderContactBlank(contact)).toBe(false)
+ }
+ })
+
+ it('ignore les espaces (trim) — un champ blanc ne compte pas', () => {
+ expect(isProviderContactBlank({ ...emptyProviderContact(), lastName: ' ' })).toBe(true)
+ })
+
+ it('un 2e telephone seul NE suffit PAS (exclu, comme le back)', () => {
+ const contact = { ...emptyProviderContact(), hasSecondaryPhone: true, phoneSecondary: '0102030405' }
+ expect(isProviderContactBlank(contact)).toBe(true)
+ })
+ })
+
+ describe('isProviderContactNamed (RG-3.04 — prenom OU nom)', () => {
+ it('vrai avec un prenom seul ou un nom seul', () => {
+ expect(isProviderContactNamed({ ...emptyProviderContact(), firstName: 'Jean' })).toBe(true)
+ expect(isProviderContactNamed({ ...emptyProviderContact(), lastName: 'Dupont' })).toBe(true)
+ })
+
+ it('faux si seuls fonction / telephone / email sont remplis (ne suffit pas)', () => {
+ expect(isProviderContactNamed({ ...emptyProviderContact(), jobTitle: 'Directeur' })).toBe(false)
+ expect(isProviderContactNamed({ ...emptyProviderContact(), email: 'a@b.fr' })).toBe(false)
+ expect(isProviderContactNamed({ ...emptyProviderContact(), phonePrimary: '0102030405' })).toBe(false)
+ })
+ })
+
+ describe('hasAtLeastOneFilledContact (RG-3.12 — au moins un contact nomme)', () => {
+ it('false si aucun bloc n\'est nomme', () => {
+ expect(hasAtLeastOneFilledContact([emptyProviderContact(), { ...emptyProviderContact(), email: 'a@b.fr' }])).toBe(false)
+ })
+
+ it('true des qu\'un bloc porte un nom ou prenom', () => {
+ expect(hasAtLeastOneFilledContact([
+ emptyProviderContact(),
+ { ...emptyProviderContact(), lastName: 'Dupont' },
+ ])).toBe(true)
+ })
+ })
+
+ describe('buildProviderContactPayload', () => {
+ it('mappe les champs et envoie null pour les vides', () => {
+ const payload = buildProviderContactPayload({ ...emptyProviderContact(), lastName: 'Doe' })
+ expect(payload).toEqual({
+ firstName: null,
+ lastName: 'Doe',
+ jobTitle: null,
+ phonePrimary: null,
+ phoneSecondary: null,
+ email: null,
+ })
+ })
+
+ it('n\'envoie le 2e telephone que si revele (max 2)', () => {
+ const masque = buildProviderContactPayload({
+ ...emptyProviderContact(),
+ phoneSecondary: '0102030405',
+ hasSecondaryPhone: false,
+ })
+ expect(masque.phoneSecondary).toBeNull()
+
+ const revele = buildProviderContactPayload({
+ ...emptyProviderContact(),
+ phoneSecondary: '0102030405',
+ hasSecondaryPhone: true,
+ })
+ expect(revele.phoneSecondary).toBe('0102030405')
+ })
+ })
+})
diff --git a/frontend/modules/technique/utils/forms/__tests__/providerDetail.spec.ts b/frontend/modules/technique/utils/forms/__tests__/providerDetail.spec.ts
new file mode 100644
index 0000000..e939f96
--- /dev/null
+++ b/frontend/modules/technique/utils/forms/__tests__/providerDetail.spec.ts
@@ -0,0 +1,167 @@
+import { describe, it, expect, vi } from 'vitest'
+
+// formatPhoneFR est auto-importe dans le helper via le chemin partage ; on le mocke
+// pour un rendu deterministe (la mise en forme exacte est testee ailleurs).
+vi.mock('~/shared/utils/phone', () => ({
+ formatPhoneFR: (v: string) => `fmt(${v})`,
+}))
+
+const {
+ canEditProvider,
+ categoryOptionsOf,
+ contactOptionsOf,
+ iriOf,
+ irisOf,
+ mapAccountingDraft,
+ mapAddressToDraft,
+ mapContactToDraft,
+ mapRibToDraft,
+ paymentTypeCodeOf,
+ referentialOptionOf,
+ showArchiveAction,
+ showRestoreAction,
+ siteOptionsOf,
+} = await import('../providerDetail')
+
+/**
+ * Helpers purs des ecrans Consultation / Modification (ERP-145) : mapping du
+ * detail embarque vers les brouillons + regles d'affichage des actions (Modifier /
+ * Archiver / Restaurer).
+ */
+describe('providerDetail helpers', () => {
+ describe('iriOf / irisOf', () => {
+ it('extrait l\'IRI d\'un objet embarque, d\'un IRI nu, ou null', () => {
+ expect(iriOf({ '@id': '/api/banks/2' })).toBe('/api/banks/2')
+ expect(iriOf('/api/banks/2')).toBe('/api/banks/2')
+ expect(iriOf(null)).toBeNull()
+ expect(iriOf(undefined)).toBeNull()
+ })
+
+ it('extrait les IRI d\'une collection embarquee', () => {
+ expect(irisOf([{ '@id': '/api/sites/1' }, { '@id': '/api/sites/2' }])).toEqual(['/api/sites/1', '/api/sites/2'])
+ expect(irisOf(undefined)).toEqual([])
+ })
+ })
+
+ describe('mapContactToDraft', () => {
+ it('mappe les champs, formate les telephones et derive hasSecondaryPhone', () => {
+ const draft = mapContactToDraft({
+ '@id': '/api/provider_contacts/5',
+ id: 5,
+ firstName: 'Jean',
+ lastName: 'Dupont',
+ phonePrimary: '0102030405',
+ phoneSecondary: '0607080910',
+ email: 'jean@x.fr',
+ })
+ expect(draft).toMatchObject({
+ id: 5,
+ iri: '/api/provider_contacts/5',
+ firstName: 'Jean',
+ lastName: 'Dupont',
+ phonePrimary: 'fmt(0102030405)',
+ phoneSecondary: 'fmt(0607080910)',
+ email: 'jean@x.fr',
+ hasSecondaryPhone: true,
+ })
+ })
+
+ it('hasSecondaryPhone faux sans 2e numero', () => {
+ const draft = mapContactToDraft({ '@id': '/api/provider_contacts/6', id: 6, lastName: 'Doe' })
+ expect(draft.hasSecondaryPhone).toBe(false)
+ expect(draft.phoneSecondary).toBeNull()
+ })
+ })
+
+ describe('mapAddressToDraft', () => {
+ it('extrait les IRI des sites / categories / contacts embarques', () => {
+ const draft = mapAddressToDraft({
+ '@id': '/api/provider_addresses/3',
+ id: 3,
+ country: 'France',
+ postalCode: '86100',
+ city: 'Châtellerault',
+ street: '1 rue du Test',
+ sites: [{ '@id': '/api/sites/1' }],
+ categories: [{ '@id': '/api/categories/7' }],
+ contacts: [{ '@id': '/api/provider_contacts/5' }, '/api/provider_contacts/6'],
+ })
+ expect(draft.siteIris).toEqual(['/api/sites/1'])
+ expect(draft.categoryIris).toEqual(['/api/categories/7'])
+ expect(draft.contactIris).toEqual(['/api/provider_contacts/5', '/api/provider_contacts/6'])
+ expect(draft.id).toBe(3)
+ })
+ })
+
+ describe('mapAccountingDraft / mapRibToDraft', () => {
+ it('mappe les scalaires et les IRI des referentiels embarques', () => {
+ const draft = mapAccountingDraft({
+ '@id': '/api/providers/9',
+ id: 9,
+ siren: '123456789',
+ accountNumber: '4010',
+ nTva: 'FR123',
+ tvaMode: { '@id': '/api/tva_modes/1', label: 'TVA' },
+ paymentType: { '@id': '/api/payment_types/3', code: 'VIREMENT' },
+ bank: { '@id': '/api/banks/2' },
+ })
+ expect(draft.tvaModeIri).toBe('/api/tva_modes/1')
+ expect(draft.paymentTypeIri).toBe('/api/payment_types/3')
+ expect(draft.bankIri).toBe('/api/banks/2')
+ expect(draft.paymentDelayIri).toBeNull()
+ expect(draft.siren).toBe('123456789')
+ })
+
+ it('mappe un RIB embarque', () => {
+ expect(mapRibToDraft({ '@id': '/api/provider_ribs/1', id: 1, label: 'Compte', bic: 'BIC', iban: 'IBAN' }))
+ .toEqual({ id: 1, label: 'Compte', bic: 'BIC', iban: 'IBAN' })
+ })
+ })
+
+ describe('options builders (libelles role-independants depuis l\'embed)', () => {
+ it('categoryOptionsOf / siteOptionsOf / contactOptionsOf', () => {
+ expect(categoryOptionsOf([{ '@id': '/api/categories/7', name: 'Maintenance', code: 'MAINT' }]))
+ .toEqual([{ value: '/api/categories/7', label: 'Maintenance' }])
+ expect(siteOptionsOf([{ '@id': '/api/sites/1', name: 'Châtellerault' }]))
+ .toEqual([{ value: '/api/sites/1', label: 'Châtellerault' }])
+ expect(contactOptionsOf([{ '@id': '/api/provider_contacts/5', id: 5, firstName: 'Jean', lastName: 'Dupont' }]))
+ .toEqual([{ value: '/api/provider_contacts/5', label: 'Jean Dupont' }])
+ })
+
+ it('referentialOptionOf / paymentTypeCodeOf', () => {
+ expect(referentialOptionOf({ '@id': '/api/banks/2', label: 'SG' }))
+ .toEqual([{ value: '/api/banks/2', label: 'SG' }])
+ expect(referentialOptionOf(null)).toEqual([])
+ expect(referentialOptionOf('/api/banks/2')).toEqual([])
+ expect(paymentTypeCodeOf({ '@id': '/api/payment_types/3', code: 'LCR' })).toBe('LCR')
+ expect(paymentTypeCodeOf(null)).toBeNull()
+ })
+ })
+
+ describe('actions selon permissions', () => {
+ /** Fabrique un `can` qui n'autorise que les codes fournis. */
+ const canFor = (granted: string[]) => (code: string) => granted.includes(code)
+ const canAnyFor = (granted: string[]) => (codes: string[]) => codes.some(c => granted.includes(c))
+
+ it('« Modifier » visible avec manage OU accounting.manage (Compta inclus)', () => {
+ expect(canEditProvider(canAnyFor(['technique.providers.manage']))).toBe(true)
+ expect(canEditProvider(canAnyFor(['technique.providers.accounting.manage']))).toBe(true)
+ expect(canEditProvider(canAnyFor(['technique.providers.view']))).toBe(false)
+ })
+
+ it('« Archiver » visible seulement avec archive ET prestataire actif (Admin seul)', () => {
+ const admin = canFor(['technique.providers.archive'])
+ const bureau = canFor(['technique.providers.manage'])
+ expect(showArchiveAction(admin, false)).toBe(true)
+ expect(showArchiveAction(admin, true)).toBe(false) // deja archive -> Restaurer
+ expect(showArchiveAction(bureau, false)).toBe(false) // pas la permission archive
+ })
+
+ it('« Restaurer » visible seulement avec archive ET prestataire archive', () => {
+ const admin = canFor(['technique.providers.archive'])
+ expect(showRestoreAction(admin, true)).toBe(true)
+ expect(showRestoreAction(admin, false)).toBe(false)
+ expect(showRestoreAction(canFor([]), true)).toBe(false)
+ })
+ })
+})
diff --git a/frontend/modules/technique/utils/forms/providerAccounting.ts b/frontend/modules/technique/utils/forms/providerAccounting.ts
new file mode 100644
index 0000000..4357acf
--- /dev/null
+++ b/frontend/modules/technique/utils/forms/providerAccounting.ts
@@ -0,0 +1,86 @@
+/**
+ * Helpers purs de l'onglet Comptabilite prestataire (M3 Technique, ERP-144) —
+ * miroir SIMPLIFIE des regles M2, reimplemente cote module Technique (regle
+ * ABSOLUE n°1 : pas d'import inter-module). Portent les RG inter-champs RG-3.07
+ * (banque si VIREMENT) et RG-3.08 (RIB si LCR), testables sans Vue ni API.
+ */
+
+import type {
+ ProviderAccountingDraft,
+ ProviderRibFormDraft,
+} from '~/modules/technique/types/providerForm'
+
+/** Code pivot du type de reglement imposant une banque (RG-3.07). */
+const PAYMENT_TYPE_VIREMENT = 'VIREMENT'
+/** Code pivot du type de reglement imposant au moins un RIB (RG-3.08). */
+const PAYMENT_TYPE_LCR = 'LCR'
+
+/** Champs RIB obligatoires non nullable cote back (NotBlank) — omis si vides au POST. */
+const RIB_REQUIRED_NON_NULLABLE_KEYS = ['label', 'bic', 'iban'] as const
+
+/** Vrai si une chaine porte au moins un caractere non-espace. */
+function isFilled(value: string | null | undefined): boolean {
+ return value !== null && value !== undefined && value.trim() !== ''
+}
+
+/** RG-3.07 : la banque n'est requise/visible que pour un reglement par VIREMENT. */
+export function isBankRequiredForPaymentType(code: string | null | undefined): boolean {
+ return code === PAYMENT_TYPE_VIREMENT
+}
+
+/** RG-3.08 : au moins un RIB n'est requis que pour un reglement par LCR. */
+export function isRibRequiredForPaymentType(code: string | null | undefined): boolean {
+ return code === PAYMENT_TYPE_LCR
+}
+
+/** Vrai si AUCUN champ du bloc RIB n'est rempli (amorce vide a ignorer au submit). */
+export function isRibBlank(rib: ProviderRibFormDraft): boolean {
+ return ![rib.label, rib.bic, rib.iban].some(isFilled)
+}
+
+/** Vrai si les 3 champs du RIB sont remplis (gating « + RIB »). */
+export function isRibComplete(rib: ProviderRibFormDraft): boolean {
+ return isFilled(rib.label) && isFilled(rib.bic) && isFilled(rib.iban)
+}
+
+/**
+ * Payload du PATCH comptable (groupe `provider:write:accounting`). Les relations
+ * sont en IRI ; la banque n'est envoyee que si elle est requise (RG-3.07), sinon
+ * `null` (le back vide la relation hors VIREMENT).
+ */
+export function buildProviderAccountingPayload(
+ accounting: ProviderAccountingDraft,
+ isBankRequired: boolean,
+): Record {
+ return {
+ siren: accounting.siren || null,
+ accountNumber: accounting.accountNumber || null,
+ tvaMode: accounting.tvaModeIri,
+ nTva: accounting.nTva || null,
+ paymentDelay: accounting.paymentDelayIri,
+ paymentType: accounting.paymentTypeIri,
+ bank: isBankRequired ? accounting.bankIri : null,
+ }
+}
+
+/**
+ * Payload d'un RIB (sous-ressource, groupe `provider:write:accounting`). Les
+ * champs requis vides sont omis a la creation pour que la 422 NotBlank porte sur
+ * le champ.
+ */
+export function buildProviderRibPayload(rib: ProviderRibFormDraft): Record {
+ const payload: Record = {
+ label: rib.label,
+ bic: rib.bic,
+ iban: rib.iban,
+ }
+
+ for (const key of RIB_REQUIRED_NON_NULLABLE_KEYS) {
+ const value = payload[key]
+ if (value === null || value === undefined || value === '') {
+ delete payload[key]
+ }
+ }
+
+ return payload
+}
diff --git a/frontend/modules/technique/utils/forms/providerAddress.ts b/frontend/modules/technique/utils/forms/providerAddress.ts
new file mode 100644
index 0000000..ff1f5ed
--- /dev/null
+++ b/frontend/modules/technique/utils/forms/providerAddress.ts
@@ -0,0 +1,50 @@
+/**
+ * Helpers purs de l'onglet Adresse prestataire (M3 Technique, ERP-143) — miroir
+ * SIMPLIFIE de `supplierFormRules`/`supplierEdit` (M2), reimplemente cote module
+ * Technique (regle ABSOLUE n°1 : pas d'import inter-module). Testables sans Vue.
+ */
+
+import type { ProviderAddressFormDraft } from '~/modules/technique/types/providerForm'
+
+/**
+ * Champs scalaires obligatoires non nullable cote back (NotBlank). A la creation
+ * (POST), on OMET du payload ceux qui sont vides pour que la 422 porte la
+ * violation NotBlank propre (sur le champ) plutot qu'une erreur de type.
+ */
+const REQUIRED_NON_NULLABLE_KEYS = ['postalCode', 'city', 'street'] as const
+
+/**
+ * RG-3.05 (+ RG-3.09) : une adresse est « valide » pour autoriser l'ajout d'un
+ * nouveau bloc des qu'elle porte au moins un site ET au moins une categorie. Les
+ * scalaires (CP/ville/rue) restent valides par le back (422 inline).
+ */
+export function isProviderAddressValid(address: ProviderAddressFormDraft): boolean {
+ return address.siteIris.length >= 1 && address.categoryIris.length >= 1
+}
+
+/**
+ * Payload de la sous-ressource addresses (groupe `provider:write:addresses`).
+ * Relations M2M en IRI. Les scalaires requis vides sont omis a la creation (cf.
+ * REQUIRED_NON_NULLABLE_KEYS).
+ */
+export function buildProviderAddressPayload(address: ProviderAddressFormDraft): Record {
+ const payload: Record = {
+ country: address.country,
+ postalCode: address.postalCode || null,
+ city: address.city || null,
+ street: address.street || null,
+ streetComplement: address.streetComplement || null,
+ categories: [...address.categoryIris],
+ sites: [...address.siteIris],
+ contacts: [...address.contactIris],
+ }
+
+ for (const key of REQUIRED_NON_NULLABLE_KEYS) {
+ const value = payload[key]
+ if (value === null || value === undefined || value === '') {
+ delete payload[key]
+ }
+ }
+
+ return payload
+}
diff --git a/frontend/modules/technique/utils/forms/providerContact.ts b/frontend/modules/technique/utils/forms/providerContact.ts
new file mode 100644
index 0000000..41f0074
--- /dev/null
+++ b/frontend/modules/technique/utils/forms/providerContact.ts
@@ -0,0 +1,66 @@
+/**
+ * Helpers purs de l'onglet Contact prestataire (M3 Technique, ERP-142) — miroir
+ * reduit de `supplierFormRules.ts` / `supplierEdit.ts` (M2). Testables sans Vue
+ * ni API : detection de bloc vide (RG-3.04) et construction du payload de
+ * sous-ressource contacts.
+ */
+
+import type { ProviderContactFormDraft } from '~/modules/technique/types/providerForm'
+
+/** Vrai si une chaine porte au moins un caractere non-espace. */
+function isFilled(value: string | null | undefined): boolean {
+ return value !== null && value !== undefined && value.trim() !== ''
+}
+
+/**
+ * RG-3.04 : un bloc Contact est VIDE tant qu'aucun des champs comptant pour la
+ * validite n'est rempli — prenom / nom / fonction / telephone principal / email.
+ *
+ * `phoneSecondary` est volontairement EXCLU : le back (CHECK
+ * `chk_provider_contact_name` + `ProviderContactProcessor`) ne le compte pas non
+ * plus, un bloc ne portant qu'un 2e numero reste invalide. Garder la meme
+ * definition cote front evite tout drift (un bloc « vide » front == bloc rejete
+ * back).
+ */
+export function isProviderContactBlank(contact: ProviderContactFormDraft): boolean {
+ return ![
+ contact.firstName,
+ contact.lastName,
+ contact.jobTitle,
+ contact.phonePrimary,
+ contact.email,
+ ].some(isFilled)
+}
+
+/**
+ * RG-3.04 : un contact est « nomme » (valide) des qu'il porte un prenom OU un nom
+ * — aligne sur le M1/M2. Sert le gating « + Nouveau contact » et la notion de
+ * contact valide (la fonction / le telephone / l'email seuls ne suffisent pas).
+ */
+export function isProviderContactNamed(contact: ProviderContactFormDraft): boolean {
+ return isFilled(contact.firstName) || isFilled(contact.lastName)
+}
+
+/**
+ * RG-3.12 : l'onglet Contact ne peut etre finalise que s'il reste au moins un
+ * contact nomme (prenom ou nom).
+ */
+export function hasAtLeastOneFilledContact(contacts: ProviderContactFormDraft[]): boolean {
+ return contacts.some(isProviderContactNamed)
+}
+
+/**
+ * Payload de la sous-ressource contacts (groupe `provider:write:contacts`). Les
+ * chaines vides sont envoyees a null (le serveur normalise/trim de toute facon).
+ * `phoneSecondary` n'est envoye que si le 2e numero a ete revele (max 2 tel).
+ */
+export function buildProviderContactPayload(contact: ProviderContactFormDraft): Record {
+ return {
+ firstName: contact.firstName || null,
+ lastName: contact.lastName || null,
+ jobTitle: contact.jobTitle || null,
+ phonePrimary: contact.phonePrimary || null,
+ phoneSecondary: contact.hasSecondaryPhone ? (contact.phoneSecondary || null) : null,
+ email: contact.email || null,
+ }
+}
diff --git a/frontend/modules/technique/utils/forms/providerDetail.ts b/frontend/modules/technique/utils/forms/providerDetail.ts
new file mode 100644
index 0000000..36bbd04
--- /dev/null
+++ b/frontend/modules/technique/utils/forms/providerDetail.ts
@@ -0,0 +1,245 @@
+/**
+ * Helpers purs des ecrans Consultation / Modification prestataire (M3 Technique,
+ * ERP-145) — miroir SIMPLIFIE de `supplierConsultation.ts` (M2). Mappent le payload
+ * `GET /api/providers/{id}` (relations embarquees, cf. groupes `provider:item:read`
+ * + `provider:read:accounting`) vers les brouillons « plats » partages avec
+ * `ProviderContactBlock` / `ProviderAddressBlock` et l'onglet Comptabilite.
+ *
+ * Ne touchent ni a l'API ni a l'etat reactif (testables unitairement).
+ *
+ * Rappels de contrat back (JSON reel fige — ERP-139, spec-back § 4.0.bis) :
+ * - categories / sites du prestataire et des adresses : OBJETS embarques (avec @id) ;
+ * - refs comptables (tvaMode/paymentDelay/paymentType/bank) : OBJETS embarques
+ * `{@id, id, label, (code pour paymentType)}` ;
+ * - champs nuls OMIS (skip_null_values) → toujours lire avec `?? null` ;
+ * - champs comptables + `ribs` TOTALEMENT ABSENTS sans permission accounting.view.
+ *
+ * Differences M2 : pas de type d'adresse / bennes / triage, pas d'onglet Information.
+ */
+
+import { formatPhoneFR } from '~/shared/utils/phone'
+import type {
+ ProviderAccountingDraft,
+ ProviderAddressFormDraft,
+ ProviderContactFormDraft,
+ ProviderRibFormDraft,
+} from '~/modules/technique/types/providerForm'
+import type { RefOption } from '~/modules/technique/composables/useProviderReferentials'
+
+/** Reference Hydra embarquee minimale (@id toujours present). */
+export interface HydraRef {
+ '@id': string
+ [key: string]: unknown
+}
+
+/** Une relation peut etre embarquee (objet), un IRI nu (chaine) ou absente. */
+export type Relation = HydraRef | string | null | undefined
+
+/** Site embarque (groupe site:read). */
+export interface SiteRead extends HydraRef {
+ name?: string
+ postalCode?: string
+ color?: string
+}
+
+/** Categorie embarquee (groupe category:read). */
+export interface CategoryRead extends HydraRef {
+ code?: string
+ name?: string
+}
+
+/** Contact embarque (groupe provider:item:read). */
+export interface ContactRead extends HydraRef {
+ id: number
+ firstName?: string | null
+ lastName?: string | null
+ jobTitle?: string | null
+ phonePrimary?: string | null
+ phoneSecondary?: string | null
+ email?: string | null
+}
+
+/** Adresse embarquee (groupe provider:item:read) — version simplifiee M3. */
+export interface AddressRead extends HydraRef {
+ id: number
+ country?: string | null
+ postalCode?: string | null
+ city?: string | null
+ street?: string | null
+ streetComplement?: string | null
+ sites?: SiteRead[]
+ categories?: CategoryRead[]
+ // L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
+ contacts?: Array
+}
+
+/** RIB embarque (groupe provider:read:accounting, present ssi accounting.view). */
+export interface RibRead extends HydraRef {
+ id: number
+ label?: string | null
+ bic?: string | null
+ iban?: string | null
+}
+
+/**
+ * Detail d'un prestataire (`GET /api/providers/{id}`). Tous les champs sont
+ * optionnels : skip_null_values + gating accounting peuvent omettre n'importe
+ * quelle cle.
+ */
+export interface ProviderDetail extends HydraRef {
+ id: number
+ companyName?: string | null
+ isArchived?: boolean
+ categories?: CategoryRead[]
+ sites?: SiteRead[]
+ contacts?: ContactRead[]
+ addresses?: AddressRead[]
+ ribs?: RibRead[]
+ // Onglet Comptabilite (present ssi accounting.view)
+ siren?: string | null
+ accountNumber?: string | null
+ nTva?: string | null
+ tvaMode?: Relation
+ paymentDelay?: Relation
+ paymentType?: Relation
+ bank?: Relation
+}
+
+/** Extrait l'IRI d'une relation (objet embarque, IRI nu, ou null si absente). */
+export function iriOf(relation: Relation): string | null {
+ if (relation === null || relation === undefined) {
+ return null
+ }
+ if (typeof relation === 'string') {
+ return relation
+ }
+ return relation['@id'] ?? null
+}
+
+/** IRI des elements d'une collection embarquee (categories / sites du prestataire). */
+export function irisOf(items: HydraRef[] | undefined): string[] {
+ return (items ?? []).map(i => i['@id'])
+}
+
+/** Mappe un contact embarque vers un brouillon (telephones formates XX XX XX XX XX). */
+export function mapContactToDraft(contact: ContactRead): ProviderContactFormDraft {
+ const phoneSecondary = contact.phoneSecondary ?? null
+ return {
+ id: contact.id,
+ iri: contact['@id'] ?? null,
+ firstName: contact.firstName ?? null,
+ lastName: contact.lastName ?? null,
+ jobTitle: contact.jobTitle ?? null,
+ phonePrimary: contact.phonePrimary ? formatPhoneFR(contact.phonePrimary) : null,
+ phoneSecondary: phoneSecondary ? formatPhoneFR(phoneSecondary) : null,
+ email: contact.email ?? null,
+ hasSecondaryPhone: phoneSecondary !== null && phoneSecondary !== '',
+ }
+}
+
+/** Mappe une adresse embarquee vers un brouillon (IRI extraits des sous-collections). */
+export function mapAddressToDraft(address: AddressRead): ProviderAddressFormDraft {
+ return {
+ id: address.id,
+ country: address.country ?? 'France',
+ postalCode: address.postalCode ?? null,
+ city: address.city ?? null,
+ street: address.street ?? null,
+ streetComplement: address.streetComplement ?? null,
+ categoryIris: (address.categories ?? []).map(c => c['@id']),
+ siteIris: (address.sites ?? []).map(s => s['@id']),
+ contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])),
+ }
+}
+
+/** Mappe un RIB embarque vers un brouillon. */
+export function mapRibToDraft(rib: RibRead): ProviderRibFormDraft {
+ return {
+ id: rib.id,
+ label: rib.label ?? null,
+ bic: rib.bic ?? null,
+ iban: rib.iban ?? null,
+ }
+}
+
+/** Mappe les champs comptables (scalaires + IRI des referentiels embarques). */
+export function mapAccountingDraft(provider: ProviderDetail): ProviderAccountingDraft {
+ return {
+ siren: provider.siren ?? null,
+ accountNumber: provider.accountNumber ?? null,
+ nTva: provider.nTva ?? null,
+ tvaModeIri: iriOf(provider.tvaMode),
+ paymentDelayIri: iriOf(provider.paymentDelay),
+ paymentTypeIri: iriOf(provider.paymentType),
+ bankIri: iriOf(provider.bank),
+ }
+}
+
+/**
+ * Options de categories (value=IRI, label=nom) construites depuis l'embed.
+ * Source role-independante : evite de dependre de `GET /categories` (403 possible
+ * pour un role metier), qui laisserait les libelles vides en consultation.
+ */
+export function categoryOptionsOf(categories: CategoryRead[] | undefined): RefOption[] {
+ return (categories ?? []).map(c => ({
+ value: c['@id'],
+ label: c.name ?? c.code ?? c['@id'],
+ }))
+}
+
+/** Options de sites (value=IRI, label=nom) construites depuis un embed. */
+export function siteOptionsOf(sites: SiteRead[] | undefined): RefOption[] {
+ return (sites ?? []).map(s => ({ value: s['@id'], label: s.name ?? s['@id'] }))
+}
+
+/** Options de contacts (value=IRI, label=nom complet ou email) depuis l'embed prestataire. */
+export function contactOptionsOf(contacts: ContactRead[] | undefined): RefOption[] {
+ return (contacts ?? []).map(c => ({
+ value: c['@id'],
+ label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? c['@id']),
+ }))
+}
+
+/**
+ * Liste a une seule option (ou vide) construite depuis un referentiel embarque
+ * (TvaMode / PaymentDelay / PaymentType / Bank) pour alimenter un MalioSelect en
+ * lecture seule. Le libelle vient de l'embed, jamais d'un GET de referentiel —
+ * l'affichage reste correct quel que soit le role.
+ */
+export function referentialOptionOf(relation: Relation): RefOption[] {
+ if (!relation || typeof relation === 'string') {
+ return []
+ }
+ const label = (relation.label as string | undefined)
+ ?? (relation.name as string | undefined)
+ ?? relation['@id']
+ return [{ value: relation['@id'], label }]
+}
+
+/** Code metier d'un referentiel embarque (PaymentType.code = 'LCR' / 'VIREMENT'), ou null. */
+export function paymentTypeCodeOf(relation: Relation): string | null {
+ if (!relation || typeof relation === 'string') {
+ return null
+ }
+ return (relation.code as string | undefined) ?? null
+}
+
+/**
+ * Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet —
+ * `manage` (onglets metier) OU `accounting.manage` (le role Compta doit pouvoir
+ * ouvrir l'edition pour son onglet Comptabilite). Le readonly fin par onglet est
+ * gere sur l'ecran d'edition.
+ */
+export function canEditProvider(canAny: (codes: string[]) => boolean): boolean {
+ return canAny(['technique.providers.manage', 'technique.providers.accounting.manage'])
+}
+
+/** Bouton « Archiver » : permission archive ET prestataire encore actif (Admin seul). */
+export function showArchiveAction(can: (code: string) => boolean, isArchived: boolean): boolean {
+ return can('technique.providers.archive') && !isArchived
+}
+
+/** Bouton « Restaurer » : permission archive ET prestataire deja archive (Admin seul). */
+export function showRestoreAction(can: (code: string) => boolean, isArchived: boolean): boolean {
+ return can('technique.providers.archive') && isArchived
+}
diff --git a/frontend/tests/e2e/_fixtures/personas.ts b/frontend/tests/e2e/_fixtures/personas.ts
index 73afbd4..ededce8 100644
--- a/frontend/tests/e2e/_fixtures/personas.ts
+++ b/frontend/tests/e2e/_fixtures/personas.ts
@@ -84,6 +84,17 @@ export const personas: Record = {
'commercial.suppliers.accounting.view',
'commercial.suppliers.accounting.manage',
'commercial.suppliers.archive',
+ // Technique — Repertoire prestataires (M3, ERP-138). Meme logique que
+ // clients/fournisseurs : mappe sur le persona "tout", pas de nouveau
+ // persona (regle ABSOLUE n°7). user-full porte deja sites.bypass_scope,
+ // donc il voit les prestataires de tous les sites (M3 § 2.13).
+ // technique.providers.view n'ajoute pas de lien dans la section
+ // Administration, donc expectedAdminLinks reste inchange.
+ 'technique.providers.view',
+ 'technique.providers.manage',
+ 'technique.providers.accounting.view',
+ 'technique.providers.accounting.manage',
+ 'technique.providers.archive',
],
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
},
diff --git a/makefile b/makefile
index f07ca38..8b52e20 100644
--- a/makefile
+++ b/makefile
@@ -231,6 +231,7 @@ test-db-setup:
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_code ON category (code) WHERE deleted_at IS NULL"
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_client_company_name_active ON client (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_company_name_active ON supplier (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
+ $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_provider_company_name_active ON provider (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
fixtures:
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
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/migrations/Version20260612100000.php b/migrations/Version20260612100000.php
new file mode 100644
index 0000000..16eacd8
--- /dev/null
+++ b/migrations/Version20260612100000.php
@@ -0,0 +1,451 @@
+= 1). Sert aussi le cloisonnement
+ * par site (idx_provider_site_site, § 2.13).
+ * - provider_address SIMPLIFIEE : pas de address_type / bennes /
+ * triage_provider (specifiques fournisseur). Champs : country / postal_code
+ * / city / street / street_complement / position + M2M sites/contacts/categories.
+ *
+ * Referentiels comptables NON recrees : tva_mode / payment_delay / payment_type
+ * / bank sont ceux du M1 (FK partagees, zero duplication — spec § 2.3).
+ *
+ * CategoryType PRESTATAIRE NON re-seede : il est cree par ERP-131
+ * (Version20260612080000) avec ses categories de demonstration. Le M2M
+ * provider_category / provider_address_category s appuie sur ce type existant.
+ *
+ * Namespace racine `DoctrineMigrations` (regle ABSOLUE Starseed n°11) et NON
+ * `App\Module\Technique\...` : la migration cree un schema avec FK cross-module
+ * (user, category, site, et les referentiels comptables M1). Avec plusieurs
+ * migrations_paths, Doctrine Migrations 3.x trie par FQCN alphabetique — un
+ * namespace modulaire s executerait avant la creation de user/category/site sur
+ * base vide -> echec des FK. Le namespace racine garantit l ordre par timestamp.
+ *
+ * Style DDL aligne sur le M1/M2 (Version20260605130000) : `INT GENERATED BY
+ * DEFAULT AS IDENTITY` (et non SERIAL), `TIMESTAMP(0) WITHOUT TIME ZONE` (et non
+ * TIMESTAMPTZ, car le TimestampableBlamableTrait mappe `datetime_immutable`).
+ * Garantit que `schema:update` restera un no-op quand les entites arriveront
+ * (ticket ERP-133).
+ *
+ * Decision unicite (alignee Q4 M1 / § 2.6 M2) : unicite metier sur le NOM DE
+ * SOCIETE uniquement (uq_provider_company_name_active, partiel). Pas d index
+ * unique sur siren ni email.
+ *
+ * COMMENT ON COLUMN inline (regle ABSOLUE n°12) : chaque colonne metier porte sa
+ * description ici-meme. Volontairement NON ajoutees a `ColumnCommentsCatalog` /
+ * `makefile test-db-setup` a ce stade : tant que les entites Provider* n existent
+ * pas (ERP-133), `schema:update --force` du setup de test droppe ces tables non
+ * mappees — les referencer dans le catalogue ferait planter
+ * `app:apply-column-comments`. Le catalogue + la ligne `dbal:run-sql`
+ * (uq_provider_company_name_active) seront ajoutes au ticket entites (ERP-133),
+ * exactement comme supplier (ERP-86) apres sa migration (ERP-85). Les 4 colonnes
+ * Timestampable/Blamable reutilisent les textes standardises du catalogue
+ * (`timestampableBlamableComments()`, simple tableau statique sans dependance DB).
+ */
+final class Version20260612100000 extends AbstractMigration
+{
+ public function getDescription(): string
+ {
+ return 'ERP-132 (M3) : tables provider + sous-collections + jointures M2M (referentiels comptables et CategoryType PRESTATAIRE reutilises).';
+ }
+
+ public function up(Schema $schema): void
+ {
+ $this->createProviderTable();
+ $this->createProviderCategory();
+ $this->createProviderSite();
+ $this->createProviderContact();
+ $this->createProviderAddress();
+ $this->createProviderAddressJoinTables();
+ $this->createProviderRib();
+ }
+
+ public function down(Schema $schema): void
+ {
+ // Ordre inverse des dependances FK : jointures et sous-collections
+ // d abord, puis provider. Les referentiels comptables et le
+ // CategoryType PRESTATAIRE ne sont pas touches (crees ailleurs).
+ $this->addSql('DROP TABLE IF EXISTS provider_address_category');
+ $this->addSql('DROP TABLE IF EXISTS provider_address_contact');
+ $this->addSql('DROP TABLE IF EXISTS provider_address_site');
+ $this->addSql('DROP TABLE IF EXISTS provider_rib');
+ $this->addSql('DROP TABLE IF EXISTS provider_address');
+ $this->addSql('DROP TABLE IF EXISTS provider_contact');
+ $this->addSql('DROP TABLE IF EXISTS provider_site');
+ $this->addSql('DROP TABLE IF EXISTS provider_category');
+ $this->addSql('DROP TABLE IF EXISTS provider');
+ }
+
+ // =================================================================
+ // Table principale `provider`
+ // =================================================================
+
+ private function createProviderTable(): void
+ {
+ $this->addSql(<<<'SQL'
+ CREATE TABLE provider (
+ id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
+ company_name VARCHAR(180) NOT NULL,
+ siren VARCHAR(20) DEFAULT NULL,
+ account_number VARCHAR(40) DEFAULT NULL,
+ tva_mode_id INT DEFAULT NULL,
+ n_tva VARCHAR(40) DEFAULT NULL,
+ payment_delay_id INT DEFAULT NULL,
+ payment_type_id INT DEFAULT NULL,
+ bank_id INT DEFAULT NULL,
+ is_archived BOOLEAN DEFAULT FALSE NOT NULL,
+ archived_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
+ deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
+ created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
+ updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
+ created_by INT DEFAULT NULL,
+ updated_by INT DEFAULT NULL,
+ PRIMARY KEY (id),
+ CONSTRAINT fk_provider_tva_mode
+ FOREIGN KEY (tva_mode_id) REFERENCES tva_mode (id) ON DELETE RESTRICT,
+ CONSTRAINT fk_provider_payment_delay
+ FOREIGN KEY (payment_delay_id) REFERENCES payment_delay (id) ON DELETE RESTRICT,
+ CONSTRAINT fk_provider_payment_type
+ FOREIGN KEY (payment_type_id) REFERENCES payment_type (id) ON DELETE RESTRICT,
+ CONSTRAINT fk_provider_bank
+ FOREIGN KEY (bank_id) REFERENCES bank (id) ON DELETE RESTRICT,
+ CONSTRAINT fk_provider_created_by
+ FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
+ CONSTRAINT fk_provider_updated_by
+ FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
+ )
+ SQL);
+
+ $this->addSql('CREATE INDEX idx_provider_is_archived ON provider (is_archived)');
+ $this->addSql('CREATE INDEX idx_provider_deleted_at ON provider (deleted_at)');
+ $this->addSql('CREATE INDEX idx_provider_created_by ON provider (created_by)');
+ $this->addSql('CREATE INDEX idx_provider_updated_by ON provider (updated_by)');
+
+ // Index sur les FK des referentiels comptables (Postgres n indexe pas
+ // automatiquement les colonnes portant une FOREIGN KEY).
+ $this->addSql('CREATE INDEX idx_provider_tva_mode_id ON provider (tva_mode_id)');
+ $this->addSql('CREATE INDEX idx_provider_payment_delay_id ON provider (payment_delay_id)');
+ $this->addSql('CREATE INDEX idx_provider_payment_type_id ON provider (payment_type_id)');
+ $this->addSql('CREATE INDEX idx_provider_bank_id ON provider (bank_id)');
+
+ // Unicite metier partielle : nom de societe insensible a la casse, parmi
+ // les non-archives ET non soft-deletes uniquement (RG-3.10). Pas d index
+ // unique sur siren ni email.
+ $this->addSql(<<<'SQL'
+ CREATE UNIQUE INDEX uq_provider_company_name_active
+ ON provider (LOWER(company_name))
+ WHERE is_archived = FALSE AND deleted_at IS NULL
+ SQL);
+
+ $this->comment('provider', '_table', 'Repertoire prestataires (M3 Technique) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M4). Pas d onglet Information (≠ supplier).');
+ $this->comment('provider', 'id', 'Identifiant interne auto-incremente.');
+ $this->comment('provider', 'company_name', 'Raison sociale du prestataire (stockee en MAJUSCULES). Unique case-insensitive parmi les actifs non archives/non supprimes (uq_provider_company_name_active, RG-3.10).');
+ $this->comment('provider', 'siren', 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-3.10).');
+ $this->comment('provider', 'account_number', 'Onglet Comptabilite : numero de compte comptable du prestataire.');
+ $this->comment('provider', 'tva_mode_id', 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id (referentiel partage M1), ON DELETE RESTRICT.');
+ $this->comment('provider', 'n_tva', 'Onglet Comptabilite : numero de TVA intracommunautaire.');
+ $this->comment('provider', 'payment_delay_id', 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id (M1), ON DELETE RESTRICT.');
+ $this->comment('provider', 'payment_type_id', 'Onglet Comptabilite : type de reglement — FK -> payment_type.id (M1), ON DELETE RESTRICT. Pilote RG-3.07 (Banque si VIREMENT) et RG-3.08 (RIB).');
+ $this->comment('provider', 'bank_id', 'Onglet Comptabilite : banque — FK -> bank.id (M1), ON DELETE RESTRICT. Obligatoire ssi payment_type = VIREMENT (RG-3.07), null sinon.');
+ $this->comment('provider', 'is_archived', 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission technique.providers.archive.');
+ $this->comment('provider', 'archived_at', 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration.');
+ $this->comment('provider', 'deleted_at', 'Horodatage du soft-delete technique (HP M4) — non expose par l API au M3. Null = ligne active.');
+ $this->addTimestampableBlamableComments('provider');
+ }
+
+ // =================================================================
+ // M2M provider <-> category (type PRESTATAIRE — RG-3.09)
+ // =================================================================
+
+ private function createProviderCategory(): void
+ {
+ $this->addSql(<<<'SQL'
+ CREATE TABLE provider_category (
+ provider_id INT NOT NULL,
+ category_id INT NOT NULL,
+ PRIMARY KEY (provider_id, category_id),
+ CONSTRAINT fk_provider_category_provider
+ FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
+ CONSTRAINT fk_provider_category_category
+ FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT
+ )
+ SQL);
+ $this->addSql('CREATE INDEX idx_provider_category_category ON provider_category (category_id)');
+
+ $this->comment('provider_category', '_table', 'Jointure M2M provider <-> category (Catalog) — categories de type PRESTATAIRE du prestataire, au moins une obligatoire (RG-3.09).');
+ $this->comment('provider_category', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire porteur de la categorie.');
+ $this->comment('provider_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie de type PRESTATAIRE rattachee au prestataire (RG-3.09).');
+ }
+
+ // =================================================================
+ // M2M provider <-> site (formulaire principal — RG-3.03)
+ // =================================================================
+
+ private function createProviderSite(): void
+ {
+ $this->addSql(<<<'SQL'
+ CREATE TABLE provider_site (
+ provider_id INT NOT NULL,
+ site_id INT NOT NULL,
+ PRIMARY KEY (provider_id, site_id),
+ CONSTRAINT fk_provider_site_provider
+ FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
+ CONSTRAINT fk_provider_site_site
+ FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT
+ )
+ SQL);
+ // Index sur site_id : sert le filtre de cloisonnement par site
+ // (WHERE site = :currentSite, § 2.13).
+ $this->addSql('CREATE INDEX idx_provider_site_site ON provider_site (site_id)');
+
+ $this->comment('provider_site', '_table', 'Jointure M2M provider <-> site (Sites) — sites du prestataire, selecteur du formulaire principal, au moins un obligatoire (RG-3.03). Sert le cloisonnement par site (§ 2.13).');
+ $this->comment('provider_site', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire porteur du site.');
+ $this->comment('provider_site', 'site_id', 'FK -> site.id, ON DELETE RESTRICT — site rattache au prestataire (RG-3.03, idx_provider_site_site).');
+ }
+
+ // =================================================================
+ // Sous-collection : contacts (1:n)
+ // =================================================================
+
+ private function createProviderContact(): void
+ {
+ $this->addSql(<<<'SQL'
+ CREATE TABLE provider_contact (
+ id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
+ provider_id INT NOT NULL,
+ first_name VARCHAR(120) DEFAULT NULL,
+ last_name VARCHAR(120) DEFAULT NULL,
+ job_title VARCHAR(120) DEFAULT NULL,
+ phone_primary VARCHAR(20) DEFAULT NULL,
+ phone_secondary VARCHAR(20) DEFAULT NULL,
+ email VARCHAR(180) DEFAULT NULL,
+ position INT DEFAULT 0 NOT NULL,
+ created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
+ updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
+ created_by INT DEFAULT NULL,
+ updated_by INT DEFAULT NULL,
+ PRIMARY KEY (id),
+ CONSTRAINT chk_provider_contact_name
+ CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR job_title IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL),
+ CONSTRAINT fk_provider_contact_provider
+ FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
+ CONSTRAINT fk_provider_contact_created_by
+ FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
+ CONSTRAINT fk_provider_contact_updated_by
+ FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
+ )
+ SQL);
+ $this->addSql('CREATE INDEX idx_provider_contact_provider ON provider_contact (provider_id)');
+
+ $this->comment('provider_contact', '_table', 'Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/fonction/telephone/email (RG-3.04, chk_provider_contact_name).');
+ $this->comment('provider_contact', 'id', 'Identifiant interne auto-incremente.');
+ $this->comment('provider_contact', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du contact.');
+ $this->comment('provider_contact', 'first_name', 'Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).');
+ $this->comment('provider_contact', 'last_name', 'Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).');
+ $this->comment('provider_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).');
+ $this->comment('provider_contact', 'phone_primary', 'Telephone principal du contact — chiffres uniquement (normalisation serveur).');
+ $this->comment('provider_contact', 'phone_secondary', 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).');
+ $this->comment('provider_contact', 'email', 'Email du contact (lowercase serveur).');
+ $this->comment('provider_contact', 'position', 'Ordre d affichage du contact dans la liste du prestataire (croissant).');
+ $this->addTimestampableBlamableComments('provider_contact');
+ }
+
+ // =================================================================
+ // Sous-collection : adresses (1:n) — SANS address_type / bennes / triage
+ // =================================================================
+
+ private function createProviderAddress(): void
+ {
+ $this->addSql(<<<'SQL'
+ CREATE TABLE provider_address (
+ id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
+ provider_id INT NOT NULL,
+ country VARCHAR(80) DEFAULT 'France' NOT NULL,
+ postal_code VARCHAR(20) NOT NULL,
+ city VARCHAR(120) NOT NULL,
+ street VARCHAR(255) NOT NULL,
+ street_complement VARCHAR(255) DEFAULT NULL,
+ position INT DEFAULT 0 NOT NULL,
+ created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
+ updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
+ created_by INT DEFAULT NULL,
+ updated_by INT DEFAULT NULL,
+ PRIMARY KEY (id),
+ CONSTRAINT fk_provider_address_provider
+ FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
+ CONSTRAINT fk_provider_address_created_by
+ FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
+ CONSTRAINT fk_provider_address_updated_by
+ FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
+ )
+ SQL);
+ $this->addSql('CREATE INDEX idx_provider_address_provider ON provider_address (provider_id)');
+
+ $this->comment('provider_address', '_table', 'Adresses d un prestataire (1:n) — >= 1 site rattache (RG-3.05). SANS address_type / bennes / triage_provider (specifiques fournisseur).');
+ $this->comment('provider_address', 'id', 'Identifiant interne auto-incremente.');
+ $this->comment('provider_address', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire de l adresse.');
+ $this->comment('provider_address', 'country', 'Pays de l adresse — defaut France.');
+ $this->comment('provider_address', 'postal_code', 'Code postal (4-5 chiffres attendus) — declenche l autocompletion ville via l API BAN cote front (RG-3.06).');
+ $this->comment('provider_address', 'city', 'Ville — preremplie depuis le code postal via API BAN cote front.');
+ $this->comment('provider_address', 'street', 'Numero et voie de l adresse.');
+ $this->comment('provider_address', 'street_complement', 'Complement d adresse (etage, batiment...) — optionnel.');
+ $this->comment('provider_address', 'position', 'Ordre d affichage de l adresse dans la liste du prestataire (croissant).');
+ $this->addTimestampableBlamableComments('provider_address');
+ }
+
+ // =================================================================
+ // Jointures de provider_address (M2M)
+ // =================================================================
+
+ private function createProviderAddressJoinTables(): void
+ {
+ $this->addSql(<<<'SQL'
+ CREATE TABLE provider_address_site (
+ provider_address_id INT NOT NULL,
+ site_id INT NOT NULL,
+ PRIMARY KEY (provider_address_id, site_id),
+ CONSTRAINT fk_provider_address_site_address
+ FOREIGN KEY (provider_address_id) REFERENCES provider_address (id) ON DELETE CASCADE,
+ CONSTRAINT fk_provider_address_site_site
+ FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT
+ )
+ SQL);
+ $this->comment('provider_address_site', '_table', 'Jointure M2M provider_address <-> site (Sites) — sites rattaches a l adresse (>= 1 obligatoire, RG-3.05).');
+ $this->comment('provider_address_site', 'provider_address_id', 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.');
+ $this->comment('provider_address_site', 'site_id', 'FK -> site.id, ON DELETE RESTRICT — site rattache a l adresse.');
+
+ $this->addSql(<<<'SQL'
+ CREATE TABLE provider_address_contact (
+ provider_address_id INT NOT NULL,
+ provider_contact_id INT NOT NULL,
+ PRIMARY KEY (provider_address_id, provider_contact_id),
+ CONSTRAINT fk_provider_address_contact_address
+ FOREIGN KEY (provider_address_id) REFERENCES provider_address (id) ON DELETE CASCADE,
+ CONSTRAINT fk_provider_address_contact_contact
+ FOREIGN KEY (provider_contact_id) REFERENCES provider_contact (id) ON DELETE CASCADE
+ )
+ SQL);
+ $this->comment('provider_address_contact', '_table', 'Jointure M2M provider_address <-> provider_contact — contacts associes a une adresse.');
+ $this->comment('provider_address_contact', 'provider_address_id', 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.');
+ $this->comment('provider_address_contact', 'provider_contact_id', 'FK -> provider_contact.id, ON DELETE CASCADE — contact associe a l adresse.');
+
+ $this->addSql(<<<'SQL'
+ CREATE TABLE provider_address_category (
+ provider_address_id INT NOT NULL,
+ category_id INT NOT NULL,
+ PRIMARY KEY (provider_address_id, category_id),
+ CONSTRAINT fk_provider_address_category_address
+ FOREIGN KEY (provider_address_id) REFERENCES provider_address (id) ON DELETE CASCADE,
+ CONSTRAINT fk_provider_address_category_category
+ FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT
+ )
+ SQL);
+ $this->comment('provider_address_category', '_table', 'Jointure M2M provider_address <-> category — categories d adresse de type PRESTATAIRE (RG-3.09).');
+ $this->comment('provider_address_category', 'provider_address_id', 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.');
+ $this->comment('provider_address_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse de type PRESTATAIRE (RG-3.09).');
+ }
+
+ // =================================================================
+ // Sous-collection : RIB (1:n)
+ // =================================================================
+
+ private function createProviderRib(): void
+ {
+ $this->addSql(<<<'SQL'
+ CREATE TABLE provider_rib (
+ id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
+ provider_id INT NOT NULL,
+ label VARCHAR(120) NOT NULL,
+ bic VARCHAR(20) NOT NULL,
+ iban VARCHAR(34) NOT NULL,
+ position INT DEFAULT 0 NOT NULL,
+ created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
+ updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
+ created_by INT DEFAULT NULL,
+ updated_by INT DEFAULT NULL,
+ PRIMARY KEY (id),
+ CONSTRAINT fk_provider_rib_provider
+ FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
+ CONSTRAINT fk_provider_rib_created_by
+ FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
+ CONSTRAINT fk_provider_rib_updated_by
+ FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
+ )
+ SQL);
+ $this->addSql('CREATE INDEX idx_provider_rib_provider ON provider_rib (provider_id)');
+
+ $this->comment('provider_rib', '_table', 'Coordonnees bancaires d un prestataire (1:n) — >= 1 RIB attendu selon le type de reglement (RG-3.08). Tous les champs audites (pas d AuditIgnore).');
+ $this->comment('provider_rib', 'id', 'Identifiant interne auto-incremente.');
+ $this->comment('provider_rib', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du RIB.');
+ $this->comment('provider_rib', 'label', 'Libelle du RIB (ex: compte principal).');
+ $this->comment('provider_rib', 'bic', 'Code BIC/SWIFT de la banque (8 ou 11 caracteres).');
+ $this->comment('provider_rib', 'iban', 'IBAN du compte (≤ 34 caracteres).');
+ $this->comment('provider_rib', 'position', 'Ordre d affichage du RIB dans la liste du prestataire (croissant).');
+ $this->addTimestampableBlamableComments('provider_rib');
+ }
+
+ // =================================================================
+ // Helpers
+ // =================================================================
+
+ /**
+ * Pose les 4 commentaires standardises Timestampable/Blamable sur une table,
+ * en reutilisant le catalogue partage (source unique, cf. ERP-67). Seul le
+ * tableau statique des textes est reutilise — aucune dependance a l etat DB.
+ */
+ private function addTimestampableBlamableComments(string $table): void
+ {
+ foreach (ColumnCommentsCatalog::timestampableBlamableComments() as $column => $description) {
+ $this->comment($table, $column, $description);
+ }
+ }
+
+ /**
+ * Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou
+ * `COMMENT ON COLUMN` en dollar-quoting Postgres ($_$...$_$) pour eviter
+ * tout echappement d apostrophe.
+ */
+ private function comment(string $table, string $column, string $description): void
+ {
+ $quotedTable = '"'.str_replace('"', '""', $table).'"';
+
+ if ('_table' === $column) {
+ $this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
+
+ return;
+ }
+
+ $this->addSql(sprintf(
+ 'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
+ $quotedTable,
+ '"'.str_replace('"', '""', $column).'"',
+ $description,
+ ));
+ }
+}
diff --git a/migrations/Version20260615120000.php b/migrations/Version20260615120000.php
new file mode 100644
index 0000000..381206b
--- /dev/null
+++ b/migrations/Version20260615120000.php
@@ -0,0 +1,50 @@
+addSql('ALTER TABLE provider_contact DROP CONSTRAINT chk_provider_contact_name');
+ $this->addSql('ALTER TABLE provider_contact ADD CONSTRAINT chk_provider_contact_name CHECK (first_name IS NOT NULL OR last_name IS NOT NULL)');
+
+ $this->addSql('COMMENT ON TABLE provider_contact IS $_$Contacts d un prestataire (1:n) — au moins le prenom OU le nom rempli (RG-3.04, chk_provider_contact_name).$_$');
+ $this->addSql('COMMENT ON COLUMN provider_contact.first_name IS $_$Prenom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).$_$');
+ $this->addSql('COMMENT ON COLUMN provider_contact.last_name IS $_$Nom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).$_$');
+ $this->addSql('COMMENT ON COLUMN provider_contact.job_title IS $_$Fonction / intitule de poste du contact (≤ 120 caracteres). Facultatif — ne suffit plus a valider le contact (RG-3.04).$_$');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE provider_contact DROP CONSTRAINT chk_provider_contact_name');
+ $this->addSql('ALTER TABLE provider_contact ADD CONSTRAINT chk_provider_contact_name CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR job_title IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL)');
+
+ $this->addSql('COMMENT ON TABLE provider_contact IS $_$Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/fonction/telephone/email (RG-3.04, chk_provider_contact_name).$_$');
+ $this->addSql('COMMENT ON COLUMN provider_contact.first_name IS $_$Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$');
+ $this->addSql('COMMENT ON COLUMN provider_contact.last_name IS $_$Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$');
+ $this->addSql('COMMENT ON COLUMN provider_contact.job_title IS $_$Fonction / intitule de poste du contact (≤ 120 caracteres). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$');
+ }
+}
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/Commercial/Domain/Entity/Bank.php b/src/Module/Commercial/Domain/Entity/Bank.php
index 1ee4542..1108cdd 100644
--- a/src/Module/Commercial/Domain/Entity/Bank.php
+++ b/src/Module/Commercial/Domain/Entity/Bank.php
@@ -21,7 +21,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
* Timestampable/Blamable (referentiel statique whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
* `client:read:accounting` permet l'embarquement dans la reponse Client ;
- * `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0).
+ * `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0) ;
+ * `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis).
*/
#[ApiResource(
operations: [
@@ -48,15 +49,15 @@ class Bank
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
- #[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
+ #[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
private ?int $id = null;
#[ORM\Column(length: 30)]
- #[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
+ #[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
- #[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
+ #[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
private ?string $label = null;
#[ORM\Column(options: ['default' => 0])]
diff --git a/src/Module/Commercial/Domain/Entity/PaymentDelay.php b/src/Module/Commercial/Domain/Entity/PaymentDelay.php
index 5e8be75..94f55af 100644
--- a/src/Module/Commercial/Domain/Entity/PaymentDelay.php
+++ b/src/Module/Commercial/Domain/Entity/PaymentDelay.php
@@ -21,7 +21,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
* Timestampable/Blamable (referentiel statique whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
* `client:read:accounting` permet l'embarquement dans la reponse Client ;
- * `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0).
+ * `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0) ;
+ * `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis).
*/
#[ApiResource(
operations: [
@@ -48,15 +49,15 @@ class PaymentDelay
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
- #[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
+ #[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
private ?int $id = null;
#[ORM\Column(length: 30)]
- #[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
+ #[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
- #[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
+ #[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
private ?string $label = null;
#[ORM\Column(options: ['default' => 0])]
diff --git a/src/Module/Commercial/Domain/Entity/PaymentType.php b/src/Module/Commercial/Domain/Entity/PaymentType.php
index af045c9..564b31a 100644
--- a/src/Module/Commercial/Domain/Entity/PaymentType.php
+++ b/src/Module/Commercial/Domain/Entity/PaymentType.php
@@ -24,7 +24,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
* Timestampable/Blamable (referentiel statique whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
* `client:read:accounting` permet l'embarquement dans la reponse Client ;
- * `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0).
+ * `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0) ;
+ * `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis).
*/
#[ApiResource(
operations: [
@@ -51,15 +52,15 @@ class PaymentType
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
- #[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
+ #[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
private ?int $id = null;
#[ORM\Column(length: 30)]
- #[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
+ #[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
- #[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
+ #[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
private ?string $label = null;
#[ORM\Column(options: ['default' => 0])]
diff --git a/src/Module/Commercial/Domain/Entity/TvaMode.php b/src/Module/Commercial/Domain/Entity/TvaMode.php
index e28072f..4cd01c1 100644
--- a/src/Module/Commercial/Domain/Entity/TvaMode.php
+++ b/src/Module/Commercial/Domain/Entity/TvaMode.php
@@ -25,7 +25,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
* EntitiesAreTimestampableBlamableTest::EXCLUDED, comme CategoryType). Le
* groupe `client:read:accounting` permet d'embarquer le mode dans la reponse
* d'un Client (onglet Comptabilite) au lieu d'un IRI ; `supplier:read:accounting`
- * fait de meme dans la reponse Fournisseur (M2, ERP-92 — sinon IRI nu, § 4.0).
+ * fait de meme dans la reponse Fournisseur (M2, ERP-92 — sinon IRI nu, § 4.0) ;
+ * `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis).
*/
#[ApiResource(
operations: [
@@ -55,15 +56,15 @@ class TvaMode
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
- #[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
+ #[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
private ?int $id = null;
#[ORM\Column(length: 30)]
- #[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
+ #[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
- #[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
+ #[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
private ?string $label = null;
#[ORM\Column(options: ['default' => 0])]
diff --git a/src/Module/Core/Application/Rbac/RbacSeeder.php b/src/Module/Core/Application/Rbac/RbacSeeder.php
index 20669e9..fd882a6 100644
--- a/src/Module/Core/Application/Rbac/RbacSeeder.php
+++ b/src/Module/Core/Application/Rbac/RbacSeeder.php
@@ -50,11 +50,19 @@ final class RbacSeeder
/**
* Definition unique des 4 roles + matrice § 2.7. La cle est le code du role,
* `label` le libelle FR affichable, `permissions` la liste des codes RBAC a
- * attacher (vide pour usine : aucun acces ; admin n'apparait pas car il
- * bypass tout via isAdmin ; `commercial.clients.archive` et
- * `commercial.suppliers.archive` ne sont attaches a aucun role metier —
+ * attacher (admin n'apparait pas car il bypass tout via isAdmin ;
+ * `commercial.clients.archive`, `commercial.suppliers.archive` et
+ * `technique.providers.archive` ne sont attaches a aucun role metier —
* admin seul).
*
+ * Cloisonnement par site des prestataires (M3 § 2.13) : la permission
+ * `sites.bypass_scope` est attribuee par defaut a Bureau / Compta /
+ * Commerciale (ils voient « Tout », d'apres le docx) ; Usine ne l'a PAS et
+ * reste cloisonnee a son site courant. Admin a le bypass total via isAdmin.
+ * C'est un cloisonnement pilote par user/permission, pas par code de role :
+ * pour cloisonner Bureau/Commerciale, il suffit de retirer la permission
+ * ici, aucun autre code a changer.
+ *
* @var array}>
*/
private const array MATRIX = [
@@ -66,6 +74,11 @@ final class RbacSeeder
// Fournisseurs (M2 § 2.9, ERP-90) : view + manage (hors Comptabilite).
'commercial.suppliers.view',
'commercial.suppliers.manage',
+ // Prestataires (M3 § 2.9, ERP-138) : view + manage (hors Comptabilite).
+ 'technique.providers.view',
+ 'technique.providers.manage',
+ // Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
+ 'sites.bypass_scope',
// Lecture des referentiels transverses pour les selects client (ERP-102).
'catalog.categories.read_ref',
'sites.read_ref',
@@ -82,6 +95,13 @@ final class RbacSeeder
'commercial.suppliers.view',
'commercial.suppliers.accounting.view',
'commercial.suppliers.accounting.manage',
+ // Prestataires (M3 § 2.9, ERP-138) : view + onglet Comptabilite uniquement
+ // (pas de manage global -> ne peut pas creer un prestataire).
+ 'technique.providers.view',
+ 'technique.providers.accounting.view',
+ 'technique.providers.accounting.manage',
+ // Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
+ 'sites.bypass_scope',
// Lecture des referentiels transverses pour les selects client (ERP-102).
'catalog.categories.read_ref',
'sites.read_ref',
@@ -96,14 +116,25 @@ final class RbacSeeder
// (onglet Comptabilite masque/filtre pour la Commerciale).
'commercial.suppliers.view',
'commercial.suppliers.manage',
+ // Prestataires (M3 § 2.9, ERP-138) : view + manage, sans accounting
+ // (onglet Comptabilite masque/filtre pour la Commerciale).
+ 'technique.providers.view',
+ 'technique.providers.manage',
+ // Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
+ 'sites.bypass_scope',
// Lecture des referentiels transverses pour les selects client (ERP-102).
'catalog.categories.read_ref',
'sites.read_ref',
],
],
self::ROLE_USINE => [
- 'label' => 'Usine',
- 'permissions' => [],
+ 'label' => 'Usine',
+ // Prestataires (M3 § 2.9 + § 2.13, ERP-138) : view en lecture seule,
+ // SANS `sites.bypass_scope` -> cloisonne aux prestataires de son site
+ // courant. Aucun autre acces metier.
+ 'permissions' => [
+ 'technique.providers.view',
+ ],
],
];
diff --git a/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php b/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php
index 7e7545b..bbef32d 100644
--- a/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php
+++ b/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php
@@ -203,6 +203,15 @@ final class SeedE2ECommand extends Command
'commercial.suppliers.accounting.view',
'commercial.suppliers.accounting.manage',
'commercial.suppliers.archive',
+ // Technique — Repertoire prestataires (M3, ERP-138). Meme
+ // logique : mappe sur le persona "tout". user-full porte deja
+ // sites.bypass_scope -> voit les prestataires de tous les
+ // sites (M3 § 2.13). Miroir de personas.ts.
+ 'technique.providers.view',
+ 'technique.providers.manage',
+ 'technique.providers.accounting.view',
+ 'technique.providers.accounting.manage',
+ 'technique.providers.archive',
],
],
[
diff --git a/src/Module/Technique/Application/Service/ProviderFieldNormalizer.php b/src/Module/Technique/Application/Service/ProviderFieldNormalizer.php
new file mode 100644
index 0000000..ea7f6f6
--- /dev/null
+++ b/src/Module/Technique/Application/Service/ProviderFieldNormalizer.php
@@ -0,0 +1,99 @@
+ "0612345678" (RG-3.11).
+ * Le formatage d'affichage "XX XX XX XX XX" est de la responsabilite du front.
+ * - email : lowercase integral (RG-3.11)
+ *
+ * Toutes les methodes sont null-safe et trim-ent l'entree ; une chaine vide
+ * apres trim devient null (evite de persister "" dans des colonnes nullable).
+ */
+final class ProviderFieldNormalizer
+{
+ /**
+ * Nom de societe en majuscules (RG-3.11). Conserve null tel quel ; une
+ * chaine non vide est trim + upper. Une chaine vide reste "" (champ
+ * obligatoire : c'est l'Assert\NotBlank qui rejette, pas le normalizer).
+ */
+ public function normalizeCompanyName(?string $value): ?string
+ {
+ if (null === $value) {
+ return null;
+ }
+
+ return mb_strtoupper(trim($value), 'UTF-8');
+ }
+
+ /**
+ * Nom/prenom de personne en Title Case (RG-3.11) : "JEAN dupont" ->
+ * "Jean Dupont". Une chaine vide apres trim devient null.
+ */
+ public function normalizePersonName(?string $value): ?string
+ {
+ if (null === $value) {
+ return null;
+ }
+
+ $value = trim($value);
+
+ return '' === $value ? null : mb_convert_case($value, MB_CASE_TITLE, 'UTF-8');
+ }
+
+ /**
+ * Email en minuscules (RG-3.11). Une chaine vide apres trim devient null.
+ */
+ public function normalizeEmail(?string $value): ?string
+ {
+ if (null === $value) {
+ return null;
+ }
+
+ $value = trim($value);
+
+ return '' === $value ? null : mb_strtolower($value, 'UTF-8');
+ }
+
+ /**
+ * Texte libre simplement trim (ex : jobTitle / Fonction du contact). Pas de
+ * changement de casse — on preserve la saisie. Une chaine vide apres trim
+ * devient null (evite de persister "" et de faire passer a tort le garde-fou
+ * RG-3.04 / le CHECK chk_provider_contact_name sur une Fonction vide).
+ */
+ public function normalizeText(?string $value): ?string
+ {
+ if (null === $value) {
+ return null;
+ }
+
+ $value = trim($value);
+
+ return '' === $value ? null : $value;
+ }
+
+ /**
+ * Telephone reduit aux chiffres (RG-3.11) : "06.12.34.56.78" ->
+ * "0612345678". Une valeur sans aucun chiffre devient null.
+ */
+ public function normalizePhone(?string $value): ?string
+ {
+ if (null === $value) {
+ return null;
+ }
+
+ $digits = preg_replace('/\D+/', '', $value) ?? '';
+
+ return '' === $digits ? null : $digits;
+ }
+}
diff --git a/src/Module/Technique/Application/Validator/ProviderAccountingCompletenessValidator.php b/src/Module/Technique/Application/Validator/ProviderAccountingCompletenessValidator.php
new file mode 100644
index 0000000..11d39da
--- /dev/null
+++ b/src/Module/Technique/Application/Validator/ProviderAccountingCompletenessValidator.php
@@ -0,0 +1,79 @@
+ valeur courante des champs obligatoires de l'onglet.
+ $fields = [
+ 'siren' => $provider->getSiren(),
+ 'accountNumber' => $provider->getAccountNumber(),
+ 'tvaMode' => $provider->getTvaMode(),
+ 'nTva' => $provider->getNTva(),
+ 'paymentDelay' => $provider->getPaymentDelay(),
+ 'paymentType' => $provider->getPaymentType(),
+ ];
+
+ $violations = new ConstraintViolationList();
+
+ foreach ($fields as $property => $value) {
+ if ($this->isMissing($value)) {
+ $violations->add(new ConstraintViolation(
+ 'Ce champ est obligatoire.',
+ null,
+ [],
+ $provider,
+ $property,
+ $value,
+ ));
+ }
+ }
+
+ if (count($violations) > 0) {
+ throw new ValidationException($violations);
+ }
+ }
+
+ /**
+ * Une valeur est manquante si null ou, pour une chaine, vide apres trim. Les
+ * references (TvaMode / PaymentDelay / PaymentType) ne sont manquantes que
+ * lorsqu'elles valent null.
+ */
+ private function isMissing(mixed $value): bool
+ {
+ if (null === $value) {
+ return true;
+ }
+
+ return is_string($value) && '' === trim($value);
+ }
+}
diff --git a/src/Module/Technique/Domain/Entity/Provider.php b/src/Module/Technique/Domain/Entity/Provider.php
new file mode 100644
index 0000000..8c95e8a
--- /dev/null
+++ b/src/Module/Technique/Domain/Entity/Provider.php
@@ -0,0 +1,607 @@
+= 1). Nouveau vs supplier
+ * (qui n'avait des sites que sur l'adresse). Sert aussi le cloisonnement par
+ * site (§ 2.13, ticket Provider/Processor ERP-134).
+ *
+ * Referentiels comptables (TvaMode / PaymentDelay / PaymentType / Bank) et Site /
+ * Category : consommes en RELATION ORM PARTAGEE (decision Matthieu, § 2.1). Site /
+ * Category passent par les contrats Shared (SiteInterface / CategoryInterface +
+ * resolve_target_entities) comme le fait deja Supplier (regle ABSOLUE n°1). Les 4
+ * referentiels comptables vivent dans le module Commercial et sont references en
+ * direct, faute de contrat Shared dedie (remontee dans Shared tracee HP-M4-2) —
+ * reference de donnees de reference, pas de logique inter-module.
+ *
+ * Contrat de serialisation (RETEX M1, 3 maillons — spec § 4.0) : les read-groups
+ * sont poses ICI (source unique). L'#[ApiResource] cable (ERP-134) le ProviderProvider
+ * (liste paginee anti-N+1, exclusion archives, cloisonnement site lecture + detail
+ * 404) et le ProviderProcessor (normalisation, archivage, mode strict par groupe,
+ * cloisonnement site ecriture, 409 doublon). Le groupe provider:read:accounting est
+ * ajoute dynamiquement au contexte par le ProviderReadGroupContextBuilder selon la
+ * permission accounting.view (ERP-134) — jamais pose en dur sur l'operation.
+ *
+ * Audite (#[Auditable], tous champs — y compris RIB embarques, § 2.7) +
+ * Timestampable / Blamable via le trait Shared.
+ */
+#[ApiResource(
+ operations: [
+ new GetCollection(
+ security: "is_granted('technique.providers.view')",
+ // La liste embarque les categories (code/name, groupe category:read) et
+ // les sites du prestataire (name/postalCode, groupe site:read — relation
+ // DIRECTE provider.sites, RG-3.03). Maillon (c) : category:read +
+ // site:read presents dans le contexte. Hydratation anti-N+1 cablee par
+ // le ProviderProvider (cf. DoctrineProviderRepository::hydrateListCollections).
+ normalizationContext: ['groups' => ['provider:read', 'category:read', 'site:read', 'default:read']],
+ provider: ProviderProvider::class,
+ ),
+ new Get(
+ security: "is_granted('technique.providers.view')",
+ // Detail : prestataire + sous-collections embarquees (contacts, adresses
+ // + leurs sites/categories/contacts) + RIB (gates compta). Le groupe
+ // provider:read:accounting est volontairement ABSENT : il est ajoute au
+ // contexte par le ProviderReadGroupContextBuilder selon la permission
+ // accounting.view (parade fuite IBAN/BIC — bug #4 M1).
+ 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', 'category:read', 'site:read', 'default:read']],
+ denormalizationContext: ['groups' => ['provider:write:main']],
+ processor: ProviderProcessor::class,
+ ),
+ new Patch(
+ // Security elargie : `manage` OU `accounting.manage` — le role Compta n'a
+ // pas `manage` global mais doit pouvoir editer l'onglet Comptabilite d'un
+ // prestataire existant (§ 2.9). Le re-gating onglet par onglet (mode strict
+ // RG-3.15) est porte par le ProviderProcessor (ERP-134).
+ security: "is_granted('technique.providers.manage') or is_granted('technique.providers.accounting.manage')",
+ normalizationContext: ['groups' => ['provider:read', 'category:read', 'site: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')]
+// Index nommes pour matcher la migration (Version20260612100000). L'index unique
+// partiel uq_provider_company_name_active reste possede par la migration : Doctrine
+// ORM ne sait pas exprimer un index fonctionnel (LOWER) + partiel (WHERE) via
+// attribut. Pas de #[ORM\UniqueConstraint] (§ 2.6).
+#[ORM\Index(name: 'idx_provider_is_archived', columns: ['is_archived'])]
+#[ORM\Index(name: 'idx_provider_deleted_at', columns: ['deleted_at'])]
+#[ORM\Index(name: 'idx_provider_created_by', columns: ['created_by'])]
+#[ORM\Index(name: 'idx_provider_updated_by', columns: ['updated_by'])]
+#[Auditable]
+class Provider implements TimestampableInterface, BlamableInterface
+{
+ use TimestampableBlamableTrait;
+
+ /**
+ * RG-3.09 : seules les categories PORTANT ce type sont autorisees sur le
+ * prestataire (entite principale) ET sur ses adresses. Miroir de
+ * ProviderAddress. S'appuie sur CategoryInterface::getCategoryTypeCodes()
+ * (pas d'import du module Catalog — regle ABSOLUE n°1).
+ */
+ private const string REQUIRED_CATEGORY_TYPE_CODE = 'PRESTATAIRE';
+
+ /** Code pivot du type de reglement imposant une banque (RG-3.07). */
+ private const string PAYMENT_TYPE_VIREMENT = 'VIREMENT';
+
+ /** Code pivot du type de reglement imposant au moins un RIB (RG-3.08). */
+ private const string PAYMENT_TYPE_LCR = 'LCR';
+
+ #[ORM\Id]
+ #[ORM\GeneratedValue]
+ #[ORM\Column]
+ #[Groups(['provider:read'])]
+ private ?int $id = null;
+
+ // === Formulaire principal ===
+ #[ORM\Column(length: 180)]
+ #[Assert\NotBlank(message: 'Le nom du prestataire est obligatoire.', normalizer: 'trim')]
+ #[Assert\Length(min: 2, max: 180, minMessage: 'Le nom du prestataire doit comporter au moins {{ limit }} caractères.', maxMessage: 'Le nom du prestataire ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Groups(['provider:read', 'provider:write:main'])]
+ private ?string $companyName = null;
+
+ // RG-3.09 : au moins une categorie (Count min 1), de type PRESTATAIRE (verifie
+ // par validateCategoryType). M2M vers Category via le contrat CategoryInterface
+ // (resolve_target_entities -> Category). Embarquee en LISTE ET DETAIL ; maillon
+ // (c) : le contexte inclut 'category:read' pour exposer id/code/name.
+ /** @var Collection */
+ #[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
+ #[ORM\JoinTable(name: 'provider_category')]
+ #[ORM\JoinColumn(name: 'provider_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
+ #[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
+ #[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')]
+ #[Groups(['provider:read', 'provider:write:main'])]
+ private Collection $categories;
+
+ // RG-3.03 (SPECIFICITE M3) : au moins un site (Count min 1). Sites rattaches
+ // DIRECTEMENT au prestataire sur le formulaire principal (le fournisseur n'avait
+ // des sites que sur l'adresse). M2M vers Site via le contrat SiteInterface
+ // (resolve_target_entities -> Site). Embarquee en LISTE ET DETAIL ; maillon (c) :
+ // le contexte inclut 'site:read' pour exposer name/postalCode (Site n'a pas de
+ // `code`). L'ecriture cloisonnee par user_site (§ 2.13) est portee par le
+ // ProviderProcessor (ERP-134).
+ /** @var Collection */
+ #[ORM\ManyToMany(targetEntity: SiteInterface::class)]
+ #[ORM\JoinTable(name: 'provider_site')]
+ #[ORM\JoinColumn(name: 'provider_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
+ #[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
+ #[Assert\Count(min: 1, minMessage: 'Au moins un site est obligatoire.')]
+ #[Groups(['provider:read', 'provider:write:main'])]
+ private Collection $sites;
+
+ // === Onglet Comptabilite ===
+ // Lecture conditionnee via le groupe `provider:read:accounting` (ajoute au
+ // contexte par le ProviderProvider / ReadGroupContextBuilder si l'user a
+ // accounting.view — ERP-134). Ecriture via `provider:write:accounting` (le
+ // Processor exige accounting.manage).
+ #[ORM\Column(length: 20, nullable: true)]
+ #[Assert\Length(max: 20, maxMessage: 'Le SIREN ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Groups(['provider:read:accounting', 'provider:write:accounting'])]
+ private ?string $siren = null;
+
+ #[ORM\Column(length: 40, nullable: true)]
+ #[Assert\Length(max: 40, maxMessage: 'Le numéro de compte ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Groups(['provider:read:accounting', 'provider:write:accounting'])]
+ private ?string $accountNumber = null;
+
+ #[ORM\ManyToOne(targetEntity: TvaMode::class)]
+ #[ORM\JoinColumn(name: 'tva_mode_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
+ #[Groups(['provider:read:accounting', 'provider:write:accounting'])]
+ private ?TvaMode $tvaMode = null;
+
+ #[ORM\Column(length: 40, nullable: true)]
+ #[Assert\Length(max: 40, maxMessage: 'Le numéro de TVA ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Groups(['provider:read:accounting', 'provider:write:accounting'])]
+ private ?string $nTva = null;
+
+ #[ORM\ManyToOne(targetEntity: PaymentDelay::class)]
+ #[ORM\JoinColumn(name: 'payment_delay_id', referencedColumnName: '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', referencedColumnName: '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', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
+ #[Groups(['provider:read:accounting', 'provider:write:accounting'])]
+ private ?Bank $bank = null;
+
+ // === Sous-collections — EMBARQUEES dans le DETAIL (RETEX M1 §2) ===
+ // Maillon (a) : le read-group est porte par le GETTER (getContacts / getAddresses
+ // / getRibs) — sans #[Groups], jamais serialisees. Edition via sous-ressources
+ // (ticket ulterieur M3).
+ /** @var Collection */
+ #[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ private Collection $contacts;
+
+ /** @var Collection */
+ #[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ private Collection $addresses;
+
+ /** @var Collection */
+ #[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderRib::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ private Collection $ribs;
+
+ // === Archive / Soft delete ===
+ // Groupe d'ECRITURE uniquement sur la propriete (denormalisation PATCH archive).
+ // Le groupe de LECTURE est declare sur le getter isArchived() avec
+ // SerializedName('isArchived') : sans cela, Symfony strip le prefixe "is" et
+ // exposerait la cle JSON "archived" — en pratique la cle est totalement DROPPEE
+ // (piege n°3 du M1). Pattern corrige : Groups + SerializedName sur le getter.
+ #[ORM\Column(name: 'is_archived', options: ['default' => false])]
+ #[Groups(['provider:write:archive'])]
+ private bool $isArchived = false;
+
+ #[ORM\Column(type: 'datetime_immutable', nullable: true)]
+ #[Groups(['provider:read'])]
+ private ?DateTimeImmutable $archivedAt = null;
+
+ // Soft delete technique (HP M4) : non expose en lecture/ecriture au M3.
+ #[ORM\Column(type: 'datetime_immutable', nullable: true)]
+ private ?DateTimeImmutable $deletedAt = null;
+
+ public function __construct()
+ {
+ $this->categories = new ArrayCollection();
+ $this->sites = new ArrayCollection();
+ $this->contacts = new ArrayCollection();
+ $this->addresses = new ArrayCollection();
+ $this->ribs = new ArrayCollection();
+ }
+
+ /**
+ * RG-3.09 : toute categorie posee sur le prestataire doit etre de type
+ * PRESTATAIRE -> sinon 422 avec violation sur le champ `categories`
+ * (propertyPath aligne ERP-101, message FR ERP-107). Miroir de
+ * ProviderAddress::validateCategoryType. S'appuie sur
+ * CategoryInterface::getCategoryTypeCodes() (multi-type — la categorie est
+ * acceptee des qu'elle PORTE le type PRESTATAIRE ; pas d'import du module
+ * Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API
+ * Platform, sur POST (categories ∈ provider:write:main) comme sur PATCH.
+ */
+ #[Assert\Callback]
+ public function validateCategoryType(ExecutionContextInterface $context): void
+ {
+ foreach ($this->categories as $category) {
+ if ($category instanceof CategoryInterface
+ && !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) {
+ $context->buildViolation('Type de catégorie non autorisé (PRESTATAIRE attendu).')
+ ->atPath('categories')
+ ->addViolation()
+ ;
+
+ return;
+ }
+ }
+ }
+
+ /**
+ * RG-3.07 / RG-3.08 : coherence du type de reglement comptable. Comme au M2
+ * (decision figee ERP-89, jumeau Supplier::validatePaymentTypeConsistency),
+ * ces RG inter-champs passent par une contrainte d'entite (Assert\Callback +
+ * ->atPath()) et NON par le ProviderProcessor, afin que chaque 422 porte un
+ * propertyPath exploitable par extractApiViolations (mapping inline sous le
+ * champ, pas un toast — convention ERP-101).
+ * - RG-3.07 : paymentType = VIREMENT impose une banque -> violation sur `bank`.
+ * - RG-3.08 : paymentType = LCR impose au moins un RIB -> violation sur
+ * `paymentType` (les RIB n'ont pas de champ de formulaire ou s'ancrer quand
+ * la liste est vide ; l'erreur s'affiche donc sous le select « Type de
+ * règlement », binde cote front). Le 409 sur DELETE du dernier RIB en LCR est
+ * porte par le ProviderRibProcessor (ERP-135).
+ *
+ * Ces champs vivant dans le groupe d'ecriture comptable (absent du POST, qui
+ * n'expose que provider:write:main), la contrainte ne mord en pratique que sur
+ * le PATCH de l'onglet Comptabilite.
+ */
+ #[Assert\Callback]
+ public function validatePaymentTypeConsistency(ExecutionContextInterface $context): void
+ {
+ $paymentCode = $this->paymentType?->getCode();
+
+ if (self::PAYMENT_TYPE_VIREMENT === $paymentCode && null === $this->bank) {
+ $context->buildViolation('La banque est obligatoire pour le type de règlement Virement.')
+ ->atPath('bank')
+ ->addViolation()
+ ;
+ }
+
+ if (self::PAYMENT_TYPE_LCR === $paymentCode && $this->ribs->isEmpty()) {
+ $context->buildViolation('Au moins un RIB est obligatoire pour le type de règlement LCR.')
+ ->atPath('paymentType')
+ ->addViolation()
+ ;
+ }
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getCompanyName(): ?string
+ {
+ return $this->companyName;
+ }
+
+ public function setCompanyName(string $companyName): static
+ {
+ $this->companyName = $companyName;
+
+ return $this;
+ }
+
+ /** @return Collection */
+ public function getCategories(): Collection
+ {
+ return $this->categories;
+ }
+
+ public function addCategory(CategoryInterface $category): static
+ {
+ if (!$this->categories->contains($category)) {
+ $this->categories->add($category);
+ }
+
+ return $this;
+ }
+
+ public function removeCategory(CategoryInterface $category): static
+ {
+ $this->categories->removeElement($category);
+
+ return $this;
+ }
+
+ /** @return Collection */
+ public function getSites(): Collection
+ {
+ return $this->sites;
+ }
+
+ public function addSite(SiteInterface $site): static
+ {
+ if (!$this->sites->contains($site)) {
+ $this->sites->add($site);
+ }
+
+ return $this;
+ }
+
+ public function removeSite(SiteInterface $site): static
+ {
+ $this->sites->removeElement($site);
+
+ return $this;
+ }
+
+ public function getSiren(): ?string
+ {
+ return $this->siren;
+ }
+
+ public function setSiren(?string $siren): static
+ {
+ $this->siren = $siren;
+
+ return $this;
+ }
+
+ public function getAccountNumber(): ?string
+ {
+ return $this->accountNumber;
+ }
+
+ public function setAccountNumber(?string $accountNumber): static
+ {
+ $this->accountNumber = $accountNumber;
+
+ return $this;
+ }
+
+ public function getTvaMode(): ?TvaMode
+ {
+ return $this->tvaMode;
+ }
+
+ public function setTvaMode(?TvaMode $tvaMode): static
+ {
+ $this->tvaMode = $tvaMode;
+
+ return $this;
+ }
+
+ public function getNTva(): ?string
+ {
+ return $this->nTva;
+ }
+
+ public function setNTva(?string $nTva): static
+ {
+ $this->nTva = $nTva;
+
+ return $this;
+ }
+
+ public function getPaymentDelay(): ?PaymentDelay
+ {
+ return $this->paymentDelay;
+ }
+
+ public function setPaymentDelay(?PaymentDelay $paymentDelay): static
+ {
+ $this->paymentDelay = $paymentDelay;
+
+ return $this;
+ }
+
+ public function getPaymentType(): ?PaymentType
+ {
+ return $this->paymentType;
+ }
+
+ public function setPaymentType(?PaymentType $paymentType): static
+ {
+ $this->paymentType = $paymentType;
+
+ return $this;
+ }
+
+ public function getBank(): ?Bank
+ {
+ return $this->bank;
+ }
+
+ public function setBank(?Bank $bank): static
+ {
+ $this->bank = $bank;
+
+ return $this;
+ }
+
+ /** @return Collection */
+ #[Groups(['provider:item:read'])]
+ public function getContacts(): Collection
+ {
+ return $this->contacts;
+ }
+
+ public function addContact(ProviderContact $contact): static
+ {
+ if (!$this->contacts->contains($contact)) {
+ $this->contacts->add($contact);
+ $contact->setProvider($this);
+ }
+
+ return $this;
+ }
+
+ public function removeContact(ProviderContact $contact): static
+ {
+ if ($this->contacts->removeElement($contact) && $contact->getProvider() === $this) {
+ $contact->setProvider(null);
+ }
+
+ return $this;
+ }
+
+ /** @return Collection */
+ #[Groups(['provider:item:read'])]
+ public function getAddresses(): Collection
+ {
+ return $this->addresses;
+ }
+
+ public function addAddress(ProviderAddress $address): static
+ {
+ if (!$this->addresses->contains($address)) {
+ $this->addresses->add($address);
+ $address->setProvider($this);
+ }
+
+ return $this;
+ }
+
+ public function removeAddress(ProviderAddress $address): static
+ {
+ if ($this->addresses->removeElement($address) && $address->getProvider() === $this) {
+ $address->setProvider(null);
+ }
+
+ return $this;
+ }
+
+ // Embed gate sur le groupe COMPTABLE (et non provider:item:read comme contacts/
+ // adresses) : provider:read:accounting n'est ajoute au contexte que si l'user a
+ // accounting.view (ProviderProvider / ReadGroupContextBuilder, ERP-134). Resultat :
+ // la cle `ribs` est TOTALEMENT ABSENTE du detail pour un user sans accounting.view
+ // (ex. Commerciale), au meme titre que les scalaires comptables — evite la fuite
+ // IBAN/BIC (piege n°4 M1).
+ /** @return Collection */
+ #[Groups(['provider:read:accounting'])]
+ public function getRibs(): Collection
+ {
+ return $this->ribs;
+ }
+
+ public function addRib(ProviderRib $rib): static
+ {
+ if (!$this->ribs->contains($rib)) {
+ $this->ribs->add($rib);
+ $rib->setProvider($this);
+ }
+
+ return $this;
+ }
+
+ public function removeRib(ProviderRib $rib): static
+ {
+ if ($this->ribs->removeElement($rib) && $rib->getProvider() === $this) {
+ $rib->setProvider(null);
+ }
+
+ return $this;
+ }
+
+ // Groupe de lecture + nom serialise explicite : sans SerializedName, Symfony
+ // exposerait la cle "archived" (strip du prefixe "is" sur les getters) et
+ // droppait silencieusement la cle du JSON (piege n°3 du M1).
+ #[Groups(['provider:read'])]
+ #[SerializedName('isArchived')]
+ public function isArchived(): bool
+ {
+ return $this->isArchived;
+ }
+
+ public function setIsArchived(bool $isArchived): static
+ {
+ $this->isArchived = $isArchived;
+
+ return $this;
+ }
+
+ public function getArchivedAt(): ?DateTimeImmutable
+ {
+ return $this->archivedAt;
+ }
+
+ public function setArchivedAt(?DateTimeImmutable $archivedAt): static
+ {
+ $this->archivedAt = $archivedAt;
+
+ return $this;
+ }
+
+ public function getDeletedAt(): ?DateTimeImmutable
+ {
+ return $this->deletedAt;
+ }
+
+ public function setDeletedAt(?DateTimeImmutable $deletedAt): static
+ {
+ $this->deletedAt = $deletedAt;
+
+ return $this;
+ }
+}
diff --git a/src/Module/Technique/Domain/Entity/ProviderAddress.php b/src/Module/Technique/Domain/Entity/ProviderAddress.php
new file mode 100644
index 0000000..55ef080
--- /dev/null
+++ b/src/Module/Technique/Domain/Entity/ProviderAddress.php
@@ -0,0 +1,370 @@
+ ['provider:item:read', 'site:read', 'category:read', 'default:read']],
+ // Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre.
+ provider: ProviderSubResourceItemProvider::class,
+ ),
+ new Post(
+ uriTemplate: '/providers/{providerId}/addresses',
+ uriVariables: [
+ 'providerId' => new Link(fromClass: Provider::class, toProperty: 'provider'),
+ ],
+ // read:false : pas de stade lecture du parent. Le Link toProperty
+ // resoudrait l'enfant (SELECT ProviderAddress ... WHERE provider = :id)
+ // et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
+ // manuellement par ProviderAddressProcessor::linkParent (404 si absent).
+ read: false,
+ security: "is_granted('technique.providers.manage')",
+ normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
+ denormalizationContext: ['groups' => ['provider:write:addresses']],
+ processor: ProviderAddressProcessor::class,
+ ),
+ new Patch(
+ security: "is_granted('technique.providers.manage')",
+ normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
+ denormalizationContext: ['groups' => ['provider:write:addresses']],
+ provider: ProviderSubResourceItemProvider::class,
+ processor: ProviderAddressProcessor::class,
+ ),
+ new Delete(
+ security: "is_granted('technique.providers.manage')",
+ provider: ProviderSubResourceItemProvider::class,
+ processor: ProviderAddressProcessor::class,
+ ),
+ ],
+)]
+#[ORM\Entity]
+#[ORM\Table(name: 'provider_address')]
+#[ORM\Index(name: 'idx_provider_address_provider', columns: ['provider_id'])]
+#[Auditable]
+class ProviderAddress implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
+{
+ use TimestampableBlamableTrait;
+
+ /**
+ * RG-3.09 : seules les categories PORTANT ce type sont autorisees sur une
+ * adresse prestataire. S'appuie sur CategoryInterface::getCategoryTypeCodes()
+ * (pas d'import du module Catalog — regle ABSOLUE n°1).
+ */
+ private const string REQUIRED_CATEGORY_TYPE_CODE = 'PRESTATAIRE';
+
+ #[ORM\Id]
+ #[ORM\GeneratedValue]
+ #[ORM\Column]
+ #[Groups(['provider:item:read'])]
+ private ?int $id = null;
+
+ #[ORM\ManyToOne(targetEntity: Provider::class, inversedBy: 'addresses')]
+ #[ORM\JoinColumn(name: 'provider_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
+ private ?Provider $provider = null;
+
+ #[ORM\Column(length: 80, options: ['default' => 'France'])]
+ #[Assert\Length(max: 80, maxMessage: 'Le pays ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Groups(['provider:item:read', 'provider:write:addresses'])]
+ private string $country = 'France';
+
+ // RG-3.06 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur).
+ // Le Regex borne deja la longueur (<= 5) : pas de Length redondant (whitelist
+ // ERP-107).
+ #[ORM\Column(length: 20)]
+ #[Assert\NotBlank(message: 'Le code postal est obligatoire.', normalizer: 'trim')]
+ #[Assert\Regex(pattern: '/^[0-9]{4,5}$/', message: 'Le code postal doit comporter 4 ou 5 chiffres.')]
+ #[Groups(['provider:item:read', 'provider:write:addresses'])]
+ private ?string $postalCode = null;
+
+ #[ORM\Column(length: 120)]
+ #[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')]
+ #[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Groups(['provider:item:read', 'provider:write:addresses'])]
+ private ?string $city = null;
+
+ #[ORM\Column(length: 255)]
+ #[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')]
+ #[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Groups(['provider:item:read', 'provider:write:addresses'])]
+ private ?string $street = null;
+
+ #[ORM\Column(length: 255, nullable: true)]
+ #[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Groups(['provider:item:read', 'provider:write:addresses'])]
+ private ?string $streetComplement = null;
+
+ // Ordre d'affichage de l'adresse (gere serveur, non expose au M3).
+ #[ORM\Column(options: ['default' => 0])]
+ private int $position = 0;
+
+ // RG-3.05 : au moins un site rattache a chaque adresse.
+ /** @var Collection */
+ #[ORM\ManyToMany(targetEntity: SiteInterface::class)]
+ #[ORM\JoinTable(name: 'provider_address_site')]
+ #[ORM\JoinColumn(name: 'provider_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
+ #[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
+ #[Assert\Count(min: 1, minMessage: 'Au moins un site est obligatoire.')]
+ #[Groups(['provider:item:read', 'provider:write:addresses'])]
+ private Collection $sites;
+
+ /** @var Collection */
+ #[ORM\ManyToMany(targetEntity: ProviderContact::class)]
+ #[ORM\JoinTable(name: 'provider_address_contact')]
+ #[ORM\JoinColumn(name: 'provider_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
+ #[ORM\InverseJoinColumn(name: 'provider_contact_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
+ #[Groups(['provider:item:read', 'provider:write:addresses'])]
+ private Collection $contacts;
+
+ // RG-3.09 : au moins une categorie de type PRESTATAIRE par adresse (le type est
+ // controle par validateCategoryType ; le minimum par Assert\Count, miroir sites).
+ /** @var Collection */
+ #[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
+ #[ORM\JoinTable(name: 'provider_address_category')]
+ #[ORM\JoinColumn(name: 'provider_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
+ #[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
+ #[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')]
+ #[Groups(['provider:item:read', 'provider:write:addresses'])]
+ private Collection $categories;
+
+ public function __construct()
+ {
+ $this->sites = new ArrayCollection();
+ $this->contacts = new ArrayCollection();
+ $this->categories = new ArrayCollection();
+ }
+
+ /**
+ * RG-3.09 : toute categorie posee sur une adresse prestataire doit etre de
+ * type PRESTATAIRE -> sinon 422 avec violation sur le champ `categories`
+ * (propertyPath aligne ERP-101, message FR ERP-107). S'appuie sur
+ * CategoryInterface::getCategoryTypeCodes() (multi-type — la categorie est
+ * acceptee des qu'elle PORTE le type PRESTATAIRE ; pas d'import du module
+ * Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API Platform.
+ */
+ #[Assert\Callback]
+ public function validateCategoryType(ExecutionContextInterface $context): void
+ {
+ foreach ($this->categories as $category) {
+ if ($category instanceof CategoryInterface
+ && !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) {
+ $context->buildViolation('Type de catégorie non autorisé (PRESTATAIRE attendu).')
+ ->atPath('categories')
+ ->addViolation()
+ ;
+
+ return;
+ }
+ }
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getProvider(): ?Provider
+ {
+ return $this->provider;
+ }
+
+ public function setProvider(?Provider $provider): static
+ {
+ $this->provider = $provider;
+
+ return $this;
+ }
+
+ public function getCountry(): string
+ {
+ return $this->country;
+ }
+
+ public function setCountry(string $country): static
+ {
+ $this->country = $country;
+
+ return $this;
+ }
+
+ public function getPostalCode(): ?string
+ {
+ return $this->postalCode;
+ }
+
+ public function setPostalCode(?string $postalCode): static
+ {
+ $this->postalCode = $postalCode;
+
+ return $this;
+ }
+
+ public function getCity(): ?string
+ {
+ return $this->city;
+ }
+
+ public function setCity(?string $city): static
+ {
+ $this->city = $city;
+
+ return $this;
+ }
+
+ public function getStreet(): ?string
+ {
+ return $this->street;
+ }
+
+ public function setStreet(?string $street): static
+ {
+ $this->street = $street;
+
+ return $this;
+ }
+
+ public function getStreetComplement(): ?string
+ {
+ return $this->streetComplement;
+ }
+
+ public function setStreetComplement(?string $streetComplement): static
+ {
+ $this->streetComplement = $streetComplement;
+
+ return $this;
+ }
+
+ public function getPosition(): int
+ {
+ return $this->position;
+ }
+
+ public function setPosition(int $position): static
+ {
+ $this->position = $position;
+
+ return $this;
+ }
+
+ /** @return Collection */
+ public function getSites(): Collection
+ {
+ return $this->sites;
+ }
+
+ public function addSite(SiteInterface $site): static
+ {
+ if (!$this->sites->contains($site)) {
+ $this->sites->add($site);
+ }
+
+ return $this;
+ }
+
+ public function removeSite(SiteInterface $site): static
+ {
+ $this->sites->removeElement($site);
+
+ return $this;
+ }
+
+ /** @return Collection */
+ public function getContacts(): Collection
+ {
+ return $this->contacts;
+ }
+
+ public function addContact(ProviderContact $contact): static
+ {
+ if (!$this->contacts->contains($contact)) {
+ $this->contacts->add($contact);
+ }
+
+ return $this;
+ }
+
+ public function removeContact(ProviderContact $contact): static
+ {
+ $this->contacts->removeElement($contact);
+
+ return $this;
+ }
+
+ /** @return Collection */
+ public function getCategories(): Collection
+ {
+ return $this->categories;
+ }
+
+ public function addCategory(CategoryInterface $category): static
+ {
+ if (!$this->categories->contains($category)) {
+ $this->categories->add($category);
+ }
+
+ return $this;
+ }
+
+ public function removeCategory(CategoryInterface $category): static
+ {
+ $this->categories->removeElement($category);
+
+ return $this;
+ }
+}
diff --git a/src/Module/Technique/Domain/Entity/ProviderContact.php b/src/Module/Technique/Domain/Entity/ProviderContact.php
new file mode 100644
index 0000000..9511f6b
--- /dev/null
+++ b/src/Module/Technique/Domain/Entity/ProviderContact.php
@@ -0,0 +1,241 @@
+ ['provider:item:read']],
+ // Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre.
+ provider: ProviderSubResourceItemProvider::class,
+ ),
+ new Post(
+ uriTemplate: '/providers/{providerId}/contacts',
+ uriVariables: [
+ 'providerId' => new Link(fromClass: Provider::class, toProperty: 'provider'),
+ ],
+ // read:false : pas de stade lecture du parent. Le Link toProperty
+ // resoudrait l'enfant (SELECT ProviderContact ... WHERE provider = :id)
+ // et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
+ // manuellement par ProviderContactProcessor::linkParent (404 si absent).
+ read: false,
+ security: "is_granted('technique.providers.manage')",
+ normalizationContext: ['groups' => ['provider:item:read']],
+ denormalizationContext: ['groups' => ['provider:write:contacts']],
+ processor: ProviderContactProcessor::class,
+ ),
+ new Patch(
+ security: "is_granted('technique.providers.manage')",
+ normalizationContext: ['groups' => ['provider:item:read']],
+ denormalizationContext: ['groups' => ['provider:write:contacts']],
+ provider: ProviderSubResourceItemProvider::class,
+ processor: ProviderContactProcessor::class,
+ ),
+ new Delete(
+ security: "is_granted('technique.providers.manage')",
+ provider: ProviderSubResourceItemProvider::class,
+ processor: ProviderContactProcessor::class,
+ ),
+ ],
+)]
+#[ORM\Entity]
+#[ORM\Table(name: 'provider_contact')]
+#[ORM\Index(name: 'idx_provider_contact_provider', columns: ['provider_id'])]
+#[Auditable]
+class ProviderContact implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
+{
+ use TimestampableBlamableTrait;
+
+ #[ORM\Id]
+ #[ORM\GeneratedValue]
+ #[ORM\Column]
+ #[Groups(['provider:item:read'])]
+ private ?int $id = null;
+
+ #[ORM\ManyToOne(targetEntity: Provider::class, inversedBy: 'contacts')]
+ #[ORM\JoinColumn(name: 'provider_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
+ private ?Provider $provider = null;
+
+ // RG-3.04 : au moins un champ du contact renseigne (CHECK BDD + Processor). Les
+ // champs restent nullable au niveau ORM.
+ #[ORM\Column(length: 120, nullable: true)]
+ #[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Groups(['provider:item:read', 'provider:write:contacts'])]
+ private ?string $firstName = null;
+
+ #[ORM\Column(length: 120, nullable: true)]
+ #[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Groups(['provider:item:read', 'provider:write:contacts'])]
+ private ?string $lastName = null;
+
+ #[ORM\Column(length: 120, nullable: true)]
+ #[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Groups(['provider:item:read', 'provider:write:contacts'])]
+ private ?string $jobTitle = null;
+
+ // Pas de validation de format telephone (saisie libre), mais une Assert\Length
+ // calee sur la colonne VARCHAR(20) evite l'erreur Postgres (500 non rattachee au
+ // champ) au profit d'une 422 propre (ERP-107).
+ #[ORM\Column(length: 20, nullable: true)]
+ #[Assert\Length(max: 20, maxMessage: 'Le téléphone ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Groups(['provider:item:read', 'provider:write:contacts'])]
+ private ?string $phonePrimary = null;
+
+ #[ORM\Column(length: 20, nullable: true)]
+ #[Assert\Length(max: 20, maxMessage: 'Le téléphone secondaire ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Groups(['provider:item:read', 'provider:write:contacts'])]
+ private ?string $phoneSecondary = null;
+
+ #[ORM\Column(length: 180, nullable: true)]
+ #[Assert\Email(message: 'L\'adresse email n\'est pas valide.')]
+ #[Assert\Length(max: 180, maxMessage: 'L\'email ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Groups(['provider:item:read', 'provider:write:contacts'])]
+ private ?string $email = null;
+
+ // Ordre d'affichage du contact (gere serveur, non expose au M3).
+ #[ORM\Column(options: ['default' => 0])]
+ private int $position = 0;
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getProvider(): ?Provider
+ {
+ return $this->provider;
+ }
+
+ public function setProvider(?Provider $provider): static
+ {
+ $this->provider = $provider;
+
+ return $this;
+ }
+
+ public function getFirstName(): ?string
+ {
+ return $this->firstName;
+ }
+
+ public function setFirstName(?string $firstName): static
+ {
+ $this->firstName = $firstName;
+
+ return $this;
+ }
+
+ public function getLastName(): ?string
+ {
+ return $this->lastName;
+ }
+
+ public function setLastName(?string $lastName): static
+ {
+ $this->lastName = $lastName;
+
+ return $this;
+ }
+
+ public function getJobTitle(): ?string
+ {
+ return $this->jobTitle;
+ }
+
+ public function setJobTitle(?string $jobTitle): static
+ {
+ $this->jobTitle = $jobTitle;
+
+ return $this;
+ }
+
+ public function getPhonePrimary(): ?string
+ {
+ return $this->phonePrimary;
+ }
+
+ public function setPhonePrimary(?string $phonePrimary): static
+ {
+ $this->phonePrimary = $phonePrimary;
+
+ return $this;
+ }
+
+ public function getPhoneSecondary(): ?string
+ {
+ return $this->phoneSecondary;
+ }
+
+ public function setPhoneSecondary(?string $phoneSecondary): static
+ {
+ $this->phoneSecondary = $phoneSecondary;
+
+ return $this;
+ }
+
+ public function getEmail(): ?string
+ {
+ return $this->email;
+ }
+
+ public function setEmail(?string $email): static
+ {
+ $this->email = $email;
+
+ return $this;
+ }
+
+ public function getPosition(): int
+ {
+ return $this->position;
+ }
+
+ public function setPosition(int $position): static
+ {
+ $this->position = $position;
+
+ return $this;
+ }
+}
diff --git a/src/Module/Technique/Domain/Entity/ProviderOwnedInterface.php b/src/Module/Technique/Domain/Entity/ProviderOwnedInterface.php
new file mode 100644
index 0000000..a6a7303
--- /dev/null
+++ b/src/Module/Technique/Domain/Entity/ProviderOwnedInterface.php
@@ -0,0 +1,16 @@
+ ['provider:read:accounting']],
+ // Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre.
+ provider: ProviderSubResourceItemProvider::class,
+ ),
+ new Post(
+ uriTemplate: '/providers/{providerId}/ribs',
+ uriVariables: [
+ 'providerId' => new Link(fromClass: Provider::class, toProperty: 'provider'),
+ ],
+ // read:false : pas de stade lecture du parent. Le Link toProperty
+ // resoudrait l'enfant (SELECT ProviderRib ... WHERE provider = :id) et
+ // casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
+ // manuellement par ProviderRibProcessor::linkParent (404 si absent).
+ read: false,
+ security: "is_granted('technique.providers.accounting.manage')",
+ normalizationContext: ['groups' => ['provider:read:accounting']],
+ denormalizationContext: ['groups' => ['provider:write:accounting']],
+ processor: ProviderRibProcessor::class,
+ ),
+ new Patch(
+ security: "is_granted('technique.providers.accounting.manage')",
+ normalizationContext: ['groups' => ['provider:read:accounting']],
+ denormalizationContext: ['groups' => ['provider:write:accounting']],
+ provider: ProviderSubResourceItemProvider::class,
+ processor: ProviderRibProcessor::class,
+ ),
+ new Delete(
+ security: "is_granted('technique.providers.accounting.manage')",
+ provider: ProviderSubResourceItemProvider::class,
+ processor: ProviderRibProcessor::class,
+ ),
+ ],
+)]
+#[ORM\Entity]
+#[ORM\Table(name: 'provider_rib')]
+#[ORM\Index(name: 'idx_provider_rib_provider', columns: ['provider_id'])]
+#[Auditable]
+class ProviderRib implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
+{
+ use TimestampableBlamableTrait;
+
+ #[ORM\Id]
+ #[ORM\GeneratedValue]
+ #[ORM\Column]
+ #[Groups(['provider:read:accounting'])]
+ private ?int $id = null;
+
+ #[ORM\ManyToOne(targetEntity: Provider::class, inversedBy: 'ribs')]
+ #[ORM\JoinColumn(name: 'provider_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
+ private ?Provider $provider = null;
+
+ #[ORM\Column(length: 120)]
+ #[Assert\NotBlank(message: 'Le libellé du RIB est obligatoire.', normalizer: 'trim')]
+ #[Assert\Length(max: 120, maxMessage: 'Le libellé ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
+ #[Groups(['provider:read:accounting', 'provider:write:accounting'])]
+ private ?string $label = null;
+
+ // Bic/Iban bornent deja le format (et donc la longueur) : pas de Length redondant
+ // calee sur la colonne (auto-exempte du miroir ERP-107). ibanPropertyPath :
+ // controle croise — le pays du BIC (positions 5-6) doit correspondre au pays de
+ // l'IBAN (positions 1-2). Violation portee sur `bic`.
+ #[ORM\Column(length: 20)]
+ #[Assert\NotBlank(message: 'Le BIC est obligatoire.', normalizer: 'trim')]
+ #[Assert\Bic(
+ message: 'Le BIC n\'est pas valide.',
+ ibanPropertyPath: 'iban',
+ ibanMessage: 'Le BIC ne correspond pas au pays de l\'IBAN.',
+ )]
+ #[Groups(['provider:read:accounting', 'provider:write:accounting'])]
+ private ?string $bic = null;
+
+ #[ORM\Column(length: 34)]
+ #[Assert\NotBlank(message: 'L\'IBAN est obligatoire.', normalizer: 'trim')]
+ #[Assert\Iban(message: 'L\'IBAN n\'est pas valide.')]
+ #[Groups(['provider:read:accounting', 'provider:write:accounting'])]
+ private ?string $iban = null;
+
+ // Ordre d'affichage du RIB (gere serveur, non expose au M3).
+ #[ORM\Column(options: ['default' => 0])]
+ private int $position = 0;
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getProvider(): ?Provider
+ {
+ return $this->provider;
+ }
+
+ public function setProvider(?Provider $provider): static
+ {
+ $this->provider = $provider;
+
+ return $this;
+ }
+
+ public function getLabel(): ?string
+ {
+ return $this->label;
+ }
+
+ public function setLabel(string $label): static
+ {
+ $this->label = $label;
+
+ return $this;
+ }
+
+ public function getBic(): ?string
+ {
+ return $this->bic;
+ }
+
+ public function setBic(string $bic): static
+ {
+ $this->bic = $bic;
+
+ return $this;
+ }
+
+ public function getIban(): ?string
+ {
+ return $this->iban;
+ }
+
+ public function setIban(string $iban): static
+ {
+ $this->iban = $iban;
+
+ return $this;
+ }
+
+ public function getPosition(): int
+ {
+ return $this->position;
+ }
+
+ public function setPosition(int $position): static
+ {
+ $this->position = $position;
+
+ return $this;
+ }
+}
diff --git a/src/Module/Technique/Domain/Repository/ProviderRepositoryInterface.php b/src/Module/Technique/Domain/Repository/ProviderRepositoryInterface.php
new file mode 100644
index 0000000..49f5fb1
--- /dev/null
+++ b/src/Module/Technique/Domain/Repository/ProviderRepositoryInterface.php
@@ -0,0 +1,98 @@
+ uniquement les archives (is_archived = true) ;
+ * - sinon $includeArchived = true -> actifs + archives (echappatoire) ;
+ * - sinon (defaut) -> uniquement les actifs (is_archived = false).
+ * $archivedOnly a la priorite sur $includeArchived.
+ * - Tri par defaut : companyName ASC (RG-3.16).
+ * - $search : recherche fuzzy insensible a la casse sur companyName + les
+ * contacts lies (firstName / lastName / email) via sous-requete.
+ * Metacaracteres LIKE echappes. Ignore si null/vide.
+ * - $categoryCodes : restreint aux prestataires possedant au moins une
+ * categorie dont le code est dans la liste (OR). Liste vide = pas de filtre.
+ * - $siteIds : restreint aux prestataires rattaches a l'un des sites donnes
+ * (OR — RG-3.03, relation DIRECTE provider.sites). Liste vide = pas de filtre.
+ *
+ * Filtrage centralise ICI (et non dans le provider/controller) pour que la
+ * liste paginee et l'export partagent strictement la meme logique de selection
+ * (miroir M2).
+ *
+ * Contrat = SELECTION uniquement (filtres + tri). Aucun fetch-join to-many :
+ * l'hydratation des collections affichees est deleguee a
+ * {@see self::hydrateListCollections()} pour ne pas imposer le cout d'un
+ * produit cartesien aux chemins non pagines (§ 2.12, cf. M1/ERP-100, M2).
+ *
+ * NB : le cloisonnement par site pilote par l'utilisateur (RG-3.17, § 2.13) est
+ * applique en AMONT par le ProviderProvider (ERP-134), pas par ce QueryBuilder
+ * (qui ne connait pas l'user courant).
+ *
+ * @param list $categoryCodes
+ * @param list $siteIds
+ */
+ public function createListQueryBuilder(
+ bool $includeArchived = false,
+ ?string $search = null,
+ array $categoryCodes = [],
+ array $siteIds = [],
+ bool $archivedOnly = false,
+ ): QueryBuilder;
+
+ /**
+ * Hydrate en lot les collections affichees par le repertoire (categories puis
+ * sites — relation DIRECTE provider.sites, RG-3.03) sur un jeu de prestataires
+ * DEJA charges, via l'identity map Doctrine (memes instances). A appeler apres
+ * une selection bornee (page courante ou jeu d'export) pour eviter le N+1 a la
+ * serialisation, sans imposer de fetch-join au QueryBuilder de selection
+ * (anti N+1, § 2.12).
+ *
+ * Charge les categories et les sites en DEUX requetes distinctes (et non un
+ * double fetch-join) pour ne pas multiplier categories x sites en un seul
+ * produit cartesien.
+ *
+ * @param list $providers
+ */
+ public function hydrateListCollections(array $providers): void;
+
+ /**
+ * Hydrate en lot la collection `contacts` sur un jeu de prestataires DEJA
+ * charges (memes instances via l'identity map). Reservee aux chemins qui ont
+ * besoin du contact principal (export) : la LISTE paginee n'embarque pas les
+ * contacts (§ 2.12), d'ou une methode dediee plutot qu'une passe supplementaire
+ * dans {@see self::hydrateListCollections()}.
+ *
+ * @param list $providers
+ */
+ public function hydrateContacts(array $providers): void;
+}
diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/Serializer/ProviderReadGroupContextBuilder.php b/src/Module/Technique/Infrastructure/ApiPlatform/Serializer/ProviderReadGroupContextBuilder.php
new file mode 100644
index 0000000..0ba501f
--- /dev/null
+++ b/src/Module/Technique/Infrastructure/ApiPlatform/Serializer/ProviderReadGroupContextBuilder.php
@@ -0,0 +1,76 @@
+decorated->createFromRequest($request, $normalization, $extractedAttributes);
+
+ // Uniquement en lecture, sur la ressource Provider, avec la permission.
+ if (!$normalization) {
+ return $context;
+ }
+
+ if (Provider::class !== ($context['resource_class'] ?? null)) {
+ return $context;
+ }
+
+ if (!$this->security->isGranted('technique.providers.accounting.view')) {
+ return $context;
+ }
+
+ $groups = $context['groups'] ?? [];
+ if (!in_array('provider:read:accounting', $groups, true)) {
+ $groups[] = 'provider:read:accounting';
+ }
+ $context['groups'] = $groups;
+
+ return $context;
+ }
+}
diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderAddressProcessor.php b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderAddressProcessor.php
new file mode 100644
index 0000000..653cf91
--- /dev/null
+++ b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderAddressProcessor.php
@@ -0,0 +1,220 @@
+= 1 site,
+ * Assert\Count), RG-3.09 (categorie de type PRESTATAIRE, Assert\Callback
+ * ProviderAddress::validateCategoryType).
+ * - DELETE : aucune regle metier specifique (suppression physique directe).
+ *
+ * La security de l'operation (technique.providers.manage) est appliquee par API
+ * Platform en amont, de meme que la validation Symfony des contraintes d'attribut.
+ *
+ * @implements ProcessorInterface
+ */
+final class ProviderAddressProcessor implements ProcessorInterface
+{
+ private const string PERM_BYPASS_SCOPE = 'sites.bypass_scope';
+
+ public function __construct(
+ #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
+ private readonly ProcessorInterface $persistProcessor,
+ #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
+ private readonly ProcessorInterface $removeProcessor,
+ private readonly Security $security,
+ private readonly RequestStack $requestStack,
+ private readonly EntityManagerInterface $em,
+ private readonly ProviderSiteScopeChecker $scopeChecker,
+ ) {}
+
+ public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
+ {
+ if (!$data instanceof ProviderAddress) {
+ return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
+ }
+
+ if ($operation instanceof DeleteOperationInterface) {
+ return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
+ }
+
+ $this->linkParent($data, $uriVariables);
+ $this->guardSiteScope($data);
+
+ return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
+ }
+
+ /**
+ * Rattache l'adresse au prestataire parent de la sous-ressource POST
+ * (/providers/{providerId}/addresses) : la relation n'est pas peuplee
+ * automatiquement par le Link sur une ecriture. Sur PATCH, no-op.
+ */
+ private function linkParent(ProviderAddress $address, array $uriVariables): void
+ {
+ if (null !== $address->getProvider()) {
+ return;
+ }
+
+ $providerId = $uriVariables['providerId'] ?? null;
+ if (null === $providerId) {
+ return;
+ }
+
+ $provider = $providerId instanceof Provider
+ ? $providerId
+ : $this->em->getRepository(Provider::class)->find($providerId);
+
+ // read:false sur le POST : sans stade lecture, un parent introuvable n'est
+ // plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
+ // contrainte provider_id NOT NULL).
+ if (!$provider instanceof Provider) {
+ throw new NotFoundHttpException('Prestataire introuvable.');
+ }
+
+ // Cloisonnement par site (§ 2.13 / RG-3.17) : interdiction de creer une
+ // sous-ressource sur un prestataire hors du perimetre de l'user -> 404
+ // (anti-enumeration). Distinct du guardSiteScope ci-dessous, qui cloisonne
+ // les sites ATTACHES a l'adresse (et non l'acces au prestataire parent).
+ $this->scopeChecker->assertInScope($provider);
+
+ $address->setProvider($provider);
+ }
+
+ /**
+ * RG-3.05 / § 2.13 (cloisonnement d'ECRITURE — decision Matthieu 11/06) : un
+ * user SANS `sites.bypass_scope` ne peut attacher a CHAQUE adresse que des
+ * sites figurant dans ses propres `user_site`. Tout site hors perimetre -> 422
+ * sur `sites` (propertyPath consommable inline, convention ERP-101). Un user
+ * `bypass_scope` (Admin) peut attacher n'importe quel site. Miroir de
+ * ProviderProcessor::guardSiteScope, applique ici a la sous-ressource adresse.
+ *
+ * Ne joue que si `sites` est effectivement soumis : POST (entite non geree,
+ * sites obligatoires RG-3.05) ou PATCH portant la cle `sites`. Un PATCH qui ne
+ * touche pas aux sites n'est pas re-valide (les sites ont ete cloisonnes a leur
+ * pose). La validation porte sur l'ETAT RESULTANT (address.getSites()).
+ */
+ private function guardSiteScope(ProviderAddress $address): void
+ {
+ if ($this->security->isGranted(self::PERM_BYPASS_SCOPE)) {
+ return;
+ }
+
+ // sites non soumis sur un PATCH : rien a cloisonner.
+ if ($this->em->contains($address) && !in_array('sites', $this->payloadKeys(), true)) {
+ return;
+ }
+
+ $allowedSiteIds = $this->currentUserSiteIds();
+
+ foreach ($address->getSites() as $site) {
+ if (!$site instanceof SiteInterface) {
+ continue;
+ }
+ if (!in_array($site->getId(), $allowedSiteIds, true)) {
+ $this->throwSitesViolation($address);
+ }
+ }
+ }
+
+ /**
+ * Identifiants des sites rattaches a l'utilisateur courant (`user_site`).
+ * Vide si pas d'user authentifie (cas defensif : la security d'operation
+ * garantit deja l'authentification).
+ *
+ * @return list
+ */
+ private function currentUserSiteIds(): array
+ {
+ $user = $this->security->getUser();
+ if (!$user instanceof User) {
+ return [];
+ }
+
+ $ids = [];
+ foreach ($user->getSites() as $site) {
+ if ($site instanceof SiteInterface && null !== $site->getId()) {
+ $ids[] = $site->getId();
+ }
+ }
+
+ return $ids;
+ }
+
+ /**
+ * Cles de premier niveau effectivement envoyees par le client (payload JSON
+ * brut). Pour un PATCH merge-patch+json, ce sont les seuls champs modifies.
+ * Corps vide ou JSON invalide -> aucune cle.
+ *
+ * @return list
+ */
+ private function payloadKeys(): array
+ {
+ $request = $this->requestStack->getCurrentRequest();
+ if (null === $request) {
+ return [];
+ }
+
+ $content = $request->getContent();
+ if ('' === $content) {
+ return [];
+ }
+
+ try {
+ $decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
+ } catch (JsonException) {
+ return [];
+ }
+
+ return is_array($decoded) ? array_values(array_filter(array_keys($decoded), 'is_string')) : [];
+ }
+
+ /**
+ * Leve une 422 portant une violation unique sur `sites` — meme rendu Hydra que
+ * les contraintes Symfony, consommable inline par extractApiViolations (ERP-101).
+ *
+ * @return never
+ */
+ private function throwSitesViolation(ProviderAddress $address): void
+ {
+ $violations = new ConstraintViolationList();
+ $violations->add(new ConstraintViolation(
+ 'Vous ne pouvez rattacher que des sites auxquels vous avez accès.',
+ null,
+ [],
+ $address,
+ 'sites',
+ null,
+ ));
+
+ throw new ValidationException($violations);
+ }
+}
diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderContactProcessor.php b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderContactProcessor.php
new file mode 100644
index 0000000..10674f6
--- /dev/null
+++ b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderContactProcessor.php
@@ -0,0 +1,144 @@
+
+ */
+final class ProviderContactProcessor implements ProcessorInterface
+{
+ public function __construct(
+ #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
+ private readonly ProcessorInterface $persistProcessor,
+ #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
+ private readonly ProcessorInterface $removeProcessor,
+ private readonly ProviderFieldNormalizer $normalizer,
+ private readonly EntityManagerInterface $em,
+ private readonly ProviderSiteScopeChecker $scopeChecker,
+ ) {}
+
+ public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
+ {
+ if (!$data instanceof ProviderContact) {
+ return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
+ }
+
+ if ($operation instanceof DeleteOperationInterface) {
+ return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
+ }
+
+ $this->linkParent($data, $uriVariables);
+ $this->normalize($data);
+ $this->validateName($data);
+
+ return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
+ }
+
+ /**
+ * Rattache le contact au prestataire parent de la sous-ressource POST
+ * (/providers/{providerId}/contacts). La relation n'est pas peuplee
+ * automatiquement par le Link sur une operation d'ecriture : on resout le
+ * parent depuis l'uri variable. Sur PATCH (entite existante), le prestataire
+ * est deja present -> no-op.
+ */
+ private function linkParent(ProviderContact $contact, array $uriVariables): void
+ {
+ if (null !== $contact->getProvider()) {
+ return;
+ }
+
+ $providerId = $uriVariables['providerId'] ?? null;
+ if (null === $providerId) {
+ return;
+ }
+
+ $provider = $providerId instanceof Provider
+ ? $providerId
+ : $this->em->getRepository(Provider::class)->find($providerId);
+
+ // read:false sur le POST : sans stade lecture, un parent introuvable n'est
+ // plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
+ // contrainte provider_id NOT NULL).
+ if (!$provider instanceof Provider) {
+ throw new NotFoundHttpException('Prestataire introuvable.');
+ }
+
+ // Cloisonnement par site (§ 2.13 / RG-3.17) : interdiction de creer une
+ // sous-ressource sur un prestataire hors du perimetre de l'user -> 404
+ // (anti-enumeration, coherent avec le detail Provider garde en 404).
+ $this->scopeChecker->assertInScope($provider);
+
+ $contact->setProvider($provider);
+ }
+
+ /**
+ * Normalisation serveur (RG-3.11). Toutes les methodes du normalizer sont
+ * null-safe : une chaine vide apres trim devient null.
+ */
+ private function normalize(ProviderContact $contact): void
+ {
+ $contact->setFirstName($this->normalizer->normalizePersonName($contact->getFirstName()));
+ $contact->setLastName($this->normalizer->normalizePersonName($contact->getLastName()));
+ $contact->setJobTitle($this->normalizer->normalizeText($contact->getJobTitle()));
+ $contact->setPhonePrimary($this->normalizer->normalizePhone($contact->getPhonePrimary()));
+ $contact->setPhoneSecondary($this->normalizer->normalizePhone($contact->getPhoneSecondary()));
+ $contact->setEmail($this->normalizer->normalizeEmail($contact->getEmail()));
+ }
+
+ /**
+ * RG-3.04 : un bloc Contact exige au moins le prenom OU le nom (aligne sur le
+ * M1/M2 — un contact se materialise par son nom ; fonction / telephone / email
+ * seuls ne suffisent pas). Double garde avec le CHECK BDD chk_provider_contact_name
+ * — leve une 422 propre rattachee au champ `firstName` plutot qu'une 500 SQL.
+ * Joue apres normalisation (les chaines vides sont deja ramenees a null).
+ */
+ private function validateName(ProviderContact $contact): void
+ {
+ if (null === $contact->getFirstName() && null === $contact->getLastName()) {
+ $violations = new ConstraintViolationList();
+ $violations->add(new ConstraintViolation(
+ 'Le prénom ou le nom du contact est obligatoire.',
+ null,
+ [],
+ $contact,
+ 'firstName',
+ null,
+ ));
+
+ throw new ValidationException($violations);
+ }
+ }
+}
diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderProcessor.php b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderProcessor.php
new file mode 100644
index 0000000..8df9e9b
--- /dev/null
+++ b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderProcessor.php
@@ -0,0 +1,590 @@
+ le prestataire est minimal ;
+ * - le formulaire principal porte `sites` (M2M provider_site, RG-3.03), soumis au
+ * CLOISONNEMENT D'ECRITURE par site (RG-3.17, § 2.13) : un user sans
+ * `sites.bypass_scope` ne peut attacher que les sites de ses `user_site`.
+ *
+ * Sequence (POST / PATCH) :
+ * 1. Autorisation additionnelle par groupe d'onglet (mode strict RG-3.15). La
+ * security d'operation du PATCH est elargie a `manage` OU `accounting.manage`
+ * pour laisser entrer le role Compta ; ce processor re-gate alors finement :
+ * - champ comptable modifie dans le payload -> exige accounting.manage (403) ;
+ * - champ main (companyName / categories / sites) modifie -> exige manage
+ * (guardManage, 403) : empeche Compta d'editer un autre onglet ;
+ * - champ isArchived dans le payload -> exige archive (RG-3.13, 403) et
+ * interdit toute autre modification dans la meme requete (RG-3.13, 422).
+ * 2. Cloisonnement d'ECRITURE des sites (RG-3.17 / RG-3.03) : tout site attache
+ * hors des `user_site` de l'appelant non-bypass -> 422 sur `sites`.
+ * 3. Normalisation serveur (RG-3.11) via ProviderFieldNormalizer (companyName).
+ * 4. Pose / retrait de archivedAt (RG-3.13 true=now, RG-3.14 false=null).
+ * 5. Persistance via le persist_processor Doctrine, avec traduction des
+ * collisions d'unicite en 409 (RG-3.10 doublon de nom ; RG-3.14 conflit de
+ * restauration).
+ *
+ * Les RG inter-champs RG-3.07 (Virement -> banque), RG-3.08 (LCR -> >= 1 RIB) et
+ * RG-3.09 (categorie de type PRESTATAIRE) sont portees par des Assert\Callback +
+ * ->atPath() sur les entites Provider / ProviderAddress (jouees par API Platform
+ * AVANT ce processor), pour que chaque 422 porte un propertyPath consommable par
+ * extractApiViolations (mapping inline, pas un toast — convention ERP-101). Le 409
+ * sur DELETE du dernier RIB en LCR (volet ecriture de RG-3.08) est porte par le
+ * ProviderRibProcessor (ERP-135).
+ *
+ * @implements ProcessorInterface
+ */
+final class ProviderProcessor implements ProcessorInterface
+{
+ /** Champs de l'onglet principal (groupe provider:write:main). */
+ private const array MAIN_FIELDS = [
+ 'companyName', 'categories', 'sites',
+ ];
+
+ /** Champs de l'onglet Comptabilite (groupe provider:write:accounting). */
+ private const array ACCOUNTING_FIELDS = [
+ 'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay',
+ 'paymentType', 'bank',
+ ];
+
+ /**
+ * Champs comptables obligatoires a la validation complete de l'onglet
+ * (spec-front M3 § Onglet Comptabilite — miroir M1/M2). bank est exclu :
+ * conditionnel (RG-3.07).
+ */
+ private const array ACCOUNTING_REQUIRED_FIELDS = [
+ 'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', 'paymentType',
+ ];
+
+ /** Champ d'archivage (groupe provider:write:archive). */
+ private const string ARCHIVE_FIELD = 'isArchived';
+
+ private const string PERM_MANAGE = 'technique.providers.manage';
+ private const string PERM_ACCOUNTING_MANAGE = 'technique.providers.accounting.manage';
+ private const string PERM_ARCHIVE = 'technique.providers.archive';
+
+ private const string PERM_BYPASS_SCOPE = 'sites.bypass_scope';
+
+ /**
+ * Memoisation du dernier corps de requete decode, clos par le contenu brut
+ * (cf. SupplierProcessor) : payloadKeys() est appele plusieurs fois par requete,
+ * on evite de rejouer json_decode. Cle = contenu lui-meme, calcul pur -> aucune
+ * fuite entre requetes sur ce service partage.
+ */
+ private ?string $decodedContent = null;
+
+ /** @var list Cles de premier niveau correspondant au corps memoise. */
+ private array $decodedPayloadKeys = [];
+
+ public function __construct(
+ #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
+ private readonly ProcessorInterface $persistProcessor,
+ private readonly ProviderFieldNormalizer $normalizer,
+ private readonly Security $security,
+ private readonly RequestStack $requestStack,
+ private readonly EntityManagerInterface $em,
+ private readonly ProviderAccountingCompletenessValidator $accountingValidator,
+ ) {}
+
+ public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
+ {
+ if (!$data instanceof Provider) {
+ return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
+ }
+
+ // Reinitialisation de la memoisation du payload : le service est partage
+ // (stateful), on repart du corps de LA requete courante.
+ $this->decodedContent = null;
+ $this->decodedPayloadKeys = [];
+
+ $writableKeys = $this->writablePayloadKeys();
+
+ $isArchiveRequest = $this->guardArchive($data, $writableKeys);
+ $this->guardAccounting($data);
+ $this->guardSiteScope($data);
+
+ $this->normalize($data);
+
+ // guardManage apres normalize : la comparaison « change vs etat persiste »
+ // des champs texte (companyName) se fait sur des valeurs normalisees des
+ // deux cotes (l'etat persiste l'a deja ete).
+ $this->guardManage($data);
+
+ // Completude de l'onglet Comptabilite (apres normalize : les chaines vides
+ // sont deja ramenees a null). Joue uniquement sur une soumission d'onglet.
+ $this->validateAccountingCompleteness($data);
+
+ try {
+ return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
+ } catch (UniqueConstraintViolationException $e) {
+ // Le seul index unique partiel est uq_provider_company_name_active
+ // (LOWER(company_name) parmi non-archives/non-deletes — § 2.6).
+ if ($isArchiveRequest && false === $data->isArchived()) {
+ // RG-3.14 : restauration en conflit avec un homonyme actif.
+ throw new ConflictHttpException(
+ 'Restauration impossible : un autre prestataire a pris le nom entre-temps.',
+ $e,
+ );
+ }
+
+ // RG-3.10 : doublon de nom de societe.
+ throw new ConflictHttpException(
+ sprintf('Un prestataire nommé "%s" existe déjà.', (string) $data->getCompanyName()),
+ $e,
+ );
+ }
+ }
+
+ /**
+ * RG-3.13 / RG-3.14 : si le payload bascule reellement isArchived, exige la
+ * permission archive (403), interdit toute autre modification (422) et
+ * pose/retire archivedAt. Retourne true si la requete est une requete
+ * d'archivage. Restreint a la mise a jour d'un prestataire existant ET au seul
+ * cas ou isArchived change vraiment (cf. SupplierProcessor).
+ *
+ * @param list $writableKeys cles ecrivables du payload (hors @* et champs inconnus)
+ */
+ private function guardArchive(Provider $data, array $writableKeys): bool
+ {
+ // POST / entite non geree : l'archivage est une action de mise a jour.
+ if (!$this->em->contains($data)) {
+ return false;
+ }
+
+ // isArchived inchange par rapport a l'etat persiste : pas une requete
+ // d'archivage (cas du PATCH representation complete).
+ if (!$this->fieldChanged($data, 'isArchived', $data->isArchived())) {
+ return false;
+ }
+
+ if (!$this->security->isGranted(self::PERM_ARCHIVE)) {
+ throw new AccessDeniedHttpException(sprintf(
+ 'Le champ "%s" requiert la permission "%s".',
+ self::ARCHIVE_FIELD,
+ self::PERM_ARCHIVE,
+ ));
+ }
+
+ // RG-3.13 : une requete d'archivage ne modifie aucun autre champ ecrivable.
+ if ([] !== array_diff($writableKeys, [self::ARCHIVE_FIELD])) {
+ throw new UnprocessableEntityHttpException(
+ 'Une requête d\'archivage ne peut modifier aucun autre champ que "isArchived".',
+ );
+ }
+
+ // RG-3.13 (true -> now) / RG-3.14 (false -> null).
+ $data->setArchivedAt($data->isArchived() ? new DateTimeImmutable() : null);
+
+ return true;
+ }
+
+ /**
+ * RG-3.15 : la modification effective d'un champ comptable exige
+ * accounting.manage, sinon 403 sur l'ensemble du payload (mode strict, pas de
+ * filtrage silencieux). On ne gate que si un champ change reellement par
+ * rapport a l'etat persiste (POST/PATCH renvoyant des champs comptables
+ * inchanges ne declenche pas de 403 parasite). Le message precise le premier
+ * champ fautif.
+ */
+ private function guardAccounting(Provider $data): void
+ {
+ $changed = $this->changedAccountingFields($data);
+
+ if ([] === $changed) {
+ return;
+ }
+
+ if (!$this->security->isGranted(self::PERM_ACCOUNTING_MANAGE)) {
+ throw new AccessDeniedHttpException(sprintf(
+ 'Le champ "%s" requiert la permission "%s".',
+ $changed[0],
+ self::PERM_ACCOUNTING_MANAGE,
+ ));
+ }
+ }
+
+ /**
+ * § 2.9 / RG-3.15 : la modification effective d'un champ « metier » (onglet
+ * principal : companyName / categories / sites) exige
+ * `technique.providers.manage`. Sans cette permission -> 403 sur l'ensemble du
+ * payload (mode strict, miroir de guardAccounting). C'est ce qui empeche le
+ * role Compta — qui entre dans le PATCH via `accounting.manage` (security
+ * d'operation elargie) — d'editer autre chose que l'onglet Comptabilite.
+ *
+ * Ne s'applique qu'aux mises a jour (entite geree) : la creation (POST) est
+ * deja gardee par la security d'operation `manage`.
+ */
+ private function guardManage(Provider $data): void
+ {
+ if (!$this->em->contains($data)) {
+ return;
+ }
+
+ $changed = $this->changedBusinessFields($data);
+
+ if ([] === $changed) {
+ return;
+ }
+
+ if (!$this->security->isGranted(self::PERM_MANAGE)) {
+ throw new AccessDeniedHttpException(sprintf(
+ 'Le champ "%s" requiert la permission "%s".',
+ $changed[0],
+ self::PERM_MANAGE,
+ ));
+ }
+ }
+
+ /**
+ * RG-3.17 / RG-3.03 (cloisonnement d'ECRITURE — § 2.13) : un user SANS
+ * `sites.bypass_scope` ne peut attacher au prestataire que des sites figurant
+ * dans ses propres `user_site`. Tout site hors perimetre -> 422 sur `sites`
+ * (propertyPath consommable inline, convention ERP-101). Un user `bypass_scope`
+ * (Admin auto) peut attacher n'importe quel site.
+ *
+ * Interaction avec SiteCollectionScopedExtension (module Sites) : pour un user
+ * sans `sites.bypass_scope` NI `sites.read_ref`, la resolution de l'IRI de site
+ * hors perimetre echoue DEJA en amont (item Site « introuvable » -> 400
+ * anti-enumeration), avant ce processor. Cette garde reste donc l'enforcement
+ * AUTORITAIRE de RG-3.17 pour le cas particulier d'un user `sites.read_ref`
+ * (qui peut resoudre N'IMPORTE quel site comme referentiel transverse mais ne
+ * doit rattacher que ses propres sites), et une defense en profondeur sinon.
+ *
+ * Ne joue que si `sites` est effectivement soumis : POST (entite non geree,
+ * sites obligatoires RG-3.03) ou PATCH portant la cle `sites`. Un PATCH qui ne
+ * touche pas aux sites n'est pas re-valide (les sites ont ete cloisonnes a leur
+ * pose). La validation porte sur l'ETAT RESULTANT (data.getSites()).
+ */
+ private function guardSiteScope(Provider $data): void
+ {
+ if ($this->security->isGranted(self::PERM_BYPASS_SCOPE)) {
+ return;
+ }
+
+ // sites non soumis sur un PATCH : rien a cloisonner.
+ if ($this->em->contains($data) && !in_array('sites', $this->payloadKeys(), true)) {
+ return;
+ }
+
+ $allowedSiteIds = $this->currentUserSiteIds();
+
+ foreach ($data->getSites() as $site) {
+ if (!$site instanceof SiteInterface) {
+ continue;
+ }
+ if (!in_array($site->getId(), $allowedSiteIds, true)) {
+ $this->throwSitesViolation($data);
+ }
+ }
+ }
+
+ /**
+ * Identifiants des sites rattaches a l'utilisateur courant (`user_site`).
+ * Vide si pas d'user authentifie (cas defensif : la security d'operation
+ * garantit deja l'authentification).
+ *
+ * @return list
+ */
+ private function currentUserSiteIds(): array
+ {
+ $user = $this->security->getUser();
+ if (!$user instanceof User) {
+ return [];
+ }
+
+ $ids = [];
+ foreach ($user->getSites() as $site) {
+ if ($site instanceof SiteInterface && null !== $site->getId()) {
+ $ids[] = $site->getId();
+ }
+ }
+
+ return $ids;
+ }
+
+ /**
+ * Champs « metier » (onglet principal : companyName / categories / sites) dont
+ * la valeur courante differe de l'etat persiste. Scalaires compares par valeur ;
+ * collections M2M (categories / sites) comparees par ensemble d'identifiants
+ * (cf. collectionChanged) — la simple presence dans le payload ne suffit pas,
+ * sous peine de 403 parasite sur un PATCH representation complete.
+ *
+ * @return list
+ */
+ private function changedBusinessFields(Provider $data): array
+ {
+ $changed = [];
+
+ if ($this->fieldChanged($data, 'companyName', $data->getCompanyName())) {
+ $changed[] = 'companyName';
+ }
+
+ if ($this->collectionChanged($data, 'categories', $data->getCategories()->toArray())) {
+ $changed[] = 'categories';
+ }
+
+ if ($this->collectionChanged($data, 'sites', $data->getSites()->toArray())) {
+ $changed[] = 'sites';
+ }
+
+ return $changed;
+ }
+
+ /**
+ * Vrai si une collection M2M (`categories` ou `sites`) differe reellement de
+ * l'etat persiste. Ces collections ne sont pas tracees par
+ * getOriginalEntityData : on compare par identifiants (independamment de
+ * l'ordre) le snapshot de la PersistentCollection (etat charge) a l'etat
+ * courant (apres application du payload). Symetrique des scalaires : seul un
+ * changement effectif compte, pas la simple presence dans le payload.
+ *
+ * - POST / entite non geree : fournir la collection est un acte metier
+ * (branche defensive, guardManage ne s'execute que sur entite geree).
+ * - cle absente du payload (PATCH partiel) : aucun changement.
+ *
+ * @param array $current
+ */
+ private function collectionChanged(Provider $data, string $field, array $current): bool
+ {
+ if (!$this->em->contains($data)) {
+ return true;
+ }
+
+ if (!in_array($field, $this->payloadKeys(), true)) {
+ return false;
+ }
+
+ $collection = 'categories' === $field ? $data->getCategories() : $data->getSites();
+
+ // Hors PersistentCollection (cas limite hors flux PATCH reel) : faute d'etat
+ // persiste comparable, on se rabat sur la presence payload.
+ if (!$collection instanceof PersistentCollection) {
+ return true;
+ }
+
+ return $this->idSet($current) !== $this->idSet($collection->getSnapshot());
+ }
+
+ /**
+ * Ensemble trie des identifiants d'une liste d'entites — pour une comparaison
+ * par valeur independante de l'ordre.
+ *
+ * @param array $entities
+ *
+ * @return list
+ */
+ private function idSet(array $entities): array
+ {
+ $ids = array_map(
+ static fn (object $entity): mixed => method_exists($entity, 'getId')
+ ? $entity->getId()
+ : spl_object_id($entity),
+ array_values($entities),
+ );
+ sort($ids);
+
+ return $ids;
+ }
+
+ /**
+ * Champs comptables dont la valeur courante differe de l'etat persiste. Les
+ * relations (tvaMode, paymentDelay, paymentType, bank) sont comparees par
+ * identite d'objet : l'identity map Doctrine renvoie la meme instance tant que
+ * la reference est inchangee.
+ *
+ * @return list
+ */
+ private function changedAccountingFields(Provider $data): array
+ {
+ $changed = [];
+
+ foreach (self::ACCOUNTING_FIELDS as $field) {
+ $newValue = match ($field) {
+ 'siren' => $data->getSiren(),
+ 'accountNumber' => $data->getAccountNumber(),
+ 'tvaMode' => $data->getTvaMode(),
+ 'nTva' => $data->getNTva(),
+ 'paymentDelay' => $data->getPaymentDelay(),
+ 'paymentType' => $data->getPaymentType(),
+ 'bank' => $data->getBank(),
+ };
+
+ if ($this->fieldChanged($data, $field, $newValue)) {
+ $changed[] = $field;
+ }
+ }
+
+ return $changed;
+ }
+
+ /**
+ * Vrai si la valeur courante d'un champ differe de l'etat persiste. Pour une
+ * entite non geree (creation/POST), l'etat persiste est vide : toute valeur
+ * non-null est alors un changement.
+ */
+ private function fieldChanged(Provider $data, string $field, mixed $newValue): bool
+ {
+ $original = $this->originalData($data);
+
+ return $newValue !== ($original[$field] ?? null);
+ }
+
+ /**
+ * Snapshot des valeurs persistees de l'entite (telles que chargees, avant
+ * application du payload). Vide pour une entite non geree (POST).
+ *
+ * @return array
+ */
+ private function originalData(Provider $data): array
+ {
+ if (!$this->em->contains($data)) {
+ return [];
+ }
+
+ return $this->em->getUnitOfWork()->getOriginalEntityData($data);
+ }
+
+ /**
+ * Normalisation serveur du formulaire principal (RG-3.11). Seul companyName est
+ * porte par le Provider (les champs de contact sont normalises par le processor
+ * de sous-ressource ProviderContact, ticket dedie). Le setter non-nullable n'est
+ * touche que si une valeur est presente, pour ne jamais ecraser l'existant lors
+ * d'un PATCH partiel.
+ */
+ private function normalize(Provider $data): void
+ {
+ if (null !== $data->getCompanyName()) {
+ $data->setCompanyName((string) $this->normalizer->normalizeCompanyName($data->getCompanyName()));
+ }
+ }
+
+ /**
+ * Cles ecrivables effectivement presentes dans le payload : on retire les cles
+ * JSON-LD (@id, @context...) et tout champ non rattache a un groupe d'ecriture
+ * connu. Base du 422 d'archivage (RG-3.13).
+ *
+ * @return list
+ */
+ private function writablePayloadKeys(): array
+ {
+ $writable = array_merge(
+ self::MAIN_FIELDS,
+ self::ACCOUNTING_FIELDS,
+ [self::ARCHIVE_FIELD],
+ );
+
+ return array_values(array_intersect($this->payloadKeys(), $writable));
+ }
+
+ /**
+ * Cles de premier niveau effectivement envoyees par le client (payload JSON
+ * brut). Pour un PATCH merge-patch+json, ce sont les seuls champs modifies.
+ *
+ * @return list
+ */
+ /**
+ * Completude de l'onglet Comptabilite (miroir SupplierProcessor) : ne se
+ * declenche que si TOUS les champs requis sont presents dans le payload
+ * (= soumission d'onglet, pas un PATCH partiel cible). Delegue au validateur
+ * qui leve une 422 listant chaque champ manquant (mapping inline ERP-101).
+ */
+ private function validateAccountingCompleteness(Provider $data): void
+ {
+ if ([] !== array_diff(self::ACCOUNTING_REQUIRED_FIELDS, $this->payloadKeys())) {
+ return;
+ }
+
+ $this->accountingValidator->validate($data);
+ }
+
+ private function payloadKeys(): array
+ {
+ $request = $this->requestStack->getCurrentRequest();
+ if (null === $request) {
+ return [];
+ }
+
+ $content = $request->getContent();
+
+ // Cache hit : meme corps brut que le dernier decodage -> memes cles.
+ if ($content === $this->decodedContent) {
+ return $this->decodedPayloadKeys;
+ }
+
+ $this->decodedContent = $content;
+ $this->decodedPayloadKeys = $this->extractPayloadKeys($content);
+
+ return $this->decodedPayloadKeys;
+ }
+
+ /**
+ * Decode le corps brut et en extrait les cles de premier niveau (chaines).
+ * Corps vide ou JSON invalide -> aucune cle.
+ *
+ * @return list
+ */
+ private function extractPayloadKeys(string $content): array
+ {
+ if ('' === $content) {
+ return [];
+ }
+
+ try {
+ $decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
+ } catch (JsonException) {
+ return [];
+ }
+
+ return is_array($decoded) ? array_values(array_filter(array_keys($decoded), 'is_string')) : [];
+ }
+
+ /**
+ * Leve une 422 portant une violation unique sur `sites` — meme rendu Hydra que
+ * les contraintes Symfony, consommable inline par extractApiViolations (ERP-101).
+ *
+ * @return never
+ */
+ private function throwSitesViolation(Provider $root): void
+ {
+ $violations = new ConstraintViolationList();
+ $violations->add(new ConstraintViolation(
+ 'Vous ne pouvez rattacher que des sites auxquels vous avez accès.',
+ null,
+ [],
+ $root,
+ 'sites',
+ null,
+ ));
+
+ throw new ValidationException($violations);
+ }
+}
diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderRibProcessor.php b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderRibProcessor.php
new file mode 100644
index 0000000..0c8107f
--- /dev/null
+++ b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderRibProcessor.php
@@ -0,0 +1,120 @@
+
+ */
+final class ProviderRibProcessor implements ProcessorInterface
+{
+ public function __construct(
+ #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
+ private readonly ProcessorInterface $persistProcessor,
+ #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
+ private readonly ProcessorInterface $removeProcessor,
+ private readonly EntityManagerInterface $em,
+ private readonly ProviderSiteScopeChecker $scopeChecker,
+ ) {}
+
+ public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
+ {
+ if (!$data instanceof ProviderRib) {
+ return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
+ }
+
+ if ($operation instanceof DeleteOperationInterface) {
+ $this->guardLastRibDeletionUnderLcr($data);
+
+ return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
+ }
+
+ $this->linkParent($data, $uriVariables);
+
+ return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
+ }
+
+ /**
+ * Rattache le RIB au prestataire parent de la sous-ressource POST
+ * (/providers/{providerId}/ribs) : la relation n'est pas peuplee
+ * automatiquement par le Link sur une ecriture. Sur PATCH, no-op.
+ */
+ private function linkParent(ProviderRib $rib, array $uriVariables): void
+ {
+ if (null !== $rib->getProvider()) {
+ return;
+ }
+
+ $providerId = $uriVariables['providerId'] ?? null;
+ if (null === $providerId) {
+ return;
+ }
+
+ $provider = $providerId instanceof Provider
+ ? $providerId
+ : $this->em->getRepository(Provider::class)->find($providerId);
+
+ // read:false sur le POST : sans stade lecture, un parent introuvable n'est
+ // plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
+ // contrainte provider_id NOT NULL).
+ if (!$provider instanceof Provider) {
+ throw new NotFoundHttpException('Prestataire introuvable.');
+ }
+
+ // Cloisonnement par site (§ 2.13 / RG-3.17) : interdiction de creer une
+ // sous-ressource sur un prestataire hors du perimetre de l'user -> 404
+ // (anti-enumeration, coherent avec le detail Provider garde en 404).
+ $this->scopeChecker->assertInScope($provider);
+
+ $rib->setProvider($provider);
+ }
+
+ /**
+ * RG-3.08 : un prestataire dont le type de reglement est LCR doit conserver au
+ * moins un RIB. La collection inclut le RIB en cours de suppression : un
+ * effectif <= 1 signifie qu'il ne resterait aucun RIB -> 409. Pour tout autre
+ * type de reglement, les RIBs sont optionnels (suppression libre).
+ */
+ private function guardLastRibDeletionUnderLcr(ProviderRib $rib): void
+ {
+ $provider = $rib->getProvider();
+ if (null === $provider) {
+ return;
+ }
+
+ if ('LCR' === $provider->getPaymentType()?->getCode() && $provider->getRibs()->count() <= 1) {
+ throw new ConflictHttpException(
+ 'Impossible de supprimer le dernier RIB : le type de règlement LCR exige au moins un RIB.',
+ );
+ }
+ }
+}
diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/State/Provider/ProviderProvider.php b/src/Module/Technique/Infrastructure/ApiPlatform/State/Provider/ProviderProvider.php
new file mode 100644
index 0000000..b5955f9
--- /dev/null
+++ b/src/Module/Technique/Infrastructure/ApiPlatform/State/Provider/ProviderProvider.php
@@ -0,0 +1,223 @@
+ (prestataires ayant >= 1 categorie
+ * de ce code, repetable) et ?siteId= (prestataires rattaches a ce site
+ * via la relation DIRECTE provider.sites, repetable) ;
+ * - pagination obligatoire (regle ABSOLUE n°13) : Paginator ORM ; echappatoire
+ * ?pagination=false pour alimenter un